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.
- package/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-DTkacfRW.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
- package/dist/assets/Mobile-MoBp9c8k.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-Ac8vnV3v.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-Cd_TAXTb.js +2 -0
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
- 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 +2 -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 +34 -272
- 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 +1059 -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 +451 -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 +513 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/routes/events.js +30 -130
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +194 -152
- 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/workspaces.js +44 -9
- package/server/server.js +47 -65
- package/server/workspace-registry.js +24 -4
- 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,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
|
+
}
|