dsh-all-usage 1.1.2 → 1.1.3

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