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,191 @@
|
|
|
1
|
+
// Wire Format v2 — session-dir readers + MECHANICAL wire replay.
|
|
2
|
+
//
|
|
3
|
+
// Replay here is deliberately interpretation-free: conversation events are
|
|
4
|
+
// applied exactly as the write side derived them from wire state
|
|
5
|
+
// (snapshot → replace state; append → concat; ctl replace-tail → swap last;
|
|
6
|
+
// ctl compact → no-op, the surrounding snapshot already carries the state).
|
|
7
|
+
// This reproduces the EXACT wire messages of every request — byte-equal to
|
|
8
|
+
// what v1's delta reconstruction yields for the same request — without any
|
|
9
|
+
// reverse-anchor/session semantics (those belong to the S5 materializer,
|
|
10
|
+
// which presents conversations; verify only needs wire truth).
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
|
|
16
|
+
import { isSupportedWireFormat } from './layout.js';
|
|
17
|
+
|
|
18
|
+
/** Parse a JSONL file tolerating a truncated tail line (spec §14). */
|
|
19
|
+
export function readJsonlTolerant(path) {
|
|
20
|
+
if (!existsSync(path)) return [];
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
23
|
+
const t = line.trim();
|
|
24
|
+
if (!t) continue;
|
|
25
|
+
try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** List session ids under LOG_DIR/<project>/sessions/ (or a staging sibling
|
|
31
|
+
* when the offline converter passes `sessionsDirName`). */
|
|
32
|
+
export function listSessionIds(projectDir, sessionsDirName = 'sessions') {
|
|
33
|
+
const root = join(projectDir, sessionsDirName);
|
|
34
|
+
if (!existsSync(root)) return [];
|
|
35
|
+
return readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read one session dir into a folded structure:
|
|
40
|
+
* { sessionId, meta, wireFormat, reqs: Map<seq, reqLine>,
|
|
41
|
+
* dones: Map<seq, doneLine>, convEvents: Map<convKey, event[] (sorted by seq)> }
|
|
42
|
+
*
|
|
43
|
+
* Reader version gate (spec §14): the version is taken from meta.json and the
|
|
44
|
+
* journal sentinel (`ph:'meta'` first line). A session stamped with a version
|
|
45
|
+
* this build doesn't understand returns `unsupported: true` with EMPTY folds —
|
|
46
|
+
* callers must refuse it loudly instead of interpreting bytes they can't parse.
|
|
47
|
+
* A missing version (torn creation) is treated as the current one.
|
|
48
|
+
*/
|
|
49
|
+
export function readSession(projectDir, sessionId, sessionsDirName = 'sessions') {
|
|
50
|
+
const dir = join(projectDir, sessionsDirName, sessionId);
|
|
51
|
+
let meta = null;
|
|
52
|
+
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { }
|
|
53
|
+
let wireFormat = meta && typeof meta.wireFormat === 'number' ? meta.wireFormat : null;
|
|
54
|
+
const unsupportedOf = (v) => ({
|
|
55
|
+
sessionId, dir, meta, wireFormat: v, unsupported: true,
|
|
56
|
+
reqs: new Map(), dones: new Map(), convEvents: new Map(),
|
|
57
|
+
});
|
|
58
|
+
if (!isSupportedWireFormat(wireFormat)) return unsupportedOf(wireFormat);
|
|
59
|
+
const reqs = new Map();
|
|
60
|
+
const dones = new Map();
|
|
61
|
+
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
62
|
+
if (line.ph === 'req') reqs.set(line.seq, line);
|
|
63
|
+
else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
|
|
64
|
+
else if (line.ph === 'meta' && typeof line.wireFormat === 'number') {
|
|
65
|
+
// Journal sentinel wins over meta.json (per-file self-description).
|
|
66
|
+
wireFormat = line.wireFormat;
|
|
67
|
+
if (!isSupportedWireFormat(wireFormat)) return unsupportedOf(wireFormat);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const convEvents = new Map();
|
|
71
|
+
const convRoot = join(dir, 'conversations');
|
|
72
|
+
if (existsSync(convRoot)) {
|
|
73
|
+
for (const entry of readdirSync(convRoot, { withFileTypes: true })) {
|
|
74
|
+
if (!entry.isDirectory()) continue;
|
|
75
|
+
const events = [];
|
|
76
|
+
for (const f of readdirSync(join(convRoot, entry.name))) {
|
|
77
|
+
if (!/^e\d+\.jsonl$/.test(f)) continue;
|
|
78
|
+
events.push(...readJsonlTolerant(join(convRoot, entry.name, f)));
|
|
79
|
+
}
|
|
80
|
+
events.sort((a, b) => a.seq - b.seq);
|
|
81
|
+
convEvents.set(entry.name, events);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { sessionId, dir, meta, wireFormat, reqs, dones, convEvents };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Digest of a messages array: sha256 over the per-message fingerprint list.
|
|
88
|
+
* Shared formula between the v1 and v2 sides of verify. Messages are
|
|
89
|
+
* normalized first (S4 decision 2026-07-14): the client's cache_control
|
|
90
|
+
* breakpoint migration rewrites OLD messages (string → block array with
|
|
91
|
+
* cache_control) — v1 delta cannot express that mutation, so without
|
|
92
|
+
* normalization every real active day fails the soak gate on false
|
|
93
|
+
* positives (plan S4 table, 2026-07-13 row). */
|
|
94
|
+
export function messagesDigest(messages) {
|
|
95
|
+
const h = createHash('sha256');
|
|
96
|
+
for (const m of messages) h.update(messageFingerprint(normalizeMsgForEquality(m)) + '\n');
|
|
97
|
+
return h.digest('hex').slice(0, 16);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Same content-hash formula as BlobStore.put — lets verify compare v1 inline
|
|
101
|
+
* tools/system against v2 blob refs WITHOUT reading blob files.
|
|
102
|
+
* KEEP IN SYNC: server/lib/v2/blob-store.js `put` is the writing twin of this
|
|
103
|
+
* formula; any change to hash/slice/serialization must land in both. */
|
|
104
|
+
export function blobRefOf(value) {
|
|
105
|
+
if (value === undefined || value === null) return null;
|
|
106
|
+
return 'sha256-' + createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mechanically replay one conversation's events, yielding the wire state
|
|
111
|
+
* digest after each event: Map<seq, {digest, len}>. Also returns integrity
|
|
112
|
+
* violations against the journal's msgTo counts (checked by the caller,
|
|
113
|
+
* which owns the journal).
|
|
114
|
+
*/
|
|
115
|
+
export function replayConversation(events) {
|
|
116
|
+
let state = [];
|
|
117
|
+
const bySeq = new Map();
|
|
118
|
+
for (const ev of events) {
|
|
119
|
+
if (ev.t === 'snapshot') {
|
|
120
|
+
state = Array.isArray(ev.msgs) ? ev.msgs : [];
|
|
121
|
+
} else if (ev.t === 'append') {
|
|
122
|
+
if (Array.isArray(ev.msgs) && ev.msgs.length > 0) state = state.concat(ev.msgs);
|
|
123
|
+
} else if (ev.t === 'ctl') {
|
|
124
|
+
if (ev.op === 'replace-tail' && ev.msg && state.length > 0) {
|
|
125
|
+
state = state.slice(0, -1).concat([ev.msg]);
|
|
126
|
+
}
|
|
127
|
+
// 'compact' is a marker for the materializer; wire state came via the
|
|
128
|
+
// snapshot event of the same request.
|
|
129
|
+
}
|
|
130
|
+
bySeq.set(ev.seq, { digest: messagesDigest(state), len: state.length });
|
|
131
|
+
}
|
|
132
|
+
return bySeq;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the per-request wire index of one session:
|
|
137
|
+
* Map<`${ts}|${url}`, Array<{seq, kind, conv, epoch, digest, len, toolsRef,
|
|
138
|
+
* sysRef, done, status, msgTo}>>
|
|
139
|
+
* — an ARRAY per key: `${ts}|${url}` is not unique in real logs (same-ms
|
|
140
|
+
* countTokens bursts), and collapsing twins loses their done lines/digests
|
|
141
|
+
* (real-data finding 2026-07-14). Also returns integrity violations
|
|
142
|
+
* (journal msgTo vs replayed length).
|
|
143
|
+
*/
|
|
144
|
+
export function indexSession(session) {
|
|
145
|
+
const replayed = new Map(); // convKey → Map<seq, {digest, len}>
|
|
146
|
+
for (const [convKey, events] of session.convEvents) replayed.set(convKey, replayConversation(events));
|
|
147
|
+
|
|
148
|
+
const index = new Map();
|
|
149
|
+
const integrity = [];
|
|
150
|
+
// Walk reqs in seq order; for reqs without a conv event (unchanged wire),
|
|
151
|
+
// carry the conv's latest replayed state at or before that seq.
|
|
152
|
+
const lastStateOf = new Map(); // convKey → {digest, len}
|
|
153
|
+
const seqs = [...session.reqs.keys()].sort((a, b) => a - b);
|
|
154
|
+
for (const seq of seqs) {
|
|
155
|
+
const req = session.reqs.get(seq);
|
|
156
|
+
const done = session.dones.get(seq) || null;
|
|
157
|
+
let digest = null;
|
|
158
|
+
let len = null;
|
|
159
|
+
if (req.conv) {
|
|
160
|
+
const conv = replayed.get(req.conv);
|
|
161
|
+
const hit = conv && conv.get(seq);
|
|
162
|
+
if (hit) {
|
|
163
|
+
lastStateOf.set(req.conv, hit);
|
|
164
|
+
digest = hit.digest; len = hit.len;
|
|
165
|
+
} else {
|
|
166
|
+
const carried = lastStateOf.get(req.conv);
|
|
167
|
+
if (carried) { digest = carried.digest; len = carried.len; }
|
|
168
|
+
}
|
|
169
|
+
if (typeof req.msgTo === 'number' && len != null && req.msgTo !== len) {
|
|
170
|
+
integrity.push({ sessionId: session.sessionId, seq, conv: req.conv, msgTo: req.msgTo, replayedLen: len });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const key = `${req.ts}|${req.url}`;
|
|
174
|
+
const rec = {
|
|
175
|
+
sessionId: session.sessionId,
|
|
176
|
+
seq,
|
|
177
|
+
kind: req.kind,
|
|
178
|
+
conv: req.conv || null,
|
|
179
|
+
epoch: req.epoch,
|
|
180
|
+
digest,
|
|
181
|
+
len,
|
|
182
|
+
toolsRef: (req.blobs && req.blobs.tools) || null,
|
|
183
|
+
sysRef: (req.blobs && req.blobs.sys) || null,
|
|
184
|
+
done: !!done,
|
|
185
|
+
status: done ? done.status : null,
|
|
186
|
+
};
|
|
187
|
+
const arr = index.get(key);
|
|
188
|
+
if (arr) arr.push(rec); else index.set(key, [rec]);
|
|
189
|
+
}
|
|
190
|
+
return { index, integrity };
|
|
191
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Cold-load fallback session selection (task B, 2026-07-15).
|
|
2
|
+
//
|
|
3
|
+
// On `ccv`/`ccv -c` startup the current session's dir does not exist until its
|
|
4
|
+
// first API request resolves a sid, so `getLiveLogSource()` would return '' and
|
|
5
|
+
// the [对话] panel loads empty. This picker supplies the session to cold-load:
|
|
6
|
+
// the most-recent MAIN (non-teammate) session that actually has a main turn,
|
|
7
|
+
// so the panel shows the last real conversation.
|
|
8
|
+
//
|
|
9
|
+
// CONTENT REQUIREMENT (2026-07-15 refresh-bug fix): a session can be the
|
|
10
|
+
// writer's "current" session (its sid resolved) while having NO main turn yet —
|
|
11
|
+
// e.g. `-c` startup, or a background sub / count_tokens request sets _currentSid
|
|
12
|
+
// before the user sends anything. Cold-loading such an empty session renders a
|
|
13
|
+
// blank panel (the symptom: fresh load shows data, a refresh once the empty
|
|
14
|
+
// current session exists shows nothing, and it only fills after the user sends
|
|
15
|
+
// a request). So selection requires at least one `kind:'main'` request — the
|
|
16
|
+
// "activated" session — and both getLiveLogSource and the picker apply it.
|
|
17
|
+
//
|
|
18
|
+
// Deliberately lightweight — meta.json + a bounded journal HEAD read only. It
|
|
19
|
+
// does NOT use listV2Sessions (dirSizeSync recursion + prompts head-reads +
|
|
20
|
+
// full turn counting): this runs on every cold `/events` connection.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
25
|
+
import { listSessionIds } from './replay.js';
|
|
26
|
+
import { isSupportedWireFormat } from './layout.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find the on-disk dir NAME of an existing session for `sessionId` (a UUID), or
|
|
30
|
+
* null. Task C: dir names are `<ts>_<uuid>` (new) or `<uuid>` (legacy/staging),
|
|
31
|
+
* so the identity UUID is recovered by suffix. Used by the writer (restart /
|
|
32
|
+
* `-c` re-attach: must reuse the existing dir, not mint a second one) and by
|
|
33
|
+
* convert's dual-write skip. Match precedence: exact `=== sessionId` (legacy),
|
|
34
|
+
* then `endsWith('_'+sessionId)`; on >1 suffix match prefer meta.sessionId===id,
|
|
35
|
+
* else the newest meta.startTs (partial-migration / live-skip can leave both).
|
|
36
|
+
* @returns {string|null} the dir basename, or null if none exists
|
|
37
|
+
*/
|
|
38
|
+
export function resolveSessionDirName(projectDir, sessionId, sessionsDirName = 'sessions') {
|
|
39
|
+
if (!projectDir || !sessionId) return null;
|
|
40
|
+
const suffix = '_' + sessionId;
|
|
41
|
+
let bestSuffix = null; // { name, startTs, idMatch }
|
|
42
|
+
for (const name of listSessionIds(projectDir, sessionsDirName)) {
|
|
43
|
+
if (name === sessionId) return name; // exact legacy dir — unambiguous
|
|
44
|
+
if (!name.endsWith(suffix)) continue;
|
|
45
|
+
let meta = null;
|
|
46
|
+
try { meta = JSON.parse(readFileSync(join(projectDir, sessionsDirName, name, 'meta.json'), 'utf-8')); } catch { /* tolerate */ }
|
|
47
|
+
const idMatch = !!(meta && meta.sessionId === sessionId);
|
|
48
|
+
const startTs = (meta && meta.startTs) || '';
|
|
49
|
+
if (bestSuffix === null
|
|
50
|
+
|| (idMatch && !bestSuffix.idMatch)
|
|
51
|
+
|| (idMatch === bestSuffix.idMatch && startTs > bestSuffix.startTs)) {
|
|
52
|
+
bestSuffix = { name, startTs, idMatch };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return bestSuffix ? bestSuffix.name : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MAIN_REQ_SCAN_BUDGET = 256 * 1024; // a session's first main req sits at the journal head
|
|
59
|
+
// The first main turn's `done` can land far past the head: journal lines are
|
|
60
|
+
// metadata-only (~0.5–2 KB each; bodies live in conversation/blob stores), but a
|
|
61
|
+
// heavy multi-agent first turn interleaves many sub/heartbeat req+done lines
|
|
62
|
+
// before the main done. 8 MB ≈ thousands of journal lines — far beyond any
|
|
63
|
+
// realistic first turn — and the streaming scanner early-exits on the first hit,
|
|
64
|
+
// so a session with a completed turn stops within its first few hundred lines.
|
|
65
|
+
const COMPLETED_TURN_SCAN_BUDGET = 8 * 1024 * 1024;
|
|
66
|
+
const SCAN_CHUNK = 64 * 1024;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Stream journal.jsonl through `onLine(line)` (trimmed, non-empty lines only),
|
|
70
|
+
* reading fixed-size chunks so memory stays bounded regardless of file size.
|
|
71
|
+
* Stops as soon as `onLine` returns truthy (→ true) or `budget` bytes have been
|
|
72
|
+
* read (→ false). The final unterminated line (possibly torn mid-write) is
|
|
73
|
+
* delivered too — predicates JSON.parse and ignore what doesn't parse. Missing
|
|
74
|
+
* file / any read error → false (callers treat that as "not activated").
|
|
75
|
+
* @param {string} dir - absolute session dir
|
|
76
|
+
* @param {number} budget - max bytes to read before giving up
|
|
77
|
+
* @param {(line:string) => boolean} onLine
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function scanJournal(dir, budget, onLine) {
|
|
81
|
+
const journal = join(dir, 'journal.jsonl');
|
|
82
|
+
if (!existsSync(journal)) return false;
|
|
83
|
+
let fd;
|
|
84
|
+
try {
|
|
85
|
+
fd = openSync(journal, 'r');
|
|
86
|
+
const chunk = Buffer.alloc(Math.min(SCAN_CHUNK, budget));
|
|
87
|
+
const decoder = new StringDecoder('utf-8'); // multi-byte chars may straddle chunks
|
|
88
|
+
let read = 0;
|
|
89
|
+
let carry = ''; // unterminated tail of the previous chunk
|
|
90
|
+
while (read < budget) {
|
|
91
|
+
const n = readSync(fd, chunk, 0, Math.min(chunk.length, budget - read), read);
|
|
92
|
+
if (n <= 0) break;
|
|
93
|
+
read += n;
|
|
94
|
+
carry += decoder.write(n === chunk.length ? chunk : chunk.subarray(0, n));
|
|
95
|
+
let start = 0;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const nl = carry.indexOf('\n', start);
|
|
98
|
+
if (nl === -1) break;
|
|
99
|
+
const line = carry.slice(start, nl).trim();
|
|
100
|
+
start = nl + 1;
|
|
101
|
+
if (line && onLine(line)) return true;
|
|
102
|
+
}
|
|
103
|
+
if (start > 0) carry = carry.slice(start);
|
|
104
|
+
}
|
|
105
|
+
const last = (carry + decoder.end()).trim();
|
|
106
|
+
return !!(last && onLine(last));
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
} finally {
|
|
110
|
+
if (fd !== undefined) { try { closeSync(fd); } catch {} }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Does this session dir have at least one `kind:'main'` request on disk? A
|
|
116
|
+
* bounded head scan of journal.jsonl — the opening main request is at the top,
|
|
117
|
+
* so a session that has ANY main turn is detected within the budget. An empty /
|
|
118
|
+
* sub-only / not-yet-activated session returns false.
|
|
119
|
+
* @param {string} dir - absolute session dir
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function sessionHasMainTurn(dir) {
|
|
123
|
+
return scanJournal(dir, MAIN_REQ_SCAN_BUDGET, (line) => {
|
|
124
|
+
// Cheap prefilter before JSON.parse: a main req line carries both.
|
|
125
|
+
if (!line.includes('"ph":"req"') || !line.includes('"kind":"main"')) return false;
|
|
126
|
+
try {
|
|
127
|
+
const o = JSON.parse(line);
|
|
128
|
+
return !!(o && o.ph === 'req' && o.kind === 'main');
|
|
129
|
+
} catch { return false; } // torn line — keep scanning
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Does this session dir have at least one COMPLETED main turn — a `kind:'main'`
|
|
135
|
+
* request whose `done` line is also present? A streaming scan correlating
|
|
136
|
+
* main-req seqs with done seqs (either arrival order), bounded by
|
|
137
|
+
* COMPLETED_TURN_SCAN_BUDGET — wide enough that a heavy multi-agent first turn
|
|
138
|
+
* whose done lands megabytes past the head is still found. Stronger than
|
|
139
|
+
* `sessionHasMainTurn`: a session with only an in-flight first main request (req
|
|
140
|
+
* written, response still streaming) returns false, so cold-load keeps showing
|
|
141
|
+
* the previous conversation until the current one has renderable content.
|
|
142
|
+
* @param {string} dir - absolute session dir
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function sessionHasCompletedMainTurn(dir) {
|
|
146
|
+
const mainSeqs = new Set(); // seqs of kind:'main' req lines seen so far
|
|
147
|
+
const doneSeqs = new Set(); // seqs of done lines seen so far
|
|
148
|
+
return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
|
|
149
|
+
if (line.includes('"ph":"req"') && line.includes('"kind":"main"')) {
|
|
150
|
+
try {
|
|
151
|
+
const o = JSON.parse(line);
|
|
152
|
+
if (o && o.ph === 'req' && o.kind === 'main' && o.seq != null) {
|
|
153
|
+
if (doneSeqs.has(o.seq)) return true;
|
|
154
|
+
mainSeqs.add(o.seq);
|
|
155
|
+
}
|
|
156
|
+
} catch { /* torn line — keep scanning */ }
|
|
157
|
+
} else if (line.includes('"ph":"done"')) {
|
|
158
|
+
try {
|
|
159
|
+
const o = JSON.parse(line);
|
|
160
|
+
if (o && o.ph === 'done' && o.seq != null) {
|
|
161
|
+
if (mainSeqs.has(o.seq)) return true;
|
|
162
|
+
doneSeqs.add(o.seq);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* torn line — keep scanning */ }
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The newest readable, non-teammate session that HAS a main turn, as
|
|
172
|
+
* `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
|
|
173
|
+
* §14): journal.jsonl exists and the wireFormat is supported. Candidates are
|
|
174
|
+
* ordered by meta.startTs (desc) and the first with a main turn wins. Returns
|
|
175
|
+
* the identity UUID (meta.sessionId) alongside the dir so a `-c` continuation
|
|
176
|
+
* can adopt this session's folder while preserving its identity.
|
|
177
|
+
*
|
|
178
|
+
* `excludeDir` skips one absolute session dir entirely: getLiveLogSource passes
|
|
179
|
+
* its current in-flight session here, because this picker's weaker
|
|
180
|
+
* has-a-main-req gate would otherwise re-select exactly the dir the caller's
|
|
181
|
+
* completed-turn gate just rejected (it IS the newest once its first main req
|
|
182
|
+
* is written), nullifying the fallback.
|
|
183
|
+
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
184
|
+
* @param {{excludeDir?: string}} [opts]
|
|
185
|
+
* @returns {{dir:string, sessionId:string}|null}
|
|
186
|
+
*/
|
|
187
|
+
export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
|
|
188
|
+
if (!projectDir) return null;
|
|
189
|
+
const candidates = []; // { dir, sessionId, startTs }
|
|
190
|
+
for (const name of listSessionIds(projectDir)) {
|
|
191
|
+
const dir = join(projectDir, 'sessions', name);
|
|
192
|
+
if (excludeDir && dir === excludeDir) continue;
|
|
193
|
+
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
|
|
194
|
+
let meta = null;
|
|
195
|
+
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
|
|
196
|
+
if (!meta) continue;
|
|
197
|
+
if (meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) continue;
|
|
198
|
+
// Teammate/sub sessions carry a `leader` pointer; only a leader session
|
|
199
|
+
// folds in its siblings when rendered, so a teammate dir loaded directly
|
|
200
|
+
// renders wrong. Keep only main (leader-less) sessions.
|
|
201
|
+
if (meta.leader) continue;
|
|
202
|
+
candidates.push({ dir, sessionId: meta.sessionId || '', startTs: meta.startTs || '' });
|
|
203
|
+
}
|
|
204
|
+
// Newest first, then return the first that actually has a main turn — skipping
|
|
205
|
+
// a not-yet-activated newest session (the refresh-bug fix).
|
|
206
|
+
candidates.sort((a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0));
|
|
207
|
+
for (const c of candidates) {
|
|
208
|
+
if (sessionHasMainTurn(c.dir)) return { dir: c.dir, sessionId: c.sessionId };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Absolute dir of the newest readable, non-teammate session that HAS a main
|
|
215
|
+
* turn, or '' if there is none. Thin wrapper over {@link latestMainSession}.
|
|
216
|
+
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
217
|
+
* @param {{excludeDir?: string}} [opts] - see {@link latestMainSession}
|
|
218
|
+
* @returns {string} absolute session dir, or ''
|
|
219
|
+
*/
|
|
220
|
+
export function latestMainSessionDir(projectDir, opts) {
|
|
221
|
+
return latestMainSession(projectDir, opts)?.dir || '';
|
|
222
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// wire-v2 S10b — in-flight coalescing + bounded TTL micro-cache for the
|
|
2
|
+
// windowed v2 reads (docs/refactor/WIRE_FORMAT_V2.md §14.1).
|
|
3
|
+
//
|
|
4
|
+
// Concurrent windowed loads of the SAME session (multi-tab /events cold loads,
|
|
5
|
+
// EventSource auto-reconnect storms, /api/local-log + IM popup on the live
|
|
6
|
+
// session) used to run one full synthesis EACH — 2-3 concurrent 1.5GB
|
|
7
|
+
// syntheses was the 2026-07-15 OOM trigger. Coalescing shares one run.
|
|
8
|
+
//
|
|
9
|
+
// Cache semantics (algorithm review F5b): callers pass `cached` per run.
|
|
10
|
+
// cached=true — read-only historical surfaces (/api/local-log tail, IM
|
|
11
|
+
// popup): may consume a ≤ttl-stale result. IM freshness is
|
|
12
|
+
// backfilled by the im_log_update SSE stream.
|
|
13
|
+
// cached=false — the /events live-attach path: NEVER reads the ≤ttl cache,
|
|
14
|
+
// and still populates the cache for later historical readers.
|
|
15
|
+
// NOTE it does NOT guarantee a maximally-fresh window: it
|
|
16
|
+
// still JOINS a currently-executing run for the same key, so
|
|
17
|
+
// its result reflects the moment that run STARTED (staleness ≤
|
|
18
|
+
// one in-flight run's duration). For /events this is bounded
|
|
19
|
+
// because the live-attach path only uses the LEVEL-1 scan
|
|
20
|
+
// flight (a light descriptor pass), not the level-2 window
|
|
21
|
+
// flight; the client's next `since` reconnect heals any gap.
|
|
22
|
+
// Refusing to join would reopen the reconnect-storm OOM.
|
|
23
|
+
// Failed runs are never cached; the in-flight slot is always released.
|
|
24
|
+
//
|
|
25
|
+
// Bounded cache: a whole window object (entries: string[]) can be hundreds of
|
|
26
|
+
// MB, so beyond the TTL sweep the cache is capped at MAX_CACHE_ENTRIES
|
|
27
|
+
// (oldest-inserted evicted) — a burst of distinct keys (rapid `before`
|
|
28
|
+
// pagination, many sessions) within one TTL must not re-accumulate the very
|
|
29
|
+
// memory pressure S10 removes.
|
|
30
|
+
const MAX_CACHE_ENTRIES = 8;
|
|
31
|
+
|
|
32
|
+
export class SingleFlight {
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ttlMs?: number, now?: () => number}} [opts] - ttlMs bounds the
|
|
35
|
+
* micro-cache; now is clock injection for tests.
|
|
36
|
+
*/
|
|
37
|
+
constructor(opts = {}) {
|
|
38
|
+
this._ttlMs = typeof opts.ttlMs === 'number' ? opts.ttlMs : 500;
|
|
39
|
+
this._now = opts.now || Date.now;
|
|
40
|
+
this._inflight = new Map(); // key → Promise
|
|
41
|
+
this._cache = new Map(); // key → {value, expiresAt}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run `fn` under `key`, coalescing with any in-flight run of the same key.
|
|
46
|
+
* @param {string} key
|
|
47
|
+
* @param {() => Promise<any>|any} fn
|
|
48
|
+
* @param {{cached?: boolean}} [opts] - cached=false bypasses the cache READ
|
|
49
|
+
* (live-attach safety) while still joining/populating.
|
|
50
|
+
* @returns {Promise<any>}
|
|
51
|
+
*/
|
|
52
|
+
run(key, fn, opts = {}) {
|
|
53
|
+
const cached = opts.cached !== false;
|
|
54
|
+
this._sweep();
|
|
55
|
+
if (cached) {
|
|
56
|
+
const c = this._cache.get(key);
|
|
57
|
+
if (c && this._now() < c.expiresAt) return Promise.resolve(c.value);
|
|
58
|
+
}
|
|
59
|
+
const existing = this._inflight.get(key);
|
|
60
|
+
if (existing) return existing;
|
|
61
|
+
// Cache write + in-flight cleanup settle in the SAME continuation as the
|
|
62
|
+
// run, BEFORE the returned promise resolves — so a later caller never joins
|
|
63
|
+
// an already-settled run (which would defeat cached=false freshness) and
|
|
64
|
+
// never sees a populated cache without the slot cleared.
|
|
65
|
+
const p = (async () => {
|
|
66
|
+
// Defer fn to a microtask so this._inflight.set(key, p) below runs FIRST
|
|
67
|
+
// — otherwise a synchronous throw in fn would run the finally (delete)
|
|
68
|
+
// before the slot is even registered, stranding it.
|
|
69
|
+
try {
|
|
70
|
+
const value = await Promise.resolve().then(fn);
|
|
71
|
+
this._cache.set(key, { value, expiresAt: this._now() + this._ttlMs });
|
|
72
|
+
this._evict();
|
|
73
|
+
return value;
|
|
74
|
+
} finally {
|
|
75
|
+
this._inflight.delete(key); // errors are never cached; slot always released
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
this._inflight.set(key, p);
|
|
79
|
+
return p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Drop expired cache entries — values can be large (window objects), so
|
|
83
|
+
* they must not outlive their TTL just because no one asked again. */
|
|
84
|
+
_sweep() {
|
|
85
|
+
const t = this._now();
|
|
86
|
+
for (const [key, e] of this._cache) {
|
|
87
|
+
if (t >= e.expiresAt) this._cache.delete(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Enforce the size cap: Map preserves insertion order, so the first keys are
|
|
92
|
+
* the oldest — evict them until at/under MAX_CACHE_ENTRIES. */
|
|
93
|
+
_evict() {
|
|
94
|
+
while (this._cache.size > MAX_CACHE_ENTRIES) {
|
|
95
|
+
const oldest = this._cache.keys().next().value;
|
|
96
|
+
this._cache.delete(oldest);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Test hook: current cache/in-flight sizes. */
|
|
101
|
+
_statsForTest() {
|
|
102
|
+
return { cacheSize: this._cache.size, inflightSize: this._inflight.size };
|
|
103
|
+
}
|
|
104
|
+
}
|