cc-viewer 1.6.348 → 1.7.1
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/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-gUSmU6Ny.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
- package/dist/assets/Mobile-qfRsRth0.js +1 -0
- package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
- package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
- package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
- package/dist/assets/clone-BQtvy9Ae.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
- package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
- package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
- package/dist/assets/index-B51Ci0GL.js +2 -0
- package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
- package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
- package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
- package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
- package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
- package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
- package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
- package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
- package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
- package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
- package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
- package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
- package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
- package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
- package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +4 -3
- package/package.json +3 -1
- package/server/i18n.js +21 -141
- package/server/interceptor.js +211 -403
- package/server/lib/async-write-queue.js +12 -1
- package/server/lib/delta-reconstructor.js +3 -3
- package/server/lib/error-report.js +45 -0
- package/server/lib/im-log-watcher.js +68 -8
- package/server/lib/interceptor-core.js +19 -174
- package/server/lib/log-management.js +214 -247
- package/server/lib/log-stream.js +59 -108
- package/server/lib/log-watcher.js +62 -274
- package/server/lib/log-zip.js +148 -0
- package/server/lib/session-boundary.js +266 -0
- package/server/lib/session-pin-store.js +10 -19
- package/server/lib/stats-worker.js +138 -267
- package/server/lib/user-prompt-extract.js +250 -0
- package/server/lib/v2/adapter.js +1124 -0
- package/server/lib/v2/blob-store.js +44 -0
- package/server/lib/v2/conversation-store.js +217 -0
- package/server/lib/v2/convert-manager.js +79 -0
- package/server/lib/v2/convert-worker.js +42 -0
- package/server/lib/v2/convert.js +409 -0
- package/server/lib/v2/identity.js +204 -0
- package/server/lib/v2/journal.js +62 -0
- package/server/lib/v2/layout.js +167 -0
- package/server/lib/v2/live-feed.js +558 -0
- package/server/lib/v2/meta-rows.js +360 -0
- package/server/lib/v2/migrate-prompt.js +54 -0
- package/server/lib/v2/replay.js +191 -0
- package/server/lib/v2/session-select.js +222 -0
- package/server/lib/v2/singleflight.js +104 -0
- package/server/lib/v2/v2-writer.js +528 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/lib/wire-compress.js +167 -0
- package/server/routes/events.js +120 -150
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +209 -164
- package/server/routes/preferences.js +0 -3
- package/server/routes/project-meta.js +2 -3
- package/server/routes/session-pin.js +7 -8
- package/server/routes/v2.js +60 -0
- package/server/routes/workspaces.js +50 -14
- package/server/server.js +70 -66
- package/server/workspace-registry.js +24 -4
- 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-VTo4NVdo.js +0 -2
- package/dist/assets/Mobile-CcmYvMm_.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
- package/dist/assets/clone-md-Mc1-T.js +0 -1
- package/dist/assets/index-DCo650PG.js +0 -2
- package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
- package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
- package/server/lib/instance-registry.js +0 -57
- package/server/lib/jsonl-archive.js +0 -180
- package/server/lib/teammate-detect.js +0 -32
- package/server/lib/zip-safety.js +0 -68
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Wire Format v2 — S4 dual-write consistency verifier (plan gate methodology).
|
|
2
|
+
//
|
|
3
|
+
// Compares one v1 JSONL file against the v2 session dirs of the same project,
|
|
4
|
+
// per request, on WIRE TRUTH:
|
|
5
|
+
// ① per-request wire-messages digest: v1 incremental delta reconstruction vs
|
|
6
|
+
// v2 mechanical replay (this is trajectory-grade — every intermediate
|
|
7
|
+
// state is compared, not just the cold final one);
|
|
8
|
+
// ② per-request tools/system content hash: v1 inline value hashed with the
|
|
9
|
+
// BlobStore formula vs the v2 journal's CAS ref (byte-equality by CAS);
|
|
10
|
+
// ③ teammate entries verified the same per-request way (v1's leader file
|
|
11
|
+
// holds them inline; v2 holds them in the teammate's own session dir —
|
|
12
|
+
// matching is by `${ts}|${url}`, so no re-join ordering is needed here);
|
|
13
|
+
// ④ inflight/error accounting: v1 keys that never completed vs v2
|
|
14
|
+
// req-without-done;
|
|
15
|
+
// ⑤ journal msgTo vs replayed length integrity (internal v2 consistency).
|
|
16
|
+
//
|
|
17
|
+
// v1-only requests are counted, not failed: history from before dual-write was
|
|
18
|
+
// enabled (or while it was toggled off) legitimately has no v2 twin.
|
|
19
|
+
//
|
|
20
|
+
// KNOWN LIMITATION (review): the ① digest is built on the client-shared
|
|
21
|
+
// messageFingerprint, which keys tool_result blocks on tool_use_id ONLY — a
|
|
22
|
+
// divergence confined to tool_result BODY content between v1 and v2 collides
|
|
23
|
+
// to identical digests and is invisible to this gate. The write side now uses
|
|
24
|
+
// the content-aware fingerprintMsg for its judgement (conversation-store.js),
|
|
25
|
+
// which removes the known producer of that divergence class; the residual risk
|
|
26
|
+
// is a v2 bug that corrupts tool_result content while preserving ids.
|
|
27
|
+
|
|
28
|
+
import { statSync } from 'node:fs';
|
|
29
|
+
import { dirname } from 'node:path';
|
|
30
|
+
import { iterateRawEntriesAsync } from '../log-stream.js';
|
|
31
|
+
import { createIncrementalReconstructor } from '../delta-reconstructor.js';
|
|
32
|
+
import { listSessionIds, readSession, indexSession, messagesDigest, blobRefOf } from './replay.js';
|
|
33
|
+
|
|
34
|
+
const MAX_REPORTED_DIFFS = 50;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify one v1 file against its project's v2 sessions.
|
|
38
|
+
* @param {string} v1File - absolute path to a v1 .jsonl (its dirname is the project dir)
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {string} [opts.projectDir] - override project dir (defaults to dirname(v1File))
|
|
41
|
+
* @param {string} [opts.sessionsDirName] - session-dir name under the project
|
|
42
|
+
* dir; the offline converter verifies its staging area ('sessions-migrating')
|
|
43
|
+
* before promoting. Defaults to the live 'sessions'.
|
|
44
|
+
* @param {(p: {entriesScanned: number}) => void} [opts.onProgress] - called
|
|
45
|
+
* every 1000 scanned v1 entries and once after the scan completes, so a
|
|
46
|
+
* long-running verify of one big file can surface live progress. Callback
|
|
47
|
+
* errors are swallowed — progress must never break the verification.
|
|
48
|
+
* @returns {Promise<object>} report (see shape at the bottom)
|
|
49
|
+
*/
|
|
50
|
+
export async function verifyV1File(v1File, opts = {}) {
|
|
51
|
+
const projectDir = opts.projectDir || dirname(v1File);
|
|
52
|
+
const sessionsDirName = opts.sessionsDirName || 'sessions';
|
|
53
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
|
|
54
|
+
|
|
55
|
+
// ---- v2 side: index every session of the project --------------------------
|
|
56
|
+
// `${ts}|${url}` is NOT unique in real logs (same-millisecond countTokens
|
|
57
|
+
// bursts, §3.6 rotation twins) — keep ALL records per key and match each v1
|
|
58
|
+
// entry to its best unmatched candidate below (real-data finding 2026-07-14).
|
|
59
|
+
const v2Index = new Map(); // key → rec[]
|
|
60
|
+
const integrity = [];
|
|
61
|
+
const v2Sessions = [];
|
|
62
|
+
const unsupportedSessions = []; // reader version gate (spec §14): a session
|
|
63
|
+
// this build can't read means the gate has a coverage hole — FAIL, don't skip.
|
|
64
|
+
for (const sid of listSessionIds(projectDir, sessionsDirName)) {
|
|
65
|
+
const session = readSession(projectDir, sid, sessionsDirName);
|
|
66
|
+
// Report the session IDENTITY (UUID from meta), not the dir name — the dir
|
|
67
|
+
// is `<ts>_<uuid>` post task C; falls back to the dir name on torn meta.
|
|
68
|
+
v2Sessions.push((session.meta && session.meta.sessionId) || sid);
|
|
69
|
+
if (session.unsupported) {
|
|
70
|
+
unsupportedSessions.push({ sessionId: sid, wireFormat: session.wireFormat });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const { index, integrity: viol } = indexSession(session);
|
|
74
|
+
integrity.push(...viol);
|
|
75
|
+
for (const [key, recs] of index) {
|
|
76
|
+
const arr = v2Index.get(key);
|
|
77
|
+
if (arr) arr.push(...recs); else v2Index.set(key, [...recs]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- v1 side: stream, reconstruct incrementally, compare ------------------
|
|
82
|
+
const reconstructor = createIncrementalReconstructor();
|
|
83
|
+
const diffs = [];
|
|
84
|
+
const inflightV1 = new Set(); // keys seen only as inProgress
|
|
85
|
+
const counters = {
|
|
86
|
+
v1Completed: 0, v1Skipped: 0, matched: 0, v1Only: 0, v1DuplicateKey: 0,
|
|
87
|
+
heartbeatsV1: 0, countTokensV1: 0, teammateV1: 0, staleOrBroken: 0,
|
|
88
|
+
};
|
|
89
|
+
// Per-session suspicion tally, keyed by the v2 session dir name (the same
|
|
90
|
+
// value `listSessionIds` / the converter's promote loop iterate). UNCAPPED —
|
|
91
|
+
// unlike `diffs` (bounded by MAX_REPORTED_DIFFS for memory/reporting), this
|
|
92
|
+
// must hold EVERY suspect session so the converter can quarantine the full
|
|
93
|
+
// set; capping it would silently promote corrupt sessions past diff #50.
|
|
94
|
+
const suspectSessions = new Map(); // sessionId → { count, reasons: string[] }
|
|
95
|
+
const MAX_REASONS = 5;
|
|
96
|
+
const flagSession = (sessionId, reason) => {
|
|
97
|
+
if (!sessionId) return;
|
|
98
|
+
let rec = suspectSessions.get(sessionId);
|
|
99
|
+
if (!rec) suspectSessions.set(sessionId, rec = { count: 0, reasons: [] });
|
|
100
|
+
rec.count++;
|
|
101
|
+
if (rec.reasons.length < MAX_REASONS) rec.reasons.push(reason);
|
|
102
|
+
};
|
|
103
|
+
const addDiff = (d) => {
|
|
104
|
+
flagSession(d.sessionId, `${d.type}@seq${d.seq}`);
|
|
105
|
+
if (diffs.length < MAX_REPORTED_DIFFS) diffs.push(d);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onScan = (raw) => {
|
|
109
|
+
let entry;
|
|
110
|
+
try { entry = JSON.parse(raw); } catch { return; }
|
|
111
|
+
if (entry.ccvRotationContext) return;
|
|
112
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
113
|
+
if (entry.inProgress) { inflightV1.add(key); return; }
|
|
114
|
+
inflightV1.delete(key);
|
|
115
|
+
|
|
116
|
+
// Incremental reconstruction restores the full wire messages for delta
|
|
117
|
+
// entries (teammate/sub entries pass through untouched, already full).
|
|
118
|
+
reconstructor.reconstruct(entry);
|
|
119
|
+
if (entry._staleReorder || entry._reconstructBroken) { counters.staleOrBroken++; return; }
|
|
120
|
+
counters.v1Completed++;
|
|
121
|
+
if (entry.isHeartbeat) counters.heartbeatsV1++;
|
|
122
|
+
if (entry.isCountTokens) counters.countTokensV1++;
|
|
123
|
+
if (entry.teammate) counters.teammateV1++;
|
|
124
|
+
|
|
125
|
+
const candidates = v2Index.get(key);
|
|
126
|
+
if (!candidates) { counters.v1Only++; return; }
|
|
127
|
+
const v1Digest = entry.body && Array.isArray(entry.body.messages) ? messagesDigest(entry.body.messages) : null;
|
|
128
|
+
const unmatched = candidates.filter(c => !c._matched);
|
|
129
|
+
if (unmatched.length === 0) {
|
|
130
|
+
// Every v2 twin already consumed: this v1 line is a duplicate completed
|
|
131
|
+
// write of the same request (§3.6 rotation race) — count, don't fail.
|
|
132
|
+
counters.v1DuplicateKey++;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Best candidate: digest-equal first (same-key bursts carry different
|
|
136
|
+
// payloads — pairing by content avoids cross-matching twins), else FIFO.
|
|
137
|
+
const v2 = (v1Digest && unmatched.find(c => c.digest === v1Digest)) || unmatched[0];
|
|
138
|
+
counters.matched++;
|
|
139
|
+
v2._matched = true;
|
|
140
|
+
|
|
141
|
+
// ① wire-messages digest (skip heartbeats — no conversation on either side)
|
|
142
|
+
if (v2.conv && v1Digest != null) {
|
|
143
|
+
if (v1Digest !== v2.digest) {
|
|
144
|
+
addDiff({ type: 'messages-digest', sessionId: v2.sessionId, key, seq: v2.seq, conv: v2.conv, v1: v1Digest, v2: v2.digest, v1Len: entry.body.messages.length, v2Len: v2.len });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ② tools/system CAS equality
|
|
148
|
+
const v1Tools = blobRefOf(entry.body && entry.body.tools);
|
|
149
|
+
const v1Sys = blobRefOf(entry.body && entry.body.system);
|
|
150
|
+
if (v1Tools !== v2.toolsRef) addDiff({ type: 'tools-ref', sessionId: v2.sessionId, key, seq: v2.seq, v1: v1Tools, v2: v2.toolsRef });
|
|
151
|
+
if (v1Sys !== v2.sysRef) addDiff({ type: 'system-ref', sessionId: v2.sessionId, key, seq: v2.seq, v1: v1Sys, v2: v2.sysRef });
|
|
152
|
+
// ③ teammate attribution
|
|
153
|
+
if (!!entry.teammate !== (v2.kind === 'teammate')) {
|
|
154
|
+
addDiff({ type: 'teammate-kind', sessionId: v2.sessionId, key, seq: v2.seq, v1: !!entry.teammate, v2: v2.kind });
|
|
155
|
+
}
|
|
156
|
+
// ④ completion accounting
|
|
157
|
+
if (!v2.done) addDiff({ type: 'missing-done', sessionId: v2.sessionId, key, seq: v2.seq });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Single streaming pass (1MB chunks): verify must stay memory-bounded on
|
|
161
|
+
// 300MB soak files — no dedup map, no second pass (review P2).
|
|
162
|
+
let entriesScanned = 0;
|
|
163
|
+
for await (const raw of iterateRawEntriesAsync(v1File)) {
|
|
164
|
+
onScan(raw);
|
|
165
|
+
entriesScanned++;
|
|
166
|
+
if (onProgress && entriesScanned % 1000 === 0) {
|
|
167
|
+
try { onProgress({ entriesScanned }); } catch { /* progress must never break the verify */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (onProgress) {
|
|
171
|
+
try { onProgress({ entriesScanned }); } catch { /* progress must never break the verify */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// v2 records never matched by any v1 entry in THIS file: only suspicious if
|
|
175
|
+
// their req/done both exist (a live v2-only write) — they may simply belong
|
|
176
|
+
// to another v1 segment (rotation), so count, don't fail.
|
|
177
|
+
let v2Unmatched = 0;
|
|
178
|
+
const v2ReqWithoutDone = [];
|
|
179
|
+
for (const [key, recs] of v2Index) {
|
|
180
|
+
for (const rec of recs) {
|
|
181
|
+
if (!rec._matched) v2Unmatched++;
|
|
182
|
+
if (!rec.done) v2ReqWithoutDone.push(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Integrity violations and unsupported-wire sessions are collected during the
|
|
187
|
+
// v2-side indexing pass above (before `flagSession` exists), so fold them into
|
|
188
|
+
// the per-session tally here — they condemn a session just as a diff does.
|
|
189
|
+
for (const v of integrity) flagSession(v.sessionId, `integrity@seq${v.seq}`);
|
|
190
|
+
for (const u of unsupportedSessions) flagSession(u.sessionId, `unsupported:${u.wireFormat}`);
|
|
191
|
+
|
|
192
|
+
const fileSize = (() => { try { return statSync(v1File).size; } catch { return 0; } })();
|
|
193
|
+
const report = {
|
|
194
|
+
v1File,
|
|
195
|
+
projectDir,
|
|
196
|
+
fileSize,
|
|
197
|
+
v2Sessions,
|
|
198
|
+
counters: { ...counters, v2Requests: v2Index.size, v2Unmatched, v1InflightOnly: inflightV1.size, v2ReqWithoutDone: v2ReqWithoutDone.length },
|
|
199
|
+
diffs,
|
|
200
|
+
integrity,
|
|
201
|
+
unsupportedSessions,
|
|
202
|
+
// Complete (uncapped) set of suspect sessions for the converter to quarantine.
|
|
203
|
+
suspectSessions: [...suspectSessions.entries()].map(([sessionId, r]) => ({ sessionId, count: r.count, reasons: r.reasons })),
|
|
204
|
+
ok: diffs.length === 0 && integrity.length === 0 && unsupportedSessions.length === 0,
|
|
205
|
+
};
|
|
206
|
+
return report;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Human-readable one-screen rendering for the CLI. */
|
|
210
|
+
export function renderReport(report) {
|
|
211
|
+
const c = report.counters;
|
|
212
|
+
const lines = [];
|
|
213
|
+
lines.push(`wire-v2 verify — ${report.v1File}`);
|
|
214
|
+
lines.push(` project: ${report.projectDir}`);
|
|
215
|
+
lines.push(` v2 sessions scanned: ${report.v2Sessions.length}, v2 requests indexed: ${c.v2Requests}`);
|
|
216
|
+
lines.push(` v1 completed: ${c.v1Completed} (heartbeat ${c.heartbeatsV1}, countTokens ${c.countTokensV1}, teammate ${c.teammateV1}, stale/broken skipped ${c.staleOrBroken})`);
|
|
217
|
+
lines.push(` matched: ${c.matched} v1-only (no v2 twin — pre-dual-write history): ${c.v1Only} v1-duplicate-key (rotation twins): ${c.v1DuplicateKey || 0} v2-unmatched (other segments): ${c.v2Unmatched}`);
|
|
218
|
+
lines.push(` inflight: v1-only ${c.v1InflightOnly}, v2 req-without-done ${c.v2ReqWithoutDone}`);
|
|
219
|
+
if (report.unsupportedSessions && report.unsupportedSessions.length > 0) {
|
|
220
|
+
lines.push(` UNSUPPORTED WIRE FORMAT (reader version gate): ${report.unsupportedSessions.length}`);
|
|
221
|
+
for (const u of report.unsupportedSessions.slice(0, 10)) lines.push(` ${u.sessionId} wireFormat=${u.wireFormat}`);
|
|
222
|
+
}
|
|
223
|
+
if (report.integrity.length > 0) {
|
|
224
|
+
lines.push(` INTEGRITY VIOLATIONS (journal msgTo vs replay): ${report.integrity.length}`);
|
|
225
|
+
for (const v of report.integrity.slice(0, 10)) lines.push(` ${v.sessionId} seq=${v.seq} conv=${v.conv} msgTo=${v.msgTo} replayed=${v.replayedLen}`);
|
|
226
|
+
}
|
|
227
|
+
if (report.diffs.length > 0) {
|
|
228
|
+
lines.push(` DIFFS: ${report.diffs.length}${report.diffs.length >= 50 ? '+ (capped)' : ''}`);
|
|
229
|
+
for (const d of report.diffs.slice(0, 10)) lines.push(` [${d.type}] seq=${d.seq} ${d.key}\n v1=${JSON.stringify(d.v1)} v2=${JSON.stringify(d.v2)}`);
|
|
230
|
+
}
|
|
231
|
+
lines.push(report.ok ? ' RESULT: OK — zero diffs' : ' RESULT: FAILED');
|
|
232
|
+
return lines.join('\n');
|
|
233
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire compression for client-facing SSE / streaming JSON responses.
|
|
3
|
+
*
|
|
4
|
+
* Negotiation is br|identity only: every real browser sends `br` in
|
|
5
|
+
* Accept-Encoding over plain http, while non-browser clients (curl, tests)
|
|
6
|
+
* that omit it keep today's plaintext bytes untouched. gzip is deliberately
|
|
7
|
+
* not offered — its 32KB window cannot back-reference the repeated multi-KB
|
|
8
|
+
* tools/system blocks or the growing conversation history, so it measured
|
|
9
|
+
* 1.8-3x against brotli's 20-80x on real sessions.
|
|
10
|
+
*
|
|
11
|
+
* CONVENTION: on a response that went through sseHead(), every subsequent
|
|
12
|
+
* byte MUST be written via sseWrite()/wireEnd() — a bare res.write() would
|
|
13
|
+
* interleave plaintext into the brotli stream and corrupt it for the client.
|
|
14
|
+
* All SSE broadcast paths (log-watcher send*ToClients, workspaces reload,
|
|
15
|
+
* server.js update badge) route through sseWrite for this reason.
|
|
16
|
+
*
|
|
17
|
+
* Escape hatches: CCV_WIRE_COMPRESSION=off disables negotiation entirely;
|
|
18
|
+
* CCV_BROTLI_QUALITY overrides the encoder quality (default 9 — measured
|
|
19
|
+
* knee on large sessions; q5 collapses to ~2.5x there).
|
|
20
|
+
*/
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import { reportSwallowed } from './error-report.js';
|
|
23
|
+
import { awaitDrainOrClose } from './sse-backpressure.js';
|
|
24
|
+
|
|
25
|
+
const C = zlib.constants;
|
|
26
|
+
const DEFAULT_QUALITY = 9;
|
|
27
|
+
const LGWIN = 24; // 16MB window: must cover the repeat distance of multi-MB live frames
|
|
28
|
+
|
|
29
|
+
/** Wire v3 flag parsing (default ON; '0' or 'off' is the escape hatch).
|
|
30
|
+
* Pure so the env contract is unit-testable — the const in server.js is
|
|
31
|
+
* evaluated once at import and can't be toggled in-process. */
|
|
32
|
+
export function isWireV3Enabled(v) {
|
|
33
|
+
return v !== '0' && v !== 'off';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function negotiatedEncoding(req) {
|
|
37
|
+
if (process.env.CCV_WIRE_COMPRESSION === 'off') return null;
|
|
38
|
+
const accept = req.headers && req.headers['accept-encoding'];
|
|
39
|
+
if (typeof accept !== 'string') return null;
|
|
40
|
+
// RFC 9110: a qvalue of 0 means "not acceptable" — `br;q=0` is a refusal.
|
|
41
|
+
for (const token of accept.split(',')) {
|
|
42
|
+
const [name, ...params] = token.trim().split(';');
|
|
43
|
+
if (name.trim() !== 'br') continue;
|
|
44
|
+
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
|
45
|
+
if (q && !(parseFloat(q.slice(2)) > 0)) return null;
|
|
46
|
+
return 'br';
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeEncoder() {
|
|
52
|
+
let quality = Number(process.env.CCV_BROTLI_QUALITY);
|
|
53
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 11) quality = DEFAULT_QUALITY;
|
|
54
|
+
return zlib.createBrotliCompress({
|
|
55
|
+
params: {
|
|
56
|
+
[C.BROTLI_PARAM_QUALITY]: quality,
|
|
57
|
+
[C.BROTLI_PARAM_LGWIN]: LGWIN,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Negotiate Content-Encoding and send the response head. On a `br` hit the
|
|
64
|
+
* encoder is piped into `res` and attached as `res._wireEnc`; otherwise this
|
|
65
|
+
* is exactly `res.writeHead(status, headers)` (plaintext path unchanged).
|
|
66
|
+
* The encoder is destroyed on response close, freeing its 16MB window.
|
|
67
|
+
*
|
|
68
|
+
* @param {import('node:http').IncomingMessage} req
|
|
69
|
+
* @param {import('node:http').ServerResponse} res
|
|
70
|
+
* @param {number} status
|
|
71
|
+
* @param {Record<string, string>} headers
|
|
72
|
+
* @param {{flush?: boolean}} [opts] - flush:false = whole-stream response (no
|
|
73
|
+
* per-macrotask flush; wireEnd's FINISH trailer is the only boundary —
|
|
74
|
+
* better ratio for bulk JSON like /api/requests that has no liveness need)
|
|
75
|
+
*/
|
|
76
|
+
export function sseHead(req, res, status, headers, opts) {
|
|
77
|
+
const enc = negotiatedEncoding(req);
|
|
78
|
+
if (!enc) {
|
|
79
|
+
// Vary on BOTH branches: the resource is negotiated either way, and a
|
|
80
|
+
// caching intermediary must key on Accept-Encoding for it.
|
|
81
|
+
res.writeHead(status, { ...headers, 'Vary': 'Accept-Encoding' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const encoder = makeEncoder();
|
|
85
|
+
if (opts && opts.flush === false) encoder._wireNoFlush = true;
|
|
86
|
+
encoder.on('error', (err) => {
|
|
87
|
+
// A compressor error mid-stream is unrecoverable for this client —
|
|
88
|
+
// surface it (silent loss here means a wedged UI) and drop the socket.
|
|
89
|
+
reportSwallowed('sse.compress', err);
|
|
90
|
+
try { res.destroy(); } catch { /* already gone */ }
|
|
91
|
+
});
|
|
92
|
+
encoder.pipe(res);
|
|
93
|
+
res._wireEnc = encoder;
|
|
94
|
+
res.on('close', () => {
|
|
95
|
+
res._wireEnc = null;
|
|
96
|
+
try { encoder.destroy(); } catch { /* already destroyed */ }
|
|
97
|
+
});
|
|
98
|
+
res.writeHead(status, { ...headers, 'Content-Encoding': 'br', 'Vary': 'Accept-Encoding' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleFlush(encoder) {
|
|
102
|
+
if (encoder._wireNoFlush || encoder._wireFlushScheduled) return;
|
|
103
|
+
encoder._wireFlushScheduled = true;
|
|
104
|
+
setImmediate(() => {
|
|
105
|
+
encoder._wireFlushScheduled = false;
|
|
106
|
+
if (encoder.destroyed || encoder.writableEnded) return;
|
|
107
|
+
// SYNC flush per macrotask: bytes of every event written this tick leave
|
|
108
|
+
// immediately (SSE liveness) while the window is retained (compression
|
|
109
|
+
// context takeover across events — the source of the measured 20-80x).
|
|
110
|
+
encoder.flush(C.BROTLI_OPERATION_FLUSH);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Write one SSE chunk through the response's negotiated encoding.
|
|
116
|
+
* Returns the write() boolean of the stream actually written to; combine
|
|
117
|
+
* with needsDrain(res) for socket-level backpressure on compressed paths.
|
|
118
|
+
*/
|
|
119
|
+
export function sseWrite(res, str) {
|
|
120
|
+
const encoder = res._wireEnc;
|
|
121
|
+
if (!encoder) return res.write(str);
|
|
122
|
+
if (encoder.destroyed || encoder.writableEnded) return false;
|
|
123
|
+
const ok = encoder.write(str);
|
|
124
|
+
scheduleFlush(encoder);
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Socket-level backpressure signal (true on both plain and compressed paths). */
|
|
129
|
+
export function needsDrain(res) {
|
|
130
|
+
return res.writableNeedDrain === true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Wait until the response's write target can accept more data (or the
|
|
135
|
+
* connection dies, or timeoutMs elapses). On the plain path this is exactly
|
|
136
|
+
* awaitDrainOrClose(res). On the compressed path the pressured stream is the
|
|
137
|
+
* ENCODER (its 'drain' fires when compression consumes the input backlog) —
|
|
138
|
+
* waiting on res 'drain' there can stall the full timeout, because compressed
|
|
139
|
+
* output is 20-80x smaller and may never backpressure the socket at all.
|
|
140
|
+
*/
|
|
141
|
+
export function awaitWireDrain(res, timeoutMs) {
|
|
142
|
+
const encoder = res._wireEnc;
|
|
143
|
+
if (!encoder) return awaitDrainOrClose(res, timeoutMs);
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
let t;
|
|
146
|
+
const done = () => {
|
|
147
|
+
clearTimeout(t);
|
|
148
|
+
encoder.off('drain', done);
|
|
149
|
+
encoder.off('close', done);
|
|
150
|
+
res.off('close', done);
|
|
151
|
+
res.off('error', done);
|
|
152
|
+
resolve();
|
|
153
|
+
};
|
|
154
|
+
t = setTimeout(done, timeoutMs);
|
|
155
|
+
encoder.once('drain', done);
|
|
156
|
+
encoder.once('close', done); // encoder is destroyed on res close
|
|
157
|
+
res.once('close', done);
|
|
158
|
+
res.once('error', done);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** End the response, flushing the encoder trailer first on compressed paths. */
|
|
163
|
+
export function wireEnd(res) {
|
|
164
|
+
const encoder = res._wireEnc;
|
|
165
|
+
if (!encoder) { res.end(); return; }
|
|
166
|
+
if (!encoder.destroyed && !encoder.writableEnded) encoder.end(); // pipe ends res
|
|
167
|
+
}
|