cc-viewer 1.7.12 → 1.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/App-CtPaGTFc.js +2 -0
- package/dist/assets/{MdxEditorPanel-DjSuLHCK.js → MdxEditorPanel-CdZjsgUe.js} +1 -1
- package/dist/assets/{Mobile-CHdZ2iSv.js → Mobile-BV5o9yFd.js} +1 -1
- package/dist/assets/{ProxyStatsModal-B6xp1knz.js → ProxyStatsModal-DGMd41pC.js} +1 -1
- package/dist/assets/index-DSTQIMmZ.js +2 -0
- package/dist/assets/seqResourceLoaders-DGyoo_5i.js +2 -0
- package/dist/assets/{seqResourceLoaders-0RXZfUKp.css → seqResourceLoaders-pBUn3c_a.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/log-management.js +57 -0
- package/server/lib/v2/adapter.js +9 -110
- package/server/lib/v2/convert-manager.js +7 -1
- package/server/lib/v2/migrate-prompt.js +39 -1
- package/server/lib/v2/session-list.js +276 -0
- package/server/lib/v2/session-select.js +4 -4
- package/server/routes/logs.js +50 -8
- package/dist/assets/App-BKG6t0Yf.js +0 -2
- package/dist/assets/index-QCUTFwkw.js +0 -2
- package/dist/assets/seqResourceLoaders-DQIaFrCX.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-DSTQIMmZ.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-DADYo_zg.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-tF6HNoR6.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CFAmRN3Y.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.13",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -4,6 +4,8 @@ import { join, sep, dirname, basename } from 'node:path';
|
|
|
4
4
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
5
5
|
import { sanitizePathComponent } from './v2/layout.js';
|
|
6
6
|
import { listV2Sessions } from './v2/adapter.js';
|
|
7
|
+
import { summarizeSessionPage } from './v2/session-list.js';
|
|
8
|
+
import { listSessionIds } from './v2/replay.js';
|
|
7
9
|
|
|
8
10
|
// wire-v2 S5 addressing (spec §12): 'v2:<project>/<session_id>' in every
|
|
9
11
|
// existing ?file= parameter slot. Components must survive the same whitelist
|
|
@@ -122,6 +124,61 @@ export function listV2Logs(logDir, currentProjectName) {
|
|
|
122
124
|
return { ...grouped, _currentProject: currentProjectName || '' };
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Server-side paginated v2 log list for ONE project (2026-07-31). The modal
|
|
129
|
+
* only ever renders the current project's sessions, so instead of summarizing
|
|
130
|
+
* every session up front we page: enumerate session dirs (readdir — cheap),
|
|
131
|
+
* read each meta.json only for the startTs ordering + leader filter, sort
|
|
132
|
+
* newest-first, then run the EXPENSIVE summarize (journal fold + dir walk +
|
|
133
|
+
* prompts head) on just the `pageSize` sessions of the requested page — those
|
|
134
|
+
* go through the same row cache as listV2Sessions, so revisiting a page is
|
|
135
|
+
* ~1-3ms. Trade-off documented inline: `size==0` and `discard` verdicts live
|
|
136
|
+
* behind that summarize, so `total` is the pre-filter session count (the empty
|
|
137
|
+
* / quota-probe sessions are excluded as their pages are computed). For
|
|
138
|
+
* realistic data (empties + probes are a small minority) this keeps cold open
|
|
139
|
+
* at ~1 page of work instead of N.
|
|
140
|
+
*
|
|
141
|
+
* @returns {{items: Array, total: number, page: number, pageSize: number}}
|
|
142
|
+
* items rows keep the exact listV2Logs shape {file, kind, timestamp, size, turns, preview}.
|
|
143
|
+
*/
|
|
144
|
+
export function listV2LogsPage(logDir, project, { page = 1, pageSize = 50 } = {}) {
|
|
145
|
+
const out = { items: [], total: 0, page, pageSize, _currentProject: project || '' };
|
|
146
|
+
if (!project) return out;
|
|
147
|
+
const projectDir = join(logDir, project);
|
|
148
|
+
if (!existsSync(projectDir)) return out;
|
|
149
|
+
|
|
150
|
+
// Cheap pass: order candidates by startTs without paying per-session folds.
|
|
151
|
+
const candidates = [];
|
|
152
|
+
for (const dirName of listSessionIds(projectDir)) {
|
|
153
|
+
let meta = null;
|
|
154
|
+
try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
|
|
155
|
+
if (meta && meta.leader) continue; // teammate — folded into its leader's row
|
|
156
|
+
candidates.push({ dirName, startTs: (meta && meta.startTs) || '' });
|
|
157
|
+
}
|
|
158
|
+
// Newest first; dirName tiebreak matches listV2Logs' file tiebreak for stability.
|
|
159
|
+
candidates.sort((a, b) => b.startTs.localeCompare(a.startTs) || b.dirName.localeCompare(a.dirName));
|
|
160
|
+
out.total = candidates.length;
|
|
161
|
+
|
|
162
|
+
const start = (page - 1) * pageSize;
|
|
163
|
+
for (const c of candidates.slice(start, start + pageSize)) {
|
|
164
|
+
let s = null;
|
|
165
|
+
try { s = summarizeSessionPage(projectDir, c.dirName); } catch { continue; }
|
|
166
|
+
if (!s) continue;
|
|
167
|
+
if (s.leader) continue;
|
|
168
|
+
if (s.size === 0) continue;
|
|
169
|
+
if (s.discard) continue; // quota-probe orphans: never listed
|
|
170
|
+
out.items.push({
|
|
171
|
+
file: `v2:${project}/${s.sid}`,
|
|
172
|
+
kind: 'v2',
|
|
173
|
+
timestamp: compactLocalTs(s.startTs),
|
|
174
|
+
size: s.size,
|
|
175
|
+
turns: s.turns,
|
|
176
|
+
preview: s.preview || [],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
125
182
|
/**
|
|
126
183
|
* 1.7.0 v1 view: list legacy v1 `.jsonl` files, grouped per project — same row
|
|
127
184
|
* shape as listV2Logs so LogTable renders both views unchanged. Every
|
package/server/lib/v2/adapter.js
CHANGED
|
@@ -27,16 +27,16 @@
|
|
|
27
27
|
// (sessionId, seq) tie-break — field-equivalent to v1's "teammate writes the
|
|
28
28
|
// leader's file".
|
|
29
29
|
|
|
30
|
-
import { existsSync, readdirSync, readFileSync, statSync
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
31
31
|
import { join, dirname, basename } from 'node:path';
|
|
32
32
|
import { reportSwallowed } from '../error-report.js';
|
|
33
33
|
import { isMainAgentRequest } from '../interceptor-core.js';
|
|
34
|
-
import {
|
|
35
|
-
import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
|
|
34
|
+
import { readSession } from './replay.js';
|
|
36
35
|
import { iterateJsonlLines } from './jsonl-read.js';
|
|
37
36
|
import { isDiscardableSession } from './session-select.js';
|
|
38
|
-
import { blobPath, isSupportedWireFormat
|
|
37
|
+
import { blobPath, isSupportedWireFormat } from './layout.js';
|
|
39
38
|
import { SingleFlight } from './singleflight.js';
|
|
39
|
+
import { listV2Sessions } from './session-list.js';
|
|
40
40
|
|
|
41
41
|
// Same stamping rules as the v1 interceptor (KEEP IN SYNC: server/interceptor.js
|
|
42
42
|
// requestEntry construction) — recomputed from the journal's url, not from kind,
|
|
@@ -1056,109 +1056,8 @@ export async function streamV2WindowedEntries(sessionDir, opts, onEntry) {
|
|
|
1056
1056
|
|
|
1057
1057
|
// ─── session listing (spec §12, list entry pulled forward from S6a) ─────────
|
|
1058
1058
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
try {
|
|
1065
|
-
fd = openSync(path, 'r');
|
|
1066
|
-
const buf = Buffer.alloc(budget);
|
|
1067
|
-
const n = readSync(fd, buf, 0, budget, 0);
|
|
1068
|
-
const head = buf.toString('utf-8', 0, n);
|
|
1069
|
-
const nl = head.indexOf('\n');
|
|
1070
|
-
if (nl <= 0) return null; // no complete first line inside the budget
|
|
1071
|
-
return JSON.parse(head.slice(0, nl));
|
|
1072
|
-
} catch {
|
|
1073
|
-
return null;
|
|
1074
|
-
} finally {
|
|
1075
|
-
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* Summarize every session under LOG_DIR/<project>/ for the log list (spec §12).
|
|
1081
|
-
* Deliberately cheap: journal lines only (small) + a bounded head read of the
|
|
1082
|
-
* main conversation's first epoch for the preview — conversation bodies are
|
|
1083
|
-
* never loaded. Teammate linkage is surfaced via `leader` so the caller can
|
|
1084
|
-
* fold those sessions into their leader's view instead of double-listing.
|
|
1085
|
-
* @returns {Array<{sid, dir, startTs, leader, turns, size, preview}>}
|
|
1086
|
-
*/
|
|
1087
|
-
export function listV2Sessions(projectDir) {
|
|
1088
|
-
const out = [];
|
|
1089
|
-
for (const sid of listSessionIds(projectDir)) {
|
|
1090
|
-
try {
|
|
1091
|
-
const dir = join(projectDir, 'sessions', sid);
|
|
1092
|
-
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
|
|
1093
|
-
let meta = null;
|
|
1094
|
-
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
|
|
1095
|
-
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
|
|
1096
|
-
// Reader version gate (spec §14): don't list a session this build
|
|
1097
|
-
// can't read — a garbage preview/turn-count is worse than absence.
|
|
1098
|
-
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`));
|
|
1099
|
-
continue;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// turns = main requests that completed (journal two-phase fold). The
|
|
1103
|
-
// journal sentinel is checked in the same pass: per §14 the per-file
|
|
1104
|
-
// sentinel WINS over meta.json, and readSession/adapter refuse such a
|
|
1105
|
-
// session — listing it would show a phantom row that opens empty.
|
|
1106
|
-
const reqKind = new Map();
|
|
1107
|
-
let turns = 0;
|
|
1108
|
-
let sentinelVersion = null;
|
|
1109
|
-
let hasMainOrTeammate = false;
|
|
1110
|
-
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
1111
|
-
if (line.ph === 'req') {
|
|
1112
|
-
reqKind.set(line.seq, line.kind);
|
|
1113
|
-
if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
|
|
1114
|
-
}
|
|
1115
|
-
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
|
|
1116
|
-
turns++;
|
|
1117
|
-
reqKind.delete(line.seq); // fold duplicate done lines (§14)
|
|
1118
|
-
} else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
|
|
1119
|
-
sentinelVersion = line.wireFormat;
|
|
1120
|
-
break;
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
if (sentinelVersion != null) {
|
|
1124
|
-
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`));
|
|
1125
|
-
continue;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// preview = ALL user prompts of the session, from the prompts.jsonl
|
|
1129
|
-
// display cache (written by V2Writer / the converter; bounded head read
|
|
1130
|
-
// so the list stays O(budget) per session). Sessions predating the
|
|
1131
|
-
// cache fall back to the first epoch's first line — routed through the
|
|
1132
|
-
// shared extractor so command/caveat chrome never leaks into the row.
|
|
1133
|
-
let preview = readPromptsHead(join(dir, 'prompts.jsonl'));
|
|
1134
|
-
if (preview.length === 0) {
|
|
1135
|
-
const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl'));
|
|
1136
|
-
if (first && Array.isArray(first.msgs)) {
|
|
1137
|
-
preview = collectPromptsFromEvents([first]);
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
out.push({
|
|
1142
|
-
sid,
|
|
1143
|
-
dir,
|
|
1144
|
-
startTs: (meta && meta.startTs) || '',
|
|
1145
|
-
leader: (meta && meta.leader) || null,
|
|
1146
|
-
turns,
|
|
1147
|
-
size: dirSizeSync(dir),
|
|
1148
|
-
preview,
|
|
1149
|
-
// Discardable-session verdict. KEEP IN SYNC: session-select.js
|
|
1150
|
-
// isDiscardableSession is the canonical rule; this fold pre-computes
|
|
1151
|
-
// it for free over the FULL journal (the canonical scan is 8MB-
|
|
1152
|
-
// budgeted — intentional asymmetry, a first main sits at the head).
|
|
1153
|
-
// When the fold says discard, the canonical predicate CONFIRMS it:
|
|
1154
|
-
// readJsonlTolerant swallows an I/O error (Windows EBUSY/EPERM lock)
|
|
1155
|
-
// into zero lines, which must KEEP the session, not hide it — the
|
|
1156
|
-
// canonical path carries that error→keep direction (ioErrorResult).
|
|
1157
|
-
// Main-bearing sessions never pay the extra read; probe journals are
|
|
1158
|
-
// ~3 lines.
|
|
1159
|
-
discard: !(meta && meta.leader) && !hasMainOrTeammate && isDiscardableSession(dir, meta),
|
|
1160
|
-
});
|
|
1161
|
-
} catch { /* one unreadable session must not break the list */ }
|
|
1162
|
-
}
|
|
1163
|
-
return out;
|
|
1164
|
-
}
|
|
1059
|
+
// listV2Sessions is re-exported from session-list.js (P0-A row cache, 2026-07-31).
|
|
1060
|
+
// The full per-session summarization logic (incl. the readFirstJsonLine preview
|
|
1061
|
+
// fallback) moved there; this re-export keeps the public API unchanged for all
|
|
1062
|
+
// callers (log-management.js, routes/im.js, tests).
|
|
1063
|
+
export { listV2Sessions };
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Worker } from 'node:worker_threads';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { readConvertState } from './convert.js';
|
|
13
|
+
import { _invalidate as _invalidateMigrationStatus } from './migrate-prompt.js';
|
|
13
14
|
|
|
14
15
|
let _running = null; // { project, logDir, worker, startedAt, progress }
|
|
15
16
|
let _lastError = null; // last worker-level failure (state file may lag on hard crashes)
|
|
@@ -36,7 +37,12 @@ export function startConvert(logDir, project) {
|
|
|
36
37
|
worker.on('message', (msg) => {
|
|
37
38
|
if (!msg || !_running || _running.worker !== worker) return;
|
|
38
39
|
if (msg.type === 'progress') _running.progress = msg.progress;
|
|
39
|
-
else if (msg.type === 'final'
|
|
40
|
+
else if (msg.type === 'final') {
|
|
41
|
+
if (msg.error) _lastError = msg.error;
|
|
42
|
+
// A finished conversion immediately changes what migrationStatus returns
|
|
43
|
+
// for this project — drop the memo so the next call re-scans.
|
|
44
|
+
_invalidateMigrationStatus(_running.logDir, msg.project);
|
|
45
|
+
}
|
|
40
46
|
});
|
|
41
47
|
worker.on('error', (err) => {
|
|
42
48
|
_lastError = String(err && err.message || err);
|
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
// pending unless the convert state marks it done AT ITS CURRENT SIZE (the
|
|
6
6
|
// converter's trust rule, convert.js), because the converter never deletes
|
|
7
7
|
// v1 sources ("files exist" alone is not "migration needed").
|
|
8
|
+
//
|
|
9
|
+
// P0-B (2026-07-31): 10s TTL memo per (logDir, project). migrationStatus is
|
|
10
|
+
// called on every SSE connect (events.js), every list refresh (logs.js), and
|
|
11
|
+
// every workspace boot — each call stat-scans ALL v1 files of ALL projects.
|
|
12
|
+
// The memo collapses repeat calls to a Map lookup. `now` is injectable for
|
|
13
|
+
// tests (same house style as singleflight.js). Invalidation hook: the convert
|
|
14
|
+
// manager calls _invalidate() when its worker posts {type:'final'} (bypasses
|
|
15
|
+
// the 1s progress throttle), so a finished conversion is reflected immediately.
|
|
8
16
|
import { statSync } from 'node:fs';
|
|
9
17
|
import { join } from 'node:path';
|
|
10
18
|
import { listV1Files, listConvertibleProjects, readConvertState } from './convert.js';
|
|
11
19
|
|
|
20
|
+
const TTL_MS = 10_000;
|
|
21
|
+
/** @type {Map<string, {value: object, expiresAt: number}>} */
|
|
22
|
+
const _memo = new Map();
|
|
23
|
+
let _now = Date.now;
|
|
24
|
+
|
|
12
25
|
/** Pending v1 files + bytes of ONE project dir. */
|
|
13
26
|
function pendingOf(projectDir) {
|
|
14
27
|
const state = readConvertState(projectDir);
|
|
@@ -36,6 +49,8 @@ function pendingOf(projectDir) {
|
|
|
36
49
|
/**
|
|
37
50
|
* Migration status of one project (plus how many OTHER projects also have
|
|
38
51
|
* pending v1 logs — the prompt mentions `ccv convert --all` for those).
|
|
52
|
+
* Memoized for TTL_MS per (logDir, project); see module header for the
|
|
53
|
+
* invalidation contract.
|
|
39
54
|
* @param {string} logDir - LOG_DIR root
|
|
40
55
|
* @param {string} project - project directory name ('' → not pending)
|
|
41
56
|
* @returns {{pending: boolean, files: number, totalBytes: number, otherProjects: number}}
|
|
@@ -43,6 +58,9 @@ function pendingOf(projectDir) {
|
|
|
43
58
|
export function migrationStatus(logDir, project) {
|
|
44
59
|
const empty = { pending: false, files: 0, totalBytes: 0, otherProjects: 0 };
|
|
45
60
|
if (!logDir || !project) return empty;
|
|
61
|
+
const cacheKey = `${logDir}\0${project}`;
|
|
62
|
+
const cached = _memo.get(cacheKey);
|
|
63
|
+
if (cached && _now() < cached.expiresAt) return cached.value;
|
|
46
64
|
try {
|
|
47
65
|
const { files, totalBytes } = pendingOf(join(logDir, project));
|
|
48
66
|
let otherProjects = 0;
|
|
@@ -50,8 +68,28 @@ export function migrationStatus(logDir, project) {
|
|
|
50
68
|
if (p === project) continue;
|
|
51
69
|
if (pendingOf(join(logDir, p)).files > 0) otherProjects++;
|
|
52
70
|
}
|
|
53
|
-
|
|
71
|
+
const value = { pending: files > 0, files, totalBytes, otherProjects };
|
|
72
|
+
_memo.set(cacheKey, { value, expiresAt: _now() + TTL_MS });
|
|
73
|
+
return value;
|
|
54
74
|
} catch {
|
|
55
75
|
return empty;
|
|
56
76
|
}
|
|
57
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Drop the memo for one (or all) projects — called by the convert manager
|
|
80
|
+
* when a conversion worker finishes, and by tests. */
|
|
81
|
+
export function _invalidate(logDir, project) {
|
|
82
|
+
if (logDir === undefined) { _memo.clear(); return; }
|
|
83
|
+
_memo.delete(`${logDir}\0${project}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Test hook: replace the clock (pass `() => t`); call without args to restore. */
|
|
87
|
+
export function _setNowForTest(fn) {
|
|
88
|
+
_now = fn || Date.now;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Test hook: drop all memoized entries. */
|
|
92
|
+
export function _resetForTest() {
|
|
93
|
+
_memo.clear();
|
|
94
|
+
_now = Date.now;
|
|
95
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Wire Format v2 — session-row cache for the log list (P0-A, 2026-07-31).
|
|
2
|
+
//
|
|
3
|
+
// `GET /api/local-logs` re-scans every session on every call: full journal
|
|
4
|
+
// fold, recursive dir walk, 256KB prompts head read, meta read — all sync,
|
|
5
|
+
// all blocking the event loop. With 100+ sessions this costs seconds.
|
|
6
|
+
//
|
|
7
|
+
// The journal is append-only (the single write path is Journal.writeReq/
|
|
8
|
+
// writeDone → AsyncWriteQueue.appendTo; creation is a one-time 'wx' sentinel;
|
|
9
|
+
// no truncate/rename/unlink anywhere). Content changes land in the journal
|
|
10
|
+
// LAST within a request (blobs → conversation → journal line → prompts), so
|
|
11
|
+
// (size, mtimeMs) is an exact freshness key for everything up to the journal
|
|
12
|
+
// line. prompts.jsonl is appended strictly AFTER the journal line (and is
|
|
13
|
+
// backfilled without a journal write on crash-resume), so the freshness key
|
|
14
|
+
// spans BOTH files: `journalSize:journalMtimeMs:promptsSize:promptsMtimeMs`.
|
|
15
|
+
// A repeated key means identical row inputs; any content change bumps one of
|
|
16
|
+
// the two stats.
|
|
17
|
+
//
|
|
18
|
+
// Cache shape: Map<projectDir, Map<sid, Row>> keyed by the project dir, with
|
|
19
|
+
// per-sid rows keyed by the freshness pair above. Insertion-order eviction
|
|
20
|
+
// caps the project map at MAX_PROJECTS. Rows are re-copied on return (see
|
|
21
|
+
// copyRow) so callers can't mutate the cache.
|
|
22
|
+
|
|
23
|
+
import { statSync, existsSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { listSessionIds, readJsonlTolerant } from './replay.js';
|
|
26
|
+
import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
|
|
27
|
+
import { isDiscardableSession } from './session-select.js';
|
|
28
|
+
import { isSupportedWireFormat, dirSizeSync } from './layout.js';
|
|
29
|
+
import { reportSwallowed } from '../error-report.js';
|
|
30
|
+
|
|
31
|
+
const MAX_PROJECTS = 32;
|
|
32
|
+
|
|
33
|
+
// ─── internal helpers (verbatim extraction from adapter.js) ──────────────────
|
|
34
|
+
|
|
35
|
+
/** Bounded head read: parse the FIRST JSONL line of a file without loading the
|
|
36
|
+
* whole thing (a main conversation's opening snapshot can be multi-MB; the
|
|
37
|
+
* list only wants a preview). Returns null on any shortfall. */
|
|
38
|
+
function readFirstJsonLine(path, budget = 256 * 1024) {
|
|
39
|
+
let fd;
|
|
40
|
+
try {
|
|
41
|
+
fd = openSync(path, 'r');
|
|
42
|
+
const buf = Buffer.alloc(budget);
|
|
43
|
+
const n = readSync(fd, buf, 0, budget, 0);
|
|
44
|
+
const head = buf.toString('utf-8', 0, n);
|
|
45
|
+
const nl = head.indexOf('\n');
|
|
46
|
+
if (nl <= 0) return null; // no complete first line inside the budget
|
|
47
|
+
return JSON.parse(head.slice(0, nl));
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
} finally {
|
|
51
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── per-session summarization ───────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the summary row for one session dir. This is the exact logic that
|
|
59
|
+
* used to live inline in listV2Sessions (adapter.js). All gates and error
|
|
60
|
+
* directions are preserved verbatim:
|
|
61
|
+
*
|
|
62
|
+
* - wireFormat gate (meta.json) → skip + reportSwallowed
|
|
63
|
+
* - wireFormat gate (journal sentinel) → skip + reportSwallowed
|
|
64
|
+
* - error→keep: journal stat failure skips the row, never caches
|
|
65
|
+
* - discard short-circuit: !leader && !hasMainOrTeammate && isDiscardableSession()
|
|
66
|
+
* - no "journal non-empty" guard — torn creation is tolerated as turns:0
|
|
67
|
+
*
|
|
68
|
+
* @returns {object|null} row {sid, dir, startTs, leader, turns, size, preview, discard}
|
|
69
|
+
* or null if the session should be skipped
|
|
70
|
+
*/
|
|
71
|
+
function summarizeSession(projectDir, sid) {
|
|
72
|
+
const dir = join(projectDir, 'sessions', sid);
|
|
73
|
+
// Journal existence is the cheapest gate — a session dir without one is
|
|
74
|
+
// either torn at creation or not a session at all.
|
|
75
|
+
if (!existsSync(join(dir, 'journal.jsonl'))) return null;
|
|
76
|
+
let meta = null;
|
|
77
|
+
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
|
|
78
|
+
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
|
|
79
|
+
// Reader version gate (spec §14): don't list a session this build
|
|
80
|
+
// can't read — a garbage preview/turn-count is worse than absence.
|
|
81
|
+
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`));
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// turns = main requests that completed (journal two-phase fold). The
|
|
86
|
+
// journal sentinel is checked in the same pass: per §14 the per-file
|
|
87
|
+
// sentinel WINS over meta.json, and readSession/adapter refuse such a
|
|
88
|
+
// session — listing it would show a phantom row that opens empty.
|
|
89
|
+
const reqKind = new Map();
|
|
90
|
+
let turns = 0;
|
|
91
|
+
let sentinelVersion = null;
|
|
92
|
+
let hasMainOrTeammate = false;
|
|
93
|
+
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
94
|
+
if (line.ph === 'req') {
|
|
95
|
+
reqKind.set(line.seq, line.kind);
|
|
96
|
+
if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
|
|
97
|
+
}
|
|
98
|
+
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
|
|
99
|
+
turns++;
|
|
100
|
+
reqKind.delete(line.seq); // fold duplicate done lines (§14)
|
|
101
|
+
} else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
|
|
102
|
+
sentinelVersion = line.wireFormat;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (sentinelVersion != null) {
|
|
107
|
+
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`));
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// preview = ALL user prompts of the session, from the prompts.jsonl
|
|
112
|
+
// display cache (written by V2Writer / the converter; bounded head read
|
|
113
|
+
// so the list stays O(budget) per session). Sessions predating the
|
|
114
|
+
// cache fall back to the first epoch's first line — routed through the
|
|
115
|
+
// shared extractor so command/caveat chrome never leaks into the row.
|
|
116
|
+
let preview = readPromptsHead(join(dir, 'prompts.jsonl'));
|
|
117
|
+
if (preview.length === 0) {
|
|
118
|
+
const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl'));
|
|
119
|
+
if (first && Array.isArray(first.msgs)) {
|
|
120
|
+
preview = collectPromptsFromEvents([first]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
sid,
|
|
126
|
+
dir,
|
|
127
|
+
startTs: (meta && meta.startTs) || '',
|
|
128
|
+
leader: (meta && meta.leader) || null,
|
|
129
|
+
turns,
|
|
130
|
+
size: dirSizeSync(dir),
|
|
131
|
+
preview,
|
|
132
|
+
// Discardable-session verdict. KEEP IN SYNC: session-select.js
|
|
133
|
+
// isDiscardableSession is the canonical rule; this fold pre-computes
|
|
134
|
+
// it for free over the FULL journal (the canonical scan is 8MB-
|
|
135
|
+
// budgeted — intentional asymmetry, a first main sits at the head).
|
|
136
|
+
// When the fold says discard, the canonical predicate CONFIRMS it:
|
|
137
|
+
// readJsonlTolerant swallows an I/O error (Windows EBUSY/EPERM lock)
|
|
138
|
+
// into zero lines, which must KEEP the session, not hide it — the
|
|
139
|
+
// canonical path carries that error→keep direction (ioErrorResult).
|
|
140
|
+
// Main-bearing sessions never pay the extra read; probe journals are
|
|
141
|
+
// ~3 lines.
|
|
142
|
+
discard: !(meta && meta.leader) && !hasMainOrTeammate && isDiscardableSession(dir, meta),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── row cache ───────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Map<projectDir, Map<sid, {key, row}>> — insertion-order eviction. */
|
|
149
|
+
const _cache = new Map();
|
|
150
|
+
|
|
151
|
+
function _evictProjects() {
|
|
152
|
+
while (_cache.size > MAX_PROJECTS) {
|
|
153
|
+
const oldest = _cache.keys().next().value;
|
|
154
|
+
_cache.delete(oldest);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Summarize every session under LOG_DIR/<project>/ for the log list (spec §12).
|
|
160
|
+
* Deliberately cheap: journal lines only (small) + a bounded head read of the
|
|
161
|
+
* main conversation's first epoch for the preview — conversation bodies are
|
|
162
|
+
* never loaded. Teammate linkage is surfaced via `leader` so the caller can
|
|
163
|
+
* fold those sessions into their leader's view instead of double-listing.
|
|
164
|
+
*
|
|
165
|
+
* Cached: repeat calls with unchanged journals return instantly (~1-3ms for
|
|
166
|
+
* 100 sessions, vs. seconds for a full rescan).
|
|
167
|
+
*
|
|
168
|
+
* @returns {Array<{sid, dir, startTs, leader, turns, size, preview, discard}>}
|
|
169
|
+
*/
|
|
170
|
+
export function listV2Sessions(projectDir) {
|
|
171
|
+
const sids = listSessionIds(projectDir);
|
|
172
|
+
const sidSet = new Set(sids); // O(1) prune lookups — sids.includes() per cached sid is O(N²)
|
|
173
|
+
let projectCache = _cache.get(projectDir);
|
|
174
|
+
if (!projectCache) {
|
|
175
|
+
projectCache = new Map();
|
|
176
|
+
_cache.set(projectDir, projectCache);
|
|
177
|
+
_evictProjects();
|
|
178
|
+
}
|
|
179
|
+
// Prune deleted/migrated sessions: a sid no longer in the readdir is gone.
|
|
180
|
+
for (const sid of projectCache.keys()) {
|
|
181
|
+
if (!sidSet.has(sid)) projectCache.delete(sid);
|
|
182
|
+
}
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const sid of sids) {
|
|
185
|
+
try {
|
|
186
|
+
const dir = join(projectDir, 'sessions', sid);
|
|
187
|
+
let key;
|
|
188
|
+
try {
|
|
189
|
+
const jst = statSync(join(dir, 'journal.jsonl'));
|
|
190
|
+
// Freshness key spans journal AND prompts.jsonl: the display cache is
|
|
191
|
+
// appended strictly AFTER the journal line (v2-writer.js §5), so a list
|
|
192
|
+
// call landing between the two queue drains — or a crash-resume
|
|
193
|
+
// backfill that appends prompts without any journal write — must not
|
|
194
|
+
// freeze a pre-prompts preview under an unchanged journal key. Absent
|
|
195
|
+
// prompts use a placeholder: their first write always follows a journal
|
|
196
|
+
// append, except exactly that backfill path, which this key detects.
|
|
197
|
+
let pKey = '-:-';
|
|
198
|
+
try {
|
|
199
|
+
const pst = statSync(join(dir, 'prompts.jsonl'));
|
|
200
|
+
pKey = `${pst.size}:${pst.mtimeMs}`;
|
|
201
|
+
} catch { /* prompts.jsonl not written yet */ }
|
|
202
|
+
key = `${jst.size}:${jst.mtimeMs}:${pKey}`;
|
|
203
|
+
} catch {
|
|
204
|
+
continue; // journal unreadable → skip row, never cache
|
|
205
|
+
}
|
|
206
|
+
const cached = projectCache.get(sid);
|
|
207
|
+
if (cached && cached.key === key) {
|
|
208
|
+
out.push(copyRow(cached.row)); // defensive copy — callers must not mutate cache
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const row = summarizeSession(projectDir, sid);
|
|
212
|
+
if (row) {
|
|
213
|
+
projectCache.set(sid, { key, row });
|
|
214
|
+
out.push(copyRow(row));
|
|
215
|
+
}
|
|
216
|
+
// row === null → session skipped (wireFormat gate, sentinel, etc.) — don't cache
|
|
217
|
+
} catch { /* one unreadable session must not break the list */ }
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Copy a row for delivery: nested `preview` (array) and `leader` (object) are
|
|
223
|
+
* re-copied so a caller mutating what it received can never poison the cache
|
|
224
|
+
* entry shared by every viewer of the project. */
|
|
225
|
+
function copyRow(row) {
|
|
226
|
+
return {
|
|
227
|
+
...row,
|
|
228
|
+
...(row.leader ? { leader: { ...row.leader } } : {}),
|
|
229
|
+
preview: [...row.preview],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Summarize ONE session for the paginated list (server-side paging, 2026-07-31).
|
|
235
|
+
* Same row + same cache as listV2Sessions: a key hit returns the cached row
|
|
236
|
+
* without re-folding the journal; a miss computes and stores it. The caller
|
|
237
|
+
* (listV2LogsPage) has already decided this sid is on the current page, so only
|
|
238
|
+
* these sessions ever pay the summarize cost.
|
|
239
|
+
*
|
|
240
|
+
* @returns {object|null} row {sid, dir, startTs, leader, turns, size, preview, discard}
|
|
241
|
+
* or null if the session should be skipped (wireFormat gate, sentinel, no journal)
|
|
242
|
+
*/
|
|
243
|
+
export function summarizeSessionPage(projectDir, sid) {
|
|
244
|
+
const dir = join(projectDir, 'sessions', sid);
|
|
245
|
+
let key;
|
|
246
|
+
try {
|
|
247
|
+
const jst = statSync(join(dir, 'journal.jsonl'));
|
|
248
|
+
let pKey = '-:-';
|
|
249
|
+
try {
|
|
250
|
+
const pst = statSync(join(dir, 'prompts.jsonl'));
|
|
251
|
+
pKey = `${pst.size}:${pst.mtimeMs}`;
|
|
252
|
+
} catch { /* prompts.jsonl not written yet */ }
|
|
253
|
+
key = `${jst.size}:${jst.mtimeMs}:${pKey}`;
|
|
254
|
+
} catch {
|
|
255
|
+
return null; // journal unreadable → skip, never cache
|
|
256
|
+
}
|
|
257
|
+
let projectCache = _cache.get(projectDir);
|
|
258
|
+
if (!projectCache) {
|
|
259
|
+
projectCache = new Map();
|
|
260
|
+
_cache.set(projectDir, projectCache);
|
|
261
|
+
_evictProjects();
|
|
262
|
+
}
|
|
263
|
+
const cached = projectCache.get(sid);
|
|
264
|
+
if (cached && cached.key === key) return copyRow(cached.row);
|
|
265
|
+
const row = summarizeSession(projectDir, sid);
|
|
266
|
+
if (row) {
|
|
267
|
+
projectCache.set(sid, { key, row });
|
|
268
|
+
return copyRow(row);
|
|
269
|
+
}
|
|
270
|
+
return null; // skipped (wireFormat gate, sentinel, etc.) — don't cache
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Test hook: drop all cached rows. */
|
|
274
|
+
export function _resetForTest() {
|
|
275
|
+
_cache.clear();
|
|
276
|
+
}
|
|
@@ -215,10 +215,10 @@ export function sessionHasMainOrTeammateReq(dir) {
|
|
|
215
215
|
* the moment a dir gains its first main req, the predicate flips and every
|
|
216
216
|
* surface picks it up on its next scan/poll.
|
|
217
217
|
*
|
|
218
|
-
* KEEP IN SYNC:
|
|
219
|
-
* inline (hasMainOrTeammate inside its
|
|
220
|
-
*
|
|
221
|
-
*
|
|
218
|
+
* KEEP IN SYNC: session-list.js summarizeSession pre-computes the same verdict
|
|
219
|
+
* inline (hasMainOrTeammate inside its full journal fold — unbounded, vs this
|
|
220
|
+
* predicate's 8MB budget; intentional asymmetry) and then CONFIRMS a discard
|
|
221
|
+
* through this predicate so the error→keep direction is shared.
|
|
222
222
|
* Change the rule here and there together.
|
|
223
223
|
*
|
|
224
224
|
* @param {string} dir - absolute session dir
|