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.
- package/cli.js +21 -3
- package/dist/assets/{App-gUSmU6Ny.js → App-DRsuJXEw.js} +1 -1
- package/dist/assets/{MdxEditorPanel-DAR39A3S.js → MdxEditorPanel-D_dewIIc.js} +1 -1
- package/dist/assets/{Mobile-qfRsRth0.js → Mobile-BIzb7vpA.js} +1 -1
- package/dist/assets/{index-B51Ci0GL.js → index-DidP9FCD.js} +2 -2
- package/dist/assets/{seqResourceLoaders-DZJxMMaR.js → seqResourceLoaders-BtGpfGRW.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/i18n.js +20 -0
- package/server/interceptor.js +45 -1
- package/server/lib/ensure-hooks.js +28 -2
- package/server/lib/log-management.js +1 -0
- package/server/lib/log-watcher.js +8 -0
- package/server/lib/pid-alive.js +15 -0
- package/server/lib/session-start-bridge.js +121 -0
- package/server/lib/settings-merge.js +170 -0
- package/server/lib/stats-worker.js +21 -2
- package/server/lib/v2/adapter.js +52 -12
- package/server/lib/v2/journal.js +9 -5
- package/server/lib/v2/jsonl-read.js +127 -0
- package/server/lib/v2/live-feed.js +302 -66
- package/server/lib/v2/meta-rows.js +22 -21
- package/server/lib/v2/replay.js +5 -5
- package/server/lib/v2/session-owner.js +135 -0
- package/server/lib/v2/session-select.js +84 -4
- package/server/lib/v2/v2-writer.js +181 -6
- package/server/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/routes/im.js +1 -1
- package/server/server.js +4 -1
- package/server/workspace-registry.js +5 -0
|
@@ -22,12 +22,15 @@
|
|
|
22
22
|
// through log-watcher's processWatchedEntry pipeline — the same enrichment,
|
|
23
23
|
// reconstruction and side-events the v1 tail produced.
|
|
24
24
|
|
|
25
|
-
import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
25
|
+
import { watch, existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { reportSwallowed } from '../error-report.js';
|
|
28
28
|
import { createIncrementalReconstructor } from '../delta-reconstructor.js';
|
|
29
29
|
import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
|
|
30
30
|
import { SessionSynthesizer } from './adapter.js';
|
|
31
|
+
import { isDiscardableSession } from './session-select.js';
|
|
32
|
+
import { isForeignLiveOwned } from './session-owner.js';
|
|
33
|
+
import { isConvertRunning } from './convert-manager.js';
|
|
31
34
|
import { computeCacheLoss } from './meta-rows.js';
|
|
32
35
|
import { classifyRequest } from '../../../src/utils/requestType.js';
|
|
33
36
|
|
|
@@ -37,12 +40,33 @@ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
|
|
|
37
40
|
const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
|
|
38
41
|
const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
|
|
39
42
|
const TICK_RETRY_MAX = 8;
|
|
43
|
+
// Idle cursor eviction: a followed dir whose journal hasn't moved for this
|
|
44
|
+
// long is detached, freeing its synthesizer/reconstructor state (a migration
|
|
45
|
+
// can promote hundreds of dirs at once — holding all of them pins ~the whole
|
|
46
|
+
// project in heap). Re-attach self-heals via the safety poll's mtime-bump
|
|
47
|
+
// scan or the leader's tick() (both suppress history on a seen dir).
|
|
48
|
+
const IDLE_EVICT_MS = 10 * 60 * 1000;
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
const READ_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
// Node's max string length (~512MiB) — a longer line can never be decoded.
|
|
52
|
+
const MAX_LINE_BYTES = 0x1fffffe8;
|
|
53
|
+
|
|
54
|
+
/** Read newly appended complete lines from a file cursor {path, offset}
|
|
55
|
+
* (partial-line carry lives on lazily-initialized _pendBufs/_pendBytes/
|
|
56
|
+
* _skipLine cursor fields). Returns an array of raw line strings (possibly
|
|
57
|
+
* empty). A file
|
|
43
58
|
* that shrank (should never happen — v2 files are append-only) returns null
|
|
44
|
-
* so the caller can rebuild the cursor.
|
|
45
|
-
|
|
59
|
+
* so the caller can rebuild the cursor.
|
|
60
|
+
*
|
|
61
|
+
* Chunked + byte-level newline split (issue #129 twin): the first attach to
|
|
62
|
+
* a session seeds from offset 0, so one whole-file read + decode would throw
|
|
63
|
+
* ERR_STRING_TOO_LONG on an oversized journal and crash-loop the boot path
|
|
64
|
+
* exactly like the cold-scan crash the streaming reader fixed. The partial
|
|
65
|
+
* tail line is carried on the cursor as Buffer fragments (never decoded
|
|
66
|
+
* until its newline lands); a single line past the string cap is skipped
|
|
67
|
+
* with a report instead of thrown. Exported for tests (chunkBytes seam).
|
|
68
|
+
*/
|
|
69
|
+
export function readNewLines(cursor, chunkBytes = READ_CHUNK_BYTES, maxLineBytes = MAX_LINE_BYTES) {
|
|
46
70
|
let size;
|
|
47
71
|
try {
|
|
48
72
|
size = statSync(cursor.path).size;
|
|
@@ -51,23 +75,74 @@ function readNewLines(cursor) {
|
|
|
51
75
|
}
|
|
52
76
|
if (size < cursor.offset) return null;
|
|
53
77
|
if (size === cursor.offset) return [];
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
if (cursor._pendBufs === undefined) {
|
|
79
|
+
cursor._pendBufs = [];
|
|
80
|
+
cursor._pendBytes = 0;
|
|
81
|
+
cursor._skipLine = false;
|
|
82
|
+
}
|
|
83
|
+
const out = [];
|
|
56
84
|
let fd;
|
|
57
85
|
try {
|
|
58
86
|
fd = openSync(cursor.path, 'r');
|
|
59
|
-
|
|
87
|
+
const chunk = Buffer.alloc(Math.min(chunkBytes, size - cursor.offset));
|
|
88
|
+
while (cursor.offset < size) {
|
|
89
|
+
const toRead = Math.min(chunk.length, size - cursor.offset);
|
|
90
|
+
const n = readSync(fd, chunk, 0, toRead, cursor.offset);
|
|
91
|
+
if (n === 0) break;
|
|
92
|
+
cursor.offset += n;
|
|
93
|
+
const view = chunk.subarray(0, n);
|
|
94
|
+
let from = 0;
|
|
95
|
+
while (from < n) {
|
|
96
|
+
const nl = view.indexOf(10, from);
|
|
97
|
+
if (nl === -1) {
|
|
98
|
+
if (!cursor._skipLine) {
|
|
99
|
+
const restLen = n - from;
|
|
100
|
+
if (cursor._pendBytes + restLen > maxLineBytes) {
|
|
101
|
+
cursor._skipLine = true;
|
|
102
|
+
cursor._pendBufs = [];
|
|
103
|
+
cursor._pendBytes = 0;
|
|
104
|
+
reportSwallowed('v2-live.read-line-too-long', new Error(`${cursor.path}: line exceeds ${maxLineBytes} bytes — skipped`));
|
|
105
|
+
} else {
|
|
106
|
+
// chunk is reused next readSync — the carried slice must own its bytes
|
|
107
|
+
cursor._pendBufs.push(Buffer.from(view.subarray(from)));
|
|
108
|
+
cursor._pendBytes += restLen;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (cursor._skipLine) {
|
|
114
|
+
cursor._skipLine = false; // the oversized line ends at this newline
|
|
115
|
+
} else {
|
|
116
|
+
const seg = view.subarray(from, nl);
|
|
117
|
+
let text = null;
|
|
118
|
+
try {
|
|
119
|
+
if (cursor._pendBufs.length > 0) {
|
|
120
|
+
cursor._pendBufs.push(seg);
|
|
121
|
+
text = Buffer.concat(cursor._pendBufs).toString('utf-8');
|
|
122
|
+
} else {
|
|
123
|
+
text = seg.toString('utf-8');
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
reportSwallowed('v2-live.read-line-too-long', err);
|
|
127
|
+
}
|
|
128
|
+
cursor._pendBufs = [];
|
|
129
|
+
cursor._pendBytes = 0;
|
|
130
|
+
if (text !== null) {
|
|
131
|
+
const t = text.trim();
|
|
132
|
+
if (t) out.push(t);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
from = nl + 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
60
138
|
} catch (err) {
|
|
139
|
+
// Deliver what was already parsed; offset only advanced past read bytes,
|
|
140
|
+
// so the next poll resumes from the failure point.
|
|
61
141
|
reportSwallowed('v2-live.read', err);
|
|
62
|
-
return [];
|
|
63
142
|
} finally {
|
|
64
143
|
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
65
144
|
}
|
|
66
|
-
|
|
67
|
-
const chunk = cursor.pending + buf.toString('utf-8');
|
|
68
|
-
const parts = chunk.split('\n');
|
|
69
|
-
cursor.pending = parts.pop() || '';
|
|
70
|
-
return parts.map((l) => l.trim()).filter(Boolean);
|
|
145
|
+
return out;
|
|
71
146
|
}
|
|
72
147
|
|
|
73
148
|
export class V2LiveFeed {
|
|
@@ -82,7 +157,11 @@ export class V2LiveFeed {
|
|
|
82
157
|
* @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
|
|
83
158
|
* _safetyTick manually)
|
|
84
159
|
* @param {number} [opts.deferMs]
|
|
160
|
+
* @param {number} [opts.idleEvictMs] - 0 disables idle cursor eviction (tests)
|
|
85
161
|
* @param {Function} [opts.now]
|
|
162
|
+
* @param {Function} [opts.isConvertRunningFn] - injection seam (tests)
|
|
163
|
+
* @param {Function} [opts.isForeignLiveOwnedFn] - injection seam (tests):
|
|
164
|
+
* (dir) => boolean, defaults to session-owner's isForeignLiveOwned
|
|
86
165
|
*/
|
|
87
166
|
constructor(opts = {}) {
|
|
88
167
|
this._clients = opts.clients || [];
|
|
@@ -92,13 +171,21 @@ export class V2LiveFeed {
|
|
|
92
171
|
this._watchImpl = opts.watchImpl || watch;
|
|
93
172
|
this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
|
|
94
173
|
this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
|
|
174
|
+
this._idleEvictMs = typeof opts.idleEvictMs === 'number' ? opts.idleEvictMs : IDLE_EVICT_MS;
|
|
95
175
|
this._now = opts.now || Date.now;
|
|
176
|
+
this._isConvertRunning = opts.isConvertRunningFn || isConvertRunning;
|
|
177
|
+
// Claim owners are compared against THIS process's pid: the feed must
|
|
178
|
+
// co-reside with its V2Writer in one process (worker_threads would still
|
|
179
|
+
// share the pid; moving the feed into a forked CHILD process would make
|
|
180
|
+
// every own dir look foreign and break single-window live-follow).
|
|
181
|
+
this._isForeignLiveOwned = opts.isForeignLiveOwnedFn || isForeignLiveOwned;
|
|
96
182
|
// Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
|
|
97
183
|
// row (v2_requests_delta). Explicit ctor param — this module has no access
|
|
98
184
|
// to the server deps object.
|
|
99
185
|
this._wireV3 = !!opts.wireV3;
|
|
100
186
|
this._sessions = new Map(); // dir → cursor bundle
|
|
101
187
|
this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
|
|
188
|
+
this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
|
|
102
189
|
this._sessionsRoot = null;
|
|
103
190
|
this._rootWatcher = null;
|
|
104
191
|
this._safetyTimer = null;
|
|
@@ -126,6 +213,7 @@ export class V2LiveFeed {
|
|
|
126
213
|
for (const cur of this._sessions.values()) this._closeCursor(cur);
|
|
127
214
|
this._sessions.clear();
|
|
128
215
|
this._seenDirs.clear();
|
|
216
|
+
this._discardGated.clear();
|
|
129
217
|
this._sessionsRoot = null;
|
|
130
218
|
}
|
|
131
219
|
|
|
@@ -138,7 +226,12 @@ export class V2LiveFeed {
|
|
|
138
226
|
if (!this._active || !sessionDir) return;
|
|
139
227
|
let cur = this._sessions.get(sessionDir);
|
|
140
228
|
if (!cur) {
|
|
141
|
-
|
|
229
|
+
// A dir we have ALREADY seen re-attaching through tick() is an idle
|
|
230
|
+
// eviction (or a watcher loss) resuming — its backlog is history and
|
|
231
|
+
// must be seeded suppressed, or the re-attach replays the whole session
|
|
232
|
+
// through the broadcast path (the exact OOM this module was fixed for).
|
|
233
|
+
// Only a genuinely never-seen dir emits from byte 0.
|
|
234
|
+
cur = this._attach(sessionDir, { suppressExisting: this._seenDirs.has(sessionDir) });
|
|
142
235
|
if (!cur) {
|
|
143
236
|
if (_retries < TICK_RETRY_MAX) {
|
|
144
237
|
setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
|
|
@@ -246,6 +339,59 @@ export class V2LiveFeed {
|
|
|
246
339
|
_attach(dir, { suppressExisting }) {
|
|
247
340
|
if (this._sessions.has(dir)) return this._sessions.get(dir);
|
|
248
341
|
if (!existsSync(join(dir, 'journal.jsonl'))) return null;
|
|
342
|
+
// Multi-window isolation: a dir exclusively claimed by ANOTHER live ccv
|
|
343
|
+
// window (owner.lock, pid-liveness validity) never enters this feed — its
|
|
344
|
+
// traffic belongs to that window. Checked BEFORE the discard gate so a
|
|
345
|
+
// foreign dir never lands in _discardGated (whose delete side-effect would
|
|
346
|
+
// flip suppressExisting to false on a later attach and flood clients with
|
|
347
|
+
// the whole foreign history). Re-evaluated on every attach attempt — no
|
|
348
|
+
// persistent gate set — so a dead owner's claim stops mattering the
|
|
349
|
+
// moment its pid dies. Own dirs (tick path) and unclaimed teammate/IM
|
|
350
|
+
// dirs pass through untouched.
|
|
351
|
+
if (this._isForeignLiveOwned(dir)) return null;
|
|
352
|
+
// Discardable sessions (quota-probe orphans — no main/teammate req, no
|
|
353
|
+
// meta.leader) are never followed: single choke point, every attach path
|
|
354
|
+
// (_initialScan / _maybeAttachNew / tick / _safetyTick / _rebuildCursor)
|
|
355
|
+
// funnels here and handles null. Self-healing when a dir later gains its
|
|
356
|
+
// first main: the leader's own dir re-attaches via tick's retry chain
|
|
357
|
+
// (sub-second); cross-process dirs via the 5s safety poll's mtime-bump
|
|
358
|
+
// scan (_seenDirs bookkeeping stays in the callers, so the bump fires).
|
|
359
|
+
if (isDiscardableSession(dir)) {
|
|
360
|
+
this._discardGated.add(dir);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
// A dir previously refused by the discard gate is attaching for the FIRST
|
|
364
|
+
// time — its first renderable turn was never broadcast, so the safety
|
|
365
|
+
// poll's suppressExisting:true (meant for "old stale dir resumed") must
|
|
366
|
+
// not swallow it: cross-process producers (IM worker, second ccv) have no
|
|
367
|
+
// cold-load fallback for a connected client.
|
|
368
|
+
if (this._discardGated.delete(dir)) suppressExisting = false;
|
|
369
|
+
// Migration output is dead history: converted sessions (meta origin:
|
|
370
|
+
// 'convert') have no producer and never append again, yet a promote
|
|
371
|
+
// renames dozens of them into sessions/ at once with fresh birthtimes —
|
|
372
|
+
// full-history seeding synthesized + JSON-round-tripped every entry of
|
|
373
|
+
// every promoted session inside the safety-poll/debounce timers (the
|
|
374
|
+
// 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
|
|
375
|
+
// read, cloned, or broadcast; the cold-load channel owns their history.
|
|
376
|
+
let convertOrigin = false;
|
|
377
|
+
try {
|
|
378
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
379
|
+
convertOrigin = !!meta && meta.origin === 'convert';
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// meta.json is written synchronously BEFORE the journal's first async
|
|
382
|
+
// drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
|
|
383
|
+
// abnormal. Never fall through to a broadcast replay: mid-conversion
|
|
384
|
+
// (same-process check — the converter worker runs inside the server
|
|
385
|
+
// process) the dir is almost certainly a promote in flight — treat as
|
|
386
|
+
// convert output; a truly missing meta otherwise seeds suppressed (cold
|
|
387
|
+
// load covers the history). Any OTHER errno is a transient lock on an
|
|
388
|
+
// EXISTING meta (Windows AV/EBUSY) — keep the caller's suppress verdict:
|
|
389
|
+
// flipping a fresh live session to suppressed would silently drop its
|
|
390
|
+
// first turn (review finding).
|
|
391
|
+
reportSwallowed('v2-live.meta-read', err);
|
|
392
|
+
if (this._isConvertRunning()) convertOrigin = true;
|
|
393
|
+
else if (err && err.code === 'ENOENT') suppressExisting = true;
|
|
394
|
+
}
|
|
249
395
|
const cur = {
|
|
250
396
|
dir,
|
|
251
397
|
synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
|
|
@@ -254,32 +400,100 @@ export class V2LiveFeed {
|
|
|
254
400
|
// rebase each other's accumulated baseline — v1's tail followed exactly
|
|
255
401
|
// one file, so a single shared reconstructor was a new interleave surface.
|
|
256
402
|
reconstructor: createIncrementalReconstructor(),
|
|
257
|
-
journal: { path: join(dir, 'journal.jsonl'), offset: 0
|
|
258
|
-
responses: { path: join(dir, 'responses.jsonl'), offset: 0
|
|
259
|
-
convFiles: new Map(), // path → {key, offset
|
|
403
|
+
journal: { path: join(dir, 'journal.jsonl'), offset: 0 },
|
|
404
|
+
responses: { path: join(dir, 'responses.jsonl'), offset: 0 },
|
|
405
|
+
convFiles: new Map(), // path → {key, offset} (+ lazy _pendBufs carry)
|
|
260
406
|
watcher: null,
|
|
261
407
|
debounce: null,
|
|
262
408
|
reading: false,
|
|
263
409
|
dirty: false,
|
|
264
410
|
suppress: !!suppressExisting,
|
|
411
|
+
convertOrigin,
|
|
412
|
+
// Journal req seqs with no done yet. The journal is only touched at
|
|
413
|
+
// request start and completion, so a single >10min request would look
|
|
414
|
+
// "idle" to the mtime-based eviction and get detached mid-flight — its
|
|
415
|
+
// completion would then re-attach suppressed and never resolve the
|
|
416
|
+
// already-broadcast placeholder (review finding). Eviction skips
|
|
417
|
+
// cursors with an open request. A crash orphan (req, no done ever)
|
|
418
|
+
// pins its one session un-evicted — acceptable: eviction is a memory
|
|
419
|
+
// optimization, a stuck live card is a correctness bug.
|
|
420
|
+
openReqs: new Set(),
|
|
265
421
|
};
|
|
266
422
|
this._sessions.set(dir, cur);
|
|
267
423
|
this._seenDirs.set(dir, this._journalMtime(dir));
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
424
|
+
if (!convertOrigin) {
|
|
425
|
+
try {
|
|
426
|
+
cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
|
|
427
|
+
cur.watcher.on('error', (err) => {
|
|
428
|
+
reportSwallowed('v2-live.session-watch', err);
|
|
429
|
+
try { cur.watcher.close(); } catch { /* already closed */ }
|
|
430
|
+
cur.watcher = null; // safety poll keeps the cursor alive
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
271
433
|
reportSwallowed('v2-live.session-watch', err);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
434
|
+
}
|
|
435
|
+
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
436
|
+
} else {
|
|
437
|
+
// No per-dir watcher either: a promote can bring hundreds of dead dirs
|
|
438
|
+
// at once and each fs.watch costs an fd. The safety poll's unconditional
|
|
439
|
+
// _readCursor still picks up any (theoretical) append past the seeked
|
|
440
|
+
// offsets until idle eviction reclaims the cursor.
|
|
441
|
+
this._seekCursorsToEof(cur);
|
|
277
442
|
}
|
|
278
|
-
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
279
443
|
cur.suppress = false;
|
|
280
444
|
return cur;
|
|
281
445
|
}
|
|
282
446
|
|
|
447
|
+
/** Position every cursor of a session at the current end of its file —
|
|
448
|
+
* attach without reading: nothing existing is synthesized or emitted, only
|
|
449
|
+
* bytes appended AFTER this point would flow. Enumerates ALL conv epoch
|
|
450
|
+
* files up front so pre-existing epochs can't be replayed from 0 later. */
|
|
451
|
+
_seekCursorsToEof(cur) {
|
|
452
|
+
const sizeOf = (p) => {
|
|
453
|
+
try { return statSync(p).size; } catch (err) {
|
|
454
|
+
if (err && err.code === 'ENOENT') return 0; // missing file: offset 0 IS its EOF
|
|
455
|
+
// Any other stat failure must never seed offset 0 — that re-arms the
|
|
456
|
+
// full replay this seek exists to prevent. Park the cursor past any
|
|
457
|
+
// real size; a later successful read sees size < offset and rebuilds.
|
|
458
|
+
reportSwallowed('v2-live.seek-stat', err);
|
|
459
|
+
return Number.MAX_SAFE_INTEGER;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
cur.journal.offset = sizeOf(cur.journal.path);
|
|
463
|
+
cur.responses.offset = sizeOf(cur.responses.path);
|
|
464
|
+
for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
|
|
465
|
+
cur.convFiles.set(path, { key, path, offset: sizeOf(path) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Enumerate a session's conversation epoch files as [{key, path}], epochs
|
|
470
|
+
* numerically ordered within each conv key — the single home of the
|
|
471
|
+
* two-level conversations/<key>/e<N>.jsonl walk (shared by the EOF-seek
|
|
472
|
+
* attach and the incremental reader; review dedup). NB: file order is NOT
|
|
473
|
+
* guaranteed to be globally seq-ordered (a pre-fix writer restart appended
|
|
474
|
+
* newer seqs into an older epoch file) — the synthesizer's ingestConvLine
|
|
475
|
+
* keeps its event window seq-sorted on insert, so consumers only need a
|
|
476
|
+
* stable feed order. */
|
|
477
|
+
_enumerateConvFiles(dir) {
|
|
478
|
+
const convRoot = join(dir, 'conversations');
|
|
479
|
+
const out = [];
|
|
480
|
+
let keys = [];
|
|
481
|
+
try {
|
|
482
|
+
keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
483
|
+
} catch {
|
|
484
|
+
return out; // no conversations dir yet
|
|
485
|
+
}
|
|
486
|
+
for (const key of keys) {
|
|
487
|
+
let files = [];
|
|
488
|
+
try {
|
|
489
|
+
files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
|
|
490
|
+
} catch { continue; }
|
|
491
|
+
files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
492
|
+
for (const f of files) out.push({ key, path: join(convRoot, key, f) });
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
283
497
|
_closeCursor(cur) {
|
|
284
498
|
if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
|
|
285
499
|
if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
|
|
@@ -319,7 +533,11 @@ export class V2LiveFeed {
|
|
|
319
533
|
try { line = JSON.parse(raw); } catch (err) {
|
|
320
534
|
reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
|
|
321
535
|
}
|
|
322
|
-
if (line)
|
|
536
|
+
if (line) {
|
|
537
|
+
if (line.ph === 'req') cur.openReqs.add(line.seq);
|
|
538
|
+
else if (line.ph === 'done') cur.openReqs.delete(line.seq);
|
|
539
|
+
cur.synth.ingestJournalLine(line);
|
|
540
|
+
}
|
|
323
541
|
}
|
|
324
542
|
this._emitDrained(cur);
|
|
325
543
|
} while (cur.dirty);
|
|
@@ -329,44 +547,22 @@ export class V2LiveFeed {
|
|
|
329
547
|
}
|
|
330
548
|
|
|
331
549
|
_feedConvFiles(cur) {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
} catch {
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
for (const key of keys) {
|
|
341
|
-
let files = [];
|
|
342
|
-
try {
|
|
343
|
-
files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
|
|
344
|
-
} catch {
|
|
345
|
-
continue;
|
|
550
|
+
for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
|
|
551
|
+
let fc = cur.convFiles.get(path);
|
|
552
|
+
if (!fc) {
|
|
553
|
+
fc = { key, path, offset: 0 };
|
|
554
|
+
cur.convFiles.set(path, fc);
|
|
346
555
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const p = join(convRoot, key, f);
|
|
354
|
-
let fc = cur.convFiles.get(p);
|
|
355
|
-
if (!fc) {
|
|
356
|
-
fc = { key, path: p, offset: 0, pending: '' };
|
|
357
|
-
cur.convFiles.set(p, fc);
|
|
556
|
+
const lines = readNewLines(fc);
|
|
557
|
+
if (lines === null) { this._rebuildCursor(cur); return; }
|
|
558
|
+
for (const raw of lines) {
|
|
559
|
+
let ev = null;
|
|
560
|
+
try { ev = JSON.parse(raw); } catch (err) {
|
|
561
|
+
reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
|
|
358
562
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
let ev = null;
|
|
363
|
-
try { ev = JSON.parse(raw); } catch (err) {
|
|
364
|
-
reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
|
|
365
|
-
}
|
|
366
|
-
if (ev) {
|
|
367
|
-
cur.synth.ingestConvLine(key, ev);
|
|
368
|
-
if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
|
|
369
|
-
}
|
|
563
|
+
if (ev) {
|
|
564
|
+
cur.synth.ingestConvLine(key, ev);
|
|
565
|
+
if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
|
|
370
566
|
}
|
|
371
567
|
}
|
|
372
568
|
}
|
|
@@ -471,8 +667,17 @@ export class V2LiveFeed {
|
|
|
471
667
|
timestamp: parsed.timestamp || '',
|
|
472
668
|
url: parsed.url || '',
|
|
473
669
|
method: parsed.method || 'POST',
|
|
474
|
-
kind
|
|
475
|
-
|
|
670
|
+
// conv/evt/kind/mainAgent mirror the cold fold (meta-rows.js foldDir) —
|
|
671
|
+
// journal truth, NOT re-derivation. conv is load-bearing: the client
|
|
672
|
+
// assembler's buildEntry is `if (row.conv)`-gated, so a conv-less live
|
|
673
|
+
// row rebuilt every entry with EMPTY messages (chat vanished at stream
|
|
674
|
+
// end). mainAgent must be kind-derived like cold: parsed.mainAgent
|
|
675
|
+
// re-derives from the body and mis-tags countTokens (main system/tools
|
|
676
|
+
// aboard) as true, which would merge its turn into the chat live-only.
|
|
677
|
+
conv: item.conv,
|
|
678
|
+
evt: item.evt,
|
|
679
|
+
kind: item.kind || (item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub')),
|
|
680
|
+
mainAgent: item.isMain === true,
|
|
476
681
|
teammate: parsed.teammate || undefined,
|
|
477
682
|
model: parsed.body?.model,
|
|
478
683
|
proxyUrl: parsed.proxyUrl || undefined,
|
|
@@ -527,7 +732,38 @@ export class V2LiveFeed {
|
|
|
527
732
|
if (!this._active) return;
|
|
528
733
|
this._armRootWatcher(); // re-arm after errors / late directory creation
|
|
529
734
|
// Attached sessions: unconditional slow re-read (fs.watch is lossy).
|
|
735
|
+
// Idle eviction first: a cursor whose journal has not moved for
|
|
736
|
+
// _idleEvictMs is detached, releasing its synthesizer/reconstructor/
|
|
737
|
+
// _v3PrevMain retained state (post-migration this is ~the whole project).
|
|
738
|
+
// _seenDirs keeps the mtime, so the existing mtime-bump scan below (or the
|
|
739
|
+
// leader's tick(), now suppress-on-seen) re-attaches on real new activity.
|
|
530
740
|
for (const cur of [...this._sessions.values()]) {
|
|
741
|
+
// Multi-window isolation: an ATTACHED dir can turn foreign-owned after
|
|
742
|
+
// the fact — an unowned (dead-owner) dir followed since startup gets
|
|
743
|
+
// adopted by another window's `ccv -c`. The attach-time gate can't see
|
|
744
|
+
// that, so re-check here and detach before the unconditional re-read
|
|
745
|
+
// would stream the adopter's traffic into this window (bounded to one
|
|
746
|
+
// safety-poll period). _seenDirs keeps the current mtime so a later
|
|
747
|
+
// ownership release (owner exits) plus new activity re-attaches through
|
|
748
|
+
// the normal mtime-bump path below.
|
|
749
|
+
if (this._isForeignLiveOwned(cur.dir)) {
|
|
750
|
+
this._closeCursor(cur);
|
|
751
|
+
this._sessions.delete(cur.dir);
|
|
752
|
+
this._seenDirs.set(cur.dir, this._journalMtime(cur.dir));
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
// openReqs guard: never evict mid-flight — a long request keeps the
|
|
756
|
+
// journal mtime frozen between its req and done lines, and evicting
|
|
757
|
+
// then would strand its already-broadcast placeholder (see openReqs).
|
|
758
|
+
if (this._idleEvictMs > 0 && cur.openReqs.size === 0) {
|
|
759
|
+
const mtime = this._journalMtime(cur.dir);
|
|
760
|
+
if (mtime > 0 && this._now() - mtime > this._idleEvictMs) {
|
|
761
|
+
this._closeCursor(cur);
|
|
762
|
+
this._sessions.delete(cur.dir);
|
|
763
|
+
this._seenDirs.set(cur.dir, mtime);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
531
767
|
this._readCursor(cur);
|
|
532
768
|
}
|
|
533
769
|
// Unattached dirs: attach brand-new ones (missed root events) and
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
19
|
import { join, basename } from 'node:path';
|
|
20
20
|
import { readJsonlTolerant } from './replay.js';
|
|
21
|
+
import { iterateJsonlLines } from './jsonl-read.js';
|
|
21
22
|
import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
|
|
22
23
|
import { SingleFlight } from './singleflight.js';
|
|
23
24
|
import { reportSwallowed } from '../error-report.js';
|
|
@@ -246,13 +247,11 @@ export async function readV2NativeCold(sessionDir, rows) {
|
|
|
246
247
|
try { files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); } catch { continue; }
|
|
247
248
|
const raws = [];
|
|
248
249
|
for (const f of files) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
for (const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
await yieldLoop(); // one yield per conv file read
|
|
250
|
+
// streamed line-by-line (issue #129): a conv epoch past Node's string
|
|
251
|
+
// cap must degrade to skipped lines, not lose the whole file silently
|
|
252
|
+
try { for (const trimmed of iterateJsonlLines(join(convRoot, key, f))) raws.push(trimmed); }
|
|
253
|
+
catch (err) { reportSwallowed('v2-native.conv-read-failed', err); }
|
|
254
|
+
await yieldLoop(); // one yield per conv file read (error path included)
|
|
256
255
|
}
|
|
257
256
|
// last snapshot at-or-before the window start for this session
|
|
258
257
|
let start = 0;
|
|
@@ -271,17 +270,15 @@ export async function readV2NativeCold(sessionDir, rows) {
|
|
|
271
270
|
// boundaries: the browser paints and the byte meter ticks between them.
|
|
272
271
|
pushChunked(convPayloads, windowRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"channel":${JSON.stringify(key)},"lines":[${linesJson}]}`);
|
|
273
272
|
}
|
|
274
|
-
// responses: exactly the window member seqs
|
|
275
|
-
let respText = '';
|
|
276
|
-
try { respText = readFileSync(join(dir, 'responses.jsonl'), 'utf-8'); } catch { /* none yet */ }
|
|
277
|
-
await yieldLoop();
|
|
273
|
+
// responses: exactly the window member seqs — streamed (issue #129)
|
|
278
274
|
const respRaws = [];
|
|
279
|
-
|
|
280
|
-
const trimmed
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
275
|
+
try {
|
|
276
|
+
for (const trimmed of iterateJsonlLines(join(dir, 'responses.jsonl'))) {
|
|
277
|
+
const m = /"seq":\s*(\d+)/.exec(trimmed);
|
|
278
|
+
if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
|
|
279
|
+
}
|
|
280
|
+
} catch (err) { reportSwallowed('v2-native.responses-read-failed', err); }
|
|
281
|
+
await yieldLoop();
|
|
285
282
|
pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
|
|
286
283
|
}
|
|
287
284
|
return { convPayloads, respPayloads };
|
|
@@ -315,7 +312,7 @@ function pushChunked(out, raws, wrap) {
|
|
|
315
312
|
async function attachBodyFields(sessionDir, rows) {
|
|
316
313
|
const rowByKey = new Map(rows.map((r) => [itemKey(r.sessionId, r.seq), r]));
|
|
317
314
|
const materialize = (sessionId, seq) => rowByKey.has(itemKey(sessionId, seq));
|
|
318
|
-
let pending = null; // { row, entry } awaiting its nextReq for classify
|
|
315
|
+
let pending = null; // { row, entry, isMain } awaiting its nextReq for classify
|
|
319
316
|
let prevMainFull = null; // previous mainAgent full-body entry (cacheLoss)
|
|
320
317
|
let n = 0;
|
|
321
318
|
const finish = (slot, nextEntry) => {
|
|
@@ -329,8 +326,12 @@ async function attachBodyFields(sessionDir, rows) {
|
|
|
329
326
|
reportSwallowed('v2-meta.row-classify', err);
|
|
330
327
|
row.typeTag = null; // journal-derived kind still renders
|
|
331
328
|
}
|
|
332
|
-
//
|
|
333
|
-
|
|
329
|
+
// Authoritative synthesis-level markers. mainAgent stays KIND-derived
|
|
330
|
+
// (item.isMain = journal kind==='main' && !leader) like foldDir and the
|
|
331
|
+
// live rows — entry.mainAgent re-derives from the blob-backfilled body
|
|
332
|
+
// and mis-tags a main-shaped countTokens probe as true, which would merge
|
|
333
|
+
// its turn into the chat after a cold reload (2026-07-16 review P1).
|
|
334
|
+
row.mainAgent = slot.isMain === true;
|
|
334
335
|
if (entry.teammate) row.teammate = entry.teammate;
|
|
335
336
|
if (entry.body && entry.body.model) row.model = entry.body.model;
|
|
336
337
|
if (row.mainAgent) {
|
|
@@ -353,7 +354,7 @@ async function attachBodyFields(sessionDir, rows) {
|
|
|
353
354
|
entry = { ...entry, body: { ...entry.body, messages: item.stateRef } };
|
|
354
355
|
}
|
|
355
356
|
if (pending) finish(pending, entry);
|
|
356
|
-
pending = { row, entry };
|
|
357
|
+
pending = { row, entry, isMain: item.isMain };
|
|
357
358
|
if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
358
359
|
}
|
|
359
360
|
if (pending) finish(pending, null);
|
package/server/lib/v2/replay.js
CHANGED
|
@@ -14,14 +14,14 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
|
|
16
16
|
import { isSupportedWireFormat } from './layout.js';
|
|
17
|
+
import { iterateJsonlLines } from './jsonl-read.js';
|
|
17
18
|
|
|
18
|
-
/** Parse a JSONL file tolerating a truncated tail line (spec §14).
|
|
19
|
+
/** Parse a JSONL file tolerating a truncated tail line (spec §14). Streams
|
|
20
|
+
* line-by-line (issue #129): a file past Node's ~512MiB string cap must not
|
|
21
|
+
* throw ERR_STRING_TOO_LONG out of the whole read path. */
|
|
19
22
|
export function readJsonlTolerant(path) {
|
|
20
|
-
if (!existsSync(path)) return [];
|
|
21
23
|
const out = [];
|
|
22
|
-
for (const
|
|
23
|
-
const t = line.trim();
|
|
24
|
-
if (!t) continue;
|
|
24
|
+
for (const t of iterateJsonlLines(path)) {
|
|
25
25
|
try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
|
|
26
26
|
}
|
|
27
27
|
return out;
|