cc-viewer 1.6.348 → 1.7.0

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.
Files changed (126) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-DTkacfRW.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
  5. package/dist/assets/Mobile-MoBp9c8k.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-Ac8vnV3v.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
  57. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +2 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +34 -272
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1059 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +451 -0
  99. package/server/lib/v2/migrate-prompt.js +54 -0
  100. package/server/lib/v2/replay.js +191 -0
  101. package/server/lib/v2/session-select.js +222 -0
  102. package/server/lib/v2/singleflight.js +104 -0
  103. package/server/lib/v2/v2-writer.js +513 -0
  104. package/server/lib/v2/verify.js +233 -0
  105. package/server/routes/events.js +30 -130
  106. package/server/routes/files-fs.js +4 -2
  107. package/server/routes/im.js +11 -2
  108. package/server/routes/logs.js +194 -152
  109. package/server/routes/preferences.js +0 -3
  110. package/server/routes/project-meta.js +2 -3
  111. package/server/routes/session-pin.js +7 -8
  112. package/server/routes/workspaces.js +44 -9
  113. package/server/server.js +47 -65
  114. package/server/workspace-registry.js +24 -4
  115. package/dist/assets/App-VTo4NVdo.js +0 -2
  116. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  117. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  118. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  119. package/dist/assets/clone-md-Mc1-T.js +0 -1
  120. package/dist/assets/index-DCo650PG.js +0 -2
  121. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  122. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  123. package/server/lib/instance-registry.js +0 -57
  124. package/server/lib/jsonl-archive.js +0 -180
  125. package/server/lib/teammate-detect.js +0 -32
  126. 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
+ }
@@ -1,10 +1,11 @@
1
1
  // SSE event stream + log-registration / resume / turn-end routes (moved verbatim from server.js).
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { LOG_FILE, _resumeState, resolveResumeChoice, _projectName } from '../interceptor.js';
4
+ import { _projectName, getLiveLogSource, isContinuedLaunch } from '../interceptor.js';
5
5
  import { LOG_DIR } from '../../findcc.js';
6
- import { watchLogFile } from '../lib/log-watcher.js';
7
- import { countLogEntries, streamRawEntriesAsync, readPagedEntries } from '../lib/log-stream.js';
6
+ import { streamRawEntriesAsync } from '../lib/log-stream.js';
7
+ import { migrationStatus } from '../lib/v2/migrate-prompt.js';
8
+ import { reportSwallowed } from '../lib/error-report.js';
8
9
  import { awaitDrainOrClose } from '../lib/sse-backpressure.js';
9
10
  import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
10
11
  import { validateLogPath } from '../lib/log-management.js';
@@ -49,77 +50,6 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
49
50
  });
50
51
  }
51
52
 
