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,167 @@
1
+ // Wire Format v2 — on-disk layout contract (docs/refactor/WIRE_FORMAT_V2.md §2/§3).
2
+ //
3
+ // Single owner of every v2 path: nothing outside this module joins path segments
4
+ // into a session directory. All directory creation is SYNCHRONOUS on purpose —
5
+ // an async mkdir would lose the race against AsyncWriteQueue's microtask drain
6
+ // and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
7
+
8
+ import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ // The wire-format version this build writes AND the only one it reads.
12
+ // Stamped into meta.json and the journal sentinel at session creation; the
13
+ // read side refuses sessions carrying any OTHER version (spec §14 reader
14
+ // version gate) — a future wireFormat:3 dir must never be silently misread
15
+ // as v2. A missing version (torn creation) is treated as the current one.
16
+ export const WIRE_FORMAT_VERSION = 2;
17
+
18
+ export function isSupportedWireFormat(v) {
19
+ return v == null || v === WIRE_FORMAT_VERSION;
20
+ }
21
+
22
+ // Path-component whitelist (spec §2): anything outside [a-zA-Z0-9._-] is replaced
23
+ // with '_'; empty / dot-only results are rejected to '_'. Guards convKey /
24
+ // sessionId derived from wire content against path injection (plan risk F14).
25
+ export function sanitizePathComponent(name) {
26
+ const cleaned = String(name == null ? '' : name).replace(/[^a-zA-Z0-9._-]/g, '_');
27
+ if (cleaned === '' || /^\.+$/.test(cleaned)) return '_';
28
+ return cleaned;
29
+ }
30
+
31
+ /** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
32
+ * for the session dir-name prefix (task C). Deliberately NO internal separator
33
+ * (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
34
+ * the dir name `<14digits>_<sessionId>` splits unambiguously on the first `_`.
35
+ * Returns '' on an unparseable ts. */
36
+ export function compactLocalTs14(iso) {
37
+ const d = new Date(iso);
38
+ if (isNaN(d.getTime())) return '';
39
+ const p = (n) => String(n).padStart(2, '0');
40
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
41
+ }
42
+
43
+ // The `<project>/<SESSIONS_DIR_NAME>/<dirName>/` nesting is load-bearing: the
44
+ // read adapter re-derives the project root as `dirname(dirname(sessionDir))`
45
+ // (adapter.js/replay.js), so anything that materializes a session dir off the
46
+ // hot path (e.g. the zip-upload extractor) MUST reuse this constant to stay in
47
+ // sync — a divergent literal would silently synthesize zero entries.
48
+ export const SESSIONS_DIR_NAME = 'sessions';
49
+
50
+ /** All absolute paths of one session directory, derived once and passed around.
51
+ * The 3rd arg is the DIR-NAME (path segment) — for live sessions that is now
52
+ * `<ts>_<uuid>` (task C), for the converter a staged name, for legacy the bare
53
+ * UUID. It is NOT the identity (the UUID lives in meta.sessionId).
54
+ * `sessionsDirName` lets the offline converter target a sibling staging root
55
+ * (`sessions-migrating`) with the exact same layout; live writers keep the
56
+ * default and never pass it. */
57
+ export function sessionPaths(logDir, project, dirName, sessionsDirName = SESSIONS_DIR_NAME) {
58
+ const dir = join(logDir, sanitizePathComponent(project), sanitizePathComponent(sessionsDirName), sanitizePathComponent(dirName));
59
+ return {
60
+ dir,
61
+ metaPath: join(dir, 'meta.json'),
62
+ journalPath: join(dir, 'journal.jsonl'),
63
+ responsesPath: join(dir, 'responses.jsonl'),
64
+ // Optional display cache: deduped user prompts, one {seq, texts} line per
65
+ // emitting request. Append-only, never read by replay/verify (spec §15).
66
+ promptsPath: join(dir, 'prompts.jsonl'),
67
+ conversationsDir: join(dir, 'conversations'),
68
+ blobsDir: join(dir, 'blobs'),
69
+ };
70
+ }
71
+
72
+ /** Recursive byte size of a directory tree (the session-dir display size). */
73
+ export function dirSizeSync(dir) {
74
+ let total = 0;
75
+ let entries;
76
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return total; }
77
+ for (const e of entries) {
78
+ const p = join(dir, e.name);
79
+ try {
80
+ if (e.isDirectory()) total += dirSizeSync(p);
81
+ else if (e.isFile()) total += statSync(p).size;
82
+ } catch { /* raced deletion — skip */ }
83
+ }
84
+ return total;
85
+ }
86
+
87
+ export function convEpochPath(paths, convKey, epoch) {
88
+ return join(paths.conversationsDir, sanitizePathComponent(convKey), `e${epoch}.jsonl`);
89
+ }
90
+
91
+ export function convDir(paths, convKey) {
92
+ return join(paths.conversationsDir, sanitizePathComponent(convKey));
93
+ }
94
+
95
+ export function blobPath(paths, ref) {
96
+ // ref is produced by blob-store ('sha256-<hex16>') but sanitize anyway — the
97
+ // reader side may feed refs parsed back from journal lines.
98
+ return join(paths.blobsDir, `${sanitizePathComponent(ref)}.json`);
99
+ }
100
+
101
+ /**
102
+ * Atomic small-file write: tmp → fsync → rename. Used for meta.json and blobs —
103
+ * the two file kinds whose partial content must never be observable (spec §3/§7).
104
+ * fsync is deliberate: blobs are the durability barrier of the write-order
105
+ * protocol (blob → conversation → journal), see spec §1.3.
106
+ */
107
+ export function writeFileAtomicSync(finalPath, data) {
108
+ const tmp = `${finalPath}.tmp-${process.pid}`;
109
+ const fd = openSync(tmp, 'w');
110
+ try {
111
+ writeSync(fd, data);
112
+ fsyncSync(fd);
113
+ } finally {
114
+ closeSync(fd);
115
+ }
116
+ renameSync(tmp, finalPath);
117
+ }
118
+
119
+ /**
120
+ * Create the session directory skeleton + meta.json + the journal self-describing
121
+ * first line, idempotently. Must be called (synchronously) before any enqueue
122
+ * targeting this session. Returns the paths object.
123
+ *
124
+ * meta: { wireFormat:2, sessionId, project, pid, startTs, userIdRaw,
125
+ * userIdEncoding, leader?, im? } — spec §3. Existing meta is never
126
+ * rewritten here (later additive updates go through updateMetaSync).
127
+ */
128
+ export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
129
+ // Path uses dirName (`<ts>_<uuid>` for live, task C); IDENTITY (meta.sessionId
130
+ // + journal sentinel) stays the bare `sessionId` (UUID). Legacy callers omit
131
+ // dirName → it defaults to sessionId → old behavior (dir == UUID).
132
+ const paths = sessionPaths(logDir, project, dirName, sessionsDirName);
133
+ mkdirSync(paths.conversationsDir, { recursive: true });
134
+ mkdirSync(paths.blobsDir, { recursive: true });
135
+ if (!existsSync(paths.metaPath)) {
136
+ // `...meta` first: identity fields below must win even if a caller ever
137
+ // passes overlapping keys (defensive — current callers pass none of them).
138
+ const record = {
139
+ ...meta,
140
+ wireFormat: WIRE_FORMAT_VERSION,
141
+ sessionId,
142
+ project,
143
+ startTs: meta.startTs || new Date().toISOString(),
144
+ };
145
+ writeFileAtomicSync(paths.metaPath, JSON.stringify(record, null, 2) + '\n');
146
+ }
147
+ if (!existsSync(paths.journalPath)) {
148
+ // Journal sentinel first frame — must exist atomically at creation (same
149
+ // hazard as the v1 rotation sentinel: a watcher may read the file the
150
+ // instant it appears). 'wx' create-exclusive keeps a concurrent creator
151
+ // from double-writing the sentinel.
152
+ const sentinel = JSON.stringify({ ph: 'meta', wireFormat: WIRE_FORMAT_VERSION, sessionId }) + '\n';
153
+ try {
154
+ writeFileSync(paths.journalPath, sentinel, { flag: 'wx' });
155
+ } catch (err) {
156
+ if (err && err.code !== 'EEXIST') throw err;
157
+ }
158
+ }
159
+ return paths;
160
+ }
161
+
162
+ /** Ensure a conversation subdirectory exists (before first enqueue to it). */
163
+ export function ensureConvDirSync(paths, convKey) {
164
+ const dir = convDir(paths, convKey);
165
+ mkdirSync(dir, { recursive: true });
166
+ return dir;
167
+ }
@@ -0,0 +1,451 @@
1
+ // Wire Format v2 — live feed (S6b): streams v2 session-dir appends to the SSE
2
+ // clients, replacing the v1 log-file tail as the live channel.
3
+ //
4
+ // Architecture (plan 1.2):
5
+ // - One incremental cursor per live session dir, built on SessionSynthesizer —
6
+ // the SAME synthesis engine cold reads use, so live emission is
7
+ // byte-identical to a later cold read by construction.
8
+ // - Two-level watcher topology: a watcher on `sessions/` sees new session dirs
9
+ // appear (cross-process producers: teammates, IM workers); a per-session-dir
10
+ // watcher sees journal.jsonl / responses.jsonl appends (direct children).
11
+ // Conversation event files live two levels deeper where non-recursive
12
+ // fs.watch cannot see them — but the writer appends the conv line BEFORE the
13
+ // journal line, so the journal append is the tick; the synthesizer's
14
+ // defer/retry covers the rare flush inversion, and a safety poll covers
15
+ // missed events entirely (fs.watch is lossy under load).
16
+ // - The leader process's own writer nudges the feed in-process (`tick()`)
17
+ // for zero-latency delivery — the nudge only schedules a cursor read; the
18
+ // DATA always comes from the files, keeping one synthesis path.
19
+ //
20
+ // Emission: every synthesized item is round-tripped through JSON (protecting
21
+ // the synthesizer's internal objects from downstream mutation) and pushed
22
+ // through log-watcher's processWatchedEntry pipeline — the same enrichment,
23
+ // reconstruction and side-events the v1 tail produced.
24
+
25
+ import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { reportSwallowed } from '../error-report.js';
28
+ import { createIncrementalReconstructor } from '../delta-reconstructor.js';
29
+ import { processWatchedEntry } from '../log-watcher.js';
30
+ import { SessionSynthesizer } from './adapter.js';
31
+
32
+ const FSWATCH_DEBOUNCE_MS = 80;
33
+ const SAFETY_POLL_MS = 5000;
34
+ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
35
+ const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
36
+ const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
37
+ const TICK_RETRY_MAX = 8;
38
+
39
+ /** Read newly appended complete lines from a file cursor {path, offset,
40
+ * pending}. Returns an array of raw line strings (possibly empty). A file
41
+ * that shrank (should never happen — v2 files are append-only) returns null
42
+ * so the caller can rebuild the cursor. */
43
+ function readNewLines(cursor) {
44
+ let size;
45
+ try {
46
+ size = statSync(cursor.path).size;
47
+ } catch {
48
+ return []; // not created yet
49
+ }
50
+ if (size < cursor.offset) return null;
51
+ if (size === cursor.offset) return [];
52
+ const toRead = size - cursor.offset;
53
+ const buf = Buffer.alloc(toRead);
54
+ let fd;
55
+ try {
56
+ fd = openSync(cursor.path, 'r');
57
+ readSync(fd, buf, 0, toRead, cursor.offset);
58
+ } catch (err) {
59
+ reportSwallowed('v2-live.read', err);
60
+ return [];
61
+ } finally {
62
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
63
+ }
64
+ cursor.offset = size;
65
+ const chunk = cursor.pending + buf.toString('utf-8');
66
+ const parts = chunk.split('\n');
67
+ cursor.pending = parts.pop() || '';
68
+ return parts.map((l) => l.trim()).filter(Boolean);
69
+ }
70
+
71
+ export class V2LiveFeed {
72
+ /**
73
+ * @param {object} opts
74
+ * @param {Array} opts.clients - shared SSE client array (server.js)
75
+ * @param {Function} opts.getClaudePid
76
+ * @param {Function} opts.runParallelHook
77
+ * @param {Function} [opts.notifyStatsWorker] - called with the session dir
78
+ * after a batch of entries was emitted from it
79
+ * @param {Function} [opts.watchImpl] - fs.watch injection (tests)
80
+ * @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
81
+ * _safetyTick manually)
82
+ * @param {number} [opts.deferMs]
83
+ * @param {Function} [opts.now]
84
+ */
85
+ constructor(opts = {}) {
86
+ this._clients = opts.clients || [];
87
+ this._getClaudePid = opts.getClaudePid || (() => null);
88
+ this._runParallelHook = opts.runParallelHook || (() => Promise.resolve());
89
+ this._notifyStatsWorker = opts.notifyStatsWorker || null;
90
+ this._watchImpl = opts.watchImpl || watch;
91
+ this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
92
+ this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
93
+ this._now = opts.now || Date.now;
94
+ this._sessions = new Map(); // dir → cursor bundle
95
+ this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
96
+ this._sessionsRoot = null;
97
+ this._rootWatcher = null;
98
+ this._safetyTimer = null;
99
+ this._startedAt = 0;
100
+ this._active = false;
101
+ }
102
+
103
+ /** Begin (or switch to) live-following a project's sessions/ root. */
104
+ start(projectDir) {
105
+ this.stop();
106
+ this._active = true;
107
+ this._startedAt = this._now();
108
+ this._sessionsRoot = join(projectDir, 'sessions');
109
+ this._armRootWatcher();
110
+ this._initialScan();
111
+ if (this._safetyPollMs > 0) {
112
+ this._safetyTimer = setInterval(() => this._safetyTick(), this._safetyPollMs);
113
+ }
114
+ }
115
+
116
+ stop() {
117
+ this._active = false;
118
+ if (this._safetyTimer) { clearInterval(this._safetyTimer); this._safetyTimer = null; }
119
+ if (this._rootWatcher) { try { this._rootWatcher.close(); } catch { /* already closed */ } this._rootWatcher = null; }
120
+ for (const cur of this._sessions.values()) this._closeCursor(cur);
121
+ this._sessions.clear();
122
+ this._seenDirs.clear();
123
+ this._sessionsRoot = null;
124
+ }
125
+
126
+ /** In-process nudge from the local V2Writer after an ingest: attach the
127
+ * session on first sight and schedule a cursor read. Zero-latency path for
128
+ * the leader's own traffic. The write queue drains asynchronously, so the
129
+ * very first tick can race the journal's creation — retry briefly (the
130
+ * per-dir watcher and safety poll are the durable fallbacks). */
131
+ tick(sessionDir, _retries = 0) {
132
+ if (!this._active || !sessionDir) return;
133
+ let cur = this._sessions.get(sessionDir);
134
+ if (!cur) {
135
+ cur = this._attach(sessionDir, { suppressExisting: false });
136
+ if (!cur) {
137
+ if (_retries < TICK_RETRY_MAX) {
138
+ setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
139
+ }
140
+ return;
141
+ }
142
+ }
143
+ this._scheduleRead(cur);
144
+ }
145
+
146
+ /** True when a session dir is being followed (tests / diagnostics). */
147
+ isFollowing(sessionDir) {
148
+ return this._sessions.has(sessionDir);
149
+ }
150
+
151
+ // ── watcher topology ───────────────────────────────────────────────────────
152
+
153
+ _armRootWatcher() {
154
+ if (this._rootWatcher || !this._sessionsRoot) return;
155
+ if (!existsSync(this._sessionsRoot)) return; // re-tried by the safety tick
156
+ try {
157
+ const watcher = this._watchImpl(this._sessionsRoot, (eventType, filename) => {
158
+ // A new (or renamed) direct child is potentially a fresh session dir.
159
+ if (!this._active) return;
160
+ if (filename) {
161
+ const dir = join(this._sessionsRoot, filename);
162
+ this._maybeAttachNew(dir);
163
+ } else {
164
+ this._scanRoot();
165
+ }
166
+ });
167
+ watcher.on('error', (err) => {
168
+ reportSwallowed('v2-live.root-watch', err);
169
+ try { watcher.close(); } catch { /* already closed */ }
170
+ if (this._rootWatcher === watcher) this._rootWatcher = null; // safety poll re-arms
171
+ });
172
+ this._rootWatcher = watcher;
173
+ } catch (err) {
174
+ reportSwallowed('v2-live.root-watch', err);
175
+ }
176
+ }
177
+
178
+ _initialScan() {
179
+ if (!existsSync(this._sessionsRoot)) return;
180
+ let names = [];
181
+ try {
182
+ names = readdirSync(this._sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
183
+ } catch (err) {
184
+ reportSwallowed('v2-live.scan', err);
185
+ return;
186
+ }
187
+ for (const name of names) {
188
+ const dir = join(this._sessionsRoot, name);
189
+ const mtime = this._journalMtime(dir);
190
+ this._seenDirs.set(dir, mtime);
191
+ // Pre-existing dirs with recent activity are live producers that predate
192
+ // this feed (mid-run server start / workspace switch): follow them but
193
+ // suppress their history — the cold reload channel covers it.
194
+ if (mtime > 0 && this._now() - mtime <= ATTACH_RECENT_MS) {
195
+ this._attach(dir, { suppressExisting: true });
196
+ }
197
+ }
198
+ }
199
+
200
+ _scanRoot() {
201
+ if (!existsSync(this._sessionsRoot)) return;
202
+ let names = [];
203
+ try {
204
+ names = readdirSync(this._sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
205
+ } catch {
206
+ return;
207
+ }
208
+ for (const name of names) this._maybeAttachNew(join(this._sessionsRoot, name));
209
+ }
210
+
211
+ /** Attach a dir first seen after feed start (root watcher / safety scan). */
212
+ _maybeAttachNew(dir) {
213
+ if (this._sessions.has(dir)) return;
214
+ const mtime = this._journalMtime(dir);
215
+ if (mtime === 0) return; // no journal yet — the next event/tick retries
216
+ const seenBefore = this._seenDirs.has(dir);
217
+ this._seenDirs.set(dir, mtime);
218
+ if (!seenBefore) {
219
+ // Genuinely new session dir: everything in it is new — emit from byte 0
220
+ // when it was born after the feed started, otherwise treat as history.
221
+ // birthtime ≤ 0 (Linux filesystems without btime) must count as new:
222
+ // suppressing would swallow a cross-process session's first entries
223
+ // (review P2).
224
+ const bornAfterStart = (() => {
225
+ try {
226
+ const bt = statSync(dir).birthtimeMs;
227
+ return !(bt > 0) || bt >= this._startedAt - 1000;
228
+ } catch { return true; }
229
+ })();
230
+ this._attach(dir, { suppressExisting: !bornAfterStart });
231
+ }
232
+ }
233
+
234
+ _journalMtime(dir) {
235
+ try { return statSync(join(dir, 'journal.jsonl')).mtimeMs; } catch { return 0; }
236
+ }
237
+
238
+ // ── per-session cursors ────────────────────────────────────────────────────
239
+
240
+ _attach(dir, { suppressExisting }) {
241
+ if (this._sessions.has(dir)) return this._sessions.get(dir);
242
+ if (!existsSync(join(dir, 'journal.jsonl'))) return null;
243
+ const cur = {
244
+ dir,
245
+ synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
246
+ // Per-session reconstructor (review P2): two concurrently-followed main
247
+ // producers in one project (a second ccv process, IM worker) must not
248
+ // rebase each other's accumulated baseline — v1's tail followed exactly
249
+ // one file, so a single shared reconstructor was a new interleave surface.
250
+ reconstructor: createIncrementalReconstructor(),
251
+ journal: { path: join(dir, 'journal.jsonl'), offset: 0, pending: '' },
252
+ responses: { path: join(dir, 'responses.jsonl'), offset: 0, pending: '' },
253
+ convFiles: new Map(), // path → {key, offset, pending}
254
+ watcher: null,
255
+ debounce: null,
256
+ reading: false,
257
+ dirty: false,
258
+ suppress: !!suppressExisting,
259
+ };
260
+ this._sessions.set(dir, cur);
261
+ this._seenDirs.set(dir, this._journalMtime(dir));
262
+ try {
263
+ cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
264
+ cur.watcher.on('error', (err) => {
265
+ reportSwallowed('v2-live.session-watch', err);
266
+ try { cur.watcher.close(); } catch { /* already closed */ }
267
+ cur.watcher = null; // safety poll keeps the cursor alive
268
+ });
269
+ } catch (err) {
270
+ reportSwallowed('v2-live.session-watch', err);
271
+ }
272
+ this._readCursor(cur); // seed (suppressed history or brand-new content)
273
+ cur.suppress = false;
274
+ return cur;
275
+ }
276
+
277
+ _closeCursor(cur) {
278
+ if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
279
+ if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
280
+ }
281
+
282
+ _scheduleRead(cur) {
283
+ if (!this._active) return;
284
+ if (cur.debounce) return;
285
+ cur.debounce = setTimeout(() => {
286
+ cur.debounce = null;
287
+ this._readCursor(cur);
288
+ }, FSWATCH_DEBOUNCE_MS);
289
+ }
290
+
291
+ /** Read all new bytes of one session (conv events → responses → journal —
292
+ * the order that maximizes gate success), feed the synthesizer, emit. */
293
+ _readCursor(cur) {
294
+ if (cur.reading) { cur.dirty = true; return; }
295
+ cur.reading = true;
296
+ try {
297
+ do {
298
+ cur.dirty = false;
299
+ this._feedConvFiles(cur);
300
+ const respLines = readNewLines(cur.responses);
301
+ if (respLines === null) { this._rebuildCursor(cur); return; }
302
+ for (const raw of respLines) cur.synth.ingestResponseLine(raw);
303
+ const journalLines = readNewLines(cur.journal);
304
+ if (journalLines === null) { this._rebuildCursor(cur); return; }
305
+ for (const raw of journalLines) {
306
+ let line = null;
307
+ try { line = JSON.parse(raw); } catch (err) {
308
+ reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
309
+ }
310
+ if (line) cur.synth.ingestJournalLine(line);
311
+ }
312
+ this._emitDrained(cur);
313
+ } while (cur.dirty);
314
+ } finally {
315
+ cur.reading = false;
316
+ }
317
+ }
318
+
319
+ _feedConvFiles(cur) {
320
+ const convRoot = join(cur.dir, 'conversations');
321
+ if (!existsSync(convRoot)) return;
322
+ let keys = [];
323
+ try {
324
+ keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
325
+ } catch {
326
+ return;
327
+ }
328
+ for (const key of keys) {
329
+ let files = [];
330
+ try {
331
+ files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
332
+ } catch {
333
+ continue;
334
+ }
335
+ // Numeric epoch order. NB: file order is NOT guaranteed to be globally
336
+ // seq-ordered (a pre-fix writer restart appended newer seqs into an
337
+ // older epoch file) — the synthesizer's ingestConvLine keeps its event
338
+ // window seq-sorted on insert, so feed order only needs to be stable.
339
+ files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
340
+ for (const f of files) {
341
+ const p = join(convRoot, key, f);
342
+ let fc = cur.convFiles.get(p);
343
+ if (!fc) {
344
+ fc = { key, path: p, offset: 0, pending: '' };
345
+ cur.convFiles.set(p, fc);
346
+ }
347
+ const lines = readNewLines(fc);
348
+ if (lines === null) { this._rebuildCursor(cur); return; }
349
+ for (const raw of lines) {
350
+ let ev = null;
351
+ try { ev = JSON.parse(raw); } catch (err) {
352
+ reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
353
+ }
354
+ if (ev) cur.synth.ingestConvLine(key, ev);
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ /** Append-only invariant broke (shrunk file): rebuild the whole cursor with
361
+ * a fresh synthesizer, replaying silently to the current state. */
362
+ _rebuildCursor(cur) {
363
+ reportSwallowed('v2-live.cursor-rebuild', new Error(`${cur.dir}: file shrank — rebuilding cursor`));
364
+ this._closeCursor(cur);
365
+ this._sessions.delete(cur.dir);
366
+ const fresh = this._attach(cur.dir, { suppressExisting: true });
367
+ if (fresh) this._scheduleRead(fresh);
368
+ }
369
+
370
+ _emitDrained(cur) {
371
+ cur.synth.sweepDeadlines();
372
+ const items = cur.synth.drain();
373
+ if (items.length === 0) return;
374
+ if (cur.suppress) {
375
+ // Seed phase: history is already covered by the cold-load channel, so
376
+ // nothing is broadcast — but the shared reconstructor still consumes it,
377
+ // giving later deltas a baseline (v1's tail restarted with no baseline
378
+ // and passed bare slices through; the cold reload had to cover them).
379
+ for (const item of items) {
380
+ try {
381
+ cur.reconstructor.reconstruct(JSON.parse(JSON.stringify(item.entry)));
382
+ } catch (err) {
383
+ reportSwallowed('v2-live.seed', err);
384
+ }
385
+ }
386
+ return;
387
+ }
388
+ let emitted = 0;
389
+ for (const item of items) {
390
+ let parsed = null;
391
+ try {
392
+ // JSON round-trip: downstream (reconstructor/enrichers) mutate entries
393
+ // in place — the synthesizer's own objects must stay pristine so the
394
+ // placeholder→completed mutation stays byte-true.
395
+ parsed = JSON.parse(JSON.stringify(item.entry));
396
+ } catch (err) {
397
+ reportSwallowed('v2-live.emit', err);
398
+ continue;
399
+ }
400
+ try {
401
+ processWatchedEntry(parsed, {
402
+ reconstructor: cur.reconstructor,
403
+ clients: this._clients,
404
+ getClaudePid: this._getClaudePid,
405
+ runParallelHook: this._runParallelHook,
406
+ });
407
+ emitted++;
408
+ } catch (err) {
409
+ reportSwallowed('v2-live.emit', err);
410
+ }
411
+ }
412
+ if (emitted > 0 && this._notifyStatsWorker) {
413
+ try { this._notifyStatsWorker(cur.dir); } catch (err) { reportSwallowed('v2-live.stats-notify', err); }
414
+ }
415
+ }
416
+
417
+ // ── safety poll ────────────────────────────────────────────────────────────
418
+
419
+ _safetyTick() {
420
+ if (!this._active) return;
421
+ this._armRootWatcher(); // re-arm after errors / late directory creation
422
+ // Attached sessions: unconditional slow re-read (fs.watch is lossy).
423
+ for (const cur of [...this._sessions.values()]) {
424
+ this._readCursor(cur);
425
+ }
426
+ // Unattached dirs: attach brand-new ones (missed root events) and
427
+ // re-attach previously idle ones whose journal moved again.
428
+ if (!existsSync(this._sessionsRoot)) return;
429
+ let names = [];
430
+ try {
431
+ names = readdirSync(this._sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
432
+ } catch {
433
+ return;
434
+ }
435
+ for (const name of names) {
436
+ const dir = join(this._sessionsRoot, name);
437
+ if (this._sessions.has(dir)) continue;
438
+ const mtime = this._journalMtime(dir);
439
+ if (mtime === 0) continue;
440
+ const seen = this._seenDirs.get(dir);
441
+ if (seen === undefined) {
442
+ this._maybeAttachNew(dir);
443
+ } else if (mtime > seen) {
444
+ // A dir skipped at the initial scan (stale then) has resumed activity:
445
+ // follow it now, history suppressed (it predates this feed).
446
+ this._seenDirs.set(dir, mtime);
447
+ this._attach(dir, { suppressExisting: true });
448
+ }
449
+ }
450
+ }
451
+ }
@@ -0,0 +1,54 @@
1
+ // 1.7.0 migration prompt (plan P2): detect legacy v1 logs that have not been
2
+ // converted to the v2 session store yet, so the UI can offer one-click
3
+ // migration at startup / workspace switch. Detection is pure filesystem
4
+ // arithmetic on top of the converter's own bookkeeping — a v1 file counts as
5
+ // pending unless the convert state marks it done AT ITS CURRENT SIZE (the
6
+ // converter's trust rule, convert.js), because the converter never deletes
7
+ // v1 sources ("files exist" alone is not "migration needed").
8
+ import { statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { listV1Files, listConvertibleProjects, readConvertState } from './convert.js';
11
+
12
+ /** Pending v1 files + bytes of ONE project dir. */
13
+ function pendingOf(projectDir) {
14
+ const state = readConvertState(projectDir);
15
+ const doneAtSize = new Map(
16
+ (state && Array.isArray(state.files) ? state.files : [])
17
+ .filter((f) => f && f.done)
18
+ .map((f) => [f.name, f.size])
19
+ );
20
+ let files = 0;
21
+ let totalBytes = 0;
22
+ for (const name of listV1Files(projectDir)) {
23
+ let size = 0;
24
+ try { size = statSync(join(projectDir, name)).size; } catch { continue; }
25
+ if (size === 0) continue; // empty shells are not worth prompting over
26
+ if (doneAtSize.get(name) === size) continue; // converted & unchanged
27
+ files++;
28
+ totalBytes += size;
29
+ }
30
+ return { files, totalBytes };
31
+ }
32
+
33
+ /**
34
+ * Migration status of one project (plus how many OTHER projects also have
35
+ * pending v1 logs — the prompt mentions `ccv convert --all` for those).
36
+ * @param {string} logDir - LOG_DIR root
37
+ * @param {string} project - project directory name ('' → not pending)
38
+ * @returns {{pending: boolean, files: number, totalBytes: number, otherProjects: number}}
39
+ */
40
+ export function migrationStatus(logDir, project) {
41
+ const empty = { pending: false, files: 0, totalBytes: 0, otherProjects: 0 };
42
+ if (!logDir || !project) return empty;
43
+ try {
44
+ const { files, totalBytes } = pendingOf(join(logDir, project));
45
+ let otherProjects = 0;
46
+ for (const p of listConvertibleProjects(logDir)) {
47
+ if (p === project) continue;
48
+ if (pendingOf(join(logDir, p)).files > 0) otherProjects++;
49
+ }
50
+ return { pending: files > 0, files, totalBytes, otherProjects };
51
+ } catch {
52
+ return empty;
53
+ }
54
+ }