dsh-all-usage 1.1.2 → 1.1.4

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.
@@ -0,0 +1,642 @@
1
+ import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes } from './usage-core.js'
2
+
3
+ const RECONCILE_INTERVAL_MS = 120000
4
+ const RECONCILE_HINT_DELAY_MS = 3000
5
+
6
+ export function createSessionSync(host) {
7
+ const { ctx, state } = host
8
+ const { sessionPersistence } = host.services
9
+ const {
10
+ validEventTime,
11
+ identityFromRoute,
12
+ identityFromMessage,
13
+ coerceIdentity,
14
+ addTurn,
15
+ addUsage,
16
+ resetAggregationState,
17
+ } = host.aggregation
18
+ const { beginSync, noteSyncError, markStatsChanged } = host
19
+ const {
20
+ buildLedgerRecord,
21
+ applyLedgerRecord,
22
+ storeLedgerRecord,
23
+ replaceLedgerRecord,
24
+ persistLedgerRecord,
25
+ drainLedgerWrites,
26
+ } = host.ledger
27
+ const backfillUnpricedCosts = (...args) => host.pricing.backfillUnpricedCosts(...args)
28
+ const reconcileTemporalPricing = (...args) => host.pricing.reconcileTemporalPricing(...args)
29
+ const safeContextTimeout = async (ms) => {
30
+ if (state.disposed) return false
31
+ try {
32
+ await ctx.timeout(ms)
33
+ return !state.disposed
34
+ } catch (err) {
35
+ if (!state.disposed) console.error('[all-usage] context timer unavailable:', err)
36
+ return false
37
+ }
38
+ }
39
+
40
+ function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
41
+ if ((type === 'turn/end' || type === 'assistant/message' || type === 'assistant/chunk') && !validEventTime(time)) return
42
+ if (type === 'request/context') {
43
+ state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
44
+ if (validEventTime(time)) {
45
+ let times = state.sessionContextTimes.get(sid)
46
+ if (times === undefined) { times = new Map(); state.sessionContextTimes.set(sid, times) }
47
+ touchContextTimes(times, contextTimeKey(data && data.turn, data && data.step), time)
48
+ }
49
+ } else if (type === 'request/header') {
50
+ state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
51
+ } else if (type === 'turn/end') {
52
+ addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, state.sessionModel.get(sid), materialization, seq)
53
+ } else if (type === 'assistant/message' || type === 'assistant/chunk') {
54
+ const usageEvent = extractUsageEvent({ type, time, data, seq })
55
+ if (usageEvent === null) return
56
+ const identity = usageEvent.kind === 'message' ? identityFromMessage(data, state.sessionModel.get(sid)) : coerceIdentity(state.sessionModel.get(sid))
57
+ state.sessionModel.set(sid, identity)
58
+ const pricing = pickPricingTime(state.sessionContextTimes.get(sid), time, usageEvent.turn, usageEvent.step)
59
+ addUsage(wsId, time, usageEvent.usage, identity, sid, data, seq, materialization, pricing.time, pricing.source)
60
+ }
61
+ }
62
+ function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
63
+ // Full folds keep their callers responsible for atomicity: runBaseline and
64
+ // syncLiveSession remove the session's old usage/turn/context/identity
65
+ // first when the snapshot is authoritative (see rebuildSession below), so
66
+ // usage deleted by a history rewrite disappears from the aggregate.
67
+ for (const ev of events) {
68
+ if (fromSeq !== undefined) {
69
+ const s = typeof ev.seq === 'number' ? ev.seq : -1
70
+ if (s <= fromSeq) continue
71
+ }
72
+ if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'assistant/chunk' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq, materialization)
73
+ }
74
+ }
75
+ function lastSeqOf(events) {
76
+ let last = -1
77
+ for (const ev of events) {
78
+ const s = Number.isSafeInteger(ev.seq) ? ev.seq : -1
79
+ if (s > last) last = s
80
+ }
81
+ return last
82
+ }
83
+ function sequenceProfile(events) {
84
+ let previous = -1
85
+ let last = -1
86
+ let nonMonotonic = false
87
+ let hasInvalid = false
88
+ for (const ev of events) {
89
+ const seq = ev && Number.isSafeInteger(ev.seq) && ev.seq >= 0 ? ev.seq : -1
90
+ if (seq < 0) { hasInvalid = true; continue }
91
+ if (seq <= previous) nonMonotonic = true
92
+ previous = seq
93
+ if (seq > last) last = seq
94
+ }
95
+ return { lastSeq: last, nonMonotonic, hasInvalid }
96
+ }
97
+ function enqueue(sid, task) {
98
+ const prev = state.chains.get(sid) || Promise.resolve()
99
+ const next = prev.then(() => task(), () => task())
100
+ state.chains.set(sid, next)
101
+ const cleanup = () => { if (state.chains.get(sid) === next) state.chains.delete(sid) }
102
+ void next.then(cleanup, cleanup)
103
+ return next
104
+ }
105
+ function markLedgerDirty(sid) {
106
+ if (typeof sid !== 'string' || sid === '') return
107
+ state.ledgerDirtySessions.add(sid)
108
+ state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
109
+ }
110
+ function wsForLiveSession(session, sid) {
111
+ let wsId = state.memberOf.get(sid)
112
+ if (wsId !== undefined) return wsId
113
+ const header = session && session.header
114
+ const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
115
+ if (cwd === '') return undefined
116
+ wsId = state.pathIndex.get(cwd)
117
+ if (wsId !== undefined) state.memberOf.set(sid, wsId)
118
+ return wsId
119
+ }
120
+ function cancelLiveResync(sid) {
121
+ const timer = state.liveResyncTimers.get(sid)
122
+ if (timer !== undefined) {
123
+ clearTimeout(timer)
124
+ state.liveResyncTimers.delete(sid)
125
+ }
126
+ state.liveResyncAttempts.delete(sid)
127
+ state.liveResyncPending.delete(sid)
128
+ }
129
+ function scheduleLiveResync(sid, wsId, generation) {
130
+ if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid) || state.liveResyncTimers.has(sid)) return
131
+ const attempt = state.liveResyncAttempts.get(sid) || 0
132
+ const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(attempt, 5)))
133
+ const timer = setTimeout(() => {
134
+ state.liveResyncTimers.delete(sid)
135
+ if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
136
+ void enqueue(sid, () => resyncLiveSession(sid, wsId, generation))
137
+ }, delay)
138
+ state.liveResyncTimers.set(sid, timer)
139
+ if (timer && typeof timer.unref === 'function') timer.unref()
140
+ }
141
+ function foldLiveFallback(sid, wsId, event) {
142
+ if (event === null || event === undefined) return
143
+ const seq = Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1
144
+ foldEvent(wsId, event.time, event.type, event.data, sid, seq, 'live')
145
+ if (seq >= 0) {
146
+ const current = state.sessionSeq.get(sid)
147
+ if (current === undefined || seq > current) state.sessionSeq.set(sid, seq)
148
+ }
149
+ state.sessionCount.add(sid)
150
+ }
151
+ async function syncLiveSession(sid, wsId, event, generation) {
152
+ try {
153
+ const snap = await ctx.sessionQuery.readSession(sid)
154
+ if (state.disposed || generation !== state.aggregationGeneration) return true
155
+ if (snap && Array.isArray(snap.events)) {
156
+ const snapshotLast = lastSeqOf(snap.events)
157
+ const snapshotProfile = sequenceProfile(snap.events)
158
+ const eventSeq = event === null || event === undefined ? -1 : (Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1)
159
+ // The snapshot may replace the session only when it is provably
160
+ // complete AND it already contains the triggering event: a timer / full
161
+ // resync (no event), or an explicitly contained, monotonic event
162
+ // stream. A snapshot that merely has a higher tail than the event is
163
+ // NOT proof — the event could be a live append the snapshot missed (or
164
+ // the snapshot could have been rewritten without it), so the event is
165
+ // upserted and the session stays pending for a follow-up alignment.
166
+ // Events without a usable seq never trigger a destructive replacement.
167
+ const containsEvent = event !== null && event !== undefined && eventSeq >= 0 && snap.events.some((candidate) => candidate && Number.isSafeInteger(candidate.seq) && candidate.seq === eventSeq)
168
+ const authoritative = event === null || event === undefined ? true : eventSeq < 0 ? false : containsEvent && !snapshotProfile.nonMonotonic && !snapshotProfile.hasInvalid
169
+ if (authoritative) {
170
+ host.aggregation.removeSession(sid)
171
+ // The authoritative snapshot is the new truth for both the in-memory
172
+ // aggregate and the durable ledger: rebuild the record from scratch
173
+ // and persist it, so a restart after a live history rewrite cannot
174
+ // resurrect usage that the snapshot deleted.
175
+ const rebuiltRecord = buildLedgerRecord({ id: sid, events: snap.events }, wsId, 'scan', undefined, null, true)
176
+ if (rebuiltRecord !== null) {
177
+ const canonical = replaceLedgerRecord(rebuiltRecord)
178
+ if (canonical === rebuiltRecord) {
179
+ void persistLedgerRecord(rebuiltRecord).then(() => {
180
+ if (state.ledgerWriteFailedSessions && state.ledgerWriteFailedSessions.has(sid)) {
181
+ console.error('[all-usage] authoritative resync ledger persist failed; session stays dirty:', sid)
182
+ noteSyncError('resync-ledger-persist-failed')
183
+ }
184
+ })
185
+ }
186
+ }
187
+ }
188
+ foldEvents(wsId, snap.events, undefined, sid, 'live')
189
+ let nextLast = snapshotLast
190
+ const needsFollowup = !authoritative && event !== null && event !== undefined
191
+ if (needsFollowup) {
192
+ foldLiveFallback(sid, wsId, event)
193
+ const current = state.sessionSeq.get(sid)
194
+ nextLast = Math.max(nextLast, current === undefined ? -1 : current)
195
+ }
196
+ state.sessionSeq.set(sid, nextLast)
197
+ state.sessionCount.add(sid)
198
+ if (needsFollowup) {
199
+ // The snapshot is behind: keep the pending flag so the follow-up
200
+ // resync realigns the cursor once the missing history arrives.
201
+ scheduleLiveResync(sid, wsId, generation)
202
+ } else {
203
+ state.liveResyncPending.delete(sid)
204
+ cancelLiveResync(sid)
205
+ }
206
+ return true
207
+ }
208
+ } catch (err) {
209
+ // Keep the event as a fallback and retry a complete session state.sync later.
210
+ }
211
+ return false
212
+ }
213
+ async function resyncLiveSession(sid, wsId, generation) {
214
+ if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
215
+ if (await syncLiveSession(sid, wsId, null, generation)) return
216
+ state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
217
+ scheduleLiveResync(sid, wsId, generation)
218
+ }
219
+ async function processLiveEvent(sid, wsId, event, generation = state.aggregationGeneration) {
220
+ if (state.disposed || generation !== state.aggregationGeneration) return
221
+ const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
222
+ const last = state.sessionSeq.get(sid)
223
+ const needsSync = last === undefined || state.liveResyncPending.has(sid) || (seq >= 0 && seq > last + 1)
224
+ if (needsSync) {
225
+ state.liveResyncPending.add(sid)
226
+ if (await syncLiveSession(sid, wsId, event, generation)) return
227
+ foldLiveFallback(sid, wsId, event)
228
+ state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
229
+ scheduleLiveResync(sid, wsId, generation)
230
+ return
231
+ }
232
+ if (seq < 0) {
233
+ foldLiveFallback(sid, wsId, event)
234
+ return
235
+ }
236
+ if (seq <= last) return
237
+ foldLiveFallback(sid, wsId, event)
238
+ }
239
+
240
+ // ---------- durable usage ledger ----------
241
+ function scheduleNativeBaselineRetry(generation, delay) {
242
+ if (state.disposed || state.baselineFallbackTimer !== null) return
243
+ state.baselineFallbackTimer = setTimeout(() => {
244
+ state.baselineFallbackTimer = null
245
+ if (!state.disposed && generation === state.aggregationGeneration && !state.scan.started && !state.scan.done) void runBaseline(generation)
246
+ }, delay)
247
+ if (state.baselineFallbackTimer && typeof state.baselineFallbackTimer.unref === 'function') state.baselineFallbackTimer.unref()
248
+ }
249
+ function scheduleBaselineRetry(generation = state.aggregationGeneration) {
250
+ if (state.disposed || state.baselineRetryScheduled || state.scan.done || generation !== state.aggregationGeneration) return
251
+ state.baselineRetryScheduled = true
252
+ const delay = state.baselineRetryDelay
253
+ state.baselineRetryDelay = Math.min(state.baselineRetryDelay * 2, 30000)
254
+ void safeContextTimeout(delay).then((ready) => {
255
+ if (generation !== state.aggregationGeneration) return undefined
256
+ state.baselineRetryScheduled = false
257
+ if (ready && !state.scan.started && !state.scan.done) return runBaseline(generation)
258
+ if (!ready && !state.disposed) scheduleNativeBaselineRetry(generation, delay)
259
+ return undefined
260
+ })
261
+ }
262
+ async function runBaseline(generation = state.aggregationGeneration) {
263
+ if (state.scan.started || state.disposed || generation !== state.aggregationGeneration) return
264
+ state.scan.started = true
265
+ beginSync()
266
+ await Promise.all([state.ledgerReady, state.pricingReady])
267
+ if (state.disposed || generation !== state.aggregationGeneration) return
268
+ let setupFailed = false
269
+ try {
270
+ const workspaces = ctx.workspaceRegistry.list()
271
+ const nextWsMeta = new Map()
272
+ const nextPathIndex = new Map()
273
+ const nextMemberOf = new Map()
274
+ for (const w of workspaces) {
275
+ const id = w && w.id
276
+ const path = w && typeof w.path === 'string' ? w.path : ''
277
+ const title = w && typeof w.title === 'string' ? w.title : ''
278
+ if (id === undefined) continue
279
+ nextWsMeta.set(id, { id, title, path })
280
+ if (path !== '') nextPathIndex.set(path, id)
281
+ if (w && Array.isArray(w.sessionIds)) {
282
+ for (const sid of w.sessionIds) nextMemberOf.set(sid, id)
283
+ }
284
+ }
285
+ let metadataChanged = state.wsMeta.size !== nextWsMeta.size || state.pathIndex.size !== nextPathIndex.size || state.memberOf.size !== nextMemberOf.size
286
+ if (!metadataChanged) {
287
+ for (const [id, value] of nextWsMeta) {
288
+ const previous = state.wsMeta.get(id)
289
+ if (previous === undefined || previous.title !== value.title || previous.path !== value.path) { metadataChanged = true; break }
290
+ }
291
+ }
292
+ if (!metadataChanged) {
293
+ for (const [path, id] of nextPathIndex) if (state.pathIndex.get(path) !== id) { metadataChanged = true; break }
294
+ }
295
+ if (!metadataChanged) {
296
+ for (const [sid, id] of nextMemberOf) if (state.memberOf.get(sid) !== id) { metadataChanged = true; break }
297
+ }
298
+ state.wsMeta.clear()
299
+ state.pathIndex.clear()
300
+ state.memberOf.clear()
301
+ for (const [id, value] of nextWsMeta) state.wsMeta.set(id, value)
302
+ for (const [path, id] of nextPathIndex) state.pathIndex.set(path, id)
303
+ for (const [sid, id] of nextMemberOf) state.memberOf.set(sid, id)
304
+ if (metadataChanged) markStatsChanged('metadata')
305
+ } catch (err) {
306
+ console.error('[all-usage] workspace list failed:', err)
307
+ setupFailed = true
308
+ noteSyncError('workspace-list-failed')
309
+ }
310
+ let records = null
311
+ try {
312
+ records = await ctx.sessionQuery.listSessions()
313
+ } catch (err) {
314
+ console.error('[all-usage] session list failed:', err)
315
+ if (state.disposed || generation !== state.aggregationGeneration) return
316
+ noteSyncError('session-list-failed')
317
+ }
318
+ if (state.disposed || generation !== state.aggregationGeneration) return
319
+ // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
320
+ let snapshots = null
321
+ if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
322
+ try {
323
+ const rows = await sessionPersistence.listSnapshots()
324
+ if (state.disposed || generation !== state.aggregationGeneration) return
325
+ if (Array.isArray(rows)) {
326
+ state.sync.persistenceSnapshotsAvailable = true
327
+ snapshots = new Map()
328
+ for (const row of rows) {
329
+ const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
330
+ if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
331
+ }
332
+ }
333
+ } catch (err) {
334
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
335
+ if (state.disposed || generation !== state.aggregationGeneration) return
336
+ state.sync.persistenceSnapshotsAvailable = false
337
+ markStatsChanged('scan')
338
+ }
339
+ }
340
+ if (state.disposed || generation !== state.aggregationGeneration) return
341
+ if (setupFailed || !Array.isArray(records)) {
342
+ // A transient registry failure must not be reported as a completed empty state.scan.
343
+ state.scan.started = false
344
+ markStatsChanged('scan')
345
+ scheduleBaselineRetry(generation)
346
+ return
347
+ }
348
+ state.scan.total = records.length
349
+ state.sync.sessionsTotal = records.length
350
+ markStatsChanged('scan')
351
+ const listedSessionIds = new Set()
352
+ for (const record of records) {
353
+ if (record === undefined || record === null || record.header === undefined) continue
354
+ const sid = record.header.id
355
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
356
+ const wsId = cwd === '' ? undefined : state.pathIndex.get(cwd)
357
+ if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
358
+ }
359
+ for (const [sid, record] of state.ledgerRecords) {
360
+ if (!listedSessionIds.has(sid)) {
361
+ applyLedgerRecord(record, 'ledger-recovery')
362
+ if (record.turns.length > 0 || record.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
363
+ }
364
+ }
365
+ if (state.sync.sessionsRestoredFromLedger > 0) markStatsChanged('scan')
366
+ for (const record of records) {
367
+ if (state.disposed || generation !== state.aggregationGeneration) return
368
+ if (record === undefined || record === null || record.header === undefined) {
369
+ state.scan.scanned += 1
370
+ markStatsChanged('scan')
371
+ continue
372
+ }
373
+ const sid = record.header.id
374
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
375
+ const wsId = cwd === '' ? undefined : state.pathIndex.get(cwd)
376
+ if (sid === undefined || wsId === undefined) {
377
+ state.scan.scanned += 1
378
+ markStatsChanged('scan')
379
+ continue
380
+ }
381
+ listedSessionIds.add(sid)
382
+ await enqueue(sid, async () => {
383
+ if (state.disposed || generation !== state.aggregationGeneration) return
384
+ try {
385
+ if (state.sessionSeq.has(sid) && !state.liveResyncPending.has(sid)) return
386
+ // v1.0.8: when the persisted log revision is unchanged since the last ledger
387
+ // write, the whole readSession (full event transfer) is skipped — the ledger
388
+ // record is applied directly and the live feed keeps catching new events.
389
+ const previousRecord = state.ledgerRecords.get(sid)
390
+ const revision = snapshots === null ? undefined : snapshots.get(sid)
391
+ if (!state.liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && previousRecord.rebuildRequired === undefined && previousRecord.workspaceId === wsId && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
392
+ state.sync.sessionsSkippedByRevision += 1
393
+ applyLedgerRecord(previousRecord, 'ledger-reuse')
394
+ state.sessionSeq.set(sid, previousRecord.lastSeq)
395
+ state.sessionCount.add(sid)
396
+ markStatsChanged('scan')
397
+ return
398
+ }
399
+ state.sync.sessionsRead += 1
400
+ markStatsChanged('scan')
401
+ const snap = await ctx.sessionQuery.readSession(sid)
402
+ if (state.disposed || generation !== state.aggregationGeneration) return
403
+ if (snap && Array.isArray(snap.events)) {
404
+ // v1.0.7: incremental seed — the durable ledger doubles as a per-session
405
+ // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
406
+ // session applies its canonical record directly and never re-folds;
407
+ // a changed session seeds the previous record once, then folds only the
408
+ // new tail (previously every listed session was re-read and fully rebuilt).
409
+ const sequence = sequenceProfile(snap.events)
410
+ const currentLastSeq = sequence.lastSeq
411
+ const previous = state.ledgerRecords.get(sid)
412
+ const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && previous.rebuildRequired === undefined && previous.workspaceId === wsId && !sequence.nonMonotonic && !sequence.hasInvalid && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
413
+ if (canFoldTail) {
414
+ applyLedgerRecord(previous, 'ledger-reuse')
415
+ foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
416
+ } else {
417
+ // A changed revision with no new tail may still contain a replacement;
418
+ // rebuild from the complete read instead of trusting lastSeq alone.
419
+ foldEvents(wsId, snap.events, undefined, sid, 'scan')
420
+ }
421
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous, !sequence.hasInvalid)
422
+ const canonical = ledger === null ? state.ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
423
+ if (canonical === ledger) {
424
+ void persistLedgerRecord(ledger)
425
+ }
426
+ const observedLastSeq = state.sessionSeq.get(sid)
427
+ const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
428
+ state.sessionSeq.set(sid, nextLastSeq)
429
+ state.sessionCount.add(sid)
430
+ if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
431
+ else scheduleLiveResync(sid, wsId, generation)
432
+ }
433
+ } catch (err) {
434
+ if (generation !== state.aggregationGeneration) return
435
+ state.sync.sessionsFailed += 1
436
+ state.scan.failed += 1
437
+ noteSyncError('session-read-failed')
438
+ const saved = state.ledgerRecords.get(sid)
439
+ if (saved !== undefined) {
440
+ applyLedgerRecord(saved, 'ledger-recovery')
441
+ if (saved.turns.length > 0 || saved.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
442
+ state.sessionSeq.set(sid, -1)
443
+ state.sessionCount.add(sid)
444
+ } else {
445
+ state.sessionSeq.set(sid, -1)
446
+ }
447
+ } finally {
448
+ if (generation === state.aggregationGeneration) {
449
+ state.scan.scanned += 1
450
+ markStatsChanged('scan')
451
+ }
452
+ }
453
+ })
454
+ if (!(await safeContextTimeout(0))) {
455
+ state.scan.started = false
456
+ noteSyncError('baseline-yield-unavailable')
457
+ scheduleBaselineRetry(generation)
458
+ return
459
+ }
460
+ }
461
+ if (state.disposed || generation !== state.aggregationGeneration) return
462
+ await drainLedgerWrites()
463
+ if (state.disposed || generation !== state.aggregationGeneration) return
464
+ const costBackfill = backfillUnpricedCosts()
465
+ if (costBackfill.priced > 0) {
466
+ await drainLedgerWrites()
467
+ markStatsChanged('pricing')
468
+ }
469
+ if (state.disposed || generation !== state.aggregationGeneration) return
470
+ const temporalReconcile = reconcileTemporalPricing()
471
+ if (temporalReconcile.reconciled > 0) {
472
+ await drainLedgerWrites()
473
+ markStatsChanged('pricing')
474
+ }
475
+ if (state.disposed || generation !== state.aggregationGeneration) return
476
+ state.knownSessionIds.clear()
477
+ for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
478
+ state.scan.done = true
479
+ state.sync.lastCompletedAt = Date.now()
480
+ if (state.sync.sessionsFailed === 0) {
481
+ state.sync.lastErrorAt = 0
482
+ state.sync.lastErrorCode = null
483
+ }
484
+ markStatsChanged('scan')
485
+ if (state.reconcilePending) scheduleReconcileHint()
486
+ }
487
+
488
+ function sessionIdsFromRecords(records) {
489
+ const ids = new Set()
490
+ for (const record of records) {
491
+ if (record === undefined || record === null || record.header === undefined) continue
492
+ const sid = record.header.id
493
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
494
+ const wsId = cwd === '' ? undefined : state.pathIndex.get(cwd)
495
+ if (sid !== undefined && wsId !== undefined) ids.add(sid)
496
+ }
497
+ return ids
498
+ }
499
+ async function reconcileSessions() {
500
+ if (state.disposed || state.reconcileInFlight || !state.scan.done) return
501
+ state.reconcilePending = false
502
+ state.reconcileInFlight = true
503
+ try {
504
+ const records = await ctx.sessionQuery.listSessions()
505
+ if (state.disposed || !Array.isArray(records)) return
506
+ const currentIds = sessionIdsFromRecords(records)
507
+ let removed = false
508
+ for (const sid of state.knownSessionIds) {
509
+ if (!currentIds.has(sid)) { removed = true; break }
510
+ }
511
+ if (removed && !state.disposed && state.scan.done) {
512
+ console.info('[all-usage] session removal detected; rebuilding usage index')
513
+ const generation = resetAggregationState()
514
+ void runBaseline(generation)
515
+ return
516
+ }
517
+ state.knownSessionIds.clear()
518
+ for (const sid of currentIds) state.knownSessionIds.add(sid)
519
+ } catch (err) {
520
+ console.error('[all-usage] session reconciliation failed:', err)
521
+ noteSyncError('session-reconcile-failed')
522
+ } finally {
523
+ state.reconcileInFlight = false
524
+ if (state.reconcilePending && !state.disposed) scheduleReconcileHint()
525
+ }
526
+ }
527
+ function scheduleReconcileHint() {
528
+ if (state.disposed) return
529
+ state.reconcilePending = true
530
+ if (state.reconcileHintScheduled || state.reconcileInFlight) return
531
+ state.reconcileHintScheduled = true
532
+ const generation = state.aggregationGeneration
533
+ void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
534
+ if (generation !== state.aggregationGeneration) return
535
+ state.reconcileHintScheduled = false
536
+ if (ready && !state.disposed) void reconcileSessions()
537
+ }, () => {
538
+ if (generation === state.aggregationGeneration) state.reconcileHintScheduled = false
539
+ })
540
+ }
541
+ function scheduleReconcileTimer() {
542
+ if (state.disposed || state.reconcileTimer !== null) return
543
+ state.reconcileTimer = setTimeout(() => {
544
+ state.reconcileTimer = null
545
+ if (!state.disposed) {
546
+ void reconcileSessions()
547
+ scheduleReconcileTimer()
548
+ }
549
+ }, RECONCILE_INTERVAL_MS)
550
+ if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
551
+ }
552
+
553
+ // ---------- live feed ----------
554
+ ctx.on('session/event', (session, event) => {
555
+ if (state.disposed) return
556
+ if (event === undefined || event === null) return
557
+ const type = event.type
558
+ if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'assistant/chunk' && type !== 'request/context' && type !== 'request/header') return
559
+ const sid = session && session.id
560
+ if (typeof sid !== 'string') return
561
+ markLedgerDirty(sid)
562
+ const wsId = wsForLiveSession(session, sid)
563
+ if (wsId === undefined) return
564
+ const generation = state.aggregationGeneration
565
+ state.knownSessionIds.add(sid)
566
+ enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
567
+ })
568
+ ctx.on('session/flush', async (session) => {
569
+ if (state.disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
570
+ const sid = session.id
571
+ const previous = state.ledgerRecords.get(sid)
572
+ const eventList = Array.isArray(session.events) ? session.events : []
573
+ // DSH sequences events with seq === log index: the tail event gives the
574
+ // latest sequence in O(1) without scanning a 500K-event session twice.
575
+ const tailEvent = eventList.length > 0 ? eventList[eventList.length - 1] : null
576
+ const currentLastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(eventList)
577
+ const hasNewEvents = state.ledgerDirtySessions.has(sid) || state.ledgerWriteFailedSessions.has(sid) || (previous === undefined && currentLastSeq >= 0) || (previous !== undefined && currentLastSeq > previous.lastSeq)
578
+ if (!hasNewEvents) return
579
+ const dirtyEpoch = state.ledgerDirtyEpochs.get(sid) || 0
580
+ await Promise.all([state.ledgerReady, state.pricingReady])
581
+ if (state.disposed) return
582
+ const wsId = wsForLiveSession(session, sid)
583
+ if (wsId === undefined) return
584
+ // Any dirty flush whose log contains invalid sequences may have rewritten
585
+ // history (even when a new tail was appended) or carries contract-external
586
+ // events; refuse to fold it and rebuild the whole usage index instead, so
587
+ // the in-memory aggregate and the persisted ledger cannot disagree.
588
+ if (previous !== undefined && eventList.length > 0) {
589
+ const sequence = sequenceProfile(eventList)
590
+ if (sequence.hasInvalid) {
591
+ if (state.scan.done && !state.disposed) {
592
+ console.info('[all-usage] invalid sequences in flushed log; rebuilding usage index')
593
+ // Persist the rebuild flag so the baseline cannot fast-path the stale
594
+ // record (the persistence revision may still match a lagging log).
595
+ const current = state.ledgerRecords.get(sid)
596
+ if (current !== undefined) {
597
+ current.rebuildRequired = 'invalid-flush-sequence'
598
+ current.needsUpgrade = true
599
+ void persistLedgerRecord(current)
600
+ }
601
+ const generation = resetAggregationState()
602
+ void runBaseline(generation)
603
+ }
604
+ return
605
+ }
606
+ }
607
+ const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, previous)
608
+ if (ledger === null) return
609
+ const canonical = storeLedgerRecord(ledger)
610
+ if (canonical === ledger) void persistLedgerRecord(ledger)
611
+ if (state.ledgerDirtyEpochs.get(sid) === dirtyEpoch) {
612
+ state.ledgerDirtySessions.delete(sid)
613
+ state.ledgerDirtyEpochs.delete(sid)
614
+ }
615
+ })
616
+ ctx.on('session/disposed', () => {
617
+ if (!state.disposed) scheduleReconcileHint()
618
+ })
619
+
620
+ return {
621
+ foldEvent,
622
+ foldEvents,
623
+ lastSeqOf,
624
+ sequenceProfile,
625
+ enqueue,
626
+ wsForLiveSession,
627
+ cancelLiveResync,
628
+ scheduleLiveResync,
629
+ foldLiveFallback,
630
+ syncLiveSession,
631
+ resyncLiveSession,
632
+ processLiveEvent,
633
+ scheduleNativeBaselineRetry,
634
+ scheduleBaselineRetry,
635
+ runBaseline,
636
+ sessionIdsFromRecords,
637
+ reconcileSessions,
638
+ scheduleReconcileHint,
639
+ scheduleReconcileTimer,
640
+ markLedgerDirty
641
+ }
642
+ }