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,1059 @@
|
|
|
1
|
+
// Wire Format v2 — v2→v1 adapter read layer (docs/refactor/WIRE_FORMAT_V2.md §11).
|
|
2
|
+
//
|
|
3
|
+
// Synthesizes a v1-shape raw-entry stream from a v2 session directory so the
|
|
4
|
+
// existing log-stream consumers (SSE cold load, tail read, paging, download)
|
|
5
|
+
// work unchanged. The synthesis is MECHANICAL replay (same semantics as
|
|
6
|
+
// replay.js): conversation events are applied exactly as the write side derived
|
|
7
|
+
// them; no reverse-anchor/merge inference happens here — the client applies its
|
|
8
|
+
// own sessionMerge to the synthesized stream exactly like it does to v1 logs.
|
|
9
|
+
//
|
|
10
|
+
// Envelope equivalence to v1 (per request, main conversation):
|
|
11
|
+
// snapshot event → checkpoint (_isCheckpoint:true, full state)
|
|
12
|
+
// ctl replace-tail → checkpoint + _inPlaceReplaceDetected (paired signal)
|
|
13
|
+
// append event → delta (_isCheckpoint:false, the appended slice)
|
|
14
|
+
// no event (wire unchanged) → empty delta (v1 wrote those too)
|
|
15
|
+
// Entries are yielded in journal seq order (initiation order), so the client's
|
|
16
|
+
// completion-order guard never fires: _staleReorder/_reconstructBroken are
|
|
17
|
+
// structurally impossible on this stream (spec §11 "绝不输出").
|
|
18
|
+
//
|
|
19
|
+
// SINGLE SYNTHESIS PATH (S6b): SessionSynthesizer below is the one and only
|
|
20
|
+
// implementation of the per-event envelope synthesis. The cold generators feed
|
|
21
|
+
// it eagerly (all lines up front, defer disabled); the live feed
|
|
22
|
+
// (server/lib/v2/live-feed.js) feeds it incrementally from file cursors, so
|
|
23
|
+
// cold reads and live emission are byte-identical by construction.
|
|
24
|
+
//
|
|
25
|
+
// Teammate re-join (spec §10): reading a leader session merges in every sibling
|
|
26
|
+
// session whose meta.leader.parentSessionId points at it, ordered by ts with
|
|
27
|
+
// (sessionId, seq) tie-break — field-equivalent to v1's "teammate writes the
|
|
28
|
+
// leader's file".
|
|
29
|
+
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
31
|
+
import { join, dirname, basename } from 'node:path';
|
|
32
|
+
import { reportSwallowed } from '../error-report.js';
|
|
33
|
+
import { isMainAgentRequest } from '../interceptor-core.js';
|
|
34
|
+
import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
|
|
35
|
+
import { readSession, readJsonlTolerant, listSessionIds } from './replay.js';
|
|
36
|
+
import { blobPath, isSupportedWireFormat, dirSizeSync } from './layout.js';
|
|
37
|
+
import { SingleFlight } from './singleflight.js';
|
|
38
|
+
|
|
39
|
+
// Same stamping rules as the v1 interceptor (KEEP IN SYNC: server/interceptor.js
|
|
40
|
+
// requestEntry construction) — recomputed from the journal's url, not from kind,
|
|
41
|
+
// so edge cases (a countTokens body that also looks main-agent) fold the same way.
|
|
42
|
+
const HEARTBEAT_URL_RE = /\/api\/eval\/sdk-/;
|
|
43
|
+
const COUNT_TOKENS_URL_RE = /\/messages\/count_tokens/;
|
|
44
|
+
|
|
45
|
+
/** A v2 read source is a session DIRECTORY containing a journal.jsonl. */
|
|
46
|
+
export function isV2SessionDir(p) {
|
|
47
|
+
if (typeof p !== 'string' || p === '') return false;
|
|
48
|
+
try {
|
|
49
|
+
if (!statSync(p).isDirectory()) return false;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return existsSync(join(p, 'journal.jsonl'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** responses.jsonl folded by seq (first line wins, mirroring §14 done-folding).
|
|
57
|
+
* Values stay RAW strings — parsed one at a time at yield to keep peak memory
|
|
58
|
+
* at "one response", not "all responses" (plan F16). */
|
|
59
|
+
function readResponsesRaw(sessionDir) {
|
|
60
|
+
const bySeq = new Map();
|
|
61
|
+
const p = join(sessionDir, 'responses.jsonl');
|
|
62
|
+
if (!existsSync(p)) return bySeq;
|
|
63
|
+
let raw = '';
|
|
64
|
+
try { raw = readFileSync(p, 'utf-8'); } catch { return bySeq; }
|
|
65
|
+
for (const line of raw.split('\n')) {
|
|
66
|
+
const t = line.trim();
|
|
67
|
+
if (!t) continue;
|
|
68
|
+
const m = t.match(/"seq":\s*(\d+)/);
|
|
69
|
+
if (!m) continue;
|
|
70
|
+
const seq = Number(m[1]);
|
|
71
|
+
if (!bySeq.has(seq)) bySeq.set(seq, t);
|
|
72
|
+
}
|
|
73
|
+
return bySeq;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parsed-blob cache, one per synthesizer: refs repeat across most requests
|
|
77
|
+
* (that is the whole point of the CAS), so each distinct blob is read+parsed
|
|
78
|
+
* once. Objects are shared by reference — safe, entries are stringified
|
|
79
|
+
* immediately and never mutated. */
|
|
80
|
+
function makeBlobLoader(paths, sessionId) {
|
|
81
|
+
const cache = new Map();
|
|
82
|
+
return (ref) => {
|
|
83
|
+
if (!ref) return undefined;
|
|
84
|
+
if (cache.has(ref)) return cache.get(ref);
|
|
85
|
+
let value;
|
|
86
|
+
try {
|
|
87
|
+
value = JSON.parse(readFileSync(blobPath(paths, ref), 'utf-8'));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Orphan journal reference (spec §14): tolerate, surface, keep reading.
|
|
90
|
+
reportSwallowed('v2-read.blob-missing', new Error(`${sessionId}/${ref}: ${err.message}`));
|
|
91
|
+
value = undefined;
|
|
92
|
+
}
|
|
93
|
+
cache.set(ref, value);
|
|
94
|
+
return value;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether an applied event set makes its request a v1 CHECKPOINT (vs a delta):
|
|
99
|
+
* an applied snapshot or a ctl replace-tail is present; an append-only or
|
|
100
|
+
* wire-unchanged (empty) event set is a delta. Single predicate shared by the
|
|
101
|
+
* descriptor Pass A and the full envelope branch in _emit so they cannot drift
|
|
102
|
+
* (KEEP the envelope's `_isCheckpoint` assignment routed through this). */
|
|
103
|
+
function appliedIsCheckpoint(applied) {
|
|
104
|
+
return applied.some((e) => e.t === 'snapshot' || (e.t === 'ctl' && e.op === 'replace-tail'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Incremental per-event synthesis engine — the single synthesis path shared by
|
|
109
|
+
* the cold generators and the live feed.
|
|
110
|
+
*
|
|
111
|
+
* Feeding contract (mirrors the writer's per-file append order, which is
|
|
112
|
+
* seq-ascending for journal req lines and file order for conversation event
|
|
113
|
+
* lines):
|
|
114
|
+
* ingestJournalLine(parsedLine) — 'meta' | 'req' | 'done' phases
|
|
115
|
+
* ingestConvLine(convKey, event) — conversation event lines, file order
|
|
116
|
+
* ingestResponseLine(rawLine) — responses.jsonl lines (raw strings)
|
|
117
|
+
* Synthesized items accumulate internally; callers collect them via drain().
|
|
118
|
+
*
|
|
119
|
+
* Item shape (full synthesis): { ts, sessionId, seq, phase:
|
|
120
|
+
* 'placeholder'|'completed', entry, isMain, stateRef }
|
|
121
|
+
* `entry` is the v1-shape object (stringify on receipt: a live placeholder's
|
|
122
|
+
* entry object is MUTATED in place into its completed form when the done
|
|
123
|
+
* arrives, exactly so both stringify byte-identical to a cold read).
|
|
124
|
+
* `stateRef` is the conversation state array as of this seq (immutably
|
|
125
|
+
* replaced by the replay, so the reference stays valid) — used by the window
|
|
126
|
+
* mode to synthesize a baseline checkpoint at an arbitrary start seq.
|
|
127
|
+
* Descriptor mode (S10a Pass A, opts.descriptorOnly) instead emits the light
|
|
128
|
+
* shape { ts, sessionId, seq, url, isMain, isMainDelta } — no entry/stateRef.
|
|
129
|
+
*
|
|
130
|
+
* Live-mode ordering hazards handled here (S6b review):
|
|
131
|
+
* - A journal req line can land before its own conversation event line
|
|
132
|
+
* (write-order window, spec §14). Cold reads retry for free on the next full
|
|
133
|
+
* read; an incremental cursor has already consumed the journal line, so the
|
|
134
|
+
* request is parked per-conversation and retried when the event arrives.
|
|
135
|
+
* deferMs=0 (cold) degrades to the historical skip-immediately behavior.
|
|
136
|
+
* - A done line can land before its responses.jsonl line (cross-path queue
|
|
137
|
+
* groups). Every completion writes a responses line, so the done is parked
|
|
138
|
+
* until the line arrives, with a deadline fallback to a null response.
|
|
139
|
+
* - Orphan safety: journal req lines are strictly seq-ascending within one
|
|
140
|
+
* journal file, so at the time req N is processed every req ≤ N that will
|
|
141
|
+
* ever exist is known — the crash-orphan check is exact even incrementally.
|
|
142
|
+
*/
|
|
143
|
+
export class SessionSynthesizer {
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} sessionDir - absolute session directory
|
|
146
|
+
* @param {object} [opts]
|
|
147
|
+
* @param {object|null} [opts.teammateOf] - meta.leader of this session when
|
|
148
|
+
* it is being re-joined into (or read as) a teammate stream
|
|
149
|
+
* @param {number} [opts.deferMs] - how long live mode parks a request whose
|
|
150
|
+
* conversation event (or a done whose response line) has not landed yet.
|
|
151
|
+
* 0 = cold semantics: skip immediately, next full read self-heals.
|
|
152
|
+
* @param {Function} [opts.now] - clock injection (tests)
|
|
153
|
+
* @param {boolean} [opts.descriptorOnly] - S10a window Pass A: emit light
|
|
154
|
+
* descriptors ({ts,sessionId,seq,url,isMain,isMainDelta}) under the SAME
|
|
155
|
+
* pump/gate/replay semantics, skipping blob backfill / message assembly /
|
|
156
|
+
* entry construction. Membership decisions (crash-orphan skips, msgTo
|
|
157
|
+
* gates) are replay-dependent, so a journal-only scan would diverge.
|
|
158
|
+
* @param {(sessionId:string, seq:number)=>boolean} [opts.materialize]
|
|
159
|
+
* S10a window Pass B: predicate deciding whether a request is synthesized
|
|
160
|
+
* at all. Falsy → state still advances (in _pumpConv) but no entry is
|
|
161
|
+
* built or retained. Baseline promotion happens on the CONSUMER side over
|
|
162
|
+
* item.stateRef, so the ring keeps the un-promoted raw (onScan parity).
|
|
163
|
+
*/
|
|
164
|
+
constructor(sessionDir, opts = {}) {
|
|
165
|
+
this.sessionDir = sessionDir;
|
|
166
|
+
this.projectDir = dirname(dirname(sessionDir));
|
|
167
|
+
this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : 0;
|
|
168
|
+
this._now = opts.now || Date.now;
|
|
169
|
+
this._descriptorOnly = !!opts.descriptorOnly;
|
|
170
|
+
this._materialize = typeof opts.materialize === 'function' ? opts.materialize : null;
|
|
171
|
+
let meta = null;
|
|
172
|
+
try { meta = JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
|
|
173
|
+
this._meta = meta || {};
|
|
174
|
+
// Task C: session IDENTITY = meta.sessionId (the UUID), NOT the dir basename
|
|
175
|
+
// (which is now `<ts>_<uuid>`). This feeds _seqEpoch, k-way tie-break and
|
|
176
|
+
// item dedup — all of which must stay stable across the dir rename and match
|
|
177
|
+
// the wire UUID (teammate parentSessionId). basename is a path token only.
|
|
178
|
+
// Fallback to basename on a torn/missing meta (self-consistent within a load).
|
|
179
|
+
this.sessionId = (meta && meta.sessionId) || basename(sessionDir);
|
|
180
|
+
this._leader = opts.teammateOf || this._meta.leader || null;
|
|
181
|
+
this._loadBlob = makeBlobLoader({ blobsDir: join(sessionDir, 'blobs') }, this.sessionId);
|
|
182
|
+
this.unsupported = false;
|
|
183
|
+
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
|
|
184
|
+
this._markUnsupported(meta.wireFormat);
|
|
185
|
+
}
|
|
186
|
+
this._reqs = new Map(); // seq → journal req line
|
|
187
|
+
this._dones = new Map(); // seq → journal done line (first wins, §14)
|
|
188
|
+
this._responses = new Map(); // seq → raw responses.jsonl line (first wins)
|
|
189
|
+
this._convs = new Map(); // convKey → {events, ptr, state, queue, deferSince}
|
|
190
|
+
this._await = new Map(); // seq → item built, waiting for its done
|
|
191
|
+
this._doneWaitingResp = new Map(); // seq → parkedAtMs (done before responses line)
|
|
192
|
+
this._out = [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_markUnsupported(v) {
|
|
196
|
+
if (this.unsupported) return;
|
|
197
|
+
// Reader version gate (spec §14): never interpret a session stamped with a
|
|
198
|
+
// version this build doesn't understand — refuse loudly, emit nothing.
|
|
199
|
+
this.unsupported = true;
|
|
200
|
+
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${this.sessionId}: wireFormat=${v}`));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_conv(key) {
|
|
204
|
+
let c = this._convs.get(key);
|
|
205
|
+
if (!c) {
|
|
206
|
+
c = { events: [], ptr: 0, state: [], queue: [], deferSince: null };
|
|
207
|
+
this._convs.set(key, c);
|
|
208
|
+
}
|
|
209
|
+
return c;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Feed one parsed journal line ('meta' sentinel, 'req', or 'done'). */
|
|
213
|
+
ingestJournalLine(line) {
|
|
214
|
+
if (this.unsupported || !line || typeof line !== 'object') return;
|
|
215
|
+
if (line.ph === 'meta') {
|
|
216
|
+
if (typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
|
|
217
|
+
this._markUnsupported(line.wireFormat);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (line.ph === 'req') {
|
|
222
|
+
if (this._reqs.has(line.seq)) return;
|
|
223
|
+
this._reqs.set(line.seq, line);
|
|
224
|
+
if (line.conv) {
|
|
225
|
+
const c = this._conv(line.conv);
|
|
226
|
+
c.queue.push(line.seq);
|
|
227
|
+
this._pumpConv(line.conv, c);
|
|
228
|
+
} else {
|
|
229
|
+
// Conversation-less request (heartbeat): nothing to gate on.
|
|
230
|
+
this._emit(line, null, []);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (line.ph === 'done') {
|
|
235
|
+
if (this._dones.has(line.seq)) return; // fold duplicate done lines (§14)
|
|
236
|
+
this._dones.set(line.seq, line);
|
|
237
|
+
this._tryComplete(line.seq);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Feed one conversation event line. Events are kept SEQ-SORTED on insert:
|
|
242
|
+
* file order across epoch files is NOT globally seq-ordered (a restarted
|
|
243
|
+
* writer historically appended newer seqs into an older epoch file, and the
|
|
244
|
+
* cold reader has always sorted globally — replay.js). The monotonic pump
|
|
245
|
+
* pointer requires the sorted invariant or a late-lower-seq event strands
|
|
246
|
+
* behind it forever (2026-07-15 fix: live missing-conv-event /
|
|
247
|
+
* state-count-mismatch false alarms). In-order arrival stays O(1). */
|
|
248
|
+
ingestConvLine(convKey, ev) {
|
|
249
|
+
if (this.unsupported || !ev || typeof ev !== 'object') return;
|
|
250
|
+
const c = this._conv(convKey);
|
|
251
|
+
const events = c.events;
|
|
252
|
+
if (events.length === 0 || ev.seq >= events[events.length - 1].seq) {
|
|
253
|
+
events.push(ev);
|
|
254
|
+
} else {
|
|
255
|
+
let i = events.length - 1;
|
|
256
|
+
while (i >= c.ptr && events[i].seq > ev.seq) i--;
|
|
257
|
+
events.splice(i + 1, 0, ev);
|
|
258
|
+
}
|
|
259
|
+
this._pumpConv(convKey, c);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Feed one raw responses.jsonl line. */
|
|
263
|
+
ingestResponseLine(rawLine) {
|
|
264
|
+
if (this.unsupported) return;
|
|
265
|
+
const t = String(rawLine).trim();
|
|
266
|
+
const m = t.match(/"seq":\s*(\d+)/);
|
|
267
|
+
if (!m) return;
|
|
268
|
+
const seq = Number(m[1]);
|
|
269
|
+
if (!this._responses.has(seq)) this._responses.set(seq, t);
|
|
270
|
+
if (this._doneWaitingResp.has(seq)) {
|
|
271
|
+
this._doneWaitingResp.delete(seq);
|
|
272
|
+
this._complete(seq);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Collect synthesized items accumulated since the last drain. */
|
|
277
|
+
drain() {
|
|
278
|
+
if (this._out.length === 0) return [];
|
|
279
|
+
const out = this._out;
|
|
280
|
+
this._out = [];
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** True when a deferred request or done is parked (live watcher uses this to
|
|
285
|
+
* keep its safety poll armed). */
|
|
286
|
+
hasPending() {
|
|
287
|
+
if (this._doneWaitingResp.size > 0) return true;
|
|
288
|
+
for (const c of this._convs.values()) if (c.deferSince != null) return true;
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Live mode: force progress past deferrals older than deferMs. */
|
|
293
|
+
sweepDeadlines() {
|
|
294
|
+
const now = this._now();
|
|
295
|
+
for (const [key, c] of this._convs) {
|
|
296
|
+
if (c.deferSince != null && now - c.deferSince >= this._deferMs) {
|
|
297
|
+
this._pumpConv(key, c, { forceHead: true });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const [seq, parkedAt] of [...this._doneWaitingResp]) {
|
|
301
|
+
if (now - parkedAt >= this._deferMs) {
|
|
302
|
+
this._doneWaitingResp.delete(seq);
|
|
303
|
+
this._complete(seq);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Process this conversation's request queue as far as the arrived events
|
|
309
|
+
* allow. forceHead skips the (expired) head request cold-style. */
|
|
310
|
+
_pumpConv(convKey, c, { forceHead = false } = {}) {
|
|
311
|
+
while (c.queue.length > 0) {
|
|
312
|
+
const seq = c.queue[0];
|
|
313
|
+
const req = this._reqs.get(seq);
|
|
314
|
+
// Apply this conversation's events up to and including this seq.
|
|
315
|
+
const applied = [];
|
|
316
|
+
while (c.ptr < c.events.length && c.events[c.ptr].seq <= seq) {
|
|
317
|
+
const ev = c.events[c.ptr++];
|
|
318
|
+
if (!this._reqs.has(ev.seq)) continue; // crash orphan (spec §14)
|
|
319
|
+
if (ev.t === 'snapshot') {
|
|
320
|
+
c.state = Array.isArray(ev.msgs) ? ev.msgs : [];
|
|
321
|
+
} else if (ev.t === 'append') {
|
|
322
|
+
if (Array.isArray(ev.msgs) && ev.msgs.length > 0) c.state = c.state.concat(ev.msgs);
|
|
323
|
+
} else if (ev.t === 'ctl' && ev.op === 'replace-tail' && ev.msg && c.state.length > 0) {
|
|
324
|
+
c.state = c.state.slice(0, -1).concat([ev.msg]);
|
|
325
|
+
} // ctl compact: marker only — the same request's snapshot carries the state
|
|
326
|
+
if (ev.seq === seq) applied.push(ev);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Gate: journal says a conversation event exists but the line is not on
|
|
330
|
+
// disk yet (write-order window, §14 pendingTail) or is lost. Emitting a
|
|
331
|
+
// delta against a state missing that slice would poison the client's
|
|
332
|
+
// reconstruction — park (live) or skip (cold/expired); a skip is safe
|
|
333
|
+
// because the next cold read retries with the line present.
|
|
334
|
+
let gap = null;
|
|
335
|
+
if (req.evt && applied.length === 0) {
|
|
336
|
+
gap = () => reportSwallowed('v2-read.missing-conv-event', new Error(`${this.sessionId}/${req.conv} seq=${seq} evt=${req.evt}`));
|
|
337
|
+
} else if (!req.evt && typeof req.msgTo === 'number' && req.msgTo !== c.state.length) {
|
|
338
|
+
// No-event request whose recorded wire count disagrees with the
|
|
339
|
+
// replayed state — an earlier line of this conv is missing.
|
|
340
|
+
gap = () => reportSwallowed('v2-read.state-count-mismatch', new Error(`${this.sessionId}/${req.conv} seq=${seq} msgTo=${req.msgTo} replayed=${c.state.length}`));
|
|
341
|
+
}
|
|
342
|
+
if (gap) {
|
|
343
|
+
if (this._deferMs > 0 && !forceHead) {
|
|
344
|
+
if (c.deferSince == null) c.deferSince = this._now();
|
|
345
|
+
return; // parked — a later ingest or sweepDeadlines resumes the pump
|
|
346
|
+
}
|
|
347
|
+
gap();
|
|
348
|
+
c.queue.shift();
|
|
349
|
+
c.deferSince = null;
|
|
350
|
+
forceHead = false;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
c.deferSince = null;
|
|
355
|
+
c.queue.shift();
|
|
356
|
+
forceHead = false;
|
|
357
|
+
this._emit(req, c, applied);
|
|
358
|
+
}
|
|
359
|
+
// Live-mode memory bound (review P2): applied events are never re-read —
|
|
360
|
+
// drop the consumed prefix so a long session doesn't keep every slice
|
|
361
|
+
// resident. Thresholded so the cold path's per-req pumping doesn't turn
|
|
362
|
+
// this into an O(n²) shift storm. (State arrays are shared immutably;
|
|
363
|
+
// this only trims the event-line objects.)
|
|
364
|
+
if (c.ptr > 64 && c.queue.length === 0) {
|
|
365
|
+
c.events.splice(0, c.ptr);
|
|
366
|
+
c.ptr = 0;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Build the v1-shape entry for one request and emit it (placeholder now /
|
|
371
|
+
* completed when the done is already known). */
|
|
372
|
+
_emit(req, conv, applied) {
|
|
373
|
+
const seq = req.seq;
|
|
374
|
+
const meta = this._meta;
|
|
375
|
+
const leader = this._leader;
|
|
376
|
+
const isTeammate = !!leader;
|
|
377
|
+
const isMain = req.kind === 'main' && !isTeammate;
|
|
378
|
+
// Single source for the checkpoint/delta decision — consumed by BOTH the
|
|
379
|
+
// descriptor Pass A (below) and the full envelope branch, so the two can
|
|
380
|
+
// never drift (adding a checkpoint-triggering event type updates one place).
|
|
381
|
+
const isCheckpoint = appliedIsCheckpoint(applied);
|
|
382
|
+
|
|
383
|
+
// ---- S10a window passes ------------------------------------------------
|
|
384
|
+
// Pass A (descriptor): same gates/replay as full synthesis, no entry build.
|
|
385
|
+
if (this._descriptorOnly) {
|
|
386
|
+
this._out.push({
|
|
387
|
+
ts: req.ts || '',
|
|
388
|
+
sessionId: this.sessionId,
|
|
389
|
+
seq,
|
|
390
|
+
url: req.url,
|
|
391
|
+
isMain,
|
|
392
|
+
isMainDelta: isMain && !!conv && !isCheckpoint,
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Pass B (materialize gate): conversation state was already advanced by
|
|
397
|
+
// _pumpConv, so skipping here skips ONLY blob backfill + entry build +
|
|
398
|
+
// retention — exactly the per-request memory cost the window excludes.
|
|
399
|
+
const mat = this._materialize ? this._materialize(this.sessionId, seq) : true;
|
|
400
|
+
if (!mat) return;
|
|
401
|
+
|
|
402
|
+
// ---- body (blob backfill is per-request by journal ref — never carried) --
|
|
403
|
+
const tools = this._loadBlob(req.blobs && req.blobs.tools);
|
|
404
|
+
const system = this._loadBlob(req.blobs && req.blobs.sys);
|
|
405
|
+
const body = {
|
|
406
|
+
...(req.model && { model: req.model }),
|
|
407
|
+
...(system !== undefined && { system }),
|
|
408
|
+
...(tools !== undefined && { tools }),
|
|
409
|
+
...(meta.userIdRaw && { metadata: { user_id: meta.userIdRaw } }),
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const isTeammateEntry = isTeammate;
|
|
413
|
+
const isMainKind = isMain;
|
|
414
|
+
const entry = {
|
|
415
|
+
timestamp: req.ts,
|
|
416
|
+
project: meta.project || basename(this.projectDir),
|
|
417
|
+
url: req.url,
|
|
418
|
+
method: req.method || 'POST',
|
|
419
|
+
...(req.headers && { headers: req.headers }),
|
|
420
|
+
body,
|
|
421
|
+
response: null,
|
|
422
|
+
duration: 0,
|
|
423
|
+
isStream: !!req.isStream,
|
|
424
|
+
isHeartbeat: HEARTBEAT_URL_RE.test(req.url || ''),
|
|
425
|
+
isCountTokens: COUNT_TOKENS_URL_RE.test(req.url || ''),
|
|
426
|
+
mainAgent: false, // finalized below once messages are attached
|
|
427
|
+
...(isTeammateEntry && { teammate: leader.agentName || true, ...(leader.teamName && { teamName: leader.teamName }) }),
|
|
428
|
+
...(req.proxy && { proxyProfile: req.proxy.profile, ...(req.proxy.url && { proxyUrl: req.proxy.url }) }),
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// ---- messages + envelope ------------------------------------------------
|
|
432
|
+
if (conv) {
|
|
433
|
+
if (isMainKind) {
|
|
434
|
+
// v1 delta envelope synthesis. The stream is seq-ordered, so the client
|
|
435
|
+
// seq guard sees a strictly increasing sequence in one epoch.
|
|
436
|
+
entry._seq = seq;
|
|
437
|
+
entry._seqEpoch = `v2:${this.sessionId}`;
|
|
438
|
+
entry._deltaFormat = 1;
|
|
439
|
+
entry._totalMessageCount = conv.state.length;
|
|
440
|
+
entry._conversationId = 'mainAgent';
|
|
441
|
+
const snapshot = applied.find((e) => e.t === 'snapshot');
|
|
442
|
+
const replaceTail = applied.find((e) => e.t === 'ctl' && e.op === 'replace-tail');
|
|
443
|
+
const append = applied.find((e) => e.t === 'append');
|
|
444
|
+
if (snapshot || replaceTail || !append) {
|
|
445
|
+
entry._isCheckpoint = isCheckpoint; // = !!(snapshot||replaceTail); empty-wire delta is false
|
|
446
|
+
if (replaceTail && !snapshot) {
|
|
447
|
+
entry._isCheckpoint = true;
|
|
448
|
+
// Paired signal (KEEP IN SYNC: src/utils/sessionManager.js
|
|
449
|
+
// applyInPlaceLastMsgReplace) — same protocol as the v1 writer.
|
|
450
|
+
entry._inPlaceReplaceDetected = true;
|
|
451
|
+
}
|
|
452
|
+
entry.body.messages = entry._isCheckpoint ? conv.state : [];
|
|
453
|
+
} else {
|
|
454
|
+
entry._isCheckpoint = false;
|
|
455
|
+
entry.body.messages = Array.isArray(append.msgs) ? append.msgs : [];
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
// sub / misc / teammate streams are full-messages in v1 (no envelope).
|
|
459
|
+
entry.body.messages = conv.state;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// v1 stamps mainAgent = isMainAgentRequest(body) at request time; kind
|
|
464
|
+
// 'main' was CLASSIFIED by that same predicate, so it maps back to true
|
|
465
|
+
// directly (its delta body would defeat a recompute). Everything else —
|
|
466
|
+
// teammate dual-tag included — recomputes over the backfilled body.
|
|
467
|
+
entry.mainAgent = isMainKind || isMainAgentRequest(entry.body);
|
|
468
|
+
|
|
469
|
+
const item = {
|
|
470
|
+
ts: req.ts || '',
|
|
471
|
+
sessionId: this.sessionId,
|
|
472
|
+
seq,
|
|
473
|
+
phase: 'placeholder',
|
|
474
|
+
entry,
|
|
475
|
+
isMain: isMainKind,
|
|
476
|
+
stateRef: conv ? conv.state : null,
|
|
477
|
+
};
|
|
478
|
+
this._await.set(seq, item);
|
|
479
|
+
|
|
480
|
+
if (this._dones.has(seq)) {
|
|
481
|
+
this._tryComplete(seq);
|
|
482
|
+
} else {
|
|
483
|
+
// req without done — in-flight or the process died (spec §4). This IS the
|
|
484
|
+
// v1 placeholder, so it wears the same flags.
|
|
485
|
+
entry.inProgress = true;
|
|
486
|
+
entry.requestId = req.rid || `${seq}`;
|
|
487
|
+
this._out.push(item);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A done is known for this seq — complete now, or park until its responses
|
|
492
|
+
* line lands (every completion writes one; cross-path flush can reorder). */
|
|
493
|
+
_tryComplete(seq) {
|
|
494
|
+
if (!this._await.has(seq)) return; // req not synthesized yet — _emit retries
|
|
495
|
+
if (this._deferMs > 0 && !this._responses.has(seq) && !this._doneWaitingResp.has(seq)) {
|
|
496
|
+
this._doneWaitingResp.set(seq, this._now());
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this._doneWaitingResp.delete(seq);
|
|
500
|
+
this._complete(seq);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_complete(seq) {
|
|
504
|
+
const item = this._await.get(seq);
|
|
505
|
+
const done = this._dones.get(seq);
|
|
506
|
+
if (!item || !done) return;
|
|
507
|
+
this._await.delete(seq);
|
|
508
|
+
// If the placeholder is still queued (req and done landed in the same
|
|
509
|
+
// batch), drop it — its entry object is about to be mutated into the
|
|
510
|
+
// completed form and emitting both would just send the same frame twice.
|
|
511
|
+
const queued = this._out.indexOf(item);
|
|
512
|
+
if (queued !== -1) this._out.splice(queued, 1);
|
|
513
|
+
const entry = item.entry;
|
|
514
|
+
// A live placeholder was already emitted for this entry object; strip its
|
|
515
|
+
// in-flight markers so the completed form stringifies byte-identical to a
|
|
516
|
+
// cold read of the same session.
|
|
517
|
+
delete entry.inProgress;
|
|
518
|
+
delete entry.requestId;
|
|
519
|
+
const respRaw = this._responses.get(seq);
|
|
520
|
+
let resp = null;
|
|
521
|
+
if (respRaw) {
|
|
522
|
+
try { resp = JSON.parse(respRaw); } catch { resp = null; }
|
|
523
|
+
}
|
|
524
|
+
entry.response = {
|
|
525
|
+
...(typeof done.http === 'number' && { status: done.http }),
|
|
526
|
+
...(resp && resp.headers && { headers: resp.headers }),
|
|
527
|
+
body: resp ? resp.body : null,
|
|
528
|
+
};
|
|
529
|
+
if (typeof done.dur === 'number') entry.duration = done.dur;
|
|
530
|
+
// Live-mode memory bound (review P2): completed seqs never need their raw
|
|
531
|
+
// response line or done line again — a duplicate done re-arriving is a
|
|
532
|
+
// no-op above (the awaited item is gone). Cold reads drain immediately,
|
|
533
|
+
// so this is pure win there too.
|
|
534
|
+
this._responses.delete(seq);
|
|
535
|
+
this._dones.delete(seq);
|
|
536
|
+
this._out.push({ ...item, phase: 'completed' });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Cold generator over ONE session dir: yields items (see SessionSynthesizer)
|
|
542
|
+
* in seq order — the eager feeding of the shared synthesis engine.
|
|
543
|
+
*/
|
|
544
|
+
function* iterateSessionItems(sessionDir, opts = {}) {
|
|
545
|
+
const projectDir = dirname(dirname(sessionDir));
|
|
546
|
+
const sessionId = basename(sessionDir);
|
|
547
|
+
const session = readSession(projectDir, sessionId);
|
|
548
|
+
if (session.unsupported) {
|
|
549
|
+
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sessionId}: wireFormat=${session.wireFormat}`));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const synth = new SessionSynthesizer(sessionDir, {
|
|
553
|
+
teammateOf: opts.teammateOf,
|
|
554
|
+
deferMs: 0,
|
|
555
|
+
descriptorOnly: opts.descriptorOnly,
|
|
556
|
+
materialize: opts.materialize,
|
|
557
|
+
});
|
|
558
|
+
if (synth.unsupported) return; // meta gate already reported by the constructor
|
|
559
|
+
// Eager seed: all conversation events, responses, and done lines are known
|
|
560
|
+
// before any req is pumped — every gate then resolves exactly like the
|
|
561
|
+
// historical whole-file read.
|
|
562
|
+
for (const [key, events] of session.convEvents) synth._conv(key).events = events;
|
|
563
|
+
synth._responses = readResponsesRaw(sessionDir);
|
|
564
|
+
synth._dones = session.dones;
|
|
565
|
+
const seqs = [...session.reqs.keys()].sort((a, b) => a - b);
|
|
566
|
+
for (const seq of seqs) {
|
|
567
|
+
synth.ingestJournalLine(session.reqs.get(seq));
|
|
568
|
+
yield* synth.drain();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Sibling sessions whose meta.leader points at this session (spec §10).
|
|
573
|
+
*
|
|
574
|
+
* Leaderless fallback (S6b review P1): native team mode spawns teammates with
|
|
575
|
+
* `--agent-name` but possibly WITHOUT `--parent-session-id`, so meta.leader
|
|
576
|
+
* carries no sid. Those sessions are attributed to the leader session that was
|
|
577
|
+
* most recently started at (or before) the teammate's own start — the exact
|
|
578
|
+
* semantics of v1's findRecentLog ("append to the newest leader log"). */
|
|
579
|
+
export function findTeammateSessionDirs(sessionDir, leaderUuid) {
|
|
580
|
+
const sessionsRoot = dirname(sessionDir);
|
|
581
|
+
const leaderSid = basename(sessionDir); // dir NAME — for the self-skip + leaderless tie-break
|
|
582
|
+
// Task C: teammates record the leader's UUID in meta.leader.parentSessionId
|
|
583
|
+
// (from the CLI `--parent-session-id`, immutable). Since the leader dir is now
|
|
584
|
+
// `<ts>_<uuid>`, we match against the leader's meta.sessionId (the UUID), NOT
|
|
585
|
+
// basename. Read it ONCE up-front (callers pass it; fall back to a direct
|
|
586
|
+
// read) — a mid-loop capture would miss when readdir visits a teammate before
|
|
587
|
+
// the leader's own dir.
|
|
588
|
+
if (!leaderUuid) {
|
|
589
|
+
try { leaderUuid = JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')).sessionId || leaderSid; }
|
|
590
|
+
catch { leaderUuid = leaderSid; }
|
|
591
|
+
}
|
|
592
|
+
const out = [];
|
|
593
|
+
let names = [];
|
|
594
|
+
try {
|
|
595
|
+
names = readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
596
|
+
} catch {
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
const orphans = []; // teammate sessions without a recorded leader sid
|
|
600
|
+
const leaderStarts = []; // {sid, startTs} of every non-teammate session
|
|
601
|
+
let ownStartTs = '';
|
|
602
|
+
for (const name of names) {
|
|
603
|
+
const dir = join(sessionsRoot, name);
|
|
604
|
+
try {
|
|
605
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
606
|
+
const l = meta && meta.leader;
|
|
607
|
+
if (name === leaderSid) {
|
|
608
|
+
ownStartTs = (meta && meta.startTs) || '';
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (!l) {
|
|
612
|
+
leaderStarts.push({ sid: name, startTs: (meta && meta.startTs) || '' });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
// The writer records parentSessionId (interceptor.js); tolerate the spec's
|
|
616
|
+
// original sessionId spelling for hand-built or future dirs. Match the
|
|
617
|
+
// leader's UUID (meta.sessionId), not its dir name (task C).
|
|
618
|
+
if (l.parentSessionId === leaderUuid || l.sessionId === leaderUuid) {
|
|
619
|
+
out.push({ dir, leader: l });
|
|
620
|
+
} else if (!l.parentSessionId && !l.sessionId) {
|
|
621
|
+
orphans.push({ dir, leader: l, startTs: (meta && meta.startTs) || '' });
|
|
622
|
+
}
|
|
623
|
+
} catch { /* not a readable session dir — skip */ }
|
|
624
|
+
}
|
|
625
|
+
for (const o of orphans) {
|
|
626
|
+
// Best temporal match: the latest leader started at or before the teammate.
|
|
627
|
+
// Ties break on sid so exactly ONE leader ever claims an orphan — a strict
|
|
628
|
+
// ">" alone let two leaders sharing a startTs both claim it, duplicating
|
|
629
|
+
// the teammate's traffic into both streams (review P2).
|
|
630
|
+
let best = null; // {startTs, sid}
|
|
631
|
+
if (ownStartTs && ownStartTs <= o.startTs) best = { startTs: ownStartTs, sid: leaderSid };
|
|
632
|
+
for (const cand of leaderStarts) {
|
|
633
|
+
if (!cand.startTs || cand.startTs > o.startTs) continue;
|
|
634
|
+
if (best == null
|
|
635
|
+
|| cand.startTs > best.startTs
|
|
636
|
+
|| (cand.startTs === best.startTs && cand.sid > best.sid)) {
|
|
637
|
+
best = cand;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (best && best.sid === leaderSid) out.push({ dir: o.dir, leader: o.leader });
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Item-level iteration of one v2 session with teammate re-join — the shared
|
|
647
|
+
* core of the raw-string generators and the window reader below.
|
|
648
|
+
*/
|
|
649
|
+
function* iterateV2Items(sessionDir, opts = {}) {
|
|
650
|
+
const streams = [iterateSessionItems(sessionDir, opts)];
|
|
651
|
+
const ownMeta = (() => {
|
|
652
|
+
try { return JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')); } catch { return null; }
|
|
653
|
+
})();
|
|
654
|
+
// A teammate session read directly renders itself (tagged via its own meta.
|
|
655
|
+
// leader inside iterateSessionItems); only a LEADER pulls in siblings.
|
|
656
|
+
if (!ownMeta || !ownMeta.leader) {
|
|
657
|
+
for (const tm of findTeammateSessionDirs(sessionDir, ownMeta && ownMeta.sessionId)) {
|
|
658
|
+
streams.push(iterateSessionItems(tm.dir, { ...opts, teammateOf: tm.leader }));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (streams.length === 1) {
|
|
662
|
+
yield* streams[0];
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// k-way merge by (ts, sessionId, seq) — ISO timestamps compare lexically;
|
|
666
|
+
// cross-process seqs are not comparable so sessionId breaks ts ties (§10).
|
|
667
|
+
const heads = streams.map((s) => ({ s, cur: s.next() }));
|
|
668
|
+
for (;;) {
|
|
669
|
+
let best = -1;
|
|
670
|
+
for (let i = 0; i < heads.length; i++) {
|
|
671
|
+
if (heads[i].cur.done) continue;
|
|
672
|
+
if (best === -1) { best = i; continue; }
|
|
673
|
+
const a = heads[i].cur.value;
|
|
674
|
+
const b = heads[best].cur.value;
|
|
675
|
+
if (a.ts < b.ts || (a.ts === b.ts && (a.sessionId < b.sessionId || (a.sessionId === b.sessionId && a.seq < b.seq)))) {
|
|
676
|
+
best = i;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (best === -1) return;
|
|
680
|
+
yield heads[best].cur.value;
|
|
681
|
+
heads[best].cur = heads[best].s.next();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Main entry: iterate one v2 session as a v1-shape raw-entry stream, teammate
|
|
687
|
+
* sessions re-joined. Yields raw JSON strings (same contract as
|
|
688
|
+
* log-stream.iterateRawEntries).
|
|
689
|
+
* @param {string} sessionDir - absolute LOG_DIR/<project>/sessions/<sid>
|
|
690
|
+
*/
|
|
691
|
+
export function* iterateV2RawEntries(sessionDir) {
|
|
692
|
+
for (const item of iterateV2Items(sessionDir)) yield JSON.stringify(item.entry);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Async wrapper with periodic event-loop yields (the synthesis itself is CPU
|
|
696
|
+
* work over small v2 files; the yields keep a live proxy responsive while a
|
|
697
|
+
* big session is being adapted). */
|
|
698
|
+
export async function* iterateV2RawEntriesAsync(sessionDir) {
|
|
699
|
+
let n = 0;
|
|
700
|
+
for (const raw of iterateV2RawEntries(sessionDir)) {
|
|
701
|
+
yield raw;
|
|
702
|
+
if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Depth of the newest-mainAgent ring folded into window results (S10a). The
|
|
707
|
+
// /events kv-cache/context-window scan consumed every raw via onScan before;
|
|
708
|
+
// the two-pass window no longer stringifies out-of-window entries, so Pass A
|
|
709
|
+
// tracks the newest ≤3 main descriptors and Pass B force-materializes them.
|
|
710
|
+
// KEEP IN SYNC: server/routes/events.js MAINAGENT_SCAN_RING.
|
|
711
|
+
const MAINAGENT_RING_DEPTH = 3;
|
|
712
|
+
|
|
713
|
+
/** Window pass key: seqs are unique per session (journal folds duplicates),
|
|
714
|
+
* so (sessionId, seq) identifies one synthesized item across the k-way merge. */
|
|
715
|
+
const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
|
|
716
|
+
|
|
717
|
+
// S10b: two-level in-flight coalescing (spec §14.1). Level 1 shares the Pass A
|
|
718
|
+
// descriptor scan per sessionDir (reconnect storms are all the same dir);
|
|
719
|
+
// level 2 shares the full-window Pass B materialization per (dir,limit,before)
|
|
720
|
+
// — since-bearing (incremental) callers stream their own cheap Pass B instead.
|
|
721
|
+
const _scanFlight = new SingleFlight({ ttlMs: 500 });
|
|
722
|
+
const _windowFlight = new SingleFlight({ ttlMs: 500 });
|
|
723
|
+
const _windowStats = { scanRuns: 0, materializeRuns: 0 };
|
|
724
|
+
export function _v2WindowStatsForTest(reset = false) {
|
|
725
|
+
const snap = { ..._windowStats };
|
|
726
|
+
if (reset) { _windowStats.scanRuns = 0; _windowStats.materializeRuns = 0; }
|
|
727
|
+
return snap;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* S10a Pass A: descriptor scan — runs the SAME synthesis gates as Pass B
|
|
732
|
+
* (a journal-only scan would count crash-orphans the synthesizer skips, P0-1)
|
|
733
|
+
* but builds no entries, loads no blobs, stringifies nothing: memory is
|
|
734
|
+
* O(request count) light records + the replayed conversation event lines.
|
|
735
|
+
* The result is IMMUTABLE and shared across coalesced callers — window
|
|
736
|
+
* selection must not mutate `recs`/`ring`.
|
|
737
|
+
*/
|
|
738
|
+
async function scanV2Descriptors(sessionDir) {
|
|
739
|
+
_windowStats.scanRuns++;
|
|
740
|
+
const dedup = new Map(); // key → descriptor (insertion order = output order)
|
|
741
|
+
const ring = []; // newest ≤3 isMain descriptors, pre-dedup iteration order
|
|
742
|
+
let nokey = 0;
|
|
743
|
+
let n = 0;
|
|
744
|
+
for (const d of iterateV2Items(sessionDir, { descriptorOnly: true })) {
|
|
745
|
+
const key = d.ts && d.url ? `${d.ts}|${d.url}` : `__nokey_${nokey++}`;
|
|
746
|
+
dedup.set(key, d);
|
|
747
|
+
// Ring criterion is item.isMain (kind==='main' on a non-teammate session):
|
|
748
|
+
// the old substring filter (`"mainAgent":true` && !`"teammate"`) also
|
|
749
|
+
// admitted sub/misc entries whose backfilled body LOOKED main-agent; the
|
|
750
|
+
// isMain ring drops those — intentional, more-correct behavior (pinned in
|
|
751
|
+
// test/v2-window-two-pass.test.js). Teammate-session main entries are
|
|
752
|
+
// excluded by both (they carry the teammate tag / isMain=false).
|
|
753
|
+
if (d.isMain) {
|
|
754
|
+
ring.push(d);
|
|
755
|
+
if (ring.length > MAINAGENT_RING_DEPTH) ring.shift();
|
|
756
|
+
}
|
|
757
|
+
if (++n % 50 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
758
|
+
}
|
|
759
|
+
return { recs: [...dedup.values()], ring };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Window selection over a (possibly shared) descriptor scan — cheap array ops,
|
|
764
|
+
* run per caller. Filter order matches the historical single pass: dedup →
|
|
765
|
+
* `before` → tail-`limit` → `since` (emission filter, applied to membership so
|
|
766
|
+
* excluded entries are never materialized). The baseline candidate is decided
|
|
767
|
+
* on the post-limit window start; `since` may then drop it — exactly like the
|
|
768
|
+
* old emission-time since filter did.
|
|
769
|
+
*/
|
|
770
|
+
function selectV2Window(scan, opts = {}) {
|
|
771
|
+
const limit = opts.limit || 0;
|
|
772
|
+
const before = opts.before || null;
|
|
773
|
+
const since = opts.since || null;
|
|
774
|
+
let recs = scan.recs;
|
|
775
|
+
if (before) recs = recs.filter((r) => r.ts && r.ts < before);
|
|
776
|
+
const totalCount = recs.length;
|
|
777
|
+
let hasMore = false;
|
|
778
|
+
if (limit > 0 && recs.length > limit) {
|
|
779
|
+
hasMore = true;
|
|
780
|
+
recs = recs.slice(recs.length - limit);
|
|
781
|
+
}
|
|
782
|
+
const oldestTimestamp = recs.length > 0 ? recs[0].ts : '';
|
|
783
|
+
// Baseline: if the first main-conversation record in the window is a delta,
|
|
784
|
+
// Pass B promotes it to a synthesized checkpoint over its replayed state.
|
|
785
|
+
const firstMainIdx = recs.findIndex((r) => r.isMain);
|
|
786
|
+
let baselineKey = null;
|
|
787
|
+
if (firstMainIdx >= 0 && recs[firstMainIdx].isMainDelta) {
|
|
788
|
+
baselineKey = itemKey(recs[firstMainIdx].sessionId, recs[firstMainIdx].seq);
|
|
789
|
+
}
|
|
790
|
+
if (since) recs = recs.filter((r) => !(r.ts && r.ts < since));
|
|
791
|
+
if (baselineKey && !recs.some((r) => itemKey(r.sessionId, r.seq) === baselineKey)) {
|
|
792
|
+
baselineKey = null; // baseline fell below `since` — incremental clients rely on their cached state
|
|
793
|
+
}
|
|
794
|
+
return { members: recs, totalCount, hasMore, oldestTimestamp, baselineKey, ring: scan.ring };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Level-1 shared Pass A + per-caller selection. `cached` gates the TTL
|
|
798
|
+
* micro-cache read (F5b: /events live-attach must never consume it). */
|
|
799
|
+
async function computeV2Window(sessionDir, opts = {}) {
|
|
800
|
+
const scan = await _scanFlight.run(`scan:${sessionDir}`, () => scanV2Descriptors(sessionDir), { cached: !!opts.cached });
|
|
801
|
+
return selectV2Window(scan, opts);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* S10a Pass B: re-synthesize the session, materializing ONLY window members
|
|
806
|
+
* (plus the mainAgent ring). Out-of-window requests advance conversation state
|
|
807
|
+
* without building entries/loading blobs/stringifying — peak memory is
|
|
808
|
+
* O(window raws) + one live conversation state + window-member blobs.
|
|
809
|
+
*
|
|
810
|
+
* `onEntry` (streaming mode) receives members in synthesis (seq) order — when
|
|
811
|
+
* duplicate timestamp|url keys exist this can differ from the historical
|
|
812
|
+
* dedup-map order, but clients re-dedup on the same key so the final state is
|
|
813
|
+
* equivalent (algorithm review F6). `collect` mode fills a slot array in
|
|
814
|
+
* Pass A output order instead: byte-identical to the historical single pass.
|
|
815
|
+
*/
|
|
816
|
+
async function materializeV2Window(sessionDir, win, { collect = false, onEntry = null } = {}) {
|
|
817
|
+
const memberIdx = new Map();
|
|
818
|
+
win.members.forEach((d, i) => memberIdx.set(itemKey(d.sessionId, d.seq), i));
|
|
819
|
+
const ringKeys = new Set(win.ring.map((d) => itemKey(d.sessionId, d.seq)));
|
|
820
|
+
const slots = collect ? new Array(win.members.length) : null;
|
|
821
|
+
const ringRaws = new Map();
|
|
822
|
+
const materialize = (sessionId, seq) => {
|
|
823
|
+
const k = itemKey(sessionId, seq);
|
|
824
|
+
return memberIdx.has(k) || ringKeys.has(k);
|
|
825
|
+
};
|
|
826
|
+
let sentCount = 0;
|
|
827
|
+
let n = 0;
|
|
828
|
+
for (const item of iterateV2Items(sessionDir, { materialize })) {
|
|
829
|
+
const k = itemKey(item.sessionId, item.seq);
|
|
830
|
+
let raw = JSON.stringify(item.entry);
|
|
831
|
+
// The ring keeps the UN-promoted raw — parity with the old onScan hook,
|
|
832
|
+
// which saw every entry before the baseline rebuild.
|
|
833
|
+
if (ringKeys.has(k)) ringRaws.set(k, raw);
|
|
834
|
+
const idx = memberIdx.get(k);
|
|
835
|
+
if (idx !== undefined) {
|
|
836
|
+
// Baseline: promote the window-start main delta to a checkpoint over its
|
|
837
|
+
// replayed state (item.stateRef aliases the live conversation state at
|
|
838
|
+
// this seq — used transiently, never retained). Same construction as the
|
|
839
|
+
// historical end-of-window rebuild, byte-for-byte.
|
|
840
|
+
if (k === win.baselineKey) {
|
|
841
|
+
const state = item.stateRef || [];
|
|
842
|
+
const rebuilt = { ...item.entry, body: { ...item.entry.body, messages: state } };
|
|
843
|
+
rebuilt._isCheckpoint = true;
|
|
844
|
+
rebuilt._totalMessageCount = state.length;
|
|
845
|
+
raw = JSON.stringify(rebuilt);
|
|
846
|
+
}
|
|
847
|
+
if (collect) {
|
|
848
|
+
slots[idx] = raw;
|
|
849
|
+
} else if (onEntry) {
|
|
850
|
+
await onEntry(raw);
|
|
851
|
+
}
|
|
852
|
+
sentCount++;
|
|
853
|
+
}
|
|
854
|
+
if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
855
|
+
}
|
|
856
|
+
const mainAgentRing = win.ring
|
|
857
|
+
.map((d) => ringRaws.get(itemKey(d.sessionId, d.seq)))
|
|
858
|
+
.filter(Boolean);
|
|
859
|
+
return { slots, sentCount, mainAgentRing };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Windowed read of a v2 session: dedup (timestamp|url, last wins), optional
|
|
864
|
+
* `before` filter, tail-`limit` slice — and a BASELINE GUARANTEE the byte-tail
|
|
865
|
+
* heuristics of v1 files cannot give: when the window starts on a main-
|
|
866
|
+
* conversation delta, that entry is synthesized as a checkpoint carrying the
|
|
867
|
+
* full replayed state at its seq, so a window is always reconstructable
|
|
868
|
+
* regardless of how sparse the session's organic snapshots are.
|
|
869
|
+
*
|
|
870
|
+
* S10a: two-pass. The historical single pass stringified and RETAINED every
|
|
871
|
+
* entry (~10x on-disk expansion resident per load — the 2026-07-15 OOM);
|
|
872
|
+
* now Pass A selects the window over light descriptors and Pass B
|
|
873
|
+
* materializes only its members. Output is byte-identical to the single pass.
|
|
874
|
+
* The onScan option is retired on this path — consumers use `mainAgentRing`
|
|
875
|
+
* from the result instead (P0-2).
|
|
876
|
+
*
|
|
877
|
+
* S10b: level-2 coalescing — a whole window result keyed (dir,limit,before) is
|
|
878
|
+
* shared across concurrent callers and TTL-cached for the read-only historical
|
|
879
|
+
* surfaces. `since` is NOT in the key (it filters emission over the same
|
|
880
|
+
* window), so this array path does not take `since`; the /events incremental
|
|
881
|
+
* path uses streamV2WindowedEntries with its own per-caller Pass B.
|
|
882
|
+
*
|
|
883
|
+
* IMMUTABILITY: the returned object (incl. `entries` and `mainAgentRing`) may
|
|
884
|
+
* be shared by reference across coalesced + TTL-cached callers — treat it as
|
|
885
|
+
* READ-ONLY. An in-place mutation (sort/reverse/push) would corrupt the shared
|
|
886
|
+
* cache for concurrent readers; copy first if you must mutate.
|
|
887
|
+
*
|
|
888
|
+
* @param {string} sessionDir
|
|
889
|
+
* @param {{limit?: number, before?: string|null, cached?: boolean}} [opts]
|
|
890
|
+
* @returns {Promise<{entries: string[], hasMore: boolean, oldestTimestamp: string, totalCount: number, mainAgentRing: string[]}>}
|
|
891
|
+
*/
|
|
892
|
+
export async function readV2WindowedEntries(sessionDir, opts = {}) {
|
|
893
|
+
const cached = !!opts.cached;
|
|
894
|
+
const key = `win:${sessionDir}|${opts.limit || 0}|${opts.before || ''}`;
|
|
895
|
+
return _windowFlight.run(key, async () => {
|
|
896
|
+
_windowStats.materializeRuns++;
|
|
897
|
+
const win = await computeV2Window(sessionDir, opts);
|
|
898
|
+
const { slots, mainAgentRing } = await materializeV2Window(sessionDir, win, { collect: true });
|
|
899
|
+
const entries = [];
|
|
900
|
+
for (let i = 0; i < slots.length; i++) {
|
|
901
|
+
if (slots[i] !== undefined) {
|
|
902
|
+
entries.push(slots[i]);
|
|
903
|
+
} else {
|
|
904
|
+
// Pass B synthesized a different member set than Pass A — both passes
|
|
905
|
+
// run the same gates, so a hole means the session changed between
|
|
906
|
+
// passes (live append) or a synthesis bug. Surface it; entry dropped.
|
|
907
|
+
reportSwallowed('v2-read.window-pass-divergence', new Error(`${basename(sessionDir)}: window slot ${i} not materialized`));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
entries,
|
|
912
|
+
hasMore: win.hasMore,
|
|
913
|
+
oldestTimestamp: win.oldestTimestamp,
|
|
914
|
+
totalCount: win.totalCount,
|
|
915
|
+
mainAgentRing,
|
|
916
|
+
};
|
|
917
|
+
}, { cached });
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Streaming window read (S10a): same selection semantics as
|
|
922
|
+
* readV2WindowedEntries but entries are delivered one at a time via `onEntry`
|
|
923
|
+
* and never accumulated — the limit=0 full-session and `/events` incremental
|
|
924
|
+
* (`since`) paths stay O(one entry) resident instead of materializing the
|
|
925
|
+
* whole expanded session (the pre-S10a reconnect-storm OOM path).
|
|
926
|
+
*
|
|
927
|
+
* @param {string} sessionDir
|
|
928
|
+
* @param {{limit?: number, before?: string|null, since?: string|null,
|
|
929
|
+
* onReady?: (info:{totalCount:number, hasMore:boolean, oldestTs:string|null})=>void}} opts
|
|
930
|
+
* @param {(raw: string) => void|Promise<void>} onEntry
|
|
931
|
+
* @returns {Promise<{sentCount: number, totalCount: number, mainAgentRing: string[]}>}
|
|
932
|
+
*/
|
|
933
|
+
export async function streamV2WindowedEntries(sessionDir, opts, onEntry) {
|
|
934
|
+
const win = await computeV2Window(sessionDir, opts);
|
|
935
|
+
if (opts.onReady) opts.onReady({ totalCount: win.totalCount, hasMore: win.hasMore, oldestTs: win.oldestTimestamp || null });
|
|
936
|
+
// Bounded window (limit>0): collect into slots and emit in Pass A output
|
|
937
|
+
// order — byte-order parity with the historical single pass. Unbounded paths
|
|
938
|
+
// (limit=0: /api/requests, workspaces reload, explicit ?limit=0) stream in
|
|
939
|
+
// synthesis (seq) order without retention. ACCEPTED ORDER CHANGE: when two
|
|
940
|
+
// entries share timestamp|url (e.g. same-ms countTokens bursts), the dedup
|
|
941
|
+
// survivor emits at its later seq position instead of the first-occurrence
|
|
942
|
+
// slot. Safe because the client re-dedups by the same key and main deltas
|
|
943
|
+
// reconstruct via _seq/_seqEpoch (not array index) — final state is
|
|
944
|
+
// order-independent; the default /events cold-load is bounded and unaffected.
|
|
945
|
+
if (opts.limit > 0) {
|
|
946
|
+
const { slots, mainAgentRing } = await materializeV2Window(sessionDir, win, { collect: true });
|
|
947
|
+
let sentCount = 0;
|
|
948
|
+
for (let i = 0; i < slots.length; i++) {
|
|
949
|
+
if (slots[i] === undefined) {
|
|
950
|
+
// Pass A selected a member Pass B didn't materialize (a live append
|
|
951
|
+
// between passes / a synthesis bug). Same invariant break the array
|
|
952
|
+
// reader reports — surface it here too rather than dropping silently
|
|
953
|
+
// (this streaming path is the /events live source, where it's most
|
|
954
|
+
// likely). The entry is omitted; the client's next `since` read heals.
|
|
955
|
+
reportSwallowed('v2-read.window-pass-divergence', new Error(`${basename(sessionDir)}: window slot ${i} not materialized (stream)`));
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
await onEntry(slots[i]);
|
|
959
|
+
if (++sentCount % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
960
|
+
}
|
|
961
|
+
return { sentCount, totalCount: win.totalCount, mainAgentRing };
|
|
962
|
+
}
|
|
963
|
+
const { sentCount, mainAgentRing } = await materializeV2Window(sessionDir, win, { onEntry });
|
|
964
|
+
return { sentCount, totalCount: win.totalCount, mainAgentRing };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ─── session listing (spec §12, list entry pulled forward from S6a) ─────────
|
|
968
|
+
|
|
969
|
+
/** Bounded head read: parse the FIRST JSONL line of a file without loading the
|
|
970
|
+
* whole thing (a main conversation's opening snapshot can be multi-MB; the
|
|
971
|
+
* list only wants a preview). Returns null on any shortfall. */
|
|
972
|
+
function readFirstJsonLine(path, budget = 256 * 1024) {
|
|
973
|
+
let fd;
|
|
974
|
+
try {
|
|
975
|
+
fd = openSync(path, 'r');
|
|
976
|
+
const buf = Buffer.alloc(budget);
|
|
977
|
+
const n = readSync(fd, buf, 0, budget, 0);
|
|
978
|
+
const head = buf.toString('utf-8', 0, n);
|
|
979
|
+
const nl = head.indexOf('\n');
|
|
980
|
+
if (nl <= 0) return null; // no complete first line inside the budget
|
|
981
|
+
return JSON.parse(head.slice(0, nl));
|
|
982
|
+
} catch {
|
|
983
|
+
return null;
|
|
984
|
+
} finally {
|
|
985
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Summarize every session under LOG_DIR/<project>/ for the log list (spec §12).
|
|
991
|
+
* Deliberately cheap: journal lines only (small) + a bounded head read of the
|
|
992
|
+
* main conversation's first epoch for the preview — conversation bodies are
|
|
993
|
+
* never loaded. Teammate linkage is surfaced via `leader` so the caller can
|
|
994
|
+
* fold those sessions into their leader's view instead of double-listing.
|
|
995
|
+
* @returns {Array<{sid, dir, startTs, leader, turns, size, preview}>}
|
|
996
|
+
*/
|
|
997
|
+
export function listV2Sessions(projectDir) {
|
|
998
|
+
const out = [];
|
|
999
|
+
for (const sid of listSessionIds(projectDir)) {
|
|
1000
|
+
try {
|
|
1001
|
+
const dir = join(projectDir, 'sessions', sid);
|
|
1002
|
+
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
|
|
1003
|
+
let meta = null;
|
|
1004
|
+
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ }
|
|
1005
|
+
if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
|
|
1006
|
+
// Reader version gate (spec §14): don't list a session this build
|
|
1007
|
+
// can't read — a garbage preview/turn-count is worse than absence.
|
|
1008
|
+
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`));
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// turns = main requests that completed (journal two-phase fold). The
|
|
1013
|
+
// journal sentinel is checked in the same pass: per §14 the per-file
|
|
1014
|
+
// sentinel WINS over meta.json, and readSession/adapter refuse such a
|
|
1015
|
+
// session — listing it would show a phantom row that opens empty.
|
|
1016
|
+
const reqKind = new Map();
|
|
1017
|
+
let turns = 0;
|
|
1018
|
+
let sentinelVersion = null;
|
|
1019
|
+
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
1020
|
+
if (line.ph === 'req') reqKind.set(line.seq, line.kind);
|
|
1021
|
+
else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') {
|
|
1022
|
+
turns++;
|
|
1023
|
+
reqKind.delete(line.seq); // fold duplicate done lines (§14)
|
|
1024
|
+
} else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) {
|
|
1025
|
+
sentinelVersion = line.wireFormat;
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (sentinelVersion != null) {
|
|
1030
|
+
reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`));
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// preview = ALL user prompts of the session, from the prompts.jsonl
|
|
1035
|
+
// display cache (written by V2Writer / the converter; bounded head read
|
|
1036
|
+
// so the list stays O(budget) per session). Sessions predating the
|
|
1037
|
+
// cache fall back to the first epoch's first line — routed through the
|
|
1038
|
+
// shared extractor so command/caveat chrome never leaks into the row.
|
|
1039
|
+
let preview = readPromptsHead(join(dir, 'prompts.jsonl'));
|
|
1040
|
+
if (preview.length === 0) {
|
|
1041
|
+
const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl'));
|
|
1042
|
+
if (first && Array.isArray(first.msgs)) {
|
|
1043
|
+
preview = collectPromptsFromEvents([first]);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
out.push({
|
|
1048
|
+
sid,
|
|
1049
|
+
dir,
|
|
1050
|
+
startTs: (meta && meta.startTs) || '',
|
|
1051
|
+
leader: (meta && meta.leader) || null,
|
|
1052
|
+
turns,
|
|
1053
|
+
size: dirSizeSync(dir),
|
|
1054
|
+
preview,
|
|
1055
|
+
});
|
|
1056
|
+
} catch { /* one unreadable session must not break the list */ }
|
|
1057
|
+
}
|
|
1058
|
+
return out;
|
|
1059
|
+
}
|