fix(console): 駕駛艙首頁改讀 live 一手資料,stale 殘骸誠實標示 #36

Merged
Leo merged 3 commits from fix/console-dashboard-live-data into main 2026-07-07 11:25:27 +00:00
8 changed files with 980 additions and 101 deletions
@@ -0,0 +1,282 @@
/**
* console 駕駛艙 dashboard 的聚合純函式層(2026-07-07 fix/console-dashboard-live-data
*
* 為什麼獨立成 libroutes/console-dashboard.ts 原本把「解析 + 判定」焊在 route 裡,
* 無法單測;本檔只放**純函式**(無 fetch、無 env),route 負責 IO、這裡負責判定,
* vitest 可直接餵資料驗證(誠實限制 mindset §7:修 stale bug 要有客觀證據)。
*
* 本次修的三個 stale 根因(leo 抱怨「等你的事錯了好幾天」的診斷結論):
* 1. dash_wait 沒有活的維護管線(最後寫入 2026-07-04;等leo清單#11 已於 07-05 銷案=誤判,
* dashboard 卻繼續掛著它)——「等 leo」的真相源其實是 InkStoneCo sprint 檔的
* 「## 等 leo 清單」表格(progress-guard 每日核實維護)。→ 改讀 Gitea sprint 檔,
* 讀不到才 fallback dash_wait 且必標 age,不假裝是今天的。
* 2. dash_task 的 scope:"today" 沒有日期概念——07-04 寫入的「今日路線」到 07-07 還被
* 當成今天的。→ 以台北日曆日判定 is_today,非今日寫入的任務誠實降級為「最後路線(N 天前)」。
* 3. 燈號 hasBlocked/hasLagMark 吃到 stale 任務——幾天前的 blocked 會讓今天亮紅燈。
* → 燈號只吃「今日寫入」的任務 + 心跳(心跳 dash_beat 是唯一有活管線的 dash_* 資料)。
*/
export interface KbdbEntry {
id: string;
content: string | null;
entry_type: string;
created_at: string | number; // leo21c 實測是 epoch 秒(number);防禦性也吃 sqlite/ISO 字串
}
/** created_at 實測(leo21c KBDB)=epoch 秒數 number;防禦性同時吃 epoch 毫秒與
* 'YYYY-MM-DD HH:MM:SS'UTC/ ISO 字串(別的部署可能不同 schema 版本)。 */
export function parseCreatedAtMs(s: string | number | null | undefined): number | null {
if (s === null || s === undefined || s === '') return null;
if (typeof s === 'number') return s < 1e12 ? s * 1000 : s; // 秒 vs 毫秒
if (/^\d+$/.test(s)) {
const n = Number(s);
return n < 1e12 ? n * 1000 : n;
}
const iso = /T/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
const ms = Date.parse(iso);
return Number.isNaN(ms) ? null : ms;
}
export function parseJsonContent(e: KbdbEntry): Record<string, unknown> | null {
if (!e.content) return null;
try {
const v = JSON.parse(e.content);
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
} catch {
return null;
}
}
/** 台北(UTC+8)日曆日 key,用來判定「這筆 dash_task 是不是今天寫的」。 */
export function taipeiDayKey(ms: number): string {
return new Date(ms + 8 * 3600 * 1000).toISOString().slice(0, 10);
}
export function agoMinutes(nowMs: number, ms: number | null): number {
return ms === null ? -1 : Math.max(0, Math.round((nowMs - ms) / 60000));
}
// ── 今日路線(dash_task)───────────────────────────────────────────────────────
export interface DashTask {
title: string;
status: string;
order: number;
scope: 'today' | 'week';
at_ms: number | null;
age_minutes: number;
/** 台北日曆日判定:這筆的最新寫入是不是「今天」寫的 */
is_today_write: boolean;
}
export interface RouteModel {
tasks: DashTask[];
/** 最新一筆 dash_task 寫入距今分鐘數;-1 = 全庫沒有 dash_task */
updated_ago_minutes: number;
/** 最新寫入是否發生在台北「今天」——false = 整包路線是殘資料 */
is_today: boolean;
today_done: number;
today_total: number;
}
/** dash_task 聚合:同 title 取最新(list 已 created_at DESC → first-seen 即最新),
* 但「今日完成 n/m」只數今天(台北)寫入的任務——幾天前的殘路線不假裝是今天的。 */
export function buildRouteModel(entries: KbdbEntry[], nowMs: number): RouteModel {
const todayKey = taipeiDayKey(nowMs);
const byTitle = new Map<string, DashTask>();
for (const e of entries) {
const j = parseJsonContent(e);
const title = typeof j?.title === 'string' ? (j.title as string) : null;
if (!title || byTitle.has(title)) continue;
const ms = parseCreatedAtMs(e.created_at);
byTitle.set(title, {
title,
status: typeof j?.status === 'string' ? (j.status as string) : 'todo',
order: typeof j?.order === 'number' ? (j.order as number) : 999,
scope: j?.scope === 'week' ? 'week' : 'today',
at_ms: ms,
age_minutes: agoMinutes(nowMs, ms),
is_today_write: ms !== null && taipeiDayKey(ms) === todayKey,
});
}
const tasks = [...byTitle.values()].sort((a, b) =>
a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
);
const newestMs = tasks.reduce<number | null>((m, t) => (t.at_ms !== null && (m === null || t.at_ms > m) ? t.at_ms : m), null);
const isToday = newestMs !== null && taipeiDayKey(newestMs) === todayKey;
const todayTasks = tasks.filter((t) => t.scope === 'today' && t.is_today_write);
return {
tasks,
updated_ago_minutes: agoMinutes(nowMs, newestMs),
is_today: isToday,
today_done: todayTasks.filter((t) => t.status === 'done').length,
today_total: todayTasks.length,
};
}
// ── 等你的事 ──────────────────────────────────────────────────────────────────
export interface WaitingItem {
title: string;
/** Gitea sprint 來源才有:等leo清單編號("13"、"R1"——07b 起用字母前綴)與急迫 emoji */
id?: string;
urgency?: string;
/** 合併多個 sprint 檔時標出處(如 "sprint-2026-07a.md" */
sprint?: string;
}
export interface WaitingModel {
items: WaitingItem[];
/** 'gitea_sprint' = 讀到活資料源;'kbdb_dash_wait' = fallback(必標 age);'none' = 管線未接 */
source: 'gitea_sprint' | 'kbdb_dash_wait' | 'none';
/** 資料源上次維護距今分鐘;-1 = 不明 */
updated_ago_minutes: number;
/** true = 這包資料超過閾值沒人維護,頁面必須明示「可能過時」 */
stale: boolean;
/** gitea_sprint 來源:實際讀了哪些 sprint 檔(新→舊) */
sprint_files?: string[];
note?: string;
}
const URGENCY_EMOJI = /(🔴|🟡|🟢|)/u;
/** 急迫欄含這些字樣=已結案,不再是「等你的事」 */
const CLOSED_MARKERS = /(||||)/u;
/** 從 sprint md 撈「## 等 leo 清單」表格(含「(RAG 線)」等帶註記變體),回傳仍 open 的項目。
* 規則(對照 sprint-2026-07a/07b.md 實際維護慣例):
* - 編號欄吃英數(07a 純數字 "13"07b 起 "R1" 字母前綴)
* - 事項欄以 ~~ 開頭(整項劃掉)→ 已結案,跳過
* - 急迫欄含 ✅/已完成/已解/銷案 → 已結案,跳過
* 解析失敗(找不到段落/表格)→ 回 nullcaller 誠實 fallback,不硬湊。排序交給 sortWaitingItems。 */
export function parseSprintWaitingTable(md: string, sprintFile?: string): WaitingItem[] | null {
const secIdx = md.search(/^##\s*\s*leo\s*/mu);
if (secIdx < 0) return null;
const section = md.slice(secIdx);
const lines = section.split('\n');
const items: WaitingItem[] = [];
let sawTable = false;
for (const line of lines.slice(1)) {
if (/^#{2,3}\s/.test(line) && !/^##\s*等/.test(line)) break; // 下一個段落,停
const m = line.match(/^\|\s*([A-Za-z]?\d+)\s*\|(.*)\|(.*)\|\s*$/u);
if (!m) continue;
sawTable = true;
const id = m[1];
const item = m[2].trim();
const urgency = m[3].trim();
if (item.startsWith('~~')) continue;
if (CLOSED_MARKERS.test(urgency)) continue;
items.push({
id,
urgency: urgency.match(URGENCY_EMOJI)?.[1] ?? '',
title: cleanWaitingTitle(item),
sprint: sprintFile,
});
}
if (!sawTable) return null;
return sortWaitingItems(items);
}
/** 🔴 > 🟡 > 其他;同急迫維持傳入順序(stable sort,現役 sprint 的項目先傳先顯示)。 */
export function sortWaitingItems(items: WaitingItem[]): WaitingItem[] {
const rank = (u?: string) => (u === '🔴' ? 0 : u === '🟡' ? 1 : 2);
return items
.map((it, i) => ({ it, i }))
.sort((a, b) => rank(a.it.urgency) - rank(b.it.urgency) || a.i - b.i)
.map((x) => x.it);
}
/** 事項欄清洗:去 markdown 粗體/劃掉/行內 code、去開頭急迫 emoji(急迫欄已有,別重複)、
* 截前 80 字(cell 動輒數百字,儀表板只要標題),截斷若留下未閉合的「(」順手剪掉。 */
export function cleanWaitingTitle(raw: string): string {
let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
s = s.replace(/^(?:🔴|🟡|🟢|)\s*/u, '');
// 常見格式「**標題**(時間 說明):長解釋…」→ 取第一個「:」前;太短則不切
const colon = s.indexOf('');
if (colon >= 12) s = s.slice(0, colon);
if (s.length > 80) s = `${s.slice(0, 79)}`;
// 截斷可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉
let depth = 0;
let lastOpen = -1;
for (let i = 0; i < s.length; i++) {
if (s[i] === '') {
if (depth === 0) lastOpen = i;
depth++;
} else if (s[i] === '') {
depth = Math.max(0, depth - 1);
}
}
if (depth > 0 && lastOpen >= 0) s = s.slice(0, lastOpen).trim();
return s;
}
/** sprint 檔命名慣例 sprint-YYYY-MM<letter>.md,字典序即時間序。
* 取最新 n 個(新→舊):現役 sprint 換檔後,前一個 sprint 的等leo清單常還有未銷案項
* (實例:07b 開了、🔴 mira 憑證外洩仍掛在 07a)——只讀最新一檔會漏掉真正在等的事。 */
export function pickLatestSprintFiles(names: string[], n = 2): string[] {
return names
.filter((name) => /^sprint-.*\.md$/.test(name))
.sort()
.slice(-n)
.reverse();
}
/** fallbackdash_wait 同 title 取最新、只留 open——但每項帶 age、整包帶 stale 判定。
* 這條路只在 Gitea 讀不到時走;它的資料 2026-07-04 之後就沒人維護,所以 stale 標記是義務。 */
export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): WaitingModel {
const byTitle = new Map<string, { status: string; at_ms: number | null }>();
for (const e of entries) {
const j = parseJsonContent(e);
const title = typeof j?.title === 'string' ? (j.title as string) : null;
if (!title || byTitle.has(title)) continue;
byTitle.set(title, {
status: typeof j?.status === 'string' ? (j.status as string) : 'open',
at_ms: parseCreatedAtMs(e.created_at),
});
}
const open = [...byTitle.entries()].filter(([, v]) => v.status === 'open');
const newestMs = [...byTitle.values()].reduce<number | null>(
(m, v) => (v.at_ms !== null && (m === null || v.at_ms > m) ? v.at_ms : m),
null,
);
const ago = agoMinutes(nowMs, newestMs);
return {
items: open.map(([title]) => ({ title })),
source: byTitle.size ? 'kbdb_dash_wait' : 'none',
updated_ago_minutes: ago,
stale: ago < 0 || ago > 24 * 60,
note: byTitle.size
? undefined
: '管線未接:dash_wait 無資料、Gitea sprint 讀取未設定(GITEA_TOKEN',
};
}
/** 人話 age:分鐘 → 「3 分鐘前 / 5 小時前 / 3 天前」。-1 → 「時間不明」。 */
export function humanAge(minutes: number): string {
if (minutes < 0) return '時間不明';
if (minutes < 60) return `${minutes} 分鐘前`;
if (minutes < 48 * 60) return `${Math.round(minutes / 60)} 小時前`;
return `${Math.round(minutes / (24 * 60))} 天前`;
}
// ── Gitea 讀取快取(總管 #36 審查要求:查詢面加快取——別讓前端 60 秒刷新變成
// 每分鐘 3-4 個 API call 打自家 Gitea;快取是讀,不違「不輪詢」紅線)──────────
/** 快取 TTL(秒)。90s 前端 60s 刷新下,Gitea 實際被打 ≤1 輪/90s
* 等leo清單由 progress-guard 每日維護,90 秒新鮮度綽綽有餘。 */
export const GITEA_WAITING_CACHE_TTL_SECONDS = 90;
/** Cache API 裡存的封套:模型 + fetch 當下的牆鐘(回放時補算 age 用)。 */
export interface CachedWaitingEnvelope {
model: WaitingModel;
fetched_at_ms: number;
}
/** 快取回放時把「維護於 N 分鐘前」隨牆鐘推進:存的是 fetch 當下算好的 ago,
* 直接回放會讓時間停走(最多差一個 TTL,雖小仍是假時間);補回經過的分鐘數、
* stale 判定同步重算,才符合「不擺 stale 殘骸」的原則。 */
export function reviveWaitingAges(model: WaitingModel, fetchedAtMs: number, nowMs: number): WaitingModel {
if (model.updated_ago_minutes < 0) return model;
const drift = Math.max(0, Math.round((nowMs - fetchedAtMs) / 60000));
const ago = model.updated_ago_minutes + drift;
return { ...model, updated_ago_minutes: ago, stale: ago > 48 * 60 };
}
+351 -94
View File
@@ -1,35 +1,59 @@
/**
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工
* 2026-07-07 fix/console-dashboard-live-datastale 資料整修,總管交辦)
*
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* - GET /console/dashboard-data從 KBDB(走既有 kbdbBase 慣例,同 kbdb-proxy)讀四種
* entry_typedash_beat / dash_task / dash_wait / inbox,租戶 = CONSOLE_TENANT,同
* console-auth 的固定租戶模型)並聚合成一包 JSON。
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),手機優先、一屏、兩格,
* 每 60 秒自動 fetch dashboard-data 刷新。無互動功能,就是儀表板。
* - GET /console/dashboard-data聚合 JSON。
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
*
* 資料契約(寫入方=總管/cloud-worker/progress-guard 往 /kbdb/entries POSTcontent 是 JSON 字串):
* dash_beat{"actor":"總管|cloud-worker|progress-guard","event":"start|done","note":"一句"}
* → 每 actor 取最新一筆(base list 已按 created_at DESCfirst-seen 即最新)。
* dash_task{"title","status":"done|doing|todo|blocked","order":1,"scope":"today|week"}
* → 以 title 為 key 取 created 最新(後寫蓋前寫)。
* dash_wait{"title","status":"open|closed"} → 同 title 取最新,只回 open
* inbox {"text","status":"new|done"} → 計數未處理(status !== 'done'
* ── 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 = 任一 task status=blocked,或最新心跳距今 > 240 分(台北時間 09:00-22:00 判定;
* 窗外心跳老是正常的睡眠狀態,不判)。
* yellow = 無 red 條件,但有 blocked 以外的落後標記(task status 不在 done/doing/todo/blocked
* 四個標準值內,如 late/behind——寫入方標了非標準狀態=落後訊號)。
* red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定),
* 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。
* yellow = 無 red 條件,但今日任務有非標準 statuslate/behind 等落後標記)。
* green = 其餘。
*
* 薄殼定位:這是「聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯
* 判定全在此端點;讀 KBDB 走 HTTPkbdbBase,同 proxy 慣例),不新增 binding、不碰 D1/SQL
* 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在
* lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTPkbdbBase 慣例),不新增 binding。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
import { kbdbBase, graphBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import {
type KbdbEntry,
type WaitingItem,
type WaitingModel,
type CachedWaitingEnvelope,
GITEA_WAITING_CACHE_TTL_SECONDS,
parseCreatedAtMs,
parseJsonContent,
agoMinutes,
buildRouteModel,
buildWaitingFallback,
parseSprintWaitingTable,
pickLatestSprintFiles,
reviveWaitingAges,
sortWaitingItems,
} from '../lib/console-dashboard-model';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -38,59 +62,184 @@ const JUDGE_START_HOUR = 9; // 台北時間,含
const JUDGE_END_HOUR = 22; // 台北時間,不含
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
interface KbdbEntry {
id: string;
content: string | null;
entry_type: string;
created_at: string | number; // leo21c 實測是 epoch 秒(number);防禦性也吃 sqlite/ISO 字串
}
/** created_at 實測(leo21c KBDB)=epoch 秒數 number;防禦性同時吃 epoch 毫秒與
* 'YYYY-MM-DD HH:MM:SS'UTC/ ISO 字串(別的部署可能不同 schema 版本)。 */
function parseCreatedAtMs(s: string | number | null | undefined): number | null {
if (s === null || s === undefined || s === '') return null;
if (typeof s === 'number') return s < 1e12 ? s * 1000 : s; // 秒 vs 毫秒
if (/^\d+$/.test(s)) {
const n = Number(s);
return n < 1e12 ? n * 1000 : n;
}
const iso = /T/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
const ms = Date.parse(iso);
return Number.isNaN(ms) ? null : ms;
}
function parseJsonContent(e: KbdbEntry): Record<string, unknown> | null {
if (!e.content) return null;
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 v = JSON.parse(e.content);
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
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;
}
}
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
/** 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({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
if (!res.ok) return [];
const data = (await res.json()) as { entries?: KbdbEntry[] };
return data.entries ?? [];
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;
}
/**
* 「等你的事」活資料源:InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)。
* 需 GITEA_BASE_URLvar+ GITEA_TOKENsecret,建議唯讀 scope)。請求序:
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項,例:07b 開了、
* 🔴 mira 憑證外洩仍掛 07a)→ 各抓 raw 解析合併 → 最新檔的最後 commit 時間當「清單維護於」。
* 任一步失敗回 null → caller fallback dash_wait(標 age),不硬湊。
*/
async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingModel | 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;
return parseSprintWaitingTable(await rawRes.text(), name);
}),
);
const readFiles = sprints.filter((_, i) => parsed[i] !== null);
const merged = parsed.filter((p): p is WaitingItem[] => p !== null).flat();
if (!readFiles.length) return null; // 全部解析失敗=誠實 fallback
// 清單上次維護時間 = 現役 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 {
items: sortWaitingItems(merged),
source: 'gitea_sprint',
updated_ago_minutes: ago,
stale: ago >= 0 && ago > 48 * 60,
sprint_files: readFiles,
};
} catch {
return null;
}
}
export type GiteaWaitingFetcher = (env: Bindings, nowMs: number) => Promise<WaitingModel | null>;
/**
* fetchGiteaWaiting 的快取層(總管 #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,直接回放會讓時間停走)。
* - **失敗不快取**negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該
* 當場 fallback dash_wait。
* - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。
* - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次
* 第二次該是 hit)。
*/
export async function cachedGiteaWaiting(
env: Bindings,
nowMs: number,
waitUntil: (p: Promise<unknown>) => void,
fetcher: GiteaWaitingFetcher = fetchGiteaWaiting,
): Promise<(WaitingModel & { 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 { ...reviveWaitingAges(envelope.model, envelope.fetched_at_ms, nowMs), cache: 'hit' };
}
} catch {
/* cache 故障不致命,走 miss 路徑 */
}
const fresh = await fetcher(env, nowMs);
if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
const envelope: CachedWaitingEnvelope = { model: 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] = await Promise.all([
const [
beatEntries,
taskEntries,
waitEntries,
inboxEntries,
giteaWaiting,
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),
cachedGiteaWaiting(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`),
fetchEntryTotal(c.env, {}),
fetchEntryTotal(c.env, { entry_type: 'wiki_card' }),
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
]);
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)
// 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) {
@@ -104,37 +253,29 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
event: typeof j?.event === 'string' ? (j.event as string) : '',
note: typeof j?.note === 'string' ? (j.note as string) : '',
at: e.created_at,
ago_minutes: ms === null ? -1 : Math.max(0, Math.round((now - ms) / 60000)),
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;
// dash_task:同 title 取最新(後寫蓋前寫
const taskByTitle = new Map<string, { title: string; status: string; order: number; scope: string }>();
for (const e of taskEntries) {
const j = parseJsonContent(e);
const title = typeof j?.title === 'string' ? (j.title as string) : null;
if (!title || taskByTitle.has(title)) continue;
taskByTitle.set(title, {
title,
status: typeof j?.status === 'string' ? (j.status as string) : 'todo',
order: typeof j?.order === 'number' ? (j.order as number) : 999,
scope: j?.scope === 'week' ? 'week' : 'today',
});
}
const tasks = [...taskByTitle.values()].sort((a, b) =>
a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
);
// 今日路線:dash_task + 台北日曆日判定(純函式,見 lib/console-dashboard-model.ts
const route = buildRouteModel(taskEntries, now);
// dash_wait:同 title 取最新,只回 open
const waitByTitle = new Map<string, string>();
for (const e of waitEntries) {
const j = parseJsonContent(e);
const title = typeof j?.title === 'string' ? (j.title as string) : null;
if (!title || waitByTitle.has(title)) continue;
waitByTitle.set(title, typeof j?.status === 'string' ? (j.status as string) : 'open');
// 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale
let waiting: WaitingModel;
let waitingCache: 'hit' | 'miss' | null = null;
if (giteaWaiting) {
const { cache: cacheState, ...model } = giteaWaiting;
waiting = model;
waitingCache = cacheState;
} 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 殘資料';
}
}
const waiting = [...waitByTitle.entries()].filter(([, s]) => s === 'open').map(([title]) => ({ title }));
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
const inboxNew = inboxEntries.reduce((n, e) => {
@@ -142,25 +283,71 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
return j && j.status !== 'done' ? n + 1 : n;
}, 0);
// 燈號
const hasBlocked = tasks.some((t) => t.status === 'blocked');
const hasLagMark = tasks.some((t) => !STANDARD_TASK_STATUS.has(t.status));
// 燈號:只吃「今日寫入」的任務 + 心跳 + 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) ? 'red' : hasLagMark ? 'yellow' : 'green';
hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
const lightReason = !kbdbOk
? 'KBDB 基本盤 /health 打不通'
: hasBlocked
? '今日任務有 blocked'
: inJudgeWindow && beatStale
? `心跳超過 ${STALE_MINUTES} 分鐘`
: hasLagMark
? '今日任務有落後標記'
: '';
const todayTasks = tasks.filter((t) => t.scope === 'today');
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,
tasks,
today_done: todayTasks.filter((t) => t.status === 'done').length,
today_total: todayTasks.length,
waiting,
// 路線:tasks 保留原欄位(console.ts 消費端相容),另帶 age 與 is_today_write
tasks: 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,
})),
route_meta: {
source: 'kbdb_dash_task',
is_today: route.is_today,
updated_ago_minutes: route.updated_ago_minutes,
},
today_done: route.today_done,
today_total: route.today_total,
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,
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(),
});
});
@@ -256,6 +443,8 @@ function renderDashboardHtml(): string {
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
.wait-meta .warn { color: var(--err); }
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
@@ -269,6 +458,14 @@ function renderDashboardHtml(): string {
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
ul.route li.blocked .ic { color: var(--err); }
ul.route.faded li { opacity: .55; }
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
.sys .ok { color: var(--ok); }
.sys .bad { color: var(--err); }
.sys .off { color: rgba(var(--ink-rgb),.5); }
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
.err { color: var(--err); font-size: 14px; }
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
@@ -307,11 +504,14 @@ function renderDashboardHtml(): string {
<div class="wait-box" id="wait-box">
<div class="wait-head">等你的事</div>
<div id="wait-body" class="wait-none">載入中…</div>
<div class="wait-meta" id="wait-meta"></div>
</div>
<div class="subhead"><span class="t">今日路線</span><span class="m">狀態即時同步</span></div>
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-m"></span></div>
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
<ul class="route" id="week-list"></ul>
<div class="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
<a class="enter" href="/console">進入完整控制台 </a>
</main>
@@ -332,6 +532,12 @@ function renderDashboardHtml(): string {
const ic = ICONS[t.status] || '●';
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
}
function humanAge(m) {
if (m == null || m < 0) return '時間不明';
if (m < 60) return m + ' 分鐘前';
if (m < 2880) return Math.round(m / 60) + ' 小時前';
return Math.round(m / 1440) + ' 天前';
}
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
const now = new Date();
@@ -350,6 +556,9 @@ function renderDashboardHtml(): string {
const m = e && e.message ? String(e.message) : String(e);
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
}
function sysRow(k, v, cls) {
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
}
async function load() {
try {
const res = await fetch('/console/dashboard-data');
@@ -361,28 +570,76 @@ function renderDashboardHtml(): string {
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('orb-char').textContent = cfg.ch;
$('orb-title').textContent = cfg.title;
$('orb-sub').textContent = d.last_beat
$('orb-sub').textContent = (d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料';
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '' + d.light_reason + '' : '');
const done = d.today_done || 0, total = d.today_total || 0;
$('done-n').textContent = done; $('total-n').textContent = total;
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
$('inbox-n').textContent = d.inbox_new || 0;
const wb = $('wait-box'), body = $('wait-body');
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
const wm = d.waiting_meta || {};
if (d.waiting && d.waiting.length) {
wb.classList.add('has');
body.className = '';
body.innerHTML = d.waiting.map((w) => '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>').join('');
body.innerHTML = d.waiting.map((w) =>
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
} else {
wb.classList.remove('has');
body.className = 'wait-none';
body.textContent = '無,你不用做任何事';
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
}
let metaTxt = '';
if (wm.source === 'gitea_sprint') {
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
} else if (wm.source === 'kbdb_dash_wait') {
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
} else {
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
}
wmeta.innerHTML = metaTxt;
// ── 今日路線:非今日寫入=誠實降級「最後路線(N 天前)」,不假裝是今天的 ──
const rm = d.route_meta || {};
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
if (rm.is_today) {
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
$('today-list').className = 'route';
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
} else if (today.length) {
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
$('today-list').className = 'route faded';
$('today-list').innerHTML =
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
} else {
$('route-m').textContent = '';
$('today-list').className = 'route';
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
}
$('week-head').style.display = week.length ? '' : 'none';
$('week-list').innerHTML = week.map(taskLine).join('');
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
const sys = d.system || {}, kb = d.kb || {};
const rows = [];
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
if (sys.embed) {
rows.push(sys.embed.enabled
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + '', 'ok')
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + '', 'bad'));
} else {
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
}
rows.push(sys.graph && sys.graph.ok
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
: sysRow('知識圖譜', '● 打不通', 'bad'));
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
rows.push(sysRow('總庫規模', (kb.entries_total == null ? '讀不到' : kb.entries_total.toLocaleString() + ' 筆') +
'・wiki 卡 ' + (kb.wiki_card_total == null ? '?' : kb.wiki_card_total), kb.entries_total == null ? 'off' : ''));
$('sys-list').innerHTML = rows.join('');
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
} catch (e) {
$('orb-char').textContent = '';
+35 -6
View File
@@ -326,6 +326,7 @@ function renderConsoleHtml(registryBase: string): string {
<div class="wait-box" id="ck-waitbox">
<div class="wait-head">等你的事</div>
<div id="ck-wait" class="wait-none">載入中…</div>
<div id="ck-waitmeta" style="margin-top:10px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.45);line-height:1.7"></div>
</div>
</div>
<div>
@@ -699,6 +700,12 @@ function renderConsoleHtml(registryBase: string): string {
var cls = TASK_ICONS[t.status] ? t.status : 'blocked';
return '<li class="' + cls + '"><span class="ic">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';
}
function ckAge(m) {
if (m == null || m < 0) return '時間不明';
if (m < 60) return m + ' 分鐘前';
if (m < 2880) return Math.round(m / 60) + ' 小時前';
return Math.round(m / 1440) + ' 天前';
}
function loadCockpit() {
var now = new Date();
$('ck-date').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
@@ -711,24 +718,46 @@ function renderConsoleHtml(registryBase: string): string {
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('ck-char').textContent = cfg.ch;
$('ck-title').textContent = cfg.title;
$('ck-sub').textContent = d.last_beat
$('ck-sub').textContent = (d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料';
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '' + d.light_reason + '' : '');
var done = d.today_done || 0, total = d.today_total || 0;
$('ck-done').textContent = done; $('ck-total').textContent = total;
$('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
$('ck-inbox').textContent = d.inbox_new || 0;
var wb = $('ck-waitbox'), body = $('ck-wait');
// 等你的事:來源與維護時間攤開講(fix/console-dashboard-live-data)——
// Gitea sprint 等leo清單是活資料源;fallback dash_wait 一定標 stale,不假裝是今天的
var wb = $('ck-waitbox'), body = $('ck-wait'), wmeta = $('ck-waitmeta');
var wm = d.waiting_meta || {};
if (d.waiting && d.waiting.length) {
wb.classList.add('has'); body.className = '';
body.innerHTML = d.waiting.map(function (w) { return '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>'; }).join('');
body.innerHTML = d.waiting.map(function (w) {
return '<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>';
}).join('');
} else {
wb.classList.remove('has'); body.className = 'wait-none';
body.textContent = '無,你不用做任何事';
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
}
if (wm.source === 'gitea_sprint') {
wmeta.innerHTML = '來源:sprint 等leo清單・維護於 ' + ckAge(wm.updated_ago_minutes) +
(wm.stale ? '<br><span class="err">⚠ 清單超過 2 天沒維護,可能過時</span>' : '');
} else if (wm.source === 'kbdb_dash_wait') {
wmeta.innerHTML = '<span class="err">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時</span>';
} else if (wm.source === 'none') {
wmeta.innerHTML = '<span class="err">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
} else {
wmeta.textContent = '';
}
// 今日路線:非今日寫入=誠實降級為「最後路線(N 天前)」,不假裝是今天的
var rm = d.route_meta || {};
var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });
var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
if (rm.is_today || !today.length) {
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目——dash_task 今天沒有寫入</span></li>';
} else {
$('ck-today').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線</span></li>' + today.map(taskLine).join('');
}
$('ck-weekhead').style.display = week.length ? '' : 'none';
$('ck-week').innerHTML = week.map(taskLine).join('');
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
+1 -1
View File
@@ -195,7 +195,7 @@ kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
// 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN
// 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。
function graphBase(env: Bindings): string {
export function graphBase(env: Bindings): string {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, '');
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
+9
View File
@@ -78,6 +78,15 @@ export type Bindings = {
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
CONSOLE_TENANT?: string;
// ── console 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data2026-07-07)──
// 等 leo 的事真相源=InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護),
// 不是沒人維護的 KBDB dash_wait。cypher 進程內 fetch Gitea API(自架 Gitea,非 GitHub
// 無 D20 頻率疑慮)。缺 GITEA_BASE_URL 或 GITEA_TOKEN → 該區塊 fallback dash_wait
// (頁面誠實標 age + stale),不假綠。
GITEA_BASE_URL?: string; // wrangler.toml [vars],如 https://git.uncle6.me
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
@@ -0,0 +1,99 @@
/**
* cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。
*
* 跑在 vitest-pool-workersworkerd)裡,caches.default 是真的 Cache API——
* 不是 mock cache 物件;fetcher 用注入的假函式計數,證明:
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hitfetcher 不再被打)
* 2. hit 回放時 age 隨牆鐘推進(不回放停走的時間)
* 3. fetcher 失敗(回 null)不快取——下次仍會重試,不把抖動放大成 90 秒盲區
* 4. 缺 GITEA_BASE_URL/GITEA_TOKEN 直接 null(快取層不啟動)
*/
import { describe, it, expect } from 'vitest';
import { cachedGiteaWaiting } from '../src/routes/console-dashboard';
import type { WaitingModel } from '../src/lib/console-dashboard-model';
import type { Bindings } from '../src/types';
function fakeEnv(repo: string): Bindings {
return {
GITEA_BASE_URL: 'https://git.example.test',
GITEA_TOKEN: 'test-token',
GITEA_SPRINT_REPO: repo, // 每測試一個 repo → cache key 隔離
GITEA_SPRINT_DIR: 'system-dev/docs/3-specs/autonomy-dispatch',
} as unknown as Bindings;
}
function model(): WaitingModel {
return {
items: [{ title: 'mira .env 歷史憑證外洩', id: '13', urgency: '🔴', sprint: 'sprint-2026-07a.md' }],
source: 'gitea_sprint',
updated_ago_minutes: 10,
stale: false,
sprint_files: ['sprint-2026-07b.md', 'sprint-2026-07a.md'],
};
}
/** 收集 waitUntil 的 promiseflush 後 cache.put 才保證落地 */
function collector() {
const pending: Promise<unknown>[] = [];
return { waitUntil: (p: Promise<unknown>) => pending.push(p), flush: () => Promise.all(pending) };
}
describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s', () => {
it('第一次 miss 打 fetcher90s 內第二次 hit、fetcher 不再被打', async () => {
const env = fakeEnv('Leo/cache-test-1');
let calls = 0;
const fetcher = async () => {
calls++;
return model();
};
const c = collector();
const t0 = Date.now();
const r1 = await cachedGiteaWaiting(env, t0, c.waitUntil, fetcher);
expect(r1?.cache).toBe('miss');
expect(calls).toBe(1);
await c.flush(); // cache.put 落地
const r2 = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, fetcher);
expect(r2?.cache).toBe('hit'); // ← 快取生效的客觀證據
expect(calls).toBe(1); // fetcher 沒有被第二次呼叫=Gitea 沒被打
expect(r2?.items[0].id).toBe('13');
});
it('hit 回放時 age 隨牆鐘推進(存 10 分鐘前、過 60s 讀 → 11 分鐘前),不停走', async () => {
const env = fakeEnv('Leo/cache-test-2');
const c = collector();
const t0 = Date.now();
await cachedGiteaWaiting(env, t0, c.waitUntil, async () => model());
await c.flush();
const r = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, async () => model());
expect(r?.cache).toBe('hit');
expect(r?.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
});
it('fetcher 失敗(null)不快取:下一次仍重打 fetcher', async () => {
const env = fakeEnv('Leo/cache-test-3');
const c = collector();
let calls = 0;
const failing = async () => {
calls++;
return null;
};
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
await c.flush();
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
expect(calls).toBe(2); // 失敗沒有被 90 秒快取住
});
it('缺 GITEA_BASE_URL/GITEA_TOKEN → null(快取層不啟動,caller 走 fallback', async () => {
const env = { GITEA_SPRINT_REPO: 'Leo/x' } as unknown as Bindings;
let calls = 0;
const fetcher = async () => {
calls++;
return model();
};
const c = collector();
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
expect(calls).toBe(0);
});
});
@@ -0,0 +1,194 @@
/**
* console 駕駛艙聚合純函式測試(fix/console-dashboard-live-data2026-07-07
*
* 針對 leo 抱怨「等你的事錯了好幾天」的三個 stale 根因逐一驗證:
* 1. dash_task 的 scope:"today" 沒日期 → 幾天前的任務不得再算「今日」
* 2. dash_wait 殘資料 fallback 必標 stale
* 3. sprint 檔「等 leo 清單」表格解析:已銷案(~~/✅/已完成)要濾掉、🔴 排最前
*
* 樣本資料直接取自 leo21c live KBDB 與 InkStoneCo sprint-2026-07a.md 的真實形態
* epoch 秒 created_at、07-04 殘 entries、含劃掉列的 markdown 表格),非憑空編造。
*/
import { describe, it, expect } from 'vitest';
import {
parseCreatedAtMs,
taipeiDayKey,
buildRouteModel,
buildWaitingFallback,
parseSprintWaitingTable,
sortWaitingItems,
cleanWaitingTitle,
pickLatestSprintFiles,
humanAge,
type KbdbEntry,
} from '../src/lib/console-dashboard-model';
// 2026-07-07 10:00 UTC(台北 18:00)當「現在」
const NOW = Date.parse('2026-07-07T10:00:00Z');
const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000);
function entry(created: string, content: unknown): KbdbEntry {
return { id: crypto.randomUUID(), entry_type: 'x', content: JSON.stringify(content), created_at: SEC(created) };
}
describe('parseCreatedAtMs / taipeiDayKey', () => {
it('吃 epoch 秒(leo21c 實測形態)、epoch 毫秒、ISO、sqlite 字串', () => {
expect(parseCreatedAtMs(1783413742)).toBe(1783413742000);
expect(parseCreatedAtMs(1783413742000)).toBe(1783413742000);
expect(parseCreatedAtMs('2026-07-07T08:42:22Z')).toBe(Date.parse('2026-07-07T08:42:22Z'));
expect(parseCreatedAtMs('2026-07-07 08:42:22')).toBe(Date.parse('2026-07-07T08:42:22Z'));
expect(parseCreatedAtMs(null)).toBeNull();
});
it('台北日曆日:UTC 20:00 已是台北隔天', () => {
expect(taipeiDayKey(Date.parse('2026-07-06T20:00:00Z'))).toBe('2026-07-07');
expect(taipeiDayKey(Date.parse('2026-07-06T15:00:00Z'))).toBe('2026-07-06');
});
});
describe('buildRouteModel — 今日路線不吃殘資料(stale 根因 2)', () => {
it('07-04 寫入的 scope:today 任務,07-07 看不再算今日(is_today=false、today_total=0', () => {
// live 實態:dash_task 最後寫入 2026-07-0407-07 dashboard 仍把它當「今日 2/4」
const entries = [
entry('2026-07-04T10:22:00Z', { title: 'T-loop4 檢討 routine 首跑', status: 'done', order: 1, scope: 'today' }),
entry('2026-07-04T04:44:00Z', { title: 'T-cockpit 駕駛艙三件套', status: 'doing', order: 2, scope: 'today' }),
entry('2026-07-04T04:44:00Z', { title: 'T-kb-skeleton 總庫 0→1', status: 'todo', order: 1, scope: 'week' }),
];
const m = buildRouteModel(entries, NOW);
expect(m.is_today).toBe(false);
expect(m.today_total).toBe(0); // 不假裝 07-04 的路線是今天的
expect(m.today_done).toBe(0);
expect(m.tasks).toHaveLength(3); // 資料仍在(頁面降級顯示「最後路線(N 天前)」)
expect(m.tasks.every((t) => !t.is_today_write)).toBe(true);
expect(m.updated_ago_minutes).toBeGreaterThan(3 * 24 * 60 - 60); // ≈3 天
});
it('今天寫入的任務照常計入今日(同 title 後寫蓋前寫)', () => {
const entries = [
entry('2026-07-07T09:00:00Z', { title: 'A', status: 'done', order: 1, scope: 'today' }), // 最新(DESC first
entry('2026-07-07T08:00:00Z', { title: 'B', status: 'doing', order: 2, scope: 'today' }),
entry('2026-07-04T04:44:00Z', { title: 'A', status: 'todo', order: 1, scope: 'today' }), // 舊值被蓋
];
const m = buildRouteModel(entries, NOW);
expect(m.is_today).toBe(true);
expect(m.today_total).toBe(2);
expect(m.today_done).toBe(1);
expect(m.tasks.find((t) => t.title === 'A')?.status).toBe('done');
});
it('空庫:誠實回 -1 / false,不編資料', () => {
const m = buildRouteModel([], NOW);
expect(m.tasks).toHaveLength(0);
expect(m.is_today).toBe(false);
expect(m.updated_ago_minutes).toBe(-1);
});
});
describe('buildWaitingFallback — dash_wait 殘資料必標 stalestale 根因 1', () => {
it('07-04 的 open 項在 07-07 仍列出,但 stale=true、age 正確', () => {
// live 實態:等leo清單#11 已於 07-05 銷案(誤判),dash_wait 卻沒人寫 closed
const entries = [
entry('2026-07-04T11:05:00Z', { title: '檢查 Claude Environment 兩個 routine trigger', status: 'open' }),
entry('2026-07-04T10:41:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'closed' }),
entry('2026-07-04T05:56:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'open' }), // 舊 open 被 closed 蓋
];
const m = buildWaitingFallback(entries, NOW);
expect(m.source).toBe('kbdb_dash_wait');
expect(m.items.map((i) => i.title)).toEqual(['檢查 Claude Environment 兩個 routine trigger']);
expect(m.stale).toBe(true); // >24h 沒維護 → 頁面必須警示「可能過時」
expect(m.updated_ago_minutes).toBeGreaterThan(24 * 60);
});
it('完全無資料 → source none + 管線未接 note', () => {
const m = buildWaitingFallback([], NOW);
expect(m.source).toBe('none');
expect(m.items).toHaveLength(0);
expect(m.note).toContain('管線未接');
});
});
describe('parseSprintWaitingTable — 等leo清單活資料源(stale 根因 3 的解)', () => {
// 取自 InkStoneCo sprint-2026-07a.md 真實形態(截短),含:劃掉列、✅ 急迫欄、🔴/🟡/⚪
const MD = `# Sprint 2026-07a
## 任務板
- [x] 東西
## 等 leo 清單(暗待辦歸零區)
| # | 事項 | 急迫 |
|---|------|------|
| 1 | ~~Claude Environment 補 secrets~~ ✅ 07-02 實測確認 | ⚪ 已完成 |
| 3 | Telegram bot token rotate(可與 #1 一起做) | 🟡 建議今天順手 |
| 9 | **credential 路徑1 不成立,SDD 需重新設計**(詳情見 Arcrun#2 | ✅ 已解,T2-T9 解凍 |
| 11 | ~~cloud-worker trigger 連續兩天疑似未觸發~~ **✅ 銷案=誤判** | ✅ 銷案(誤判)+防複發已上線 |
| 14 | **\`NPM_API_TOKEN\` 缺失,acr search 等 CLI 改動全數卡在 npm 未發版**2026-07-05 核實):Environment 現只有五把 | 🟡 建議本週補 |
| 13 | 🔴 **mira \`.env\` 歷史憑證外洩(2026-07-05 發現,已止血未 rotate**mira repo 曾把 .env 誤入 git 追蹤 | 🔴 需 leo 裁(安全事件) |
| 15 | **T-cockpit③(md→Gitea Project 投影)方向選擇**2026-07-06 spike 核實):需 leo 三選一 | 🟡 品味/方向題 |
### 下一段
別的內容`;
it('已銷案列(~~ 或急迫欄 ✅/已完成)全濾掉;open 項留下、🔴 排最前', () => {
const items = parseSprintWaitingTable(MD, 'sprint-2026-07a.md')!;
expect(items).not.toBeNull();
expect(items.map((i) => i.id)).toEqual(['13', '3', '14', '15']); // 🔴 最前,餘依表序
expect(items[0].urgency).toBe('🔴');
expect(items[0].title).toContain('mira');
expect(items[0].sprint).toBe('sprint-2026-07a.md');
expect(items.every((i) => !i.title.includes('~~') && !i.title.includes('**'))).toBe(true);
});
it('07b 形態:段落標題帶註記「(RAG 線)」、編號帶字母前綴 R1', () => {
const md07b = `## 等 leo 清單(RAG 線)
| # | 事項 | 急迫 |
|---|------|------|
| R1 | pilot 客戶挑選(1–2 家)+產品命名/定價區間(rag-wave1 T0 | 🟡 Phase 0 前置 |
| R2 | ~~Gemini key~~ ✅ 已補 | ⚪ 已完成 |
`;
const items = parseSprintWaitingTable(md07b, 'sprint-2026-07b.md')!;
expect(items.map((i) => i.id)).toEqual(['R1']);
expect(items[0].urgency).toBe('🟡');
});
it('跨 sprint 合併排序:現役檔項目先傳,🔴 仍浮最上', () => {
const b = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| R1 | RAG pilot | 🟡 x |', 'b.md')!;
const a = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| 13 | 憑證外洩 | 🔴 x |', 'a.md')!;
const merged = sortWaitingItems([...b, ...a]);
expect(merged.map((i) => i.id)).toEqual(['13', 'R1']);
});
it('沒有等 leo 段落 → nullcaller 誠實 fallback,不硬湊)', () => {
expect(parseSprintWaitingTable('# 別的檔\n沒有清單')).toBeNull();
expect(parseSprintWaitingTable('## 等 leo 清單\n(還沒建表)')).toBeNull();
});
it('cleanWaitingTitle:去粗體、長標題在「:」截斷、80 字上限', () => {
expect(cleanWaitingTitle('**NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版**07-05 核實):Environment 現只有五把')).toBe(
'NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版(07-05 核實)',
);
expect(cleanWaitingTitle('短標題').length).toBeLessThan(80);
});
it('cleanWaitingTitle:去開頭急迫 emoji(急迫欄已有)、截斷後不留未閉合括號', () => {
// 等leo清單#13 實際形態:事項欄自帶 🔴 開頭
expect(cleanWaitingTitle('🔴 **mira `.env` 歷史憑證外洩(2026-07-05 發現)**mira repo 曾把 .env 誤入 git')).toBe(
'mira .env 歷史憑證外洩(2026-07-05 發現)',
);
// 等leo清單#3 實際形態:「:」在括號內 → 截斷會留半個「(」,要剪掉
expect(cleanWaitingTitle('Telegram bot token rotate(可與 #1 一起做:rotate 後直接填新值進 Environment')).toBe(
'Telegram bot token rotate',
);
});
});
describe('pickLatestSprintFiles / humanAge', () => {
it('sprint-*.md 字典序取最新兩個(新→舊);無 sprint 檔回空陣列', () => {
// live 實態(2026-07-07):07b 已開、07a 的 🔴 未銷案項仍要讀 → 兩檔都拿
expect(pickLatestSprintFiles(['sprint-2026-07a.md', 'sprint-2026-07b.md', 'tasks.md', 'routines'])).toEqual([
'sprint-2026-07b.md',
'sprint-2026-07a.md',
]);
expect(pickLatestSprintFiles(['sprint-2026-06b.md', 'sprint-2026-07a.md', 'sprint-2026-05a.md'])).toEqual([
'sprint-2026-07a.md',
'sprint-2026-06b.md',
]);
expect(pickLatestSprintFiles(['tasks.md'])).toEqual([]);
});
it('humanAge 誠實顯示天級 age', () => {
expect(humanAge(-1)).toBe('時間不明');
expect(humanAge(30)).toBe('30 分鐘前');
expect(humanAge(3 * 24 * 60)).toBe('3 天前');
});
});
+9
View File
@@ -138,6 +138,15 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
CONSOLE_TENANT = "leo"
# 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data2026-07-07):
# InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有
# 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。
# ⚠ 還需 `wrangler secret put GITEA_TOKEN`(建議唯讀 scope)才會啟用;缺 token → dashboard
# 「等你的事」fallback dash_wait 並誠實標 age/stale。Self-hosted fork:清空 GITEA_BASE_URL 即整段停用。
GITEA_BASE_URL = "https://git.uncle6.me"
GITEA_SPRINT_REPO = "Leo/InkStoneCo"
GITEA_SPRINT_DIR = "system-dev/docs/3-specs/autonomy-dispatch"
[[routes]]
pattern = "cypher.arcrun.dev/*"
zone_name = "arcrun.dev"