fix(console): 真實性稽核修正——時區統一台北、精耕層數字、今日完成接 sprint 任務板
稽核輪(fix/console-truth-audit,總管派工+leo 三裁決): B1 時區:console 全站日期/時間顯示統一 Asia/Taipei(新 lib/taipei-time.ts, server 判定與頁面 client JS 同一套、等價性單測互鎖)。原本用瀏覽器本地 getter——換裝置就換日期;憑證頁改顯「日期+時分(台北)」:raw 1783349834 =台北 2026-07-06 22:57,7/6 顯示是真的,缺的是時分與固定時區。 B2 總庫數字(leo 裁:14-E 遺產 deprecated 不再顯示):總庫搜尋/設定頁頭部 統計改讀新 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入+最近 寫入,全 live、limit=1/stats 聚合不掃庫);搜尋仍搜全庫、結果 wiki_card 加「精耕」綠標;dashboard-data 的 entries 計數補 owner_id(原混租戶: 459,137 vs leo 458,732)。 B3 設定頁:vectorize 文案改具體事實(config kbdb_embed+acr update,#32 教訓)+live 狀態;新增 MCP token TTL 誠實佔位(GET /console/settings-data, 可調功能=Arcrun#19,不假裝能遠端改)。 C 今日完成/今日路線接 sprint 任務板(leo 拍板):比照 #36 等leo清單模式, 同一輪 Gitea fetch(90s CF cache 共用)解析「## 任務板」checkbox;「今日 完成」只認「完成(今天台北日)」標記,[x] 無日期不冒領;快取存原始 completed_days,跨台北午夜不誤算;板檔今天沒 commit → 頁面誠實標「今日 任務板未更新」;dash_task 降 fallback、燈號判定不變。parser 以 live 07a/07b 檔實測(標題抽取棄「首個粗體」——實讀發現會抽到完成註記)。 驗證:tsc exit 0;vitest 82/83(1 失敗=pre-existing executor「不存在的 零件」,stash 於 main 複驗同樣紅);新增 taipei-time 10 案例(含跨日/跨年/ client-server 等價/leo 實測 epoch)+任務板 parser 7 案例;兩頁 inline JS 渲染後抽 <script> node --check 全過(mistakes #24 手法)。
This commit is contained in:
@@ -16,6 +16,12 @@
|
||||
* → 燈號只吃「今日寫入」的任務 + 心跳(心跳 dash_beat 是唯一有活管線的 dash_* 資料)。
|
||||
*/
|
||||
|
||||
import { taipeiDayKey } from './taipei-time';
|
||||
|
||||
// 台北日曆日 key 的唯一實作搬到 lib/taipei-time.ts(fix/console-truth-audit:console 全站
|
||||
// 日期顯示統一台北,server/client 共用同一套)。這裡 re-export 維持既有 import 路徑不破。
|
||||
export { taipeiDayKey };
|
||||
|
||||
export interface KbdbEntry {
|
||||
id: string;
|
||||
content: string | null;
|
||||
@@ -47,11 +53,6 @@ export function parseJsonContent(e: KbdbEntry): Record<string, unknown> | 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));
|
||||
}
|
||||
@@ -194,19 +195,8 @@ export function cleanWaitingTitle(raw: string): string {
|
||||
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;
|
||||
// 截斷可能留下未閉合的全形括號 → 共用 trimUnbalancedParen(板標題同款處理)
|
||||
return trimUnbalancedParen(s);
|
||||
}
|
||||
|
||||
/** sprint 檔命名慣例 sprint-YYYY-MM<letter>.md,字典序即時間序。
|
||||
@@ -250,6 +240,113 @@ export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): Waiti
|
||||
};
|
||||
}
|
||||
|
||||
// ── sprint 任務板(「今日完成」活資料源,leo 2026-07-07 拍板)──────────────────────
|
||||
//
|
||||
// 動機(leo 原話):「今天做了這麼多事……其實就是我們到底完成了多少事」。dash_task 沒有
|
||||
// 活管線(同 dash_wait 病),真相源=InkStoneCo sprint 檔「## 任務板」(progress-guard /
|
||||
// cloud-worker 每日維護勾選)。比照「等你的事」#36 模式:同一輪 Gitea fetch 撈的檔案
|
||||
// 順手解析任務板,dash_task 降為 fallback。
|
||||
|
||||
export interface SprintBoardTask {
|
||||
title: string;
|
||||
/** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked(板上維護慣例,對照 sprint-2026-07a.md) */
|
||||
status: 'done' | 'doing' | 'todo' | 'blocked';
|
||||
/** 行內所有「完成(YYYY-MM-DD…)」標記的日期(一行可有多次完成,如 T6-T9 分兩天)。
|
||||
* [x] 但沒有帶日期的完成標記 → 空陣列=無法歸日,任何一天都不冒領(寧可略過不誤判)。 */
|
||||
completed_days: string[];
|
||||
/** 合併多個 sprint 檔時標出處(如 "sprint-2026-07b.md") */
|
||||
sprint?: string;
|
||||
}
|
||||
|
||||
const BOARD_LINE = /^- \[( |x|X|🔄|!)\]\s*(.*)$/u;
|
||||
const COMPLETED_DAY = /完成((\d{4}-\d{2}-\d{2})/gu;
|
||||
|
||||
/** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉。
|
||||
* 整串都在括號裡(如「(T10 廢 ENCRYPTION_KEY:…」註記行)會剪成空字串——
|
||||
* caller 視空標題為「不是任務」跳過,恰好把括號註記行濾掉。 */
|
||||
function trimUnbalancedParen(s: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 板上事項標題=「第一個全形冒號前」的裸文字(板的維護慣例:`**任務代號**:長敘述…`)。
|
||||
* 不能用「第一個粗體」——實讀 07a 發現長敘述裡的 `**完成(2026-07-02…)**` 註記常比
|
||||
* 任務名先出現粗體,會抽成「完成(…)」假標題。冒號太前(<4 字,如「附帶:」)不切。 */
|
||||
export function cleanBoardTitle(raw: string): string {
|
||||
let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
|
||||
const colon = s.indexOf(':');
|
||||
if (colon >= 4) s = s.slice(0, colon);
|
||||
if (s.length > 80) s = `${s.slice(0, 79)}…`;
|
||||
return trimUnbalancedParen(s.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 sprint md 撈「## 任務板」段(到下一個 `## ` 為止,`###` 子段照收),解析頂層
|
||||
* checkbox 事項。只認**行首**的 `- [x]` 型式——縮排子彈(「 - 做法:」)與無 checkbox
|
||||
* 的候補區純敘述行都不是任務,natural 濾掉。找不到任務板段落 → null(caller 誠實
|
||||
* fallback dash_task,不硬湊)。解析不了的行一律略過,不誤判成完成(寬鬆容錯原則)。
|
||||
*/
|
||||
export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoardTask[] | null {
|
||||
const secIdx = md.search(/^##\s*任務板/mu);
|
||||
if (secIdx < 0) return null;
|
||||
const section = md.slice(secIdx);
|
||||
const lines = section.split('\n');
|
||||
const tasks: SprintBoardTask[] = [];
|
||||
for (const line of lines.slice(1)) {
|
||||
if (/^##\s/.test(line) && !/^###/.test(line)) break; // 下一個 H2 段落,任務板結束
|
||||
const m = line.match(BOARD_LINE);
|
||||
if (!m) continue;
|
||||
const mark = m[1];
|
||||
const body = m[2];
|
||||
const title = cleanBoardTitle(body);
|
||||
if (!title) continue;
|
||||
const completed: string[] = [];
|
||||
for (const cm of body.matchAll(COMPLETED_DAY)) completed.push(cm[1]);
|
||||
tasks.push({
|
||||
title,
|
||||
status: mark === 'x' || mark === 'X' ? 'done' : mark === '🔄' ? 'doing' : mark === '!' ? 'blocked' : 'todo',
|
||||
completed_days: completed,
|
||||
sprint: sprintFile,
|
||||
});
|
||||
}
|
||||
return tasks.length ? tasks : null;
|
||||
}
|
||||
|
||||
export interface SprintRouteModel {
|
||||
/** 今日路線清單:今天完成的(done)+ 板上仍開著的(doing/todo/blocked),板序即優先序 */
|
||||
tasks: { title: string; status: 'done' | 'doing' | 'todo' | 'blocked'; sprint?: string }[];
|
||||
today_done: number;
|
||||
/** 今日完成 + 仍開著的板上事項(今天的可做面)——不是「今天排定量」,板不按日排定 */
|
||||
today_total: number;
|
||||
done_today_titles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 任務板 →「今日完成」模型:done 只數「完成標記日期=今天(台北)」的項;歷史完成項
|
||||
* (前幾天勾的)不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列(今日路線)。
|
||||
*/
|
||||
export function buildSprintRouteModel(tasks: SprintBoardTask[], nowMs: number): SprintRouteModel {
|
||||
const todayKey = taipeiDayKey(nowMs);
|
||||
const doneToday = tasks.filter((t) => t.status === 'done' && t.completed_days.includes(todayKey));
|
||||
const open = tasks.filter((t) => t.status !== 'done');
|
||||
return {
|
||||
tasks: [...doneToday, ...open].map((t) => ({ title: t.title, status: t.status, sprint: t.sprint })),
|
||||
today_done: doneToday.length,
|
||||
today_total: doneToday.length + open.length,
|
||||
done_today_titles: doneToday.map((t) => t.title),
|
||||
};
|
||||
}
|
||||
|
||||
/** 人話 age:分鐘 → 「3 分鐘前 / 5 小時前 / 3 天前」。-1 → 「時間不明」。 */
|
||||
export function humanAge(minutes: number): string {
|
||||
if (minutes < 0) return '時間不明';
|
||||
@@ -265,9 +362,18 @@ export function humanAge(minutes: number): string {
|
||||
* 等leo清單由 progress-guard 每日維護,90 秒新鮮度綽綽有餘。 */
|
||||
export const GITEA_WAITING_CACHE_TTL_SECONDS = 90;
|
||||
|
||||
/** Cache API 裡存的封套:模型 + fetch 當下的牆鐘(回放時補算 age 用)。 */
|
||||
/** 同一輪 Gitea fetch 的兩個產物:等你的事(等leo清單)+ 今日完成/路線(任務板)。
|
||||
* 板任務存「原始 completed_days」而非「今天完成幾件」——今天是誰由請求當下判定
|
||||
* (buildSprintRouteModel),快取跨台北午夜也不會把昨天的完成假裝成今天的。 */
|
||||
export interface SprintSnapshot {
|
||||
waiting: WaitingModel;
|
||||
/** null = 檔案讀到了但沒有可解析的任務板(caller fallback dash_task) */
|
||||
board: SprintBoardTask[] | null;
|
||||
}
|
||||
|
||||
/** Cache API 裡存的封套:快照 + fetch 當下的牆鐘(回放時補算 age 用)。 */
|
||||
export interface CachedWaitingEnvelope {
|
||||
model: WaitingModel;
|
||||
snapshot: SprintSnapshot;
|
||||
fetched_at_ms: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Asia/Taipei(UTC+8,無日光節約)時間顯示 helper(fix/console-truth-audit,2026-07-07)
|
||||
*
|
||||
* 為什麼存在:console 各頁原本用瀏覽器本地時區(`Date#getFullYear/getMonth/...`)渲染
|
||||
* created_at / last_used_at / 刷新時間戳——顯示結果隨看的裝置漂移(換個時區的裝置就換一個
|
||||
* 日期),且與駕駛艙 #36 的「台北日曆日」判定(`taipeiDayKey`)不是同一套。本檔把
|
||||
* 「epoch ms → 台北牆鐘」收斂成唯一實作:
|
||||
* - server 側(TS):dashboard-data 的台北日判定 import 這裡。
|
||||
* - client 側(頁面 inline JS):console.ts / console-dashboard.ts 的 HTML 內嵌
|
||||
* `TAIPEI_CLIENT_JS`(同邏輯的 ES5 版)。等價性由 tests/taipei-time.test.ts 用
|
||||
* `new Function` 求值 client 版與 server 版逐案對照,防兩份漂移。
|
||||
*
|
||||
* 台北無 DST、固定 +8——用「UTC getter +固定位移」即可,不需要 Intl 時區資料庫。
|
||||
*/
|
||||
|
||||
export const TAIPEI_OFFSET_MS = 8 * 3600 * 1000;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 台北日曆日 key('YYYY-MM-DD')。#36 駕駛艙「這筆是不是今天寫的」判定用。 */
|
||||
export function taipeiDayKey(ms: number): string {
|
||||
return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 台北日期字串 'YYYY-MM-DD'(顯示用,同 taipeiDayKey,語意別名)。 */
|
||||
export function taipeiDateStr(ms: number): string {
|
||||
return taipeiDayKey(ms);
|
||||
}
|
||||
|
||||
/** 台北日期+時分 'YYYY-MM-DD HH:MM'。 */
|
||||
export function taipeiDateTimeStr(ms: number): string {
|
||||
const d = new Date(ms + TAIPEI_OFFSET_MS);
|
||||
return `${taipeiDayKey(ms)} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** 台北時分秒 'HH:MM:SS'(駕駛艙「上次刷新」戳記用)。 */
|
||||
export function taipeiTimeStr(ms: number): string {
|
||||
const d = new Date(ms + TAIPEI_OFFSET_MS);
|
||||
return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* client 版(ES5,內嵌進 console 頁 inline <script>)。與上面 server 版同邏輯——
|
||||
* 測試以 `new Function(TAIPEI_CLIENT_JS + 'return {...}')()` 求值後逐案對照 server 版,
|
||||
* 任一份改了另一份沒跟上會直接紅。頁面內以 `${TAIPEI_CLIENT_JS}` 插入,故這裡不用
|
||||
* template literal 語法(頁面本身是 template string,反引號會提前終結)。
|
||||
*/
|
||||
export const TAIPEI_CLIENT_JS = [
|
||||
"var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST",
|
||||
"function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }",
|
||||
"function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }",
|
||||
"function taipeiDateStr(ms) { return taipeiDayKey(ms); }",
|
||||
"function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }",
|
||||
"function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }",
|
||||
"function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }",
|
||||
].join('\n');
|
||||
@@ -2,9 +2,18 @@
|
||||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||||
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||||
*
|
||||
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||
* - GET /console/dashboard-data:聚合 JSON。
|
||||
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
|
||||
* - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁
|
||||
* 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。
|
||||
* - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。
|
||||
*
|
||||
* ── 2026-07-07 二修(fix/console-truth-audit):「今日完成/今日路線」接 sprint 任務板 ──
|
||||
* leo 拍板(「今天做了這麼多事……其實就是我們到底完成了多少事」):dash_task 同 dash_wait
|
||||
* 病(沒活管線),真相源=sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式:同一輪
|
||||
* Gitea fetch(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記,
|
||||
* dash_task 降 fallback;板檔今天沒 commit → 頁面誠實標「今日任務板未更新(最後 N 小時前)」。
|
||||
*
|
||||
* ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
|
||||
*
|
||||
@@ -43,17 +52,23 @@ import {
|
||||
type WaitingItem,
|
||||
type WaitingModel,
|
||||
type CachedWaitingEnvelope,
|
||||
type SprintBoardTask,
|
||||
type SprintSnapshot,
|
||||
GITEA_WAITING_CACHE_TTL_SECONDS,
|
||||
parseCreatedAtMs,
|
||||
parseJsonContent,
|
||||
agoMinutes,
|
||||
buildRouteModel,
|
||||
buildSprintRouteModel,
|
||||
buildWaitingFallback,
|
||||
parseSprintTaskBoard,
|
||||
parseSprintWaitingTable,
|
||||
pickLatestSprintFiles,
|
||||
reviveWaitingAges,
|
||||
sortWaitingItems,
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-model';
|
||||
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -95,13 +110,16 @@ async function fetchEntryTotal(env: Bindings, filters: Record<string, string>):
|
||||
}
|
||||
|
||||
/**
|
||||
* 「等你的事」活資料源:InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)。
|
||||
* sprint 檔活資料源(同一輪 fetch 兩個產物,leo 2026-07-07 拍板加「今日完成」):
|
||||
* - 「等你的事」=「## 等 leo 清單」表格(progress-guard 每日維護)。
|
||||
* - 「今日完成/今日路線」=「## 任務板」checkbox(今天勾的才算今日完成)。
|
||||
* 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序:
|
||||
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項,例:07b 開了、
|
||||
* 🔴 mira 憑證外洩仍掛 07a)→ 各抓 raw 解析合併 → 最新檔的最後 commit 時間當「清單維護於」。
|
||||
* 任一步失敗回 null → caller fallback dash_wait(標 age),不硬湊。
|
||||
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項/未收項,例:07b 開了、
|
||||
* 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 raw、兩個 parser 吃同一份文字 →
|
||||
* 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback
|
||||
* dash_wait / dash_task(標 age),不硬湊。
|
||||
*/
|
||||
async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingModel | null> {
|
||||
async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise<SprintSnapshot | null> {
|
||||
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
|
||||
const token = env.GITEA_TOKEN;
|
||||
if (!base || !token) return null;
|
||||
@@ -117,12 +135,15 @@ async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingM
|
||||
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 text = await rawRes.text();
|
||||
return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(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
|
||||
const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
|
||||
const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
|
||||
if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback
|
||||
// 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → null(fallback dash_task)
|
||||
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
|
||||
// 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale)
|
||||
let ago = -1;
|
||||
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
|
||||
@@ -135,40 +156,44 @@ async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingM
|
||||
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,
|
||||
waiting: {
|
||||
items: sortWaitingItems(merged),
|
||||
source: 'gitea_sprint',
|
||||
updated_ago_minutes: ago,
|
||||
stale: ago >= 0 && ago > 48 * 60,
|
||||
sprint_files: readFiles,
|
||||
},
|
||||
board: boardMerged.length ? boardMerged : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type GiteaWaitingFetcher = (env: Bindings, nowMs: number) => Promise<WaitingModel | null>;
|
||||
export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise<SprintSnapshot | null>;
|
||||
|
||||
/**
|
||||
* fetchGiteaWaiting 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、
|
||||
* fetchGiteaSprint 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、
|
||||
* TTL 90s(GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
|
||||
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
|
||||
*
|
||||
* - key:合成 URL(Cache API 要求合法 URL;host 用不會真的被打的保留名),帶
|
||||
* base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。
|
||||
* - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch
|
||||
* 當下的 ago,直接回放會讓時間停走)。
|
||||
* 當下的 ago,直接回放會讓時間停走)。任務板存原始 completed_days,「今天完成幾件」
|
||||
* 由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。
|
||||
* - **失敗不快取**:negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該
|
||||
* 當場 fallback dash_wait。
|
||||
* 當場 fallback dash_wait / dash_task。
|
||||
* - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。
|
||||
* - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次
|
||||
* 第二次該是 hit)。
|
||||
*/
|
||||
export async function cachedGiteaWaiting(
|
||||
export async function cachedGiteaSprint(
|
||||
env: Bindings,
|
||||
nowMs: number,
|
||||
waitUntil: (p: Promise<unknown>) => void,
|
||||
fetcher: GiteaWaitingFetcher = fetchGiteaWaiting,
|
||||
): Promise<(WaitingModel & { cache: 'hit' | 'miss' }) | null> {
|
||||
fetcher: GiteaSprintFetcher = fetchGiteaSprint,
|
||||
): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
|
||||
if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
|
||||
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||
@@ -180,14 +205,18 @@ export async function cachedGiteaWaiting(
|
||||
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' };
|
||||
return {
|
||||
waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
|
||||
board: envelope.snapshot.board,
|
||||
cache: 'hit',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* cache 故障不致命,走 miss 路徑 */
|
||||
}
|
||||
const fresh = await fetcher(env, nowMs);
|
||||
if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
|
||||
const envelope: CachedWaitingEnvelope = { model: fresh, fetched_at_ms: nowMs };
|
||||
const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
|
||||
try {
|
||||
waitUntil(
|
||||
cache.put(
|
||||
@@ -218,7 +247,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
taskEntries,
|
||||
waitEntries,
|
||||
inboxEntries,
|
||||
giteaWaiting,
|
||||
giteaSprint,
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
@@ -230,12 +259,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
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)),
|
||||
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||
fetchEntryTotal(c.env, {}),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card' }),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
|
||||
]);
|
||||
|
||||
@@ -258,16 +288,12 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
}
|
||||
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
|
||||
|
||||
// 今日路線:dash_task + 台北日曆日判定(純函式,見 lib/console-dashboard-model.ts)
|
||||
const route = buildRouteModel(taskEntries, now);
|
||||
|
||||
// 等你的事: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;
|
||||
if (giteaSprint) {
|
||||
waiting = giteaSprint.waiting;
|
||||
waitingCache = giteaSprint.cache;
|
||||
} else {
|
||||
waiting = buildWaitingFallback(waitEntries, now);
|
||||
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
|
||||
@@ -277,6 +303,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
|
||||
// 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
|
||||
// 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
|
||||
const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
|
||||
const route = buildRouteModel(taskEntries, now); // fallback + 燈號仍吃 dash_task 今日寫入
|
||||
const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
|
||||
const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);
|
||||
|
||||
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
|
||||
const inboxNew = inboxEntries.reduce((n, e) => {
|
||||
const j = parseJsonContent(e);
|
||||
@@ -308,22 +342,45 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
light_reason: lightReason,
|
||||
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
|
||||
beats,
|
||||
// 路線: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,
|
||||
// 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
|
||||
// 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB,
|
||||
// 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
|
||||
tasks: sprintRoute
|
||||
? sprintRoute.tasks.map((t, i) => ({
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
order: i,
|
||||
scope: 'today' as const,
|
||||
age_minutes: boardAgo,
|
||||
is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾)
|
||||
sprint: t.sprint ?? null,
|
||||
}))
|
||||
: route.tasks.map((t) => ({
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
order: t.order,
|
||||
scope: t.scope,
|
||||
age_minutes: t.age_minutes,
|
||||
is_today_write: t.is_today_write,
|
||||
sprint: null,
|
||||
})),
|
||||
route_meta: sprintRoute
|
||||
? {
|
||||
source: 'gitea_sprint_board',
|
||||
// is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
|
||||
is_today: boardUpdatedToday,
|
||||
updated_ago_minutes: boardAgo,
|
||||
sprint_files: waiting.sprint_files ?? null,
|
||||
}
|
||||
: {
|
||||
source: 'kbdb_dash_task',
|
||||
is_today: route.is_today,
|
||||
updated_ago_minutes: route.updated_ago_minutes,
|
||||
sprint_files: null,
|
||||
},
|
||||
today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
|
||||
today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
|
||||
done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
|
||||
waiting: waiting.items,
|
||||
waiting_meta: {
|
||||
source: waiting.source,
|
||||
@@ -352,6 +409,51 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
|
||||
// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
|
||||
// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
|
||||
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now = Date.now();
|
||||
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||
headers,
|
||||
),
|
||||
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
|
||||
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
// 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
||||
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
|
||||
embedded: embedStatus?.embedded ?? null,
|
||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
|
||||
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`(mcp/src/types.ts,預設 2592000=30 天);
|
||||
// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
|
||||
// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
|
||||
consoleDashboardRouter.get('/console/settings-data', (c) => {
|
||||
const raw = c.env.MCP_TOKEN_TTL;
|
||||
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||
const fromEnv = Number.isFinite(parsed) && parsed > 0;
|
||||
return c.json({
|
||||
mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
|
||||
mcp_token_ttl_source: fromEnv ? 'env' : 'default',
|
||||
});
|
||||
});
|
||||
|
||||
// GET /console/inbox-data — 收件匣清單(Mira Console 頁 7)。**需 console session**(Bearer):
|
||||
// dashboard-data 只吐計數可免登入;這裡吐訊息原文(leo 丟給 Telegram bot 的指令內容)屬機敏,鎖登入。
|
||||
// 資料源同 dashboard-data 的 inbox entries(entry_type=inbox,content JSON {"text","status","result"?})。
|
||||
@@ -517,6 +619,8 @@ function renderDashboardHtml(): string {
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
|
||||
${TAIPEI_CLIENT_JS}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const LIGHT = {
|
||||
green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
|
||||
@@ -540,8 +644,9 @@ function renderDashboardHtml(): string {
|
||||
}
|
||||
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();
|
||||
$('date-str').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
|
||||
// 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)
|
||||
const nowTpe = taipeiMonthDay(Date.now());
|
||||
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
|
||||
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
|
||||
$('theme-btn').addEventListener('click', () => {
|
||||
@@ -601,11 +706,19 @@ function renderDashboardHtml(): string {
|
||||
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||
}
|
||||
wmeta.innerHTML = metaTxt;
|
||||
// ── 今日路線:非今日寫入=誠實降級「最後路線(N 天前)」,不假裝是今天的 ──
|
||||
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
|
||||
const rm = d.route_meta || {};
|
||||
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||
if (rm.is_today) {
|
||||
if (rm.source === 'gitea_sprint_board') {
|
||||
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
const staleHead = rm.is_today ? '' :
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
|
||||
$('today-list').innerHTML = staleHead + (today.length
|
||||
? today.map(taskLine).join('')
|
||||
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
|
||||
} else 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>';
|
||||
@@ -637,10 +750,11 @@ function renderDashboardHtml(): string {
|
||||
? 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' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
|
||||
$('sys-list').innerHTML = rows.join('');
|
||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
|
||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
|
||||
} catch (e) {
|
||||
$('orb-char').textContent = '?';
|
||||
$('orb-title').textContent = '讀不到狀態';
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* - 駕駛艙:GET /console/dashboard-data(免登入聚合端點,console-dashboard.ts)
|
||||
* - 總庫搜尋:GET /kbdb/search(mode=semantic 未開 Vectorize 會誠實降級 + capability_hint;
|
||||
* chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類);
|
||||
* 規模感 total 來自 GET /kbdb/entries?limit=1 的誠實 total 欄位
|
||||
* 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07
|
||||
* 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫)
|
||||
* - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name
|
||||
* (cypher 代轉 kbdb-graph-plugin,kbdb-proxy.ts;查無 triplet → 「尚無關聯資料」)
|
||||
* - 工作流:GET /webhooks/named(list)+ POST /webhooks/named/:name/trigger(手動觸發)
|
||||
@@ -24,7 +25,16 @@
|
||||
* + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」
|
||||
* - 收件匣:GET /console/inbox-data(需 session——訊息原文屬機敏,計數才免登入)
|
||||
* - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點;
|
||||
* 開關本體需部署端啟用 kbdb_embed → 標「即將開通」)+ /console/setup/reset 換帳密 + 系統資訊
|
||||
* 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開)
|
||||
* + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19)
|
||||
* + /console/setup/reset 換帳密 + 系統資訊(精耕層規模)
|
||||
*
|
||||
* 2026-07-07 fix/console-truth-audit(總管稽核派工 + leo 裁決):
|
||||
* 1. 全站日期/時間顯示統一 Asia/Taipei(lib/taipei-time.ts,與駕駛艙 #36 台北日判定同套;
|
||||
* 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。
|
||||
* 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證——
|
||||
* 但 leo 裁 14-E 遺產 deprecated 不再顯示)。
|
||||
* 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。
|
||||
*
|
||||
* 認證模型沿 v1(console-auth.ts):單一管理員 email+password,session token 在 localStorage,
|
||||
* 實際打 /kbdb/* 用後端回的固定租戶字串(CONSOLE_TENANT)。駕駛艙免登入版在 /console/dashboard。
|
||||
@@ -42,6 +52,7 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||
|
||||
export const consoleRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -331,7 +342,7 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin:24px 0 10px">
|
||||
<span class="subhead">今日路線</span><span style="font-size:13px;color:rgba(var(--ink-rgb),.4)">狀態即時同步</span>
|
||||
<span class="subhead">今日路線</span><span id="ck-route-src" style="font-size:13px;color:rgba(var(--ink-rgb),.4)">狀態即時同步</span>
|
||||
</div>
|
||||
<ul class="route" id="ck-today"><li class="todo"><span class="ic">○</span><span class="muted">載入中…</span></li></ul>
|
||||
<div class="subhead" id="ck-weekhead" style="display:none;margin:18px 0 10px">本週</div>
|
||||
@@ -431,10 +442,17 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
<div style="font-size:17px;font-weight:600">語意搜尋(vectorize)</div>
|
||||
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
|
||||
</div>
|
||||
<div class="switch" id="st-vec-switch" title="開關即將開通"><i></i></div>
|
||||
<div class="switch" id="st-vec-switch" title="此開關不能遠端改,只如實顯示狀態"><i></i></div>
|
||||
</div>
|
||||
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
|
||||
誠實提示:這裡的開關<b style="color:var(--amber)">即將開通</b>——啟用/停用需在部署端設定(acr config 開 kbdb_embed 後重新部署),Console 目前只如實顯示狀態,不假裝能遠端開啟。
|
||||
誠實提示:開關真相=部署端 <code style="font-size:12.5px">~/.arcrun/config.yaml</code> 的 <code style="font-size:12.5px">kbdb_embed: true</code> + <code style="font-size:12.5px">acr update</code> 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:<b style="color:var(--amber)" id="st-vec-state">偵測中…</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">MCP token 有效期(TTL)</div>
|
||||
<div id="st-mcp-ttl" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">讀取中…</div>
|
||||
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
|
||||
誠實佔位:此頁<b style="color:var(--amber)">還不能改</b>這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env <code style="font-size:12.5px">MCP_TOKEN_TTL</code>(mcp worker)設定。
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -480,6 +498,9 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——
|
||||
// console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移
|
||||
${TAIPEI_CLIENT_JS}
|
||||
var REGISTRY_BASE = ${JSON.stringify(registryBase)};
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
@@ -487,7 +508,6 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
var S = {
|
||||
token: localStorage.getItem('arcrun_console_session') || '',
|
||||
tenant: '',
|
||||
total: null, // KBDB 總筆數(誠實 total)
|
||||
chip: '', // entry_type filter('' = 全部)
|
||||
semantic: false,
|
||||
cardId: '',
|
||||
@@ -545,19 +565,16 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
var ms = Date.parse(s);
|
||||
return isNaN(ms) ? null : ms;
|
||||
}
|
||||
// 顯示一律台北時間(bug 修正:原本用瀏覽器本地 getFullYear/getHours——顯示結果
|
||||
// 隨看的裝置時區漂移,且與後端「台北日」判定不同套;憑證頁 7/6 22:57(台北)建立的
|
||||
// credential 在非 +8 裝置上會顯示成另一天)
|
||||
function fmtDate(v) {
|
||||
var ms = parseAtMs(v);
|
||||
if (ms == null) return '';
|
||||
var d = new Date(ms);
|
||||
var p = function (n) { return String(n).padStart(2, '0'); };
|
||||
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
||||
return ms == null ? '' : taipeiDateStr(ms);
|
||||
}
|
||||
function fmtDateTime(v) {
|
||||
var ms = parseAtMs(v);
|
||||
if (ms == null) return '';
|
||||
var d = new Date(ms);
|
||||
var p = function (n) { return String(n).padStart(2, '0'); };
|
||||
return fmtDate(v) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
||||
return ms == null ? '' : taipeiDateTimeStr(ms);
|
||||
}
|
||||
|
||||
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──
|
||||
@@ -707,8 +724,8 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
return Math.round(m / 1440) + ' 天前';
|
||||
}
|
||||
function loadCockpit() {
|
||||
var now = new Date();
|
||||
$('ck-date').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
|
||||
var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區
|
||||
$('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||
fetch('/console/dashboard-data')
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
@@ -749,18 +766,27 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
} else {
|
||||
wmeta.textContent = '';
|
||||
}
|
||||
// 今日路線:非今日寫入=誠實降級為「最後路線(N 天前)」,不假裝是今天的
|
||||
// 今日路線:sprint 任務板優先(來源與更新時間攤開講);dash_task fallback 沿舊誠實降級
|
||||
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'; });
|
||||
if (rm.is_today || !today.length) {
|
||||
if (rm.source === 'gitea_sprint_board') {
|
||||
$('ck-route-src').textContent = '來源 sprint 任務板・更新於 ' + ckAge(rm.updated_ago_minutes);
|
||||
var staleHead = rm.is_today ? '' :
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + ckAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
|
||||
$('ck-today').innerHTML = staleHead + (today.length
|
||||
? today.map(taskLine).join('')
|
||||
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
|
||||
} else if (rm.is_today || !today.length) {
|
||||
$('ck-route-src').textContent = '來源 dash_task';
|
||||
$('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-route-src').textContent = '來源 dash_task';
|
||||
$('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 });
|
||||
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + taipeiTimeStr(Date.parse(d.generated_at)) + '(台北)';
|
||||
})
|
||||
.catch(function (e) {
|
||||
$('ck-char').textContent = '?';
|
||||
@@ -785,7 +811,7 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
if (searchInited) { return; }
|
||||
searchInited = true;
|
||||
renderChips();
|
||||
ensureTotal();
|
||||
loadScale();
|
||||
}
|
||||
function renderChips() {
|
||||
$('se-chips').innerHTML = CHIPS.map(function (c, i) {
|
||||
@@ -799,15 +825,18 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
renderChips();
|
||||
if ($('se-q').value.trim()) doSearch();
|
||||
});
|
||||
function ensureTotal() {
|
||||
fetch('/kbdb/entries?limit=1', { headers: apiHeaders() })
|
||||
// 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、
|
||||
// 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。
|
||||
function loadScale() {
|
||||
fetch('/console/kb-scale-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (d && typeof d.total === 'number') {
|
||||
S.total = d.total;
|
||||
$('se-scale').textContent = '總庫 ' + d.total.toLocaleString() + ' 筆';
|
||||
$('se-q').placeholder = '搜尋 ' + d.total.toLocaleString() + ' 筆知識…';
|
||||
}
|
||||
if (!d) return;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
|
||||
var latest = d.wiki_card_latest_ago_minutes;
|
||||
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
|
||||
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
|
||||
})
|
||||
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
|
||||
}
|
||||
@@ -848,17 +877,21 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
|
||||
}
|
||||
var entries = d.entries || [];
|
||||
var scale = S.total != null ? '於 ' + S.total.toLocaleString() + ' 筆中' : '';
|
||||
$('se-count').textContent = scale + '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword');
|
||||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + '・搜尋範圍=全庫(含 14-E 遺產)';
|
||||
if (!entries.length) {
|
||||
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
|
||||
return;
|
||||
}
|
||||
$('se-results').innerHTML = entries.map(function (e) {
|
||||
// 精耕層(wiki_card)綠標突顯;其餘照 entry_type 原樣(不誤標——block/value/note
|
||||
// 大宗是 14-E 遺產,但 entry_type 本身不足以斷言,僅精耕層正向標記)
|
||||
var typeTag = e.entry_type === 'wiki_card'
|
||||
? '<span class="tag green">wiki_card・精耕</span>'
|
||||
: '<span class="tag">' + esc(e.entry_type || 'entry') + '</span>';
|
||||
return '<div class="kcard" data-card="' + esc(e.id) + '">' +
|
||||
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
|
||||
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
|
||||
'<div class="km"><span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
|
||||
'<div class="km">' + typeTag +
|
||||
(e.source ? '<span class="dim">' + esc(e.source) + '</span>' : '') +
|
||||
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
|
||||
}).join('');
|
||||
@@ -1158,8 +1191,8 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
'<div class="rowhead" data-credtoggle="' + esc(c.name) + '">' +
|
||||
'<span class="avatar">' + esc(initial) + '</span>' +
|
||||
'<div style="min-width:0"><div style="font-size:16.5px;font-weight:600;word-break:break-all">' + esc(c.name) + '</div>' +
|
||||
'<div style="margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDate(c.created_at) || '—') + '</div></div>' +
|
||||
'<div style="margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none">最後使用<br>' + esc(c.last_used_at ? fmtDate(c.last_used_at) : '尚未記錄') + '</div></div>' +
|
||||
'<div style="margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDateTime(c.created_at) || '—') + '(台北)</div></div>' +
|
||||
'<div style="margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none">最後使用<br>' + esc(c.last_used_at ? fmtDateTime(c.last_used_at) : '尚未記錄') + '</div></div>' +
|
||||
(open
|
||||
? '<div class="rowbody">' +
|
||||
'<div class="maskline" style="margin-top:14px"><span class="mono" style="font-size:16px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.4)">••••••••••••••••</span><span style="margin-left:auto;font-size:12.5px;color:rgba(var(--ink-rgb),.4)">已加密・不可回看</span></div>' +
|
||||
@@ -1257,21 +1290,36 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
$('st-vec').textContent = on
|
||||
? '已啟用・語意搜尋可用(搜尋頁切「語意」)'
|
||||
: '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorize(kbdb_embed)');
|
||||
$('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic)' : '未啟用(live 探測降級 keyword)';
|
||||
})
|
||||
.catch(function () { $('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)'; });
|
||||
// 系統資訊:worker 自報 + 誠實 total
|
||||
.catch(function () {
|
||||
$('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)';
|
||||
$('st-vec-state').textContent = '偵測失敗';
|
||||
});
|
||||
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
|
||||
fetch('/console/settings-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) { $('st-mcp-ttl').textContent = '讀取失敗'; return; }
|
||||
var days = Math.round(d.mcp_token_ttl_seconds / 86400 * 10) / 10;
|
||||
$('st-mcp-ttl').textContent = '目前生效:' + d.mcp_token_ttl_seconds.toLocaleString() + ' 秒(約 ' + days + ' 天)・' +
|
||||
(d.mcp_token_ttl_source === 'env' ? '來自部署端 env MCP_TOKEN_TTL' : '未設 env,用預設值 30 天');
|
||||
})
|
||||
.catch(function () { $('st-mcp-ttl').textContent = '讀取失敗'; });
|
||||
// 系統資訊:worker 自報 + 精耕層規模(14-E 遺產總數不再顯示,leo 2026-07-07 裁)
|
||||
Promise.allSettled([
|
||||
fetch('/').then(function (r) { return r.json(); }),
|
||||
fetch('/kbdb/entries?limit=1', { headers: apiHeaders() }).then(function (r) { return r.ok ? r.json() : null; })
|
||||
fetch('/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })
|
||||
]).then(function (rs) {
|
||||
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
||||
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
var rows = '';
|
||||
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">部署位置</span><span class="mono" style="font-size:14px">' + esc(location.host) + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">租戶</span><span class="mono">' + esc(S.tenant) + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">總庫規模</span><span class="mono" style="color:var(--amber)">' + (kb && typeof kb.total === 'number' ? kb.total.toLocaleString() + ' 筆' : '讀取失敗') + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">精耕層</span><span class="mono" style="color:var(--amber)">' + (kb ? 'wiki 卡 ' + n(kb.wiki_card_total) + '・三元組 ' + n(kb.triplets_total) + '・已嵌入 ' + n(kb.embedded) : '讀取失敗') + '</span></div>';
|
||||
$('st-info').innerHTML = rows;
|
||||
});
|
||||
}
|
||||
@@ -1297,7 +1345,7 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
|
||||
});
|
||||
$('st-vec-switch').addEventListener('click', function () {
|
||||
toast('開關即將開通——目前需在部署端啟用 kbdb_embed');
|
||||
toast('此開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署');
|
||||
});
|
||||
$('st-logout').addEventListener('click', function () {
|
||||
var t = S.token;
|
||||
|
||||
@@ -87,6 +87,11 @@ export type Bindings = {
|
||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||
// 可調功能(設定頁改→存 KBDB→發 token 時讀)=Arcrun#19,實作前先在部署端 env 調。
|
||||
MCP_TOKEN_TTL?: string;
|
||||
// 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)。
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。
|
||||
* cachedGiteaSprint 快取層測試(總管 #36 審查要求的證據;fix/console-truth-audit 起
|
||||
* 同一輪快取多帶任務板 board——「等你的事」與「今日完成」共用同一次 Gitea fetch)。
|
||||
*
|
||||
* 跑在 vitest-pool-workers(workerd)裡,caches.default 是真的 Cache API——
|
||||
* 不是 mock cache 物件;fetcher 用注入的假函式計數,證明:
|
||||
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hit(fetcher 不再被打)
|
||||
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hit(fetcher 不再被打),board 一起回放
|
||||
* 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 { cachedGiteaSprint } from '../src/routes/console-dashboard';
|
||||
import type { SprintSnapshot } from '../src/lib/console-dashboard-model';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
function fakeEnv(repo: string): Bindings {
|
||||
@@ -22,13 +23,19 @@ function fakeEnv(repo: string): Bindings {
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
function model(): WaitingModel {
|
||||
function snapshot(): SprintSnapshot {
|
||||
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'],
|
||||
waiting: {
|
||||
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'],
|
||||
},
|
||||
board: [
|
||||
{ title: 'T5-graph(Graph 查詢 workflow 化)', status: 'todo', completed_days: [], sprint: 'sprint-2026-07b.md' },
|
||||
{ title: 'T-kb2 07-07', status: 'done', completed_days: ['2026-07-05'], sprint: 'sprint-2026-07a.md' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,37 +45,40 @@ function collector() {
|
||||
return { waitUntil: (p: Promise<unknown>) => pending.push(p), flush: () => Promise.all(pending) };
|
||||
}
|
||||
|
||||
describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => {
|
||||
it('第一次 miss 打 fetcher;90s 內第二次 hit、fetcher 不再被打', async () => {
|
||||
describe('cachedGiteaSprint — Gitea 讀取快取(TTL 90s,waiting + board 同輪)', () => {
|
||||
it('第一次 miss 打 fetcher;90s 內第二次 hit、fetcher 不再被打,board 一起回放', async () => {
|
||||
const env = fakeEnv('Leo/cache-test-1');
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls++;
|
||||
return model();
|
||||
return snapshot();
|
||||
};
|
||||
const c = collector();
|
||||
const t0 = Date.now();
|
||||
|
||||
const r1 = await cachedGiteaWaiting(env, t0, c.waitUntil, fetcher);
|
||||
const r1 = await cachedGiteaSprint(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);
|
||||
const r2 = await cachedGiteaSprint(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');
|
||||
expect(r2?.waiting.items[0].id).toBe('13');
|
||||
// 任務板同輪回放:存的是原始 completed_days(「今天完成幾件」由請求當下算,跨午夜不冒領)
|
||||
expect(r2?.board?.length).toBe(2);
|
||||
expect(r2?.board?.[1].completed_days).toEqual(['2026-07-05']);
|
||||
});
|
||||
|
||||
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 cachedGiteaSprint(env, t0, c.waitUntil, async () => snapshot());
|
||||
await c.flush();
|
||||
const r = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, async () => model());
|
||||
const r = await cachedGiteaSprint(env, t0 + 60_000, c.waitUntil, async () => snapshot());
|
||||
expect(r?.cache).toBe('hit');
|
||||
expect(r?.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
|
||||
expect(r?.waiting.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
|
||||
});
|
||||
|
||||
it('fetcher 失敗(null)不快取:下一次仍重打 fetcher', async () => {
|
||||
@@ -79,9 +89,9 @@ describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => {
|
||||
calls++;
|
||||
return null;
|
||||
};
|
||||
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
await c.flush();
|
||||
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, failing)).toBeNull();
|
||||
expect(calls).toBe(2); // 失敗沒有被 90 秒快取住
|
||||
});
|
||||
|
||||
@@ -90,10 +100,10 @@ describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => {
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls++;
|
||||
return model();
|
||||
return snapshot();
|
||||
};
|
||||
const c = collector();
|
||||
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
|
||||
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,3 +192,98 @@ describe('pickLatestSprintFiles / humanAge', () => {
|
||||
expect(humanAge(3 * 24 * 60)).toBe('3 天前');
|
||||
});
|
||||
});
|
||||
|
||||
// ── sprint 任務板(「今日完成」活資料源,fix/console-truth-audit)────────────────────
|
||||
// 樣本取自 sprint-2026-07a/07b.md 真實維護慣例:`- [x]`+行內「**完成(YYYY-MM-DD…)**」、
|
||||
// `- [🔄]` 進行中、`- [!]` 卡住、縮排子彈(「 - 做法:」)非任務、一行多次完成(T6-T9 分兩天)。
|
||||
import {
|
||||
parseSprintTaskBoard,
|
||||
buildSprintRouteModel,
|
||||
cleanBoardTitle,
|
||||
} from '../src/lib/console-dashboard-model';
|
||||
|
||||
const BOARD_MD = `# Sprint 2026-07x
|
||||
|
||||
## 任務板(P1 > P2 > P3,worker 由上往下取)
|
||||
|
||||
### P1
|
||||
|
||||
- [ ] **T5-graph(Graph 查詢 workflow 化)**:把 neighbors/traverse 改寫成 workflow。
|
||||
- 做法:在 \`Leo/arcrun-rag\` 建 workflows/graph-neighbors.yaml
|
||||
- 驗收:curl 原始輸出貼證據。
|
||||
- [🔄] **T-cockpit 駕駛艙三件套**:①②完成(2026-07-04,[總管]),③ spike 卡方向。
|
||||
- [x] RECON:git proxy 不攔 Gitea(06-30 已驗)
|
||||
- [x] **T-loop1d 今晚任務**:實作 console v0。**完成(2026-07-02,[cloud-worker])**:全綠。
|
||||
- [x] T6-T9(07-06~08 提前):**T8/T9 完成(2026-07-04,commit 1d19d46)**;**T6/T7 完成(2026-07-05 本機總管)**。
|
||||
- [!] 附帶:acr search npm 漏發——**卡住(2026-07-05,[cloud-worker])**:缺 NPM_API_TOKEN。
|
||||
|
||||
### 候補區(本 sprint 不佔額度)
|
||||
- 短網址通用化——規劃見 content-pipeline.md
|
||||
|
||||
## 等 leo 清單
|
||||
|
||||
| # | 事項 | 急迫 |
|
||||
|---|------|------|
|
||||
| 1 | 假裝這裡有一項 | 🟡 |
|
||||
`;
|
||||
|
||||
describe('parseSprintTaskBoard — 任務板解析(寬鬆容錯,解析不了寧可略過)', () => {
|
||||
it('只收行首 checkbox;縮排子彈/候補區敘述/等leo清單表格都不是任務', () => {
|
||||
const tasks = parseSprintTaskBoard(BOARD_MD, 'sprint-2026-07x.md');
|
||||
expect(tasks).not.toBeNull();
|
||||
expect(tasks!.map((t) => t.status)).toEqual(['todo', 'doing', 'done', 'done', 'done', 'blocked']);
|
||||
expect(tasks!.every((t) => t.sprint === 'sprint-2026-07x.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('完成日期:抓行內所有「完成(YYYY-MM-DD」;[x] 無日期=空陣列(不冒領)', () => {
|
||||
const tasks = parseSprintTaskBoard(BOARD_MD)!;
|
||||
expect(tasks[2].completed_days).toEqual([]); // RECON:勾了但沒帶日期 → 不歸任何一天
|
||||
expect(tasks[3].completed_days).toEqual(['2026-07-02']);
|
||||
expect(tasks[4].completed_days).toEqual(['2026-07-04', '2026-07-05']); // 一行兩次完成
|
||||
expect(tasks[1].completed_days).toEqual(['2026-07-04']); // doing 項的部分完成標記也記著(狀態仍 doing)
|
||||
});
|
||||
|
||||
it('標題:優先取第一個粗體;無粗體取全形冒號前', () => {
|
||||
const tasks = parseSprintTaskBoard(BOARD_MD)!;
|
||||
expect(tasks[0].title).toBe('T5-graph(Graph 查詢 workflow 化)');
|
||||
expect(tasks[2].title).toBe('RECON');
|
||||
expect(cleanBoardTitle('附帶:`acr search` npm 漏發')).toBe('附帶:acr search npm 漏發'.slice(0, 80));
|
||||
});
|
||||
|
||||
it('沒有任務板段落 → null(caller 誠實 fallback dash_task)', () => {
|
||||
expect(parseSprintTaskBoard('# 只有標題\n\n## 等 leo 清單\n| 1 | x | 🔴 |')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSprintRouteModel — 「今日完成」只認今天(台北)的完成標記', () => {
|
||||
it('NOW=07-07:07-04/07-05 完成的不算今日;開項全列(今日路線)', () => {
|
||||
const tasks = parseSprintTaskBoard(BOARD_MD)!;
|
||||
const m = buildSprintRouteModel(tasks, NOW);
|
||||
expect(m.today_done).toBe(0); // 板上今天沒勾任何完成 → 誠實 0
|
||||
expect(m.done_today_titles).toEqual([]);
|
||||
// 開項:todo + doing + blocked = 3;today_total = 0 完成 + 3 開
|
||||
expect(m.today_total).toBe(3);
|
||||
expect(m.tasks.map((t) => t.status)).toEqual(['todo', 'doing', 'blocked']);
|
||||
});
|
||||
|
||||
it('完成標記日期=今天 → 進今日完成清單;歷史完成項不出現', () => {
|
||||
const tasks = parseSprintTaskBoard(BOARD_MD)!;
|
||||
// 模擬 progress-guard 今天勾掉 T5-graph
|
||||
tasks[0] = { ...tasks[0], status: 'done', completed_days: [taipeiDayKey(NOW)] };
|
||||
const m = buildSprintRouteModel(tasks, NOW);
|
||||
expect(m.today_done).toBe(1);
|
||||
expect(m.done_today_titles).toEqual(['T5-graph(Graph 查詢 workflow 化)']);
|
||||
expect(m.today_total).toBe(1 + 2); // 1 完成 + doing/blocked 2 開項
|
||||
expect(m.tasks[0].status).toBe('done'); // 今日完成排最前(✓ 給 leo 看「做了什麼」)
|
||||
// 07-02/07-04/07-05 完成的三項(含無日期 RECON)都不在清單裡
|
||||
expect(m.tasks.find((t) => t.title === 'RECON')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('跨台北午夜:昨天的完成不會被冒領成今天(快取安全性的模型面保證)', () => {
|
||||
const tasks = parseSprintTaskBoard(BOARD_MD)!;
|
||||
tasks[0] = { ...tasks[0], status: 'done', completed_days: ['2026-07-07'] };
|
||||
// 台北 07-08 00:10(UTC 07-07 16:10)再看:07-07 的完成已不是「今日」
|
||||
const afterMidnight = Date.parse('2026-07-07T16:10:00Z');
|
||||
expect(buildSprintRouteModel(tasks, afterMidnight).today_done).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* lib/taipei-time.ts 測試(fix/console-truth-audit):
|
||||
* 1. server 版跨日案例——UTC 傍晚已是台北隔天(時區 bug 的核心場景)。
|
||||
* 2. client 版(TAIPEI_CLIENT_JS,內嵌進 console 頁的 ES5 字串)用 new Function 求值,
|
||||
* 與 server 版逐案對照——兩份實作漂移會直接紅,這是「同一套 helper」的客觀證據。
|
||||
* 3. leo 實測案例:leo21c /credentials 的 gitea_token created_at=1783349834(epoch 秒)
|
||||
* → 台北 2026-07-06 22:57(顯示 7/6 是真的,但原本只顯示日期且吃裝置時區——本修加時分固定台北)。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TAIPEI_CLIENT_JS,
|
||||
taipeiDayKey,
|
||||
taipeiDateStr,
|
||||
taipeiDateTimeStr,
|
||||
taipeiTimeStr,
|
||||
} from '../src/lib/taipei-time';
|
||||
|
||||
// client 版求值:跟頁面一樣把字串當 JS 跑起來,拿到同名函式
|
||||
const client = new Function(
|
||||
`${TAIPEI_CLIENT_JS}; return { taipeiDayKey, taipeiDateStr, taipeiDateTimeStr, taipeiTimeStr, taipeiMonthDay };`,
|
||||
)() as {
|
||||
taipeiDayKey: (ms: number) => string;
|
||||
taipeiDateStr: (ms: number) => string;
|
||||
taipeiDateTimeStr: (ms: number) => string;
|
||||
taipeiTimeStr: (ms: number) => string;
|
||||
taipeiMonthDay: (ms: number) => { month: number; day: number };
|
||||
};
|
||||
|
||||
describe('taipei-time — server 版', () => {
|
||||
it('UTC 16:30 已是台北隔天 00:30(跨日核心案例)', () => {
|
||||
const ms = Date.parse('2026-07-06T16:30:00Z');
|
||||
expect(taipeiDayKey(ms)).toBe('2026-07-07');
|
||||
expect(taipeiDateTimeStr(ms)).toBe('2026-07-07 00:30');
|
||||
});
|
||||
|
||||
it('UTC 15:59 仍是台北同日 23:59(跨日邊界的另一側)', () => {
|
||||
const ms = Date.parse('2026-07-06T15:59:00Z');
|
||||
expect(taipeiDayKey(ms)).toBe('2026-07-06');
|
||||
expect(taipeiDateTimeStr(ms)).toBe('2026-07-06 23:59');
|
||||
});
|
||||
|
||||
it('跨月/跨年也對:UTC 12-31 20:00 → 台北 01-01 04:00', () => {
|
||||
const ms = Date.parse('2026-12-31T20:00:00Z');
|
||||
expect(taipeiDateStr(ms)).toBe('2027-01-01');
|
||||
expect(taipeiTimeStr(ms)).toBe('04:00:00');
|
||||
});
|
||||
|
||||
it('leo 實測值:credentials created_at=1783349834(epoch 秒)→ 台北 2026-07-06 22:57', () => {
|
||||
expect(taipeiDateTimeStr(1783349834 * 1000)).toBe('2026-07-06 22:57');
|
||||
// 同一時刻 UTC 是 14:57——若顯示端誤用 UTC 或別的裝置時區,日期/時間就會漂
|
||||
expect(new Date(1783349834 * 1000).toISOString().slice(0, 16)).toBe('2026-07-06T14:57');
|
||||
});
|
||||
});
|
||||
|
||||
describe('taipei-time — client 版(TAIPEI_CLIENT_JS)與 server 版等價', () => {
|
||||
const cases = [
|
||||
Date.parse('2026-07-06T16:30:00Z'), // 跨日
|
||||
Date.parse('2026-07-06T15:59:59Z'), // 跨日邊界前一秒
|
||||
Date.parse('2026-12-31T20:00:00Z'), // 跨年
|
||||
Date.parse('2026-02-28T17:00:00Z'), // 跨月(非閏年 2 月)
|
||||
1783349834 * 1000, // leo 實測 credential created_at
|
||||
0, // epoch 起點(1970-01-01 08:00 台北)
|
||||
];
|
||||
|
||||
it('taipeiDayKey / DateStr / DateTimeStr / TimeStr 全案例一致', () => {
|
||||
for (const ms of cases) {
|
||||
expect(client.taipeiDayKey(ms)).toBe(taipeiDayKey(ms));
|
||||
expect(client.taipeiDateStr(ms)).toBe(taipeiDateStr(ms));
|
||||
expect(client.taipeiDateTimeStr(ms)).toBe(taipeiDateTimeStr(ms));
|
||||
expect(client.taipeiTimeStr(ms)).toBe(taipeiTimeStr(ms));
|
||||
}
|
||||
});
|
||||
|
||||
it('taipeiMonthDay(頁首中文日期用):跨日案例回台北的月/日', () => {
|
||||
expect(client.taipeiMonthDay(Date.parse('2026-07-06T16:30:00Z'))).toEqual({ month: 7, day: 7 });
|
||||
expect(client.taipeiMonthDay(Date.parse('2026-12-31T20:00:00Z'))).toEqual({ month: 1, day: 1 });
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,14 @@
|
||||
- [x] 3.4 手工注入部署 leo21c+curl 驗證(禁 acr update)
|
||||
- [x] 3.5 SDD 補件(本目錄,issue #3 授權;Phase 1/2 追認)
|
||||
|
||||
## Phase 5 — console 真實性稽核修正(2026-07-07,總管派工+leo 裁決,fix/console-truth-audit)
|
||||
|
||||
- [x] 5.1 全站日期顯示統一 Asia/Taipei(lib/taipei-time.ts server/client 同套、等價單測;憑證頁加時分)
|
||||
- [x] 5.2 頭部統計改精耕層(leo 裁:14-E 遺產 458,732 不再顯示;GET /console/kb-scale-data)
|
||||
- [x] 5.3 設定頁誠實文案升級(vectorize 開關真相+live 狀態;MCP token TTL 佔位=Arcrun#19 前置)
|
||||
- [x] 5.4 駕駛艙「今日完成/今日路線」接 sprint 任務板(同 #36 等leo清單模式、共用 90s 快取;dash_task 降 fallback;parser 以 live 07a/07b 檔實測)
|
||||
- [ ] 5.5 部署 leo21c+端到端 curl 驗證(不歸稽核輪——紅線不部署,留總管;證據標準同 3.4)
|
||||
|
||||
## Phase 4 — v1(未排)
|
||||
|
||||
- [ ] config 真寫入開關
|
||||
|
||||
Reference in New Issue
Block a user