52
- // 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
53
- function registerLog(req, res, parsedUrl, isLocal, deps) {
54
- let body = '';
55
- req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
56
- req.on('end', () => {
57
- try {
58
- const { logFile } = JSON.parse(body);
59
- if (logFile && typeof logFile === 'string' && logFile.startsWith(LOG_DIR) && existsSync(logFile)) {
60
- watchLogFile(deps.logWatcherOpts(logFile));
61
- res.writeHead(200, { 'Content-Type': 'application/json' });
62
- res.end(JSON.stringify({ ok: true }));
63
- } else {
64
- res.writeHead(400, { 'Content-Type': 'application/json' });
65
- res.end(JSON.stringify({ error: 'Invalid log file path' }));
66
- }
67
- } catch {
68
- res.writeHead(400, { 'Content-Type': 'application/json' });
69
- res.end(JSON.stringify({ error: 'Invalid request body' }));
70
- }
71
- });
72
- }
73
-
74
- // 用户选择继续/新开日志
75
- function resumeChoice(req, res, parsedUrl, isLocal, deps) {
76
- let body = '';
77
- req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
78
- req.on('end', async () => {
79
- try {
80
- const { choice } = JSON.parse(body);
81
- if (choice !== 'continue' && choice !== 'new') {
82
- res.writeHead(400, { 'Content-Type': 'application/json' });
83
- res.end(JSON.stringify({ error: 'Invalid choice' }));
84
- return;
85
- }
86
- const result = resolveResumeChoice(choice);
87
- if (!result) {
88
- res.writeHead(409, { 'Content-Type': 'application/json' });
89
- res.end(JSON.stringify({ error: 'Already resolved' }));
90
- return;
91
- }
92
- // 重新 watch 最终的日志文件
93
- watchLogFile(deps.logWatcherOpts(result.logFile));
94
- // 广播 resume_resolved + full_reload
95
- const resolvedData = JSON.stringify({ logFile: result.logFile });
96
- deps.clients.forEach(client => {
97
- try {
98
- client.write(`event: resume_resolved\ndata: ${resolvedData}\n\n`);
99
- } catch { }
100
- });
101
- // 流式分段广播 full_reload,避免全量加载 OOM
102
- const reloadTotal = await countLogEntries(LOG_FILE);
103
- deps.clients.forEach(client => {
104
- try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: reloadTotal, incremental: false })}\n\n`); } catch { }
105
- });
106
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
107
- deps.clients.forEach(client => {
108
- try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch { }
109
- });
110
- });
111
- deps.clients.forEach(client => {
112
- try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
113
- });
114
- res.writeHead(200, { 'Content-Type': 'application/json' });
115
- res.end(JSON.stringify({ ok: true, logFile: result.logFile }));
116
- } catch {
117
- res.writeHead(400, { 'Content-Type': 'application/json' });
118
- res.end(JSON.stringify({ error: 'Invalid request body' }));
119
- }
120
- });
121
- }
122
-
123
53
  // SSE endpoint
124
54
  // 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
125
55
  // 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
@@ -157,16 +87,22 @@ async function events(req, res, parsedUrl, isLocal, deps) {
157
87
  console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
158
88
  }
159
89
 
160
- // 如果有待决的 resume 选择,发送 resume_prompt 事件
161
- if (_resumeState) {
162
- res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
163
- }
164
-
165
90
  // 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
166
91
  // 置于 deps.clients.push(res) 之前 → 新客户端仅得一份,不与一次性广播竞态。
167
92
  const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
168
93
  if (updFrame) res.write(updFrame);
169
94
 
95
+ // 1.7.0 迁移引导(P2):当前项目仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
96
+ // 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
97
+ // 再提醒一次——`-c` 续接的对话前半段在旧格式里,不迁移就看不到)。工作区切换后的
98
+ // 提示由 workspaces launch 路由对存量连接广播(本帧只覆盖新连接)。
99
+ try {
100
+ const mig = migrationStatus(LOG_DIR, _projectName || '');
101
+ if (mig.pending) {
102
+ res.write(`event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
103
+ }
104
+ } catch (e) { reportSwallowed('sse.migrate_prompt', e); }
105
+
170
106
  // 增量加载参数:移动端带 since/cc/project 请求增量数据
171
107
  const sinceParam = parsedUrl.searchParams.get('since');
172
108
  const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
@@ -202,10 +138,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
202
138
  // ring=3 的容错意义:团队会话末尾常有连续 teammate 伪 mainAgent 条目,单记忆位会被
203
139
  // 挤掉真实 mainAgent;子串预过滤的理论误伤(键/值恰为 "teammate" 的真实条目)也由
204
140
  // 环内更早候选兜底。
141
+ // KEEP IN SYNC: server/lib/v2/adapter.js MAINAGENT_RING_DEPTH — on the v2 path
142
+ // the adapter's mainAgentRing (that depth) wholesale REPLACES this ring, so a
143
+ // mismatch would silently give v1 and v2 sessions different candidate depths.
205
144
  const MAINAGENT_SCAN_RING = 3;
206
145
  const mainAgentRawRing = [];
207
146
 
208
- await streamRawEntriesAsync(LOG_FILE, async (raw) => {
147
+ // S6b: the cold-load source is the current v2 session dir when the v2
148
+ // writer is active (adapter stream), else the v1 file.
149
+ const coldLoadResult = await streamRawEntriesAsync(getLiveLogSource(), async (raw) => {
209
150
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
210
151
  // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
211
152
  if (res.destroyed || !res.writable) return;
@@ -255,6 +196,14 @@ async function events(req, res, parsedUrl, isLocal, deps) {
255
196
 
256
197
  res.write(`event: load_end\ndata: {}\n\n`);
257
198
 
199
+ // S10a: v2 sources no longer invoke onScan (the two-pass window never
200
+ // stringifies out-of-window entries) — the adapter returns the newest-main
201
+ // raws as `mainAgentRing` instead. v1 sources still fill the ring via onScan.
202
+ if (Array.isArray(coldLoadResult?.mainAgentRing) && coldLoadResult.mainAgentRing.length > 0) {
203
+ mainAgentRawRing.length = 0;
204
+ mainAgentRawRing.push(...coldLoadResult.mainAgentRing);
205
+ }
206
+
258
207
  // Pass 1 入环的候选 newest-first 校验 + parse(≤K 次)。kv_cache 与 context_window
259
208
  // 各自取"最新一条能提供该值的真实 mainAgent"—— 与旧版逐条覆盖语义等价(环深度内)。
260
209
  for (let ri = mainAgentRawRing.length - 1; ri >= 0 && (!latestKvCache || !latestContextWindow); ri--) {
@@ -328,7 +277,7 @@ async function requests(req, res) {
328
277
  res.writeHead(200, { 'Content-Type': 'application/json' });
329
278
  res.write('[');
330
279
  let first = true;
331
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
280
+ await streamRawEntriesAsync(getLiveLogSource(), (raw) => {
332
281
  if (!first) res.write(',');
333
282
  res.write(enrichRawIfNeeded(raw));
334
283
  first = false;
@@ -337,57 +286,8 @@ async function requests(req, res) {
337
286
  res.end();
338
287
  }
339
288
 
340
- // 分页历史条目端点:移动端"加载更多"按需拉取
341
- // 支持可选 ?file= 参数指定目标文件(用于本地日志分页),默认使用活跃会话文件
342
- async function entriesPage(req, res, parsedUrl) {
343
- const before = parsedUrl.searchParams.get('before');
344
- const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 100, 500);
345
- if (!before || isNaN(new Date(before).getTime())) {
346
- res.writeHead(400, { 'Content-Type': 'application/json' });
347
- res.end(JSON.stringify({ error: 'missing or invalid "before" parameter' }));
348
- return;
349
- }
350
- const file = parsedUrl.searchParams.get('file');
351
- let targetFile = LOG_FILE;
352
- if (file) {
353
- if (file.includes('..') || (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip'))) {
354
- res.writeHead(400, { 'Content-Type': 'application/json' });
355
- res.end(JSON.stringify({ error: 'Invalid file name' }));
356
- return;
357
- }
358
- try { validateLogPath(LOG_DIR, file); } catch (e) {
359
- const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ACCESS_DENIED' ? 403 : 400;
360
- res.writeHead(status, { 'Content-Type': 'application/json' });
361
- res.end(JSON.stringify({ error: e.message }));
362
- return;
363
- }
364
- targetFile = join(LOG_DIR, file);
365
- }
366
- try {
367
- const result = await readPagedEntries(targetFile, { before, limit: limitVal });
368
- // entries 是原始 JSON 字符串数组,parse 后返回给客户端
369
- // ExitPlanMode V2 空 input 的条目用 enrichRawIfNeeded 在 raw 阶段补全
370
- const entries = result.entries.map(raw => {
371
- try { return JSON.parse(enrichRawIfNeeded(raw)); } catch { return null; }
372
- }).filter(Boolean);
373
- res.writeHead(200, { 'Content-Type': 'application/json' });
374
- res.end(JSON.stringify({
375
- entries,
376
- hasMore: result.hasMore,
377
- oldestTimestamp: result.oldestTimestamp,
378
- count: entries.length,
379
- }));
380
- } catch (err) {
381
- res.writeHead(500, { 'Content-Type': 'application/json' });
382
- res.end(JSON.stringify({ error: err.message }));
383
- }
384
- }
385
-
386
289
  export const eventsRoutes = [
387
290
  { method: 'POST', match: 'exact', path: '/api/turn-end-notify', handler: turnEndNotify },
388
- { method: 'POST', match: 'exact', path: '/api/register-log', handler: registerLog },
389
- { method: 'POST', match: 'exact', path: '/api/resume-choice', handler: resumeChoice },
390
291
  { method: 'GET', match: 'exact', path: '/events', handler: events },
391
292
  { method: 'GET', match: 'exact', path: '/api/requests', handler: requests },
392
- { method: 'GET', match: 'exact', path: '/api/entries/page', handler: entriesPage },
393
293
  ];
@@ -8,7 +8,7 @@ import { homedir, tmpdir } from 'node:os';
8
8
  import { execFile, spawn } from 'node:child_process';
9
9
  import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
10
10
  import { validateImportDir } from '../lib/file-api.js';
11
- import { LOG_FILE, PROFILE_PATH, _projectName } from '../interceptor.js';
11
+ import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
12
12
  import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
13
13
 
14
14
  function upload(req, res, parsedUrl, isLocal, deps) {
@@ -844,7 +844,9 @@ function createDir(req, res, parsedUrl, isLocal, deps) {
844
844
  }
845
845
 
846
846
  function openLogDir(req, res) {
847
- const dir = LOG_FILE ? dirname(LOG_FILE) : LOG_DIR;
847
+ // Project dir works for both formats (legacy v1 files and the v2 sessions/
848
+ // tree are siblings under it); LOG_DIR is the pre-workspace fallback.
849
+ const dir = _logDir || LOG_DIR;
848
850
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
849
851
  execFile(cmd, [dir], { windowsHide: true }, () => {});
850
852
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -21,6 +21,7 @@
21
21
  // reports its own in-process adapter (deps.im.getBridgeStatus) — that's what the manager probes.
22
22
  import { getDescriptor, loadConfig, loadState, saveConfig } from '../lib/im-config.js';
23
23
  import { findRecentLog } from '../lib/interceptor-core.js';
24
+ import { listV2Sessions } from '../lib/v2/adapter.js';
24
25
  import { readSenders } from '../lib/im-senders.js';
25
26
  import { readImAppendSystem, writeImAppendSystem, buildImAppendSystemPreset, MAX_IM_APPEND_SYSTEM_CHARS } from '../lib/im-append-system.js';
26
27
  import { imDir } from '../lib/im-lock.js';
@@ -256,8 +257,16 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
256
257
  const project = `IM_${id}`;
257
258
  let latest = null;
258
259
  try {
259
- const abs = findRecentLog(join(LOG_DIR, project), project); // 已排除 *_temp.jsonl
260
- if (abs) latest = `${project}/${basename(abs)}`; // 相对 LOG_DIR,直接喂给 /api/local-log?file=
260
+ // wire-v2: prefer the newest v2 session (by meta.startTs, journal mtime as
261
+ // tie-break); fall back to legacy v1 files until they are migrated.
262
+ const sessions = listV2Sessions(join(LOG_DIR, project)).filter((s) => !s.leader);
263
+ if (sessions.length > 0) {
264
+ sessions.sort((a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0));
265
+ latest = `v2:${project}/${sessions[0].sid}`; // 直接喂给 /api/local-log?file=
266
+ } else {
267
+ const abs = findRecentLog(join(LOG_DIR, project), project); // 已排除 *_temp.jsonl
268
+ if (abs) latest = `${project}/${basename(abs)}`; // 相对 LOG_DIR
269
+ }
261
270
  } catch { /* 无目录/无日志 → latest=null */ }
262
271
  res.writeHead(200, JSON_HEADERS);
263
272
  res.end(JSON.stringify({ project, latest }));