cc-viewer 1.7.0 → 1.7.2
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-B224r-8M.js +2 -0
- package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-BUubiE6Y.js} +1 -1
- package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-CKmfRRvo.js} +1 -1
- package/dist/assets/index-CjsQEQc2.js +2 -0
- package/dist/assets/seqResourceLoaders-C-T_EMiO.js +2 -0
- package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/server/lib/log-management.js +1 -0
- package/server/lib/log-watcher.js +29 -3
- package/server/lib/stats-worker.js +21 -2
- package/server/lib/v2/adapter.js +123 -18
- package/server/lib/v2/journal.js +9 -5
- package/server/lib/v2/jsonl-read.js +127 -0
- package/server/lib/v2/live-feed.js +222 -20
- package/server/lib/v2/meta-rows.js +361 -0
- package/server/lib/v2/replay.js +5 -5
- package/server/lib/v2/session-select.js +69 -2
- package/server/lib/v2/v2-writer.js +15 -0
- package/server/lib/wire-compress.js +167 -0
- package/server/routes/events.js +96 -26
- package/server/routes/im.js +1 -1
- package/server/routes/logs.js +15 -12
- package/server/routes/v2.js +60 -0
- package/server/routes/workspaces.js +7 -6
- package/server/server.js +23 -1
- package/server/workspace-registry.js +5 -0
- package/src/utils/apiUrl.js +26 -0
- package/src/utils/askFallback.js +21 -0
- package/src/utils/askOptionDesc.js +55 -0
- package/src/utils/askPortalMatcher.js +54 -0
- package/src/utils/autoApproveOptions.js +27 -0
- package/src/utils/avatarAnimationPostPass.js +49 -0
- package/src/utils/builtinPresets.js +18 -0
- package/src/utils/clearCheckpoint.js +7 -0
- package/src/utils/commandValidator.js +45 -0
- package/src/utils/contentFilter.js +620 -0
- package/src/utils/contextRaw.js +18 -0
- package/src/utils/contextSidebarNavigation.js +16 -0
- package/src/utils/contextTurns.js +117 -0
- package/src/utils/displayScaleHelper.js +47 -0
- package/src/utils/effectiveModel.js +13 -0
- package/src/utils/entry-slim.js +563 -0
- package/src/utils/entryCache.js +144 -0
- package/src/utils/errorReport.js +6 -0
- package/src/utils/fileExpandedPathsStorage.js +70 -0
- package/src/utils/fileIcons.jsx +36 -0
- package/src/utils/fileOpen.js +37 -0
- package/src/utils/formatters.js +48 -0
- package/src/utils/gitApi.js +50 -0
- package/src/utils/gitTreeBuilder.js +19 -0
- package/src/utils/helpers.js +846 -0
- package/src/utils/identityHeal.js +99 -0
- package/src/utils/imConnState.js +59 -0
- package/src/utils/imOrigin.js +24 -0
- package/src/utils/imTr.js +7 -0
- package/src/utils/imageCompress.js +83 -0
- package/src/utils/imageDownscale.js +28 -0
- package/src/utils/imageResize.js +112 -0
- package/src/utils/ingestPipeline.js +47 -0
- package/src/utils/lazyWithReload.js +53 -0
- package/src/utils/markdown.js +30 -0
- package/src/utils/markdownIncremental.js +39 -0
- package/src/utils/markdownProfiler.js +113 -0
- package/src/utils/mdExtensionDetect.js +64 -0
- package/src/utils/memoryLinkParser.js +44 -0
- package/src/utils/modalMask.js +15 -0
- package/src/utils/monotime.js +6 -0
- package/src/utils/pinnedMenu.js +55 -0
- package/src/utils/presetShortcuts.js +14 -0
- package/src/utils/projectAlias.js +135 -0
- package/src/utils/promptClassifier.js +112 -0
- package/src/utils/promptDetect.js +196 -0
- package/src/utils/promptNav.js +60 -0
- package/src/utils/ptyChunkBuilder.js +251 -0
- package/src/utils/quickMenuHoverIntent.js +35 -0
- package/src/utils/rateLimitParser.js +119 -0
- package/src/utils/readResultPool.js +106 -0
- package/src/utils/refreshCachedItemProp.js +30 -0
- package/src/utils/requestType.js +208 -0
- package/src/utils/resizeCalc.js +21 -0
- package/src/utils/resolveLocalized.js +17 -0
- package/src/utils/searchApi.js +47 -0
- package/src/utils/searchReplace.js +51 -0
- package/src/utils/seqResourceLoaders.js +79 -0
- package/src/utils/sessionManager.js +471 -0
- package/src/utils/sessionMerge.js +180 -0
- package/src/utils/skillModalController.js +111 -0
- package/src/utils/skillsParser.js +116 -0
- package/src/utils/slashCommandLabels.js +80 -0
- package/src/utils/splitDragCalc.js +73 -0
- package/src/utils/stickyBottomController.js +643 -0
- package/src/utils/svgSanitize.js +48 -0
- package/src/utils/systemTags.js +47 -0
- package/src/utils/tClaude.js +20 -0
- package/src/utils/teamModalBuilder.js +351 -0
- package/src/utils/teamSessionParser.js +190 -0
- package/src/utils/teammateAvatars.js +194 -0
- package/src/utils/teammateDetector.js +121 -0
- package/src/utils/termDiag.js +114 -0
- package/src/utils/terminalClipboard.js +117 -0
- package/src/utils/terminalWriteQueue.js +398 -0
- package/src/utils/thinkingMerge.js +49 -0
- package/src/utils/toolCatalog.js +29 -0
- package/src/utils/toolResultBuilder.js +393 -0
- package/src/utils/toolResultClassifier.js +16 -0
- package/src/utils/toolResultCore.js +167 -0
- package/src/utils/toolsDiff.js +44 -0
- package/src/utils/toolsXmlFormatter.js +3 -0
- package/src/utils/ultraplanController.js +109 -0
- package/src/utils/ultraplanExperts.js +86 -0
- package/src/utils/ultraplanTemplates.js +154 -0
- package/src/utils/userImageRefs.js +49 -0
- package/src/utils/v3Assembler.js +112 -0
- package/src/utils/v3Rows.js +48 -0
- package/src/utils/voicePackPlayer.js +233 -0
- package/src/utils/webSearchGrouping.js +111 -0
- package/src/utils/workflowFormat.js +52 -0
- package/src/utils/workflowRuns.js +118 -0
- package/src/utils/workflowStore.js +94 -0
- package/dist/assets/App-DTkacfRW.js +0 -2
- package/dist/assets/index-Ac8vnV3v.js +0 -2
- package/dist/assets/seqResourceLoaders-Cd_TAXTb.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-CjsQEQc2.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-DI7JL-mE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B9c49dtM.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-B6cpBtIE.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
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",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"findcc.js",
|
|
67
67
|
"interceptor.js",
|
|
68
68
|
"server/",
|
|
69
|
+
"src/utils/",
|
|
69
70
|
"plugins/",
|
|
70
71
|
"concepts/",
|
|
71
72
|
"ultraAgents/"
|
|
@@ -99,6 +99,7 @@ export function listV2Logs(logDir, currentProjectName) {
|
|
|
99
99
|
for (const s of listV2Sessions(join(logDir, project))) {
|
|
100
100
|
if (s.leader) continue;
|
|
101
101
|
if (s.size === 0) continue;
|
|
102
|
+
if (s.discard) continue; // quota-probe orphans: never listed (2026-07-16)
|
|
102
103
|
if (!grouped[project]) grouped[project] = [];
|
|
103
104
|
grouped[project].push({
|
|
104
105
|
file: `v2:${project}/${s.sid}`,
|
|
@@ -4,6 +4,7 @@ import { buildContextWindowEvent, getContextSizeForModel } from './context-watch
|
|
|
4
4
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
5
5
|
import { enrichEntry } from './enrich-plan-input.js';
|
|
6
6
|
import { enrichEntry as enrichWorkflowEntry } from './enrich-workflow.js';
|
|
7
|
+
import { sseWrite } from './wire-compress.js';
|
|
7
8
|
|
|
8
9
|
// 1.7.0: the v1 log-file tail (fs.watch + byte cursors + rotation follow)
|
|
9
10
|
// retired with the v1 write path — the live channel is server/lib/v2/
|
|
@@ -60,7 +61,11 @@ function _safeSseWrite(clients, client, payload) {
|
|
|
60
61
|
}
|
|
61
62
|
let ok;
|
|
62
63
|
try {
|
|
63
|
-
|
|
64
|
+
// sseWrite routes through the client's negotiated Content-Encoding
|
|
65
|
+
// (wire-compress.js) — a bare client.write here would corrupt compressed
|
|
66
|
+
// streams. The drain listener below stays on the res: the encoder is
|
|
67
|
+
// piped into it, so socket drain still propagates.
|
|
68
|
+
ok = sseWrite(client, payload);
|
|
64
69
|
} catch {
|
|
65
70
|
_removeClient(clients, client);
|
|
66
71
|
return false;
|
|
@@ -68,9 +73,18 @@ function _safeSseWrite(clients, client, payload) {
|
|
|
68
73
|
if (!ok) {
|
|
69
74
|
if (!client._sseBackpressureSince) {
|
|
70
75
|
client._sseBackpressureSince = Date.now();
|
|
71
|
-
|
|
76
|
+
// On compressed clients the false write() came from the ENCODER (its
|
|
77
|
+
// input buffer), whose own 'drain' fires when compression catches up —
|
|
78
|
+
// the res may never emit 'drain' at all (compressed output is 20-80x
|
|
79
|
+
// smaller than what the socket can absorb). Arming the reset on the
|
|
80
|
+
// wrong stream would let the timeout below kill healthy clients.
|
|
81
|
+
const pressured = client._wireEnc || client;
|
|
82
|
+
pressured.once('drain', () => { client._sseBackpressureSince = 0; });
|
|
72
83
|
} else if (Date.now() - client._sseBackpressureSince > SSE_BACKPRESSURE_TIMEOUT_MS) {
|
|
73
84
|
_removeClient(clients, client);
|
|
85
|
+
// Destroy the encoder first: end() alone leaves the 16MB brotli window
|
|
86
|
+
// alive until TCP teardown of the already-stuck socket.
|
|
87
|
+
try { if (client._wireEnc) client._wireEnc.destroy(); } catch {}
|
|
74
88
|
try { client.end(); } catch {}
|
|
75
89
|
return false;
|
|
76
90
|
}
|
|
@@ -92,6 +106,15 @@ export function sendEventToClients(clients, eventName, data) {
|
|
|
92
106
|
}
|
|
93
107
|
}
|
|
94
108
|
|
|
109
|
+
/** Like sendEventToClients but `json` is an ALREADY-serialized JSON string —
|
|
110
|
+
* wire v3 forwards raw stored lines verbatim without a parse/stringify trip. */
|
|
111
|
+
export function sendEventRawToClients(clients, eventName, json) {
|
|
112
|
+
const payload = `event: ${eventName}\ndata: ${json}\n\n`;
|
|
113
|
+
for (let i = clients.length - 1; i >= 0; i--) {
|
|
114
|
+
_safeSseWrite(clients, clients[i], payload);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
export function sendChunkToClients(clients, dataJson) {
|
|
96
119
|
const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
|
|
97
120
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
@@ -115,7 +138,10 @@ export function processWatchedEntry(parsed, ctx) {
|
|
|
115
138
|
reconstructor.reconstruct(parsed);
|
|
116
139
|
try { enrichEntry(parsed); } catch {}
|
|
117
140
|
try { enrichWorkflowEntry(parsed); } catch {}
|
|
118
|
-
|
|
141
|
+
// Wire v3 (V3.S5): the flagged v2 live feed suppresses the full-entry
|
|
142
|
+
// broadcast — clients rebuild entries from native lines + rows. The kv/
|
|
143
|
+
// context side events below still fire (they are slim and file-agnostic).
|
|
144
|
+
if (!ctx.suppressEntryBroadcast) sendToClients(clients, parsed);
|
|
119
145
|
runParallelHook('onNewEntry', parsed).catch(() => {});
|
|
120
146
|
if (isMainAgentEntry(parsed) && !parsed.inProgress) {
|
|
121
147
|
const cached = extractCachedContent(parsed);
|
|
@@ -9,6 +9,8 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from '
|
|
|
9
9
|
import { join, basename } from 'node:path';
|
|
10
10
|
import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
|
|
11
11
|
import { dirSizeSync } from './v2/layout.js';
|
|
12
|
+
import { isDiscardableSession } from './v2/session-select.js';
|
|
13
|
+
import { reportSwallowed } from './error-report.js';
|
|
12
14
|
import {
|
|
13
15
|
INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
|
|
14
16
|
collectPromptsFromEvents, sortEpochFiles,
|
|
@@ -23,7 +25,11 @@ export { INTER_SESSION_TYPES, isSystemText, extractUserTexts };
|
|
|
23
25
|
// v9: v2 session-dir units (files map keyed `sessions/<sid>`), journal-based counts
|
|
24
26
|
// v10: per-session `size` = recursive session-dir bytes (was journal-only);
|
|
25
27
|
// `journalSize` carries the incremental-cache key
|
|
26
|
-
|
|
28
|
+
// v11: discardable sessions (quota-probe orphans) excluded — the bump
|
|
29
|
+
// invalidates pre-discard caches so their probe units can't be reused
|
|
30
|
+
// back into filesStats, which lets the discard check sit AFTER the
|
|
31
|
+
// cache-reuse branch (cache hits skip the journal head scan entirely)
|
|
32
|
+
const STATS_VERSION = 11;
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
35
|
* Parse one v2 session directory into the same stats shape parseJsonlFile
|
|
@@ -199,7 +205,20 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
const sessionDir = join(projectDir, 'sessions', sid);
|
|
202
|
-
|
|
208
|
+
// Discardable sessions (quota-probe orphans — no main/teammate req, no
|
|
209
|
+
// leader) never count toward stats. Runs only on cache misses: v11+
|
|
210
|
+
// caches are written exclusively by post-discard code, so a cache hit can
|
|
211
|
+
// never resurrect a probe unit (the v10→v11 bump invalidated older ones).
|
|
212
|
+
if (isDiscardableSession(sessionDir)) continue;
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = parseSessionDir(sessionDir);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// One unreadable session must not kill the whole worker (issue #129) —
|
|
218
|
+
// skip its stats and keep counting the healthy ones.
|
|
219
|
+
reportSwallowed('stats-worker.session-parse-failed', new Error(`${sid}: ${err.message}`));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
203
222
|
filesStats[unitKey] = {
|
|
204
223
|
models: parsed.models,
|
|
205
224
|
summary: parsed.summary,
|
package/server/lib/v2/adapter.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
}
|
|
@@ -402,12 +405,30 @@ export class SessionSynthesizer {
|
|
|
402
405
|
// ---- body (blob backfill is per-request by journal ref — never carried) --
|
|
403
406
|
const tools = this._loadBlob(req.blobs && req.blobs.tools);
|
|
404
407
|
const system = this._loadBlob(req.blobs && req.blobs.sys);
|
|
408
|
+
// req.params carries every residual body field (params-era journals);
|
|
409
|
+
// dedicated fields override it so precedence is deterministic. user_id is
|
|
410
|
+
// ALWAYS meta.userIdRaw — the client does equality-based session-boundary
|
|
411
|
+
// detection on it, and a `-c` adopted folder mixes real user_ids that
|
|
412
|
+
// would otherwise split the timeline mid-session.
|
|
413
|
+
const params = req.params && typeof req.params === 'object' ? req.params : null;
|
|
405
414
|
const body = {
|
|
415
|
+
...params,
|
|
406
416
|
...(req.model && { model: req.model }),
|
|
407
417
|
...(system !== undefined && { system }),
|
|
408
418
|
...(tools !== undefined && { tools }),
|
|
409
|
-
...(meta.userIdRaw && { metadata: { user_id: meta.userIdRaw } }),
|
|
410
419
|
};
|
|
420
|
+
if (meta.userIdRaw) {
|
|
421
|
+
const extra = body.metadata && typeof body.metadata === 'object' ? body.metadata : null;
|
|
422
|
+
body.metadata = { ...extra, user_id: meta.userIdRaw };
|
|
423
|
+
} else if (body.metadata && typeof body.metadata === 'object' && body.metadata.user_id !== undefined) {
|
|
424
|
+
// noid session (no parseable user_id at write time): a raw params
|
|
425
|
+
// user_id may vary per request, and surfacing it would re-open the
|
|
426
|
+
// spurious client boundary-split this block exists to prevent. Drop
|
|
427
|
+
// ONLY user_id; other metadata keys keep full fidelity.
|
|
428
|
+
const { user_id, ...restMeta } = body.metadata;
|
|
429
|
+
if (Object.keys(restMeta).length > 0) body.metadata = restMeta;
|
|
430
|
+
else delete body.metadata;
|
|
431
|
+
}
|
|
411
432
|
|
|
412
433
|
const isTeammateEntry = isTeammate;
|
|
413
434
|
const isMainKind = isMain;
|
|
@@ -473,6 +494,14 @@ export class SessionSynthesizer {
|
|
|
473
494
|
phase: 'placeholder',
|
|
474
495
|
entry,
|
|
475
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 }),
|
|
476
505
|
stateRef: conv ? conv.state : null,
|
|
477
506
|
};
|
|
478
507
|
this._await.set(seq, item);
|
|
@@ -523,6 +552,7 @@ export class SessionSynthesizer {
|
|
|
523
552
|
}
|
|
524
553
|
entry.response = {
|
|
525
554
|
...(typeof done.http === 'number' && { status: done.http }),
|
|
555
|
+
...(resp && resp.statusText && { statusText: resp.statusText }),
|
|
526
556
|
...(resp && resp.headers && { headers: resp.headers }),
|
|
527
557
|
body: resp ? resp.body : null,
|
|
528
558
|
};
|
|
@@ -544,7 +574,15 @@ export class SessionSynthesizer {
|
|
|
544
574
|
function* iterateSessionItems(sessionDir, opts = {}) {
|
|
545
575
|
const projectDir = dirname(dirname(sessionDir));
|
|
546
576
|
const sessionId = basename(sessionDir);
|
|
547
|
-
|
|
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
|
+
}
|
|
548
586
|
if (session.unsupported) {
|
|
549
587
|
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sessionId}: wireFormat=${session.wireFormat}`));
|
|
550
588
|
return;
|
|
@@ -609,6 +647,12 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
|
|
|
609
647
|
continue;
|
|
610
648
|
}
|
|
611
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;
|
|
612
656
|
leaderStarts.push({ sid: name, startTs: (meta && meta.startTs) || '' });
|
|
613
657
|
continue;
|
|
614
658
|
}
|
|
@@ -646,7 +690,7 @@ export function findTeammateSessionDirs(sessionDir, leaderUuid) {
|
|
|
646
690
|
* Item-level iteration of one v2 session with teammate re-join — the shared
|
|
647
691
|
* core of the raw-string generators and the window reader below.
|
|
648
692
|
*/
|
|
649
|
-
function* iterateV2Items(sessionDir, opts = {}) {
|
|
693
|
+
export function* iterateV2Items(sessionDir, opts = {}) {
|
|
650
694
|
const streams = [iterateSessionItems(sessionDir, opts)];
|
|
651
695
|
const ownMeta = (() => {
|
|
652
696
|
try { return JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { return null; }
|
|
@@ -735,7 +779,7 @@ export function _v2WindowStatsForTest(reset = false) {
|
|
|
735
779
|
* The result is IMMUTABLE and shared across coalesced callers — window
|
|
736
780
|
* selection must not mutate `recs`/`ring`.
|
|
737
781
|
*/
|
|
738
|
-
async function scanV2Descriptors(sessionDir) {
|
|
782
|
+
export async function scanV2Descriptors(sessionDir) {
|
|
739
783
|
_windowStats.scanRuns++;
|
|
740
784
|
const dedup = new Map(); // key → descriptor (insertion order = output order)
|
|
741
785
|
const ring = []; // newest ≤3 isMain descriptors, pre-dedup iteration order
|
|
@@ -796,7 +840,7 @@ function selectV2Window(scan, opts = {}) {
|
|
|
796
840
|
|
|
797
841
|
/** Level-1 shared Pass A + per-caller selection. `cached` gates the TTL
|
|
798
842
|
* micro-cache read (F5b: /events live-attach must never consume it). */
|
|
799
|
-
async function computeV2Window(sessionDir, opts = {}) {
|
|
843
|
+
export async function computeV2Window(sessionDir, opts = {}) {
|
|
800
844
|
const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: !!opts.cached });
|
|
801
845
|
return selectV2Window(scan, opts);
|
|
802
846
|
}
|
|
@@ -813,7 +857,7 @@ async function computeV2Window(sessionDir, opts = {}) {
|
|
|
813
857
|
* equivalent (algorithm review F6). `collect` mode fills a slot array in
|
|
814
858
|
* Pass A output order instead: byte-identical to the historical single pass.
|
|
815
859
|
*/
|
|
816
|
-
async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null } = {}) {
|
|
860
|
+
async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null, promoteKeys = null } = {}) {
|
|
817
861
|
const memberIdx = new Map();
|
|
818
862
|
win.members.forEach((d, i) => memberIdx.set(itemKey(d.sessionId, d.seq), i));
|
|
819
863
|
const ringKeys = new Set(win.ring.map((d) => itemKey(d.sessionId, d.seq)));
|
|
@@ -837,7 +881,12 @@ async function materializeV2Window(sessionDir, win, { collect = false, onEntry =
|
|
|
837
881
|
// replayed state (item.stateRef aliases the live conversation state at
|
|
838
882
|
// this seq — used transiently, never retained). Same construction as the
|
|
839
883
|
// historical end-of-window rebuild, byte-for-byte.
|
|
840
|
-
|
|
884
|
+
// `promoteKeys` (V3.S1): per-member force-checkpoint for single-entry
|
|
885
|
+
// reads — a mid-session main delta carries only its appended slice, but
|
|
886
|
+
// the detail view needs the full replayed state for BOTH the target and
|
|
887
|
+
// its prevMain (Body Diff / cacheLoss compare whole messages arrays).
|
|
888
|
+
// The window baseline promotes only win.baselineKey (the window start).
|
|
889
|
+
if (k === win.baselineKey || (promoteKeys && promoteKeys.has(k))) {
|
|
841
890
|
const state = item.stateRef || [];
|
|
842
891
|
const rebuilt = { ...item.entry, body: { ...item.entry.body, messages: state } };
|
|
843
892
|
rebuilt._isCheckpoint = true;
|
|
@@ -889,6 +938,47 @@ async function materializeV2Window(sessionDir, win, { collect = false, onEntry =
|
|
|
889
938
|
* @param {{limit?: number, before?: string|null, cached?: boolean}} [opts]
|
|
890
939
|
* @returns {Promise<{entries: string[], hasMore: boolean, oldestTimestamp: string, totalCount: number, mainAgentRing: string[]}>}
|
|
891
940
|
*/
|
|
941
|
+
/**
|
|
942
|
+
* V3.S1: on-demand single-entry read — the detail view's data source once the
|
|
943
|
+
* request list is metadata rows. Locates the target (sessionId, seq) in the
|
|
944
|
+
* shared descriptor scan, pairs it with its preceding isMain descriptor
|
|
945
|
+
* (Body Diff / Context tab need the previous mainAgent, raw-seq adjacency),
|
|
946
|
+
* and materializes exactly those members with per-member checkpoint promotion
|
|
947
|
+
* (main deltas come back with the full replayed state, matching what the
|
|
948
|
+
* client holds after reconstruction on the legacy channel).
|
|
949
|
+
*
|
|
950
|
+
* @param {string} sessionDir - leader session dir (teammate rows resolve
|
|
951
|
+
* through the leader's k-way fold so tags/order match the list)
|
|
952
|
+
* @param {{seq: number, sessionId?: string|null, cached?: boolean}} opts -
|
|
953
|
+
* sessionId (UUID) disambiguates teammate rows; null = any session's seq
|
|
954
|
+
* @returns {Promise<{entry: string, prevMain: string|null}|null>} raw JSON
|
|
955
|
+
* strings (never re-parsed here), or null when the seq is unknown / gated
|
|
956
|
+
* out by synthesis (crash-orphan) — callers answer 404.
|
|
957
|
+
*/
|
|
958
|
+
export async function readV2SingleEntry(sessionDir, opts = {}) {
|
|
959
|
+
const seq = opts.seq;
|
|
960
|
+
const sessionId = opts.sessionId || null;
|
|
961
|
+
const key = `entry:${sessionDir}|${sessionId || ''}|${seq}`;
|
|
962
|
+
return _windowFlight.run(key, async () => {
|
|
963
|
+
const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: true });
|
|
964
|
+
const recs = scan.recs;
|
|
965
|
+
const idx = recs.findIndex((d) => d.seq === seq && (!sessionId || d.sessionId === sessionId));
|
|
966
|
+
if (idx === -1) return null;
|
|
967
|
+
const target = recs[idx];
|
|
968
|
+
let prevMain = null;
|
|
969
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
970
|
+
if (recs[i].isMain) { prevMain = recs[i]; break; }
|
|
971
|
+
}
|
|
972
|
+
const members = prevMain ? [prevMain, target] : [target];
|
|
973
|
+
const win = { members, ring: [], baselineKey: null };
|
|
974
|
+
const promoteKeys = new Set(members.filter((d) => d.isMainDelta).map((d) => itemKey(d.sessionId, d.seq)));
|
|
975
|
+
const { slots } = await materializeV2Window(sessionDir, win, { collect: true, promoteKeys });
|
|
976
|
+
const entry = slots[members.length - 1];
|
|
977
|
+
if (entry === undefined) return null; // synthesis gate dropped it between passes
|
|
978
|
+
return { entry, prevMain: prevMain ? (slots[0] ?? null) : null };
|
|
979
|
+
}, { cached: opts.cached !== false });
|
|
980
|
+
}
|
|
981
|
+
|
|
892
982
|
export async function readV2WindowedEntries(sessionDir, opts = {}) {
|
|
893
983
|
const cached = !!opts.cached;
|
|
894
984
|
const key = `win:${sessionDir}|${opts.limit || 0}|${opts.before || ''}`;
|
|
@@ -1016,8 +1106,12 @@ export function listV2Sessions(projectDir) {
|
|
|
1016
1106
|
const reqKind = new Map();
|
|
1017
1107
|
let turns = 0;
|
|
1018
1108
|
let sentinelVersion = null;
|
|
1109
|
+
let hasMainOrTeammate = false;
|
|
1019
1110
|
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
1020
|
-
if (line.ph === 'req')
|
|
1111
|
+
if (line.ph === 'req') {
|
|
1112
|
+
reqKind.set(line.seq, line.kind);
|
|
1113
|
+
if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true;
|
|
1114
|
+
}
|
|
1021
1115
|
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
|
|
1022
1116
|
turns++;
|
|
1023
1117
|
reqKind.delete(line.seq); // fold duplicate done lines (§14)
|
|
@@ -1052,6 +1146,17 @@ export function listV2Sessions(projectDir) {
|
|
|
1052
1146
|
turns,
|
|
1053
1147
|
size: dirSizeSync(dir),
|
|
1054
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),
|
|
1055
1160
|
});
|
|
1056
1161
|
} catch { /* one unreadable session must not break the list */ }
|
|
1057
1162
|
}
|
package/server/lib/v2/journal.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
+
}
|