diff --git a/cypher-executor/src/lib/console-dashboard-model.ts b/cypher-executor/src/lib/console-dashboard-model.ts new file mode 100644 index 0000000..da8d2d0 --- /dev/null +++ b/cypher-executor/src/lib/console-dashboard-model.ts @@ -0,0 +1,282 @@ +/** + * console 駕駛艙 dashboard 的聚合純函式層(2026-07-07 fix/console-dashboard-live-data) + * + * 為什麼獨立成 lib:routes/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 | null { + if (!e.content) return null; + try { + const v = JSON.parse(e.content); + return v && typeof v === 'object' ? (v as Record) : 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(); + 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((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" 字母前綴) + * - 事項欄以 ~~ 開頭(整項劃掉)→ 已結案,跳過 + * - 急迫欄含 ✅/已完成/已解/銷案 → 已結案,跳過 + * 解析失敗(找不到段落/表格)→ 回 null,caller 誠實 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.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(); +} + +/** fallback:dash_wait 同 title 取最新、只留 open——但每項帶 age、整包帶 stale 判定。 + * 這條路只在 Gitea 讀不到時走;它的資料 2026-07-04 之後就沒人維護,所以 stale 標記是義務。 */ +export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): WaitingModel { + const byTitle = new Map(); + 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( + (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 }; +} diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts index 58f6ee9..e9636f8 100644 --- a/cypher-executor/src/routes/console-dashboard.ts +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -1,35 +1,59 @@ /** - * arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工) + * arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工; + * 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦) * * 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數): - * - GET /console/dashboard-data:從 KBDB(走既有 kbdbBase 慣例,同 kbdb-proxy)讀四種 - * entry_type(dash_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 POST,content 是 JSON 字串): - * dash_beat:{"actor":"總管|cloud-worker|progress-guard","event":"start|done","note":"一句"} - * → 每 actor 取最新一筆(base list 已按 created_at DESC,first-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/status(enabled: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 條件,但今日任務有非標準 status(late/behind 等落後標記)。 * green = 其餘。 * - * 薄殼定位:這是「聚合端點」(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯, - * 判定全在此端點;讀 KBDB 走 HTTP(kbdbBase,同 proxy 慣例),不新增 binding、不碰 D1/SQL。 + * 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在 + * lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 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 | null { - if (!e.content) return null; +async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise { + const { base, headers } = kbdbBase(env); + const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) }); try { - const v = JSON.parse(e.content); - return v && typeof v === 'object' ? (v as Record) : 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(url: string, headers?: Record): Promise { + try { + const res = await fetch(url, headers ? { headers } : undefined); + if (!res.ok) return null; + return (await res.json()) as T; } catch { return null; } } -async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise { +/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */ +async function fetchEntryTotal(env: Bindings, filters: Record): Promise { 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_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序: + * 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項,例:07b 開了、 + * 🔴 mira 憑證外洩仍掛 07a)→ 各抓 raw 解析合併 → 最新檔的最後 commit 時間當「清單維護於」。 + * 任一步失敗回 null → caller fallback dash_wait(標 age),不硬湊。 + */ +async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise { + const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, ''); + const token = env.GITEA_TOKEN; + if (!base || !token) return null; + const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo'; + const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch'; + const headers = { Authorization: `token ${token}` }; + try { + const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers); + if (!files) return null; + const sprints = pickLatestSprintFiles(files.map((f) => f.name)); + if (!sprints.length) return null; + const parsed = await Promise.all( + sprints.map(async (name) => { + const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers }); + if (!rawRes.ok) return null; + 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 檔最後 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 { + 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; + +/** + * fetchGiteaWaiting 的快取層(總管 #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,直接回放會讓時間停走)。 + * - **失敗不快取**: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) => 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(); 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(); - 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(); - 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/miss;fallback 路徑為 null)——快取生效的客觀證據 + cache: waitingCache, + }, inbox_new: inboxNew, + system: { + kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false, + embed: embedStatus + ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } + : null, + graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null }, + workflow_total: workflowTotal, + }, + kb: { + entries_total: entriesTotal, + wiki_card_total: wikiCardTotal, + triplets_total: graphStats?.total ?? null, + }, generated_at: new Date(now).toISOString(), }); }); @@ -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 {
等你的事
載入中…
+
-
今日路線狀態即時同步
+
今日路線
  • 載入中…
    +
    系統狀況live 健康信號
    +
    載入中…
    每 60 秒自動刷新
    進入完整控制台 › @@ -332,6 +532,12 @@ function renderDashboardHtml(): string { const ic = ICONS[t.status] || '●'; return '
  • ' + ic + '' + esc(t.title) + '
  • '; } + 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 '
    ' + esc(k) + '' + esc(v) + '
    '; + } 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) => '
    ' + esc(w.title) + '
    ').join(''); + body.innerHTML = d.waiting.map((w) => + '
    ' + (w.urgency ? esc(w.urgency) : '◆') + '' + + (w.id ? '#' + esc(w.id) + ' ' : '') + esc(w.title) + '
    ').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 += '
    ⚠ 清單超過 2 天沒維護,可能過時'; + } else if (wm.source === 'kbdb_dash_wait') { + metaTxt = '⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時'; + } else { + metaTxt = '管線未接:Gitea sprint 清單與 dash_wait 皆無資料'; + } + 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('') : '
  • 今日無排定項目
  • '; + 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('') : '
  • 今日無排定項目
  • '; + } else if (today.length) { + $('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入'; + $('today-list').className = 'route faded'; + $('today-list').innerHTML = + '
  • 今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) + + '的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)
  • ' + today.map(taskLine).join(''); + } else { + $('route-m').textContent = ''; + $('today-list').className = 'route'; + $('today-list').innerHTML = '
  • 無資料——dash_task 管線未接
  • '; + } $('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 })) + '
    此頁不含機敏內容,免登入'; } catch (e) { $('orb-char').textContent = '?'; diff --git a/cypher-executor/src/routes/console.ts b/cypher-executor/src/routes/console.ts index a3a62b8..6de60e2 100644 --- a/cypher-executor/src/routes/console.ts +++ b/cypher-executor/src/routes/console.ts @@ -326,6 +326,7 @@ function renderConsoleHtml(registryBase: string): string {
    等你的事
    載入中…
    +
    @@ -699,6 +700,12 @@ function renderConsoleHtml(registryBase: string): string { var cls = TASK_ICONS[t.status] ? t.status : 'blocked'; return '
  • ' + (TASK_ICONS[t.status] || '●') + '' + esc(t.title) + '
  • '; } + 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 '
    ' + esc(w.title) + '
    '; }).join(''); + body.innerHTML = d.waiting.map(function (w) { + return '
    ' + (w.urgency ? esc(w.urgency) : '◆') + '' + + (w.id ? '#' + esc(w.id) + ' ' : '') + esc(w.title) + '
    '; + }).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 ? '
    ⚠ 清單超過 2 天沒維護,可能過時' : ''); + } else if (wm.source === 'kbdb_dash_wait') { + wmeta.innerHTML = '⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時'; + } else if (wm.source === 'none') { + wmeta.innerHTML = '管線未接:Gitea sprint 清單與 dash_wait 皆無資料'; + } 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('') : '
  • 今日無排定項目
  • '; + if (rm.is_today || !today.length) { + $('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '
  • 今日無排定項目——dash_task 今天沒有寫入
  • '; + } else { + $('ck-today').innerHTML = '
  • 今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線
  • ' + 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 }); diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 8d12267..14a0781 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -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`; } diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 1c0bd36..84e57b4 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -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-data,2026-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..workers.dev(該 repo wrangler.toml name 固定)。 // console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。 diff --git a/cypher-executor/tests/console-dashboard-cache.test.ts b/cypher-executor/tests/console-dashboard-cache.test.ts new file mode 100644 index 0000000..8383583 --- /dev/null +++ b/cypher-executor/tests/console-dashboard-cache.test.ts @@ -0,0 +1,99 @@ +/** + * cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。 + * + * 跑在 vitest-pool-workers(workerd)裡,caches.default 是真的 Cache API—— + * 不是 mock cache 物件;fetcher 用注入的假函式計數,證明: + * 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hit(fetcher 不再被打) + * 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 的 promise,flush 後 cache.put 才保證落地 */ +function collector() { + const pending: Promise[] = []; + return { waitUntil: (p: Promise) => pending.push(p), flush: () => Promise.all(pending) }; +} + +describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => { + it('第一次 miss 打 fetcher;90s 內第二次 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); + }); +}); diff --git a/cypher-executor/tests/console-dashboard-model.test.ts b/cypher-executor/tests/console-dashboard-model.test.ts new file mode 100644 index 0000000..9a4c392 --- /dev/null +++ b/cypher-executor/tests/console-dashboard-model.test.ts @@ -0,0 +1,194 @@ +/** + * console 駕駛艙聚合純函式測試(fix/console-dashboard-live-data,2026-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-04,07-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 殘資料必標 stale(stale 根因 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 段落 → null(caller 誠實 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 天前'); + }); +}); diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 98aadc3..35492bd 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -138,6 +138,15 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev" # (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。 CONSOLE_TENANT = "leo" +# 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-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"