摘要
Dashboard deep link 到超出 MAX_ENTRIES(5000)視窗的 session 時顯示「Session not found」。根因:sessionsMap 從 store.entries 衍生,entries 有固定上限,早期 session 被截斷後完全不存在於前端。
實測數據(2026-07-19)
| 指標 |
值 |
| index.ndjson |
119,063 entries / 328MB |
| 獨立 sessions |
2,890 |
| 獨立 projects |
100 |
| 日期範圍 |
2026-05 ~ 2026-07(~2 個月) |
| 年化預測 |
~70 萬 entries / ~17K sessions |
| MAX_ENTRIES |
5,000(store.js:10) |
重現:http://localhost:5577/?p=ccxray&s=e622e4d2(2026-07-15 session,280 筆 entries 在 index.ndjson 中,但不在 store.entries 的 5000 筆視窗內)。
設計(四位專家圓桌 + Fable 對抗審查收斂)
核心原則
Session index 跟 entry loading 分離。 Session index 全量、輕量、always loaded。Entry data 分熱區(即時)和冷區(on-demand)。
架構
startup:
1. 讀 sessions.json (物化視圖, <1MB)
→ sessionIndex: Map<sid, {firstId, lastId, count, model, project, cost, title}>
sessions.json 不存在或損壞 → fallback 掃 index.ndjson 重建(慢但正確)
2. load recent MAX_ENTRIES entries → 熱區(現行行為不變)
API:
/_api/sessions → 回傳 sessionIndex 全量(給 client 建 sessionsMap)
/_api/session/:sid/entries → on-demand 從 ndjson 載入特定 session 的 entries
Client:
sessionsMap 從 /_api/sessions 建立(全量 2890 sessions)
session list 用 virtual scroll 渲染(全量可見不分頁)
deep link / click:
_resolveSessionId() 查 sessionsMap → 一定查得到
entries 在熱區 → 即時導航
entries 不在熱區 → fetch /_api/session/:sid/entries → spinner → 渲染
三個必補的洞(Fable 審查)
洞 1:sessions.json 物化視圖
為什麼:startup 掃 328MB index.ndjson 的 readline 要 3-5 秒,加在啟動時間上使用者會感受到。
做法:
store.addEntry() 時同時 append/update sessions.json(每 session 一行 JSON,只含彙總欄位)
- startup 讀
sessions.json(<1MB,<100ms)建 sessionIndex
sessions.json 損壞或不存在 → 從 index.ndjson 全量重建(一次性,3-5 秒)
sessions.json 是 index.ndjson 的物化視圖,壞了隨時可重建
彙總欄位(每 session 一筆):
{
"sid": "e622e4d2-977a-4f68-9d05-bf3fa526365e",
"firstId": "2026-07-15T09-41-59-779",
"lastId": "2026-07-15T11-23-45-123",
"count": 280,
"model": "claude-opus-4-6",
"project": "ccxray",
"cwd": "/Users/justinlee/dev/ccxray",
"totalCost": 12.34,
"title": "Plan restructure session",
"lastReceivedAt": 1752578625000
}
洞 2:熱冷邊界 session merge
問題:一個 session 的 entries 可能橫跨熱區邊界——前 200 筆在冷區、後 80 筆在熱區。
做法:
/_api/session/:sid/entries 回傳該 session 的全部 entries(不只冷區部分)
- client 收到後替換(不是合併)該 session 在 allEntries 中的所有 entries
- 或者:server 端先查熱區有哪些、只補冷區缺的,client 按
id 排序 merge
- 選較簡單的方案(替換),因為 session 粒度的 entry 數通常 <500
洞 3:evict 策略 = LRU + pin current
問題:maxSessions cap evict「最舊的」但使用者可能跳回之前看過的 session。
做法:
- 維護一個 LRU list of loaded cold sessions
maxLoadedColdSessions(建議初始值 5)
- 超過時 evict LRU 最久未存取的,但永遠 pin 當前 selectedSessionId
- 熱區 entries 不參與 evict(它們走現行 MAX_ENTRIES 機制)
Acceptance Criteria
-
deep link 到 store 視窗外的 session 能導航成功:
?s=e622e4d2 → spinner → session 載入 → turns 可瀏覽
- gate: 用
CCXRAY_MAX_ENTRIES=100 啟動(強制幾乎所有 session 在冷區),deep link 到任意已記錄 session 皆成功
-
startup 不退步:
- sessions.json 存在時 startup 時間不增加(<100ms 讀取 sessions.json)
- sessions.json 不存在時 fallback 重建、下次 startup 正常
-
session list 顯示全量:
sessionsMap.size === index.ndjson 中的獨立 sessionId 數
- session list virtual scroll 渲染,scrollbar 反映全量
-
記憶體穩定:
- 連續 deep link 到 10 個冷 session 後,loaded cold sessions 不超過
maxLoadedColdSessions
- 瀏覽器 heap 不持續增長
-
現行行為不退步:
- 熱區 session 的導航速度不變
- live SSE 串流不受影響
- 既有測試全過
實作建議(非強制)
Phase 1:sessions.json + /_api/sessions(最小可用)
- server/store.js: addEntry 時 append sessions.json
- server/restore.js: startup 讀 sessions.json(fallback 掃 index.ndjson)
- server/routes/api.js: 新增
/_api/sessions endpoint
- public/miller-columns.js: sessionsMap 從
/_api/sessions 建立(不再只從 entries 衍生)
- public/miller-columns.js:
_resolveSessionId 查 sessionsMap(已覆蓋全量)
Phase 2:on-demand entry loading
- server/routes/api.js: 新增
/_api/session/:sid/entries(從 index.ndjson grep sid 的 entries)
- public/entry-rendering.js: cold session deep link → fetch → spinner → addEntry → navigate
- public/miller-columns.js: LRU cold session evict
Phase 3(可選,效能):byte-offset index
- 如果 Phase 2 的 grep 延遲 >3s,建 session → byte-offset 映射加速查找
不做
- 不遷移到 SQLite 或其他 DB(ndjson 是現行架構,本 issue 不改)
- 不做 session list 的搜尋/過濾(另開 issue)
- 不改 MAX_ENTRIES 的預設值(熱區大小是獨立的調校問題)
Type
Type: perf
摘要
Dashboard deep link 到超出
MAX_ENTRIES(5000)視窗的 session 時顯示「Session not found」。根因:sessionsMap從store.entries衍生,entries 有固定上限,早期 session 被截斷後完全不存在於前端。實測數據(2026-07-19)
重現:
http://localhost:5577/?p=ccxray&s=e622e4d2(2026-07-15 session,280 筆 entries 在 index.ndjson 中,但不在 store.entries 的 5000 筆視窗內)。設計(四位專家圓桌 + Fable 對抗審查收斂)
核心原則
Session index 跟 entry loading 分離。 Session index 全量、輕量、always loaded。Entry data 分熱區(即時)和冷區(on-demand)。
架構
三個必補的洞(Fable 審查)
洞 1:sessions.json 物化視圖
為什麼:startup 掃 328MB index.ndjson 的 readline 要 3-5 秒,加在啟動時間上使用者會感受到。
做法:
store.addEntry()時同時 append/updatesessions.json(每 session 一行 JSON,只含彙總欄位)sessions.json(<1MB,<100ms)建 sessionIndexsessions.json損壞或不存在 → 從 index.ndjson 全量重建(一次性,3-5 秒)sessions.json是 index.ndjson 的物化視圖,壞了隨時可重建彙總欄位(每 session 一筆):
{ "sid": "e622e4d2-977a-4f68-9d05-bf3fa526365e", "firstId": "2026-07-15T09-41-59-779", "lastId": "2026-07-15T11-23-45-123", "count": 280, "model": "claude-opus-4-6", "project": "ccxray", "cwd": "/Users/justinlee/dev/ccxray", "totalCost": 12.34, "title": "Plan restructure session", "lastReceivedAt": 1752578625000 }洞 2:熱冷邊界 session merge
問題:一個 session 的 entries 可能橫跨熱區邊界——前 200 筆在冷區、後 80 筆在熱區。
做法:
/_api/session/:sid/entries回傳該 session 的全部 entries(不只冷區部分)id排序 merge洞 3:evict 策略 = LRU + pin current
問題:maxSessions cap evict「最舊的」但使用者可能跳回之前看過的 session。
做法:
maxLoadedColdSessions(建議初始值 5)Acceptance Criteria
deep link 到 store 視窗外的 session 能導航成功:
?s=e622e4d2→ spinner → session 載入 → turns 可瀏覽CCXRAY_MAX_ENTRIES=100啟動(強制幾乎所有 session 在冷區),deep link 到任意已記錄 session 皆成功startup 不退步:
session list 顯示全量:
sessionsMap.size=== index.ndjson 中的獨立 sessionId 數記憶體穩定:
maxLoadedColdSessions現行行為不退步:
實作建議(非強制)
Phase 1:sessions.json + /_api/sessions(最小可用)
/_api/sessionsendpoint/_api/sessions建立(不再只從 entries 衍生)_resolveSessionId查 sessionsMap(已覆蓋全量)Phase 2:on-demand entry loading
/_api/session/:sid/entries(從 index.ndjson grep sid 的 entries)Phase 3(可選,效能):byte-offset index
不做
Type
Type: perf