cc-viewer 1.7.1 → 1.7.3

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.
@@ -33,6 +33,8 @@ import { reportSwallowed } from '../error-report.js';
33
33
  import { isMainAgentRequest } from '../interceptor-core.js';
34
34
  import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
35
35
  import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
36
+ import { iterateJsonlLines } from './jsonl-read.js';
37
+ import { isDiscardableSession } from './session-select.js';
36
38
  import { blobPath, isSupportedWireFormat, dirSizeSync } from './layout.js';
37
39
  import { SingleFlight } from './singleflight.js';
38
40
 
@@ -59,16 +61,17 @@ export function isV2SessionDir(p) {
59
61
  function readResponsesRaw(sessionDir) {
60
62
  const bySeq = new Map();
61
63
  const p = join(sessionDir, 'responses.jsonl');
62
- if (!existsSync(p)) return bySeq;
63
- let raw = '';
64
- try { raw = readFileSync(p, 'utf-8'); } catch { return bySeq; }
65
- for (const line of raw.split('\n')) {
66
- const t = line.trim();
67
- if (!t) continue;
68
- const m = t.match(/"seq":\s*(\d+)/);
69
- if (!m) continue;
70
- const seq = Number(m[1]);
71
- if (!bySeq.has(seq)) bySeq.set(seq, t);
64
+ try {
65
+ // streamed line-by-line (issue #129): responses.jsonl is the most likely
66
+ // file to outgrow Node's string cap (full response bodies, one per request)
67
+ for (const t of iterateJsonlLines(p)) {
68
+ const m = t.match(/"seq":\s*(\d+)/);
69
+ if (!m) continue;
70
+ const seq = Number(m[1]);
71
+ if (!bySeq.has(seq)) bySeq.set(seq, t);
72
+ }
73
+ } catch (err) {
74
+ reportSwallowed('v2-read.responses-read-failed', err);
72
75
  }
73
76
  return bySeq;
74
77
  }
@@ -491,6 +494,14 @@ export class SessionSynthesizer {
491
494
  phase: 'placeholder',
492
495
  entry,
493
496
  isMain: isMainKind,
497
+ // Journal-truth request identity for the live row builder (live-feed
498
+ // _rowFrom): conv/evt are the V3.S5 assembler inputs the cold fold
499
+ // (meta-rows foldDir) already carries — without them on live rows the
500
+ // client rebuilds entries with EMPTY messages and the chat vanished at
501
+ // stream end until a cold reload (2026-07-16 live-render regression).
502
+ kind: req.kind,
503
+ ...(req.conv && { conv: req.conv }),
504
+ ...(req.evt && { evt: req.evt }),
494
505
  stateRef: conv ? conv.state : null,
495
506
  };
496
507
  this._await.set(seq, item);
@@ -563,7 +574,15 @@ export class SessionSynthesizer {
563
574
  function* iterateSessionItems(sessionDir, opts = {}) {
564
575
  const projectDir = dirname(dirname(sessionDir));
565
576
  const sessionId = basename(sessionDir);
566
- const session = readSession(projectDir, sessionId);
577
+ let session;
578
+ try {
579
+ session = readSession(projectDir, sessionId);
580
+ } catch (err) {
581
+ // One unreadable session must degrade to "not rendered", never crash the
582
+ // whole scan (issue #129: an oversized file crash-looped every startup).
583
+ reportSwallowed('v2-read.session-read-failed', new Error(`${sessionId}: ${err.message}`));
584
+ return;
585
+ }
567
586
  if (session.unsupported) {
568
587
  reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sessionId}: wireFormat=${session.wireFormat}`));
569
588
  return;
@@ -628,6 +647,12 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
628
647
  continue;
629
648
  }
630
649
  if (!l) {
650
+ // Discardable dirs (quota-probe orphans — no main/teammate req) must
651
+ // never act as leader candidates: a probe fires at process startup,
652
+ // so its startTs sits right next to the real leader's and can win the
653
+ // orphan tie-break below, stealing the teammate's traffic from the
654
+ // real leader's folded stream.
655
+ if (isDiscardableSession(dir, meta)) continue;
631
656
  leaderStarts.push({ sid: name, startTs: (meta && meta.startTs) || '' });
632
657
  continue;
633
658
  }
@@ -1081,8 +1106,12 @@ export function listV2Sessions(projectDir) {
1081
1106
  const reqKind = new Map();
1082
1107
  let turns = 0;
1083
1108
  let sentinelVersion = null;
1109
+ let hasMainOrTeammate = false;
1084
1110
  for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
1085
- if (line.ph === 'req') reqKind.set(line.seq, line.kind);
1111
+ if (line.ph === 'req') {
1112
+ reqKind.set(line.seq, line.kind);
1113
+ if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
1114
+ }
1086
1115
  else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
1087
1116
  turns++;
1088
1117
  reqKind.delete(line.seq); // fold duplicate done lines (§14)
@@ -1117,6 +1146,17 @@ export function listV2Sessions(projectDir) {
1117
1146
  turns,
1118
1147
  size: dirSizeSync(dir),
1119
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),
1120
1160
  });
1121
1161
  } catch { /* one unreadable session must not break the list */ }
1122
1162
  }
@@ -8,7 +8,7 @@
8
8
  // The reader folds the two phases by seq; a req without a done is in-flight
9
9
  // (or the process died) — that IS the v1 placeholder, minus the duplicated body.
10
10
 
11
- import { existsSync, readFileSync } from 'node:fs';
11
+ import { iterateJsonlLines } from './jsonl-read.js';
12
12
 
13
13
  export class Journal {
14
14
  /**
@@ -29,11 +29,15 @@ export class Journal {
29
29
 
30
30
  static _maxSeqIn(journalPath) {
31
31
  try {
32
- if (!existsSync(journalPath)) return 0;
33
32
  let max = 0;
34
- // Regex scan instead of per-line JSON.parse: tolerant of a truncated
35
- // tail line and ~10x cheaper on large journals.
36
- for (const m of readFileSync(journalPath, 'utf-8').matchAll(/"seq":\s*(\d+)/g)) {
33
+ // Per-line regex instead of JSON.parse: tolerant of a truncated tail
34
+ // line and ~10x cheaper on large journals. Streamed line-by-line
35
+ // (issue #129): a whole-file read past Node's string cap would land in
36
+ // the catch below and silently RESET seq to 0 — colliding with every
37
+ // existing line, which the reader folds into corrupted sessions.
38
+ for (const line of iterateJsonlLines(journalPath)) {
39
+ const m = /"seq":\s*(\d+)/.exec(line);
40
+ if (!m) continue;
37
41
  const n = Number(m[1]);
38
42
  if (n > max) max = n;
39
43
  }
@@ -0,0 +1,127 @@
1
+ // Wire Format v2 — streaming JSONL line reader (issue #129).
2
+ //
3
+ // `readFileSync(path, 'utf-8')` materializes the WHOLE file as one JS string
4
+ // and throws ERR_STRING_TOO_LONG past Node's ~512MiB string cap — a single
5
+ // oversized session file (giant conv epoch / journal / responses.jsonl, seen
6
+ // in the wild after migrating very large v1 logs) crash-looped the server on
7
+ // every startup scan. This reader never builds a whole-file string: it reads
8
+ // fixed-size chunks and splits lines at the BYTE level (scanning for 0x0A
9
+ // before decoding), so multi-byte UTF-8 characters straddling a chunk
10
+ // boundary are never torn, and only one line at a time ever becomes a string.
11
+ //
12
+ // A single line at/above the string cap can never be decoded at all — it is
13
+ // skipped (reported once per file via reportSwallowed) instead of throwing,
14
+ // mirroring readJsonlTolerant's torn-tail tolerance (spec §14).
15
+
16
+ import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
17
+ import { reportSwallowed } from '../error-report.js';
18
+
19
+ const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
20
+ // Node's max string length is 0x1fffffe8 (~512MiB) — a line this long can
21
+ // never become a JS string, so it is unreadable by construction.
22
+ const DEFAULT_MAX_LINE_BYTES = 0x1fffffe8;
23
+
24
+ /**
25
+ * Iterate a JSONL file's lines as trimmed strings (blank lines skipped),
26
+ * without ever holding the whole file in one string.
27
+ *
28
+ * @param {string} path
29
+ * @param {{maxLineBytes?: number, chunkBytes?: number}} [opts] - test seams;
30
+ * production callers use the defaults.
31
+ * @yields {string} one trimmed non-empty line
32
+ */
33
+ export function* iterateJsonlLines(path, opts = {}) {
34
+ const maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
35
+ const chunkBytes = opts.chunkBytes || DEFAULT_CHUNK_BYTES;
36
+ if (!existsSync(path)) return;
37
+ let fd;
38
+ try {
39
+ fd = openSync(path, 'r');
40
+ } catch (err) {
41
+ // ENOENT = vanished mid-scan (benign TOCTOU); anything else (EBUSY/EPERM
42
+ // Windows locks, EMFILE) silently rendering a session empty is
43
+ // diagnostic-worthy — CLAUDE.md swallow rule.
44
+ if (err && err.code !== 'ENOENT') reportSwallowed('v2-read.open-failed', err);
45
+ return;
46
+ }
47
+ try {
48
+ // Cap the chunk to the file's actual size (like the sibling readers):
49
+ // a fixed 8MB zero-filled alloc per call is a ~2600x regression over the
50
+ // old readFileSync for the KB-scale journals startup scans walk.
51
+ let capBytes = chunkBytes;
52
+ try { capBytes = Math.min(chunkBytes, Math.max(statSync(path).size, 1)); }
53
+ catch { /* stat raced a delete — keep the full chunk; the read loop decides */ }
54
+ const chunk = Buffer.alloc(capBytes);
55
+ let carry = []; // Buffer fragments of the current (incomplete) line
56
+ let carryBytes = 0;
57
+ let skipping = false; // inside an oversized line — drop bytes until \n
58
+ let reported = false; // one report per file, not per oversized line
59
+ let pos = 0;
60
+ const reportOnce = (err) => {
61
+ if (reported) return;
62
+ reported = true;
63
+ reportSwallowed('v2-read.jsonl-line-too-long', err);
64
+ };
65
+ while (true) {
66
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
67
+ if (n === 0) break;
68
+ pos += n;
69
+ const view = chunk.subarray(0, n);
70
+ let from = 0;
71
+ while (from < n) {
72
+ const nl = view.indexOf(10, from);
73
+ if (nl === -1) {
74
+ // no newline in the rest of this chunk — carry (or keep skipping)
75
+ if (!skipping) {
76
+ const restLen = n - from;
77
+ if (carryBytes + restLen > maxLineBytes) {
78
+ skipping = true;
79
+ carry = [];
80
+ carryBytes = 0;
81
+ reportOnce(new Error(`${path}: line exceeds ${maxLineBytes} bytes — skipped`));
82
+ } else {
83
+ // chunk is reused by the next readSync — the carried slice must own its bytes
84
+ carry.push(Buffer.from(view.subarray(from)));
85
+ carryBytes += restLen;
86
+ }
87
+ }
88
+ break;
89
+ }
90
+ if (skipping) {
91
+ skipping = false; // the oversized line ends at this newline
92
+ } else {
93
+ const seg = view.subarray(from, nl);
94
+ let text = null;
95
+ try {
96
+ if (carry.length > 0) {
97
+ carry.push(seg);
98
+ text = Buffer.concat(carry).toString('utf-8');
99
+ } else {
100
+ text = seg.toString('utf-8');
101
+ }
102
+ } catch (err) {
103
+ reportOnce(err); // decode failed (line at the string cap) — skip it
104
+ }
105
+ carry = [];
106
+ carryBytes = 0;
107
+ if (text !== null) {
108
+ const t = text.trim();
109
+ if (t) yield t;
110
+ }
111
+ }
112
+ from = nl + 1;
113
+ }
114
+ }
115
+ // trailing line without a final newline (torn tail is the caller's concern)
116
+ if (!skipping && carry.length > 0) {
117
+ let text = null;
118
+ try { text = Buffer.concat(carry).toString('utf-8'); } catch (err) { reportOnce(err); }
119
+ if (text !== null) {
120
+ const t = text.trim();
121
+ if (t) yield t;
122
+ }
123
+ }
124
+ } finally {
125
+ closeSync(fd);
126
+ }
127
+ }