dsh-all-usage 1.1.18 → 1.2.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +453 -394
  2. package/LICENSE +21 -21
  3. package/README.en.md +270 -0
  4. package/README.md +142 -279
  5. package/assets/model-icons/ai2-color.svg +1 -0
  6. package/assets/model-icons/ai21-brand-color.svg +1 -0
  7. package/assets/model-icons/aionlabs.svg +1 -0
  8. package/assets/model-icons/alephalpha.svg +1 -0
  9. package/assets/model-icons/baai.svg +1 -0
  10. package/assets/model-icons/baichuan-color.svg +1 -0
  11. package/assets/model-icons/baidu-color.svg +1 -0
  12. package/assets/model-icons/bytedance-color.svg +1 -0
  13. package/assets/model-icons/cohere-color.svg +1 -0
  14. package/assets/model-icons/hunyuan-color.svg +1 -0
  15. package/assets/model-icons/ibm.svg +1 -0
  16. package/assets/model-icons/iflytekcloud-color.svg +1 -0
  17. package/assets/model-icons/internlm-color.svg +1 -0
  18. package/assets/model-icons/kling-color.svg +1 -0
  19. package/assets/model-icons/liquid.svg +1 -0
  20. package/assets/model-icons/longcat-color.svg +1 -0
  21. package/assets/model-icons/manifest.json +739 -213
  22. package/assets/model-icons/microsoft-color.svg +1 -0
  23. package/assets/model-icons/mistral-color.svg +1 -0
  24. package/assets/model-icons/nousresearch.svg +1 -0
  25. package/assets/model-icons/nova-color.svg +1 -0
  26. package/assets/model-icons/nvidia-color.svg +1 -0
  27. package/assets/model-icons/perplexity-color.svg +1 -0
  28. package/assets/model-icons/sensenova-color.svg +1 -0
  29. package/assets/model-icons/skywork-color.svg +1 -0
  30. package/assets/model-icons/snowflake-color.svg +1 -0
  31. package/assets/model-icons/stepfun-color.svg +1 -0
  32. package/assets/model-icons/tencent-color.svg +1 -0
  33. package/assets/model-icons/tii-color.svg +1 -0
  34. package/assets/model-icons/upstage-color.svg +1 -0
  35. package/assets/model-icons/voyage-color.svg +1 -0
  36. package/assets/model-icons/xiaomimimo.svg +1 -0
  37. package/assets/model-icons/yandex.svg +1 -0
  38. package/assets/readme-architecture-en.svg +95 -0
  39. package/assets/readme-architecture-zh.svg +95 -0
  40. package/assets/readme-sync-en.svg +81 -0
  41. package/assets/readme-sync-zh.svg +81 -0
  42. package/assets/screenshots/en/01-overview.png +0 -0
  43. package/assets/screenshots/en/02-heatmap.png +0 -0
  44. package/assets/screenshots/en/03-model-usage.png +0 -0
  45. package/assets/screenshots/en/04-request-logs.png +0 -0
  46. package/assets/screenshots/en/05-cost-settings.png +0 -0
  47. package/assets/screenshots/zh/01-overview.png +0 -0
  48. package/assets/screenshots/zh/02-heatmap.png +0 -0
  49. package/assets/screenshots/zh/03-model-usage.png +0 -0
  50. package/assets/screenshots/zh/04-request-logs.png +0 -0
  51. package/assets/screenshots/zh/05-cost-settings.png +0 -0
  52. package/cordis.patch.yml +5 -5
  53. package/lib/aggregation.js +1842 -1112
  54. package/lib/client.js +1 -1
  55. package/lib/http.js +417 -385
  56. package/lib/icons.js +40 -0
  57. package/lib/ledger.js +1000 -531
  58. package/lib/plugin.js +347 -327
  59. package/lib/pricing-runtime.js +615 -551
  60. package/lib/pricing.js +1463 -1412
  61. package/lib/records-cache.js +113 -0
  62. package/lib/session-sync.js +976 -841
  63. package/lib/usage-core.js +33 -10
  64. package/lib/version-check.js +133 -133
  65. package/package.json +7 -3
  66. package/screenshots.json +10 -3
  67. package/assets/screenshot-1.png +0 -0
  68. package/assets/screenshot-2.png +0 -0
  69. package/assets/screenshot-3.png +0 -0
@@ -1,841 +1,976 @@
1
- import { RETIRED_WORKSPACE_ID, retargetLedgerRecord } from './ledger.js'
2
- import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes, workspacePathKey } from './usage-core.js'
3
-
4
- const RECONCILE_INTERVAL_MS = 120000
5
- const RECONCILE_HINT_DELAY_MS = 3000
6
- const WORKSPACE_SYNC_DEBOUNCE_MS = 300
7
- // The registry event is a fast path, not a guarantee: a change that lands while a
8
- // baseline runs used to be dropped outright, and a missed event left the plugin
9
- // stale until the next restart. The (in-memory) registry list is polled as well.
10
- const WORKSPACE_PROBE_INTERVAL_MS = 30000
11
-
12
- export function createSessionSync(host) {
13
- const { ctx, state } = host
14
- const { sessionPersistence } = host.services
15
- const {
16
- validEventTime,
17
- identityFromRoute,
18
- identityFromMessage,
19
- coerceIdentity,
20
- addTurn,
21
- addUsage,
22
- resetAggregationState,
23
- } = host.aggregation
24
- const { beginSync, noteSyncError, markStatsChanged } = host
25
- const {
26
- sessionEvents,
27
- buildLedgerRecord,
28
- applyLedgerRecord,
29
- storeLedgerRecord,
30
- replaceLedgerRecord,
31
- persistLedgerRecord,
32
- drainLedgerWrites,
33
- } = host.ledger
34
- const backfillUnpricedCosts = (...args) => host.pricing.backfillUnpricedCosts(...args)
35
- const reconcileTemporalPricing = (...args) => host.pricing.reconcileTemporalPricing(...args)
36
- const safeContextTimeout = async (ms) => {
37
- if (state.disposed) return false
38
- try {
39
- await ctx.timeout(ms)
40
- return !state.disposed
41
- } catch (err) {
42
- if (!state.disposed) console.error('[all-usage] context timer unavailable:', err)
43
- return false
44
- }
45
- }
46
-
47
- function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
48
- if ((type === 'turn/end' || type === 'assistant/message' || type === 'assistant/chunk') && !validEventTime(time)) return
49
- if (type === 'request/context') {
50
- state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
51
- if (validEventTime(time)) {
52
- let times = state.sessionContextTimes.get(sid)
53
- if (times === undefined) { times = new Map(); state.sessionContextTimes.set(sid, times) }
54
- touchContextTimes(times, contextTimeKey(data && data.turn, data && data.step), time)
55
- }
56
- } else if (type === 'request/header') {
57
- state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
58
- } else if (type === 'turn/end') {
59
- addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, state.sessionModel.get(sid), materialization, seq)
60
- } else if (type === 'assistant/message' || type === 'assistant/chunk') {
61
- const usageEvent = extractUsageEvent({ type, time, data, seq })
62
- if (usageEvent === null) return
63
- const identity = usageEvent.kind === 'message' ? identityFromMessage(data, state.sessionModel.get(sid)) : coerceIdentity(state.sessionModel.get(sid))
64
- state.sessionModel.set(sid, identity)
65
- const pricing = pickPricingTime(state.sessionContextTimes.get(sid), time, usageEvent.turn, usageEvent.step)
66
- addUsage(wsId, time, usageEvent.usage, identity, sid, data, seq, materialization, pricing.time, pricing.source)
67
- }
68
- }
69
- function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
70
- // Full folds keep their callers responsible for atomicity: runBaseline and
71
- // syncLiveSession remove the session's old usage/turn/context/identity
72
- // first when the snapshot is authoritative (see rebuildSession below), so
73
- // usage deleted by a history rewrite disappears from the aggregate.
74
- for (const ev of events) {
75
- if (fromSeq !== undefined) {
76
- const s = typeof ev.seq === 'number' ? ev.seq : -1
77
- if (s <= fromSeq) continue
78
- }
79
- 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)
80
- }
81
- }
82
- function lastSeqOf(events) {
83
- let last = -1
84
- for (const ev of events) {
85
- const s = Number.isSafeInteger(ev.seq) ? ev.seq : -1
86
- if (s > last) last = s
87
- }
88
- return last
89
- }
90
- function sequenceProfile(events) {
91
- let previous = -1
92
- let last = -1
93
- let nonMonotonic = false
94
- let hasInvalid = false
95
- for (const ev of events) {
96
- const seq = ev && Number.isSafeInteger(ev.seq) && ev.seq >= 0 ? ev.seq : -1
97
- if (seq < 0) { hasInvalid = true; continue }
98
- if (seq <= previous) nonMonotonic = true
99
- previous = seq
100
- if (seq > last) last = seq
101
- }
102
- return { lastSeq: last, nonMonotonic, hasInvalid }
103
- }
104
- function enqueue(sid, task) {
105
- const prev = state.chains.get(sid) || Promise.resolve()
106
- const next = prev.then(() => task(), () => task())
107
- state.chains.set(sid, next)
108
- const cleanup = () => { if (state.chains.get(sid) === next) state.chains.delete(sid) }
109
- void next.then(cleanup, cleanup)
110
- return next
111
- }
112
- function markLedgerDirty(sid) {
113
- if (typeof sid !== 'string' || sid === '') return
114
- state.ledgerDirtySessions.add(sid)
115
- state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
116
- }
117
- function buildWorkspaceSnapshot(workspaces) {
118
- const wsMeta = new Map()
119
- const pathIndex = new Map()
120
- const memberOf = new Map()
121
- for (const w of workspaces) {
122
- const id = w && w.id
123
- const path = w && typeof w.path === 'string' ? w.path : ''
124
- const title = w && typeof w.title === 'string' ? w.title : ''
125
- if (id === undefined) continue
126
- wsMeta.set(id, { id, title, path })
127
- if (path !== '') pathIndex.set(workspacePathKey(path), id)
128
- if (w && Array.isArray(w.sessionIds)) {
129
- for (const sid of w.sessionIds) memberOf.set(sid, id)
130
- }
131
- }
132
- return { wsMeta, pathIndex, memberOf }
133
- }
134
- function installWorkspaceSnapshot(next) {
135
- let metadataChanged = state.wsMeta.size !== next.wsMeta.size || state.pathIndex.size !== next.pathIndex.size || state.memberOf.size !== next.memberOf.size
136
- if (!metadataChanged) {
137
- for (const [id, value] of next.wsMeta) {
138
- const previous = state.wsMeta.get(id)
139
- if (previous === undefined || previous.title !== value.title || previous.path !== value.path) { metadataChanged = true; break }
140
- }
141
- }
142
- if (!metadataChanged) {
143
- for (const [path, id] of next.pathIndex) if (state.pathIndex.get(path) !== id) { metadataChanged = true; break }
144
- }
145
- if (!metadataChanged) {
146
- for (const [sid, id] of next.memberOf) if (state.memberOf.get(sid) !== id) { metadataChanged = true; break }
147
- }
148
- state.wsMeta.clear()
149
- state.pathIndex.clear()
150
- state.memberOf.clear()
151
- for (const [id, value] of next.wsMeta) state.wsMeta.set(id, value)
152
- for (const [path, id] of next.pathIndex) state.pathIndex.set(path, id)
153
- for (const [sid, id] of next.memberOf) state.memberOf.set(sid, id)
154
- if (metadataChanged) markStatsChanged('metadata')
155
- return metadataChanged
156
- }
157
- function workspaceForCwd(cwd) {
158
- if (typeof cwd !== 'string' || cwd === '') return undefined
159
- return state.pathIndex.get(workspacePathKey(cwd))
160
- }
161
- // DSH 0.1.5 replaced \`sessionPersistence.listSnapshots()\` with \`list()\`; both
162
- // return per-session \`{ header, revision }\` rows. Without a revision the
163
- // ledger-reuse fast path never fires, so every baseline re-reads every log.
164
- async function loadSessionRevisions() {
165
- const source = sessionPersistence
166
- if (source === undefined || source === null) return null
167
- const read = typeof source.list === 'function'
168
- ? () => source.list()
169
- : (typeof source.listSnapshots === 'function' ? () => source.listSnapshots() : null)
170
- if (read === null) return null
171
- const rows = await read()
172
- if (!Array.isArray(rows)) return null
173
- const revisions = new Map()
174
- for (const row of rows) {
175
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
176
- if (rid !== undefined && row && typeof row.revision === 'string') revisions.set(rid, row.revision)
177
- }
178
- return revisions
179
- }
180
- function wsForLiveSession(session, sid) {
181
- let wsId = state.memberOf.get(sid)
182
- if (wsId !== undefined) return wsId
183
- const header = session && session.header
184
- const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
185
- wsId = workspaceForCwd(cwd)
186
- if (wsId !== undefined) state.memberOf.set(sid, wsId)
187
- return wsId
188
- }
189
- function cancelLiveResync(sid) {
190
- const timer = state.liveResyncTimers.get(sid)
191
- if (timer !== undefined) {
192
- clearTimeout(timer)
193
- state.liveResyncTimers.delete(sid)
194
- }
195
- state.liveResyncAttempts.delete(sid)
196
- state.liveResyncPending.delete(sid)
197
- }
198
- function scheduleLiveResync(sid, wsId, generation) {
199
- if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid) || state.liveResyncTimers.has(sid)) return
200
- const attempt = state.liveResyncAttempts.get(sid) || 0
201
- const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(attempt, 5)))
202
- const timer = setTimeout(() => {
203
- state.liveResyncTimers.delete(sid)
204
- if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
205
- void enqueue(sid, () => resyncLiveSession(sid, wsId, generation))
206
- }, delay)
207
- state.liveResyncTimers.set(sid, timer)
208
- if (timer && typeof timer.unref === 'function') timer.unref()
209
- }
210
- function foldLiveFallback(sid, wsId, event) {
211
- if (event === null || event === undefined) return
212
- const seq = Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1
213
- foldEvent(wsId, event.time, event.type, event.data, sid, seq, 'live')
214
- if (seq >= 0) {
215
- const current = state.sessionSeq.get(sid)
216
- if (current === undefined || seq > current) state.sessionSeq.set(sid, seq)
217
- }
218
- state.sessionCount.add(sid)
219
- }
220
- async function syncLiveSession(sid, wsId, event, generation) {
221
- try {
222
- const snap = await ctx.sessionQuery.readSession(sid)
223
- if (state.disposed || generation !== state.aggregationGeneration) return true
224
- if (snap && Array.isArray(snap.events)) {
225
- const snapshotLast = lastSeqOf(snap.events)
226
- const snapshotProfile = sequenceProfile(snap.events)
227
- const eventSeq = event === null || event === undefined ? -1 : (Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1)
228
- // The snapshot may replace the session only when it is provably
229
- // complete AND it already contains the triggering event: a timer / full
230
- // resync (no event), or an explicitly contained, monotonic event
231
- // stream. A snapshot that merely has a higher tail than the event is
232
- // NOT proof — the event could be a live append the snapshot missed (or
233
- // the snapshot could have been rewritten without it), so the event is
234
- // upserted and the session stays pending for a follow-up alignment.
235
- // Events without a usable seq never trigger a destructive replacement.
236
- const containsEvent = event !== null && event !== undefined && eventSeq >= 0 && snap.events.some((candidate) => candidate && Number.isSafeInteger(candidate.seq) && candidate.seq === eventSeq)
237
- const authoritative = event === null || event === undefined ? true : eventSeq < 0 ? false : containsEvent && !snapshotProfile.nonMonotonic && !snapshotProfile.hasInvalid
238
- if (authoritative) {
239
- host.aggregation.removeSession(sid)
240
- // The authoritative snapshot is the new truth for both the in-memory
241
- // aggregate and the durable ledger: rebuild the record from scratch
242
- // and persist it, so a restart after a live history rewrite cannot
243
- // resurrect usage that the snapshot deleted.
244
- const rebuiltRecord = buildLedgerRecord({ id: sid, events: snap.events }, wsId, 'scan', undefined, null, true)
245
- if (rebuiltRecord !== null) {
246
- const canonical = replaceLedgerRecord(rebuiltRecord)
247
- if (canonical === rebuiltRecord) {
248
- void persistLedgerRecord(rebuiltRecord).then(() => {
249
- if (state.ledgerWriteFailedSessions && state.ledgerWriteFailedSessions.has(sid)) {
250
- console.error('[all-usage] authoritative resync ledger persist failed; session stays dirty:', sid)
251
- noteSyncError('resync-ledger-persist-failed')
252
- }
253
- })
254
- }
255
- }
256
- }
257
- foldEvents(wsId, snap.events, undefined, sid, 'live')
258
- let nextLast = snapshotLast
259
- const needsFollowup = !authoritative && event !== null && event !== undefined
260
- if (needsFollowup) {
261
- foldLiveFallback(sid, wsId, event)
262
- const current = state.sessionSeq.get(sid)
263
- nextLast = Math.max(nextLast, current === undefined ? -1 : current)
264
- }
265
- state.sessionSeq.set(sid, nextLast)
266
- state.sessionCount.add(sid)
267
- if (needsFollowup) {
268
- // The snapshot is behind: keep the pending flag so the follow-up
269
- // resync realigns the cursor once the missing history arrives.
270
- scheduleLiveResync(sid, wsId, generation)
271
- } else {
272
- state.liveResyncPending.delete(sid)
273
- cancelLiveResync(sid)
274
- }
275
- return true
276
- }
277
- } catch (err) {
278
- // Keep the event as a fallback and retry a complete session state.sync later.
279
- }
280
- return false
281
- }
282
- async function resyncLiveSession(sid, wsId, generation) {
283
- if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
284
- if (await syncLiveSession(sid, wsId, null, generation)) return
285
- state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
286
- scheduleLiveResync(sid, wsId, generation)
287
- }
288
- async function processLiveEvent(sid, wsId, event, generation = state.aggregationGeneration) {
289
- if (state.disposed || generation !== state.aggregationGeneration) return
290
- const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
291
- const last = state.sessionSeq.get(sid)
292
- const needsSync = last === undefined || state.liveResyncPending.has(sid) || (seq >= 0 && seq > last + 1)
293
- if (needsSync) {
294
- state.liveResyncPending.add(sid)
295
- if (await syncLiveSession(sid, wsId, event, generation)) return
296
- foldLiveFallback(sid, wsId, event)
297
- state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
298
- scheduleLiveResync(sid, wsId, generation)
299
- return
300
- }
301
- if (seq < 0) {
302
- foldLiveFallback(sid, wsId, event)
303
- return
304
- }
305
- if (seq <= last) return
306
- foldLiveFallback(sid, wsId, event)
307
- }
308
-
309
- // ---------- durable usage ledger ----------
310
- function scheduleNativeBaselineRetry(generation, delay) {
311
- if (state.disposed || state.baselineFallbackTimer !== null) return
312
- state.baselineFallbackTimer = setTimeout(() => {
313
- state.baselineFallbackTimer = null
314
- if (!state.disposed && generation === state.aggregationGeneration && !state.scan.started && !state.scan.done) void runBaseline(generation)
315
- }, delay)
316
- if (state.baselineFallbackTimer && typeof state.baselineFallbackTimer.unref === 'function') state.baselineFallbackTimer.unref()
317
- }
318
- function scheduleBaselineRetry(generation = state.aggregationGeneration) {
319
- if (state.disposed || state.baselineRetryScheduled || state.scan.done || generation !== state.aggregationGeneration) return
320
- state.baselineRetryScheduled = true
321
- const delay = state.baselineRetryDelay
322
- state.baselineRetryDelay = Math.min(state.baselineRetryDelay * 2, 30000)
323
- void safeContextTimeout(delay).then((ready) => {
324
- if (generation !== state.aggregationGeneration) return undefined
325
- state.baselineRetryScheduled = false
326
- if (ready && !state.scan.started && !state.scan.done) return runBaseline(generation)
327
- if (!ready && !state.disposed) scheduleNativeBaselineRetry(generation, delay)
328
- return undefined
329
- })
330
- }
331
- function scanOneSession(sid, record, wsId, generation, snapshots) {
332
- return enqueue(sid, async () => {
333
- if (state.disposed || generation !== state.aggregationGeneration) return
334
- try {
335
- if (state.sessionSeq.has(sid) && !state.liveResyncPending.has(sid)) return
336
- // v1.0.8: when the persisted log revision is unchanged since the last ledger
337
- // write, the whole readSession (full event transfer) is skipped — the ledger
338
- // record is applied directly and the live feed keeps catching new events.
339
- const previousRecord = state.ledgerRecords.get(sid)
340
- const revision = snapshots === null ? undefined : snapshots.get(sid)
341
- 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) {
342
- state.sync.sessionsSkippedByRevision += 1
343
- applyLedgerRecord(previousRecord, 'ledger-reuse')
344
- state.sessionSeq.set(sid, previousRecord.lastSeq)
345
- state.sessionCount.add(sid)
346
- markStatsChanged('scan')
347
- return
348
- }
349
- state.sync.sessionsRead += 1
350
- markStatsChanged('scan')
351
- const snap = await ctx.sessionQuery.readSession(sid)
352
- if (state.disposed || generation !== state.aggregationGeneration) return
353
- if (snap && Array.isArray(snap.events)) {
354
- // v1.0.7: incremental seed — the durable ledger doubles as a per-session
355
- // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
356
- // session applies its canonical record directly and never re-folds;
357
- // a changed session seeds the previous record once, then folds only the
358
- // new tail (previously every listed session was re-read and fully rebuilt).
359
- const sequence = sequenceProfile(snap.events)
360
- const currentLastSeq = sequence.lastSeq
361
- const previous = state.ledgerRecords.get(sid)
362
- const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && previous.rebuildRequired === undefined && previous.workspaceId === wsId && !sequence.nonMonotonic && !sequence.hasInvalid && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
363
- if (canFoldTail) {
364
- applyLedgerRecord(previous, 'ledger-reuse')
365
- foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
366
- } else {
367
- // A changed revision with no new tail may still contain a replacement;
368
- // rebuild from the complete read instead of trusting lastSeq alone.
369
- foldEvents(wsId, snap.events, undefined, sid, 'scan')
370
- }
371
- const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous, !sequence.hasInvalid)
372
- const canonical = ledger === null ? state.ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
373
- if (canonical === ledger) {
374
- void persistLedgerRecord(ledger)
375
- }
376
- const observedLastSeq = state.sessionSeq.get(sid)
377
- const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
378
- state.sessionSeq.set(sid, nextLastSeq)
379
- state.sessionCount.add(sid)
380
- if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
381
- else scheduleLiveResync(sid, wsId, generation)
382
- }
383
- } catch (err) {
384
- if (generation !== state.aggregationGeneration) return
385
- state.sync.sessionsFailed += 1
386
- state.scan.failed += 1
387
- noteSyncError('session-read-failed')
388
- const saved = state.ledgerRecords.get(sid)
389
- if (saved !== undefined) {
390
- applyLedgerRecord(saved, 'ledger-recovery')
391
- if (saved.turns.length > 0 || saved.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
392
- state.sessionSeq.set(sid, -1)
393
- state.sessionCount.add(sid)
394
- } else {
395
- state.sessionSeq.set(sid, -1)
396
- }
397
- } finally {
398
- if (generation === state.aggregationGeneration) {
399
- state.scan.scanned += 1
400
- markStatsChanged('scan')
401
- }
402
- }
403
- })
404
- }
405
-
406
- async function runBaseline(generation = state.aggregationGeneration) {
407
- if (state.scan.started || state.disposed || generation !== state.aggregationGeneration) return
408
- state.scan.started = true
409
- beginSync()
410
- await Promise.all([state.ledgerReady, state.pricingReady])
411
- if (state.disposed || generation !== state.aggregationGeneration) return
412
- let setupFailed = false
413
- try {
414
- const workspaces = ctx.workspaceRegistry.list()
415
- installWorkspaceSnapshot(buildWorkspaceSnapshot(workspaces))
416
- } catch (err) {
417
- console.error('[all-usage] workspace list failed:', err)
418
- setupFailed = true
419
- noteSyncError('workspace-list-failed')
420
- }
421
- let records = null
422
- try {
423
- records = await ctx.sessionQuery.listSessions()
424
- } catch (err) {
425
- console.error('[all-usage] session list failed:', err)
426
- if (state.disposed || generation !== state.aggregationGeneration) return
427
- noteSyncError('session-list-failed')
428
- }
429
- if (state.disposed || generation !== state.aggregationGeneration) return
430
- // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
431
- let snapshots = null
432
- try {
433
- const revisions = await loadSessionRevisions()
434
- if (state.disposed || generation !== state.aggregationGeneration) return
435
- if (revisions !== null) {
436
- state.sync.persistenceSnapshotsAvailable = true
437
- snapshots = revisions
438
- }
439
- } catch (err) {
440
- console.error('[all-usage] session persistence snapshots unavailable:', err)
441
- if (state.disposed || generation !== state.aggregationGeneration) return
442
- state.sync.persistenceSnapshotsAvailable = false
443
- markStatsChanged('scan')
444
- }
445
- if (state.disposed || generation !== state.aggregationGeneration) return
446
- if (setupFailed || !Array.isArray(records)) {
447
- // A transient registry failure must not be reported as a completed empty state.scan.
448
- state.scan.started = false
449
- markStatsChanged('scan')
450
- scheduleBaselineRetry(generation)
451
- return
452
- }
453
- state.scan.total = records.length
454
- state.sync.sessionsTotal = records.length
455
- markStatsChanged('scan')
456
- const listedSessionIds = new Set()
457
- for (const record of records) {
458
- if (record === undefined || record === null || record.header === undefined) continue
459
- const sid = record.header.id
460
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
461
- const wsId = workspaceForCwd(cwd)
462
- if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
463
- }
464
- for (const [sid, record] of state.ledgerRecords) {
465
- if (!listedSessionIds.has(sid)) {
466
- // Recorded usage survives deletion. A session whose workspace, cwd or
467
- // log is gone still counts from its ledger row; only legacy synthetic
468
- // rows, which never belonged to a registered workspace, stay excluded.
469
- if (typeof record.workspaceId === 'string' && record.workspaceId.startsWith('unregistered:')) continue
470
- // Usage from a workspace that is no longer registered is summed into the
471
- // shared deleted bucket, so history survives without a row per dead
472
- // workspace. The stored row keeps its original workspace id and cwd.
473
- const retired = typeof record.workspaceId !== 'string' || !state.wsMeta.has(record.workspaceId)
474
- const carriesUsage = record.turns.length > 0 || record.usage.length > 0
475
- // An empty row must not advertise a bucket that holds nothing.
476
- if (retired && carriesUsage) state.retiredBucketUsed = true
477
- applyLedgerRecord(retired ? retargetLedgerRecord(record) : record, retired ? 'ledger-retired' : 'ledger-recovery')
478
- if (carriesUsage) state.sync.sessionsRestoredFromLedger += 1
479
- }
480
- }
481
- if (state.sync.sessionsRestoredFromLedger > 0) markStatsChanged('scan')
482
- for (const record of records) {
483
- if (state.disposed || generation !== state.aggregationGeneration) return
484
- if (record === undefined || record === null || record.header === undefined) {
485
- state.scan.scanned += 1
486
- markStatsChanged('scan')
487
- continue
488
- }
489
- const sid = record.header.id
490
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
491
- const wsId = workspaceForCwd(cwd)
492
- if (sid === undefined || wsId === undefined) {
493
- state.scan.scanned += 1
494
- markStatsChanged('scan')
495
- continue
496
- }
497
- listedSessionIds.add(sid)
498
- await scanOneSession(sid, record, wsId, generation, snapshots)
499
- if (!(await safeContextTimeout(0))) {
500
- state.scan.started = false
501
- noteSyncError('baseline-yield-unavailable')
502
- scheduleBaselineRetry(generation)
503
- return
504
- }
505
- }
506
- if (state.disposed || generation !== state.aggregationGeneration) return
507
- await drainLedgerWrites()
508
- if (state.disposed || generation !== state.aggregationGeneration) return
509
- const costBackfill = backfillUnpricedCosts()
510
- if (costBackfill.priced > 0) {
511
- await drainLedgerWrites()
512
- markStatsChanged('pricing')
513
- }
514
- if (state.disposed || generation !== state.aggregationGeneration) return
515
- const temporalReconcile = reconcileTemporalPricing()
516
- if (temporalReconcile.reconciled > 0) {
517
- await drainLedgerWrites()
518
- markStatsChanged('pricing')
519
- }
520
- if (state.disposed || generation !== state.aggregationGeneration) return
521
- state.knownSessionIds.clear()
522
- for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
523
- state.scan.done = true
524
- if (state.workspaceSyncDeferred) {
525
- state.workspaceSyncDeferred = false
526
- scheduleWorkspaceSync()
527
- }
528
- state.sync.lastCompletedAt = Date.now()
529
- if (state.sync.sessionsFailed === 0) {
530
- state.sync.lastErrorAt = 0
531
- state.sync.lastErrorCode = null
532
- }
533
- markStatsChanged('scan')
534
- if (state.reconcilePending) scheduleReconcileHint()
535
- }
536
-
537
- function scheduleWorkspaceSync() {
538
- if (state.disposed) return
539
- if (state.workspaceSyncTimer !== null) return
540
- state.workspaceSyncTimer = setTimeout(() => {
541
- state.workspaceSyncTimer = null
542
- if (!state.disposed) void runWorkspaceSync()
543
- }, WORKSPACE_SYNC_DEBOUNCE_MS)
544
- if (state.workspaceSyncTimer && typeof state.workspaceSyncTimer.unref === 'function') state.workspaceSyncTimer.unref()
545
- }
546
- async function runWorkspaceSync() {
547
- if (state.disposed) return
548
- if (state.workspaceSyncInFlight) {
549
- state.workspaceSyncPending = true
550
- return
551
- }
552
- state.workspaceSyncInFlight = true
553
- state.sync.workspaceSyncRuns += 1
554
- try {
555
- await synchronizeWorkspaceRegistry()
556
- } catch (err) {
557
- console.error('[all-usage] workspace synchronize failed:', err)
558
- noteSyncError('workspace-sync-failed')
559
- } finally {
560
- state.workspaceSyncInFlight = false
561
- if (state.workspaceSyncPending) {
562
- state.workspaceSyncPending = false
563
- scheduleWorkspaceSync()
564
- }
565
- }
566
- }
567
- async function synchronizeWorkspaceRegistry() {
568
- if (state.disposed) return
569
- // A registry change that lands while a baseline is running used to be
570
- // dropped outright, which is why a new workspace could stay invisible until
571
- // the next restart. Remember it and re-run once the scan settles.
572
- if (!state.scan.done) {
573
- state.workspaceSyncDeferred = true
574
- state.sync.workspaceSyncDeferred += 1
575
- return
576
- }
577
- let workspaces
578
- try {
579
- workspaces = ctx.workspaceRegistry.list()
580
- } catch (err) {
581
- console.error('[all-usage] workspace list failed:', err)
582
- noteSyncError('workspace-list-failed')
583
- return
584
- }
585
- const next = buildWorkspaceSnapshot(workspaces)
586
- const removedWs = new Set()
587
- for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
588
- const addedWs = new Set()
589
- for (const id of next.wsMeta.keys()) if (!state.wsMeta.has(id)) addedWs.add(id)
590
- state.sync.workspaceSyncAdded += addedWs.size
591
- state.sync.workspaceSyncRemoved += removedWs.size
592
- // Reuse path: no membership change — metadata only, zero rescan.
593
- if (removedWs.size === 0 && addedWs.size === 0) {
594
- installWorkspaceSnapshot(next)
595
- return
596
- }
597
- // Retire path: a deregistered workspace keeps everything it recorded. The
598
- // ledger's promise is that flushed usage survives deletion, so sessions are
599
- // re-applied under the shared deleted bucket instead of being subtracted and
600
- // dropped. New sessions still cannot enter the workspace: pathIndex only
601
- // ever carries registered workspaces, so workspaceForCwd() no longer
602
- // resolves its cwd and the scan skips it.
603
- if (removedWs.size > 0) {
604
- const retiredSids = new Set()
605
- for (const [sid, record] of state.ledgerRecords) {
606
- if (record === null || record === undefined || !removedWs.has(record.workspaceId)) continue
607
- retiredSids.add(sid)
608
- }
609
- for (const [sid, id] of state.memberOf) if (removedWs.has(id)) retiredSids.add(sid)
610
- let moved = 0
611
- for (const sid of retiredSids) {
612
- if (typeof sid !== 'string' || sid === '') continue
613
- // Re-key the live aggregate first: usage folded from the feed but not yet
614
- // persisted has no ledger row to replay, and a session the registry no
615
- // longer lists is never re-scanned afterwards.
616
- moved += host.aggregation.retargetSession(sid, RETIRED_WORKSPACE_ID)
617
- const record = state.ledgerRecords.get(sid)
618
- // Keep the in-memory record aligned with the aggregate; the persisted row
619
- // keeps its original workspace id and cwd, so recovery re-derives this.
620
- if (record !== undefined && (record.turns.length > 0 || record.usage.length > 0)) moved += 1
621
- if (record !== undefined) state.ledgerRecords.set(sid, retargetLedgerRecord(record))
622
- }
623
- if (moved > 0) state.retiredBucketUsed = true
624
- }
625
- installWorkspaceSnapshot(next)
626
- // Add path: scan only the sessions that belong to newly registered workspaces.
627
- if (addedWs.size === 0) return
628
- let records = null
629
- try {
630
- records = await ctx.sessionQuery.listSessions()
631
- } catch (err) {
632
- console.error('[all-usage] session list failed:', err)
633
- noteSyncError('session-list-failed')
634
- }
635
- if (!Array.isArray(records)) return
636
- state.sync.sessionsTotal = records.length
637
- markStatsChanged('scan')
638
- let snapshots = null
639
- try {
640
- snapshots = await loadSessionRevisions()
641
- } catch (err) {
642
- console.error('[all-usage] session persistence snapshots unavailable:', err)
643
- }
644
- for (const record of records) {
645
- if (state.disposed) return
646
- if (record === undefined || record === null || record.header === undefined) continue
647
- const sid = record.header.id
648
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
649
- const wsId = workspaceForCwd(cwd)
650
- if (sid === undefined || wsId === undefined) continue
651
- if (!addedWs.has(wsId)) continue
652
- state.knownSessionIds.add(sid)
653
- scanOneSession(sid, record, wsId, state.aggregationGeneration, snapshots)
654
- }
655
- }
656
-
657
- function sessionIdsFromRecords(records) {
658
- const ids = new Set()
659
- for (const record of records) {
660
- if (record === undefined || record === null || record.header === undefined) continue
661
- const sid = record.header.id
662
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
663
- const wsId = workspaceForCwd(cwd)
664
- if (sid !== undefined && wsId !== undefined) ids.add(sid)
665
- }
666
- return ids
667
- }
668
- async function reconcileSessions() {
669
- if (state.disposed || state.reconcileInFlight || !state.scan.done) return
670
- state.reconcilePending = false
671
- state.reconcileInFlight = true
672
- try {
673
- const records = await ctx.sessionQuery.listSessions()
674
- if (state.disposed || !Array.isArray(records)) return
675
- const currentIds = sessionIdsFromRecords(records)
676
- let removed = false
677
- for (const sid of state.knownSessionIds) {
678
- if (!currentIds.has(sid)) { removed = true; break }
679
- }
680
- if (removed && !state.disposed && state.scan.done) {
681
- console.info('[all-usage] session removal detected; rebuilding usage index')
682
- const generation = resetAggregationState()
683
- void runBaseline(generation)
684
- return
685
- }
686
- state.knownSessionIds.clear()
687
- for (const sid of currentIds) state.knownSessionIds.add(sid)
688
- } catch (err) {
689
- console.error('[all-usage] session reconciliation failed:', err)
690
- noteSyncError('session-reconcile-failed')
691
- } finally {
692
- state.reconcileInFlight = false
693
- if (state.reconcilePending && !state.disposed) scheduleReconcileHint()
694
- }
695
- }
696
- function scheduleReconcileHint() {
697
- if (state.disposed) return
698
- state.reconcilePending = true
699
- if (state.reconcileHintScheduled || state.reconcileInFlight) return
700
- state.reconcileHintScheduled = true
701
- const generation = state.aggregationGeneration
702
- void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
703
- if (generation !== state.aggregationGeneration) return
704
- state.reconcileHintScheduled = false
705
- if (ready && !state.disposed) void reconcileSessions()
706
- }, () => {
707
- if (generation === state.aggregationGeneration) state.reconcileHintScheduled = false
708
- })
709
- }
710
- function scheduleReconcileTimer() {
711
- if (state.disposed || state.reconcileTimer !== null) return
712
- state.reconcileTimer = setTimeout(() => {
713
- state.reconcileTimer = null
714
- if (!state.disposed) {
715
- void reconcileSessions()
716
- scheduleReconcileTimer()
717
- }
718
- }, RECONCILE_INTERVAL_MS)
719
- if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
720
- }
721
- // Belt and braces for the registry probe: the event fast path can be missed
722
- // (dropped while scanning, or never delivered), and unlike the event this
723
- // cannot silently leave the workspace list stale until a restart.
724
- function scheduleWorkspaceProbeTimer() {
725
- if (state.disposed || state.workspaceProbeTimer !== null) return
726
- state.workspaceProbeTimer = setTimeout(() => {
727
- state.workspaceProbeTimer = null
728
- if (!state.disposed) {
729
- void runWorkspaceSync()
730
- scheduleWorkspaceProbeTimer()
731
- }
732
- }, WORKSPACE_PROBE_INTERVAL_MS)
733
- if (state.workspaceProbeTimer && typeof state.workspaceProbeTimer.unref === 'function') state.workspaceProbeTimer.unref()
734
- }
735
-
736
- // ---------- live feed ----------
737
- ctx.on('session/event', (session, event) => {
738
- if (state.disposed) return
739
- if (event === undefined || event === null) return
740
- const type = event.type
741
- if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'assistant/chunk' && type !== 'request/context' && type !== 'request/header') return
742
- const sid = session && session.id
743
- if (typeof sid !== 'string') return
744
- markLedgerDirty(sid)
745
- const wsId = wsForLiveSession(session, sid)
746
- if (wsId === undefined) return
747
- const generation = state.aggregationGeneration
748
- state.knownSessionIds.add(sid)
749
- enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
750
- })
751
- ctx.on('session/flush', async (session) => {
752
- if (state.disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
753
- const sid = session.id
754
- const previous = state.ledgerRecords.get(sid)
755
- const eventList = sessionEvents(session)
756
- // DSH sequences events with seq === log index: the tail event gives the
757
- // latest sequence in O(1) without scanning a 500K-event session twice.
758
- const tailEvent = eventList.length > 0 ? eventList[eventList.length - 1] : null
759
- const currentLastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(eventList)
760
- const hasNewEvents = state.ledgerDirtySessions.has(sid) || state.ledgerWriteFailedSessions.has(sid) || (previous === undefined && currentLastSeq >= 0) || (previous !== undefined && currentLastSeq > previous.lastSeq)
761
- if (!hasNewEvents) return
762
- const dirtyEpoch = state.ledgerDirtyEpochs.get(sid) || 0
763
- await Promise.all([state.ledgerReady, state.pricingReady])
764
- if (state.disposed) return
765
- const wsId = wsForLiveSession(session, sid)
766
- if (wsId === undefined) return
767
- // Any dirty flush whose log contains invalid sequences may have rewritten
768
- // history (even when a new tail was appended) or carries contract-external
769
- // events; refuse to fold it and rebuild the whole usage index instead, so
770
- // the in-memory aggregate and the persisted ledger cannot disagree.
771
- if (previous !== undefined && eventList.length > 0) {
772
- const sequence = sequenceProfile(eventList)
773
- if (sequence.hasInvalid) {
774
- if (state.scan.done && !state.disposed) {
775
- console.info('[all-usage] invalid sequences in flushed log; rebuilding usage index')
776
- // Persist the rebuild flag so the baseline cannot fast-path the stale
777
- // record (the persistence revision may still match a lagging log).
778
- const current = state.ledgerRecords.get(sid)
779
- if (current !== undefined) {
780
- current.rebuildRequired = 'invalid-flush-sequence'
781
- current.needsUpgrade = true
782
- void persistLedgerRecord(current)
783
- }
784
- const generation = resetAggregationState()
785
- void runBaseline(generation)
786
- }
787
- return
788
- }
789
- }
790
- const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, previous)
791
- if (ledger === null) return
792
- const canonical = storeLedgerRecord(ledger)
793
- if (canonical === ledger) void persistLedgerRecord(ledger)
794
- if (state.ledgerDirtyEpochs.get(sid) === dirtyEpoch) {
795
- state.ledgerDirtySessions.delete(sid)
796
- state.ledgerDirtyEpochs.delete(sid)
797
- }
798
- })
799
- ctx.on('session/disposed', () => {
800
- if (!state.disposed) scheduleReconcileHint()
801
- })
802
- // Workspace registry probe: DSH emits domain/changed after every durable
803
- // workspace-domain write (create/delete/rename/reorder/archive/attach/detach).
804
- // On any such change we re-read workspaceRegistry.list() and, when membership
805
- // changed, rescan only the affected workspaces — unchanged ones keep their
806
- // computed aggregates and ledger untouched.
807
- ctx.on('domain/changed', (change) => {
808
- if (state.disposed) return
809
- if (change === undefined || change === null) return
810
- if (change.domain !== 'workspace') return
811
- if (change.table !== 'workspaces' && change.table !== '') return
812
- state.sync.workspaceProbeEvents += 1
813
- scheduleWorkspaceSync()
814
- })
815
-
816
- return {
817
- foldEvent,
818
- foldEvents,
819
- lastSeqOf,
820
- sequenceProfile,
821
- enqueue,
822
- wsForLiveSession,
823
- cancelLiveResync,
824
- scheduleLiveResync,
825
- foldLiveFallback,
826
- syncLiveSession,
827
- resyncLiveSession,
828
- processLiveEvent,
829
- scheduleNativeBaselineRetry,
830
- scheduleBaselineRetry,
831
- runBaseline,
832
- synchronizeWorkspaceRegistry,
833
- scheduleWorkspaceSync,
834
- sessionIdsFromRecords,
835
- reconcileSessions,
836
- scheduleReconcileHint,
837
- scheduleReconcileTimer,
838
- scheduleWorkspaceProbeTimer,
839
- markLedgerDirty
840
- }
841
- }
1
+ import { RETIRED_WORKSPACE_ID, retargetLedgerRecord } from './ledger.js'
2
+ import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes, usageStepKey, workspacePathKey } from './usage-core.js'
3
+
4
+ const RECONCILE_INTERVAL_MS = 120000
5
+ const RECONCILE_HINT_DELAY_MS = 3000
6
+ const WORKSPACE_SYNC_DEBOUNCE_MS = 300
7
+ // The registry event is a fast path, not a guarantee: a change that lands while a
8
+ // baseline runs used to be dropped outright, and a missed event left the plugin
9
+ // stale until the next restart. The (in-memory) registry list is polled as well.
10
+ const WORKSPACE_PROBE_INTERVAL_MS = 30000
11
+
12
+ export function createSessionSync(host) {
13
+ const { ctx, state } = host
14
+ state.retiringWorkspaceIds = state.retiringWorkspaceIds || new Set()
15
+ if (!(state.liveResyncTriggers instanceof Map)) state.liveResyncTriggers = new Map()
16
+ const { sessionPersistence } = host.services
17
+ const {
18
+ validEventTime,
19
+ identityFromRoute,
20
+ identityFromMessage,
21
+ coerceIdentity,
22
+ addTurn,
23
+ addUsage,
24
+ resetAggregationState,
25
+ } = host.aggregation
26
+ const { beginSync, noteSyncError, markStatsChanged } = host
27
+ const {
28
+ sessionEvents,
29
+ buildLedgerRecord,
30
+ applyLedgerRecord,
31
+ storeLedgerRecord,
32
+ replaceLedgerRecord,
33
+ persistLedgerRecord,
34
+ drainLedgerWrites,
35
+ } = host.ledger
36
+ const safeContextTimeout = async (ms) => {
37
+ if (state.disposed) return false
38
+ try {
39
+ await ctx.timeout(ms)
40
+ return !state.disposed
41
+ } catch (err) {
42
+ if (!state.disposed) console.error('[all-usage] context timer unavailable:', err)
43
+ return false
44
+ }
45
+ }
46
+ // Session history reads prefer the official observation API: one immutable cut
47
+ // with projection work skipped, released through the lease's Symbol.dispose.
48
+ // A Host without observeSession keeps the legacy readSession snapshot.
49
+ function readSessionSnapshot(sid) {
50
+ const query = ctx.sessionQuery
51
+ if (typeof query.observeSession === 'function') return query.observeSession(sid, { projectionMode: 'none' })
52
+ return query.readSession(sid)
53
+ }
54
+ // Releasing is synchronous and only applies to a lease; a readSession snapshot
55
+ // carries no disposal protocol and needs no action.
56
+ function releaseSessionSnapshot(snap) {
57
+ if (snap === null || snap === undefined) return
58
+ const dispose = typeof Symbol.dispose === 'symbol' ? snap[Symbol.dispose] : undefined
59
+ if (typeof dispose === 'function') dispose.call(snap)
60
+ }
61
+
62
+ function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
63
+ // The durable record keeps the original workspace id. Its in-memory
64
+ // contribution must follow a removal immediately, including queued live
65
+ // events that captured the old mapping before retirement began.
66
+ if (state.retiringWorkspaceIds.has(wsId)) wsId = RETIRED_WORKSPACE_ID
67
+ if ((type === 'turn/end' || type === 'assistant/message' || type === 'assistant/chunk') && !validEventTime(time)) return
68
+ if (type === 'request/context') {
69
+ state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
70
+ if (validEventTime(time)) {
71
+ let times = state.sessionContextTimes.get(sid)
72
+ if (times === undefined) { times = new Map(); state.sessionContextTimes.set(sid, times) }
73
+ touchContextTimes(times, contextTimeKey(data && data.turn, data && data.step), time)
74
+ }
75
+ } else if (type === 'request/header') {
76
+ state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
77
+ } else if (type === 'turn/end') {
78
+ addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, state.sessionModel.get(sid), materialization, seq)
79
+ } else if (type === 'assistant/message' || type === 'assistant/chunk') {
80
+ const usageEvent = extractUsageEvent({ type, time, data, seq })
81
+ if (usageEvent === null) return
82
+ const identity = usageEvent.kind === 'message' ? identityFromMessage(data, state.sessionModel.get(sid)) : coerceIdentity(state.sessionModel.get(sid))
83
+ state.sessionModel.set(sid, identity)
84
+ const pricing = pickPricingTime(state.sessionContextTimes.get(sid), time, usageEvent.turn, usageEvent.step)
85
+ addUsage(wsId, time, usageEvent.usage, identity, sid, data, seq, materialization, pricing.time, pricing.source)
86
+ }
87
+ }
88
+ function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
89
+ // Full folds keep their callers responsible for atomicity: runBaseline and
90
+ // syncLiveSession remove the session's old usage/turn/context/identity
91
+ // first when the snapshot is authoritative (see rebuildSession below), so
92
+ // usage deleted by a history rewrite disappears from the aggregate.
93
+ for (const ev of events) {
94
+ if (fromSeq !== undefined) {
95
+ const s = typeof ev.seq === 'number' ? ev.seq : -1
96
+ if (s <= fromSeq) continue
97
+ }
98
+ 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)
99
+ }
100
+ }
101
+ function lastSeqOf(events) {
102
+ let last = -1
103
+ for (const ev of events) {
104
+ const s = Number.isSafeInteger(ev.seq) ? ev.seq : -1
105
+ if (s > last) last = s
106
+ }
107
+ return last
108
+ }
109
+ function sequenceProfile(events) {
110
+ let previous = -1
111
+ let last = -1
112
+ let nonMonotonic = false
113
+ let hasInvalid = false
114
+ for (const ev of events) {
115
+ const seq = ev && Number.isSafeInteger(ev.seq) && ev.seq >= 0 ? ev.seq : -1
116
+ if (seq < 0) { hasInvalid = true; continue }
117
+ if (seq <= previous) nonMonotonic = true
118
+ previous = seq
119
+ if (seq > last) last = seq
120
+ }
121
+ return { lastSeq: last, nonMonotonic, hasInvalid }
122
+ }
123
+ function enqueue(sid, task) {
124
+ const prev = state.chains.get(sid) || Promise.resolve()
125
+ const next = prev.then(() => task(), () => task())
126
+ state.chains.set(sid, next)
127
+ const cleanup = () => { if (state.chains.get(sid) === next) state.chains.delete(sid) }
128
+ void next.then(cleanup, cleanup)
129
+ return next
130
+ }
131
+ function markLedgerDirty(sid) {
132
+ if (typeof sid !== 'string' || sid === '') return
133
+ state.ledgerDirtySessions.add(sid)
134
+ state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
135
+ }
136
+ function buildWorkspaceSnapshot(workspaces) {
137
+ const wsMeta = new Map()
138
+ const pathIndex = new Map()
139
+ const memberOf = new Map()
140
+ for (const w of workspaces) {
141
+ const id = w && w.id
142
+ const path = w && typeof w.path === 'string' ? w.path : ''
143
+ const title = w && typeof w.title === 'string' ? w.title : ''
144
+ if (id === undefined) continue
145
+ wsMeta.set(id, { id, title, path })
146
+ if (path !== '') pathIndex.set(workspacePathKey(path), id)
147
+ if (w && Array.isArray(w.sessionIds)) {
148
+ for (const sid of w.sessionIds) memberOf.set(sid, id)
149
+ }
150
+ }
151
+ return { wsMeta, pathIndex, memberOf }
152
+ }
153
+ function installWorkspaceSnapshot(next) {
154
+ let metadataChanged = state.wsMeta.size !== next.wsMeta.size || state.pathIndex.size !== next.pathIndex.size || state.memberOf.size !== next.memberOf.size
155
+ if (!metadataChanged) {
156
+ for (const [id, value] of next.wsMeta) {
157
+ const previous = state.wsMeta.get(id)
158
+ if (previous === undefined || previous.title !== value.title || previous.path !== value.path) { metadataChanged = true; break }
159
+ }
160
+ }
161
+ if (!metadataChanged) {
162
+ for (const [path, id] of next.pathIndex) if (state.pathIndex.get(path) !== id) { metadataChanged = true; break }
163
+ }
164
+ if (!metadataChanged) {
165
+ for (const [sid, id] of next.memberOf) if (state.memberOf.get(sid) !== id) { metadataChanged = true; break }
166
+ }
167
+ state.wsMeta.clear()
168
+ state.pathIndex.clear()
169
+ state.memberOf.clear()
170
+ for (const [id, value] of next.wsMeta) { state.retiringWorkspaceIds.delete(id); state.wsMeta.set(id, value) }
171
+ for (const [path, id] of next.pathIndex) state.pathIndex.set(path, id)
172
+ for (const [sid, id] of next.memberOf) state.memberOf.set(sid, id)
173
+ if (metadataChanged) markStatsChanged('metadata')
174
+ return metadataChanged
175
+ }
176
+ function workspaceForCwd(cwd) {
177
+ if (typeof cwd !== 'string' || cwd === '') return undefined
178
+ return state.pathIndex.get(workspacePathKey(cwd))
179
+ }
180
+ // DSH 0.1.5 replaced \`sessionPersistence.listSnapshots()\` with \`list()\`; both
181
+ // return per-session \`{ header, revision }\` rows. Without a revision the
182
+ // ledger-reuse fast path never fires, so every baseline re-reads every log.
183
+ async function loadSessionRevisions() {
184
+ const source = sessionPersistence
185
+ if (source === undefined || source === null) return null
186
+ const read = typeof source.list === 'function'
187
+ ? () => source.list()
188
+ : (typeof source.listSnapshots === 'function' ? () => source.listSnapshots() : null)
189
+ if (read === null) return null
190
+ const rows = await read()
191
+ if (!Array.isArray(rows)) return null
192
+ const revisions = new Map()
193
+ for (const row of rows) {
194
+ const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
195
+ if (rid !== undefined && row && typeof row.revision === 'string') revisions.set(rid, row.revision)
196
+ }
197
+ return revisions
198
+ }
199
+ function wsForLiveSession(session, sid) {
200
+ let wsId = state.memberOf.get(sid)
201
+ if (wsId !== undefined) return wsId
202
+ const header = session && session.header
203
+ const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
204
+ wsId = workspaceForCwd(cwd)
205
+ if (wsId !== undefined) state.memberOf.set(sid, wsId)
206
+ return wsId
207
+ }
208
+ function cancelLiveResync(sid) {
209
+ const timer = state.liveResyncTimers.get(sid)
210
+ if (timer !== undefined) {
211
+ clearTimeout(timer)
212
+ state.liveResyncTimers.delete(sid)
213
+ }
214
+ state.liveResyncAttempts.delete(sid)
215
+ state.liveResyncPending.delete(sid)
216
+ state.liveResyncTriggers.delete(sid)
217
+ }
218
+ function usageEventSignature(event) {
219
+ const data = event.data || {}
220
+ const message = data.message || {}
221
+ const usage = event.type === 'assistant/chunk' ? data.chunk && data.chunk.usage : data.usage
222
+ return JSON.stringify([event.seq, event.time, event.type, data.turn, data.step, data.usageId, data.sampleId, data.requestId, data.callId, data.stepId, data.messageId, message.id, message.source, usage])
223
+ }
224
+ function retainLiveResyncTrigger(sid, key, event) {
225
+ let pending = state.liveResyncTriggers.get(sid)
226
+ if (pending === undefined) { pending = new Map(); state.liveResyncTriggers.set(sid, pending) }
227
+ const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
228
+ const previous = pending.get(key)
229
+ if (previous !== undefined && previous.seq > seq) return
230
+ pending.set(key, { seq, signature: usageEventSignature(event) })
231
+ }
232
+ function snapshotCoversTriggers(sid, events) {
233
+ const pending = state.liveResyncTriggers.get(sid)
234
+ if (pending === undefined || pending.size === 0) return true
235
+ const candidates = new Map()
236
+ for (const event of events) {
237
+ if (event === null || typeof event !== 'object' || (event.type !== 'assistant/message' && event.type !== 'assistant/chunk') || event.data === null || typeof event.data !== 'object') continue
238
+ const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
239
+ const key = usageStepKey(sid, event.data, seq, event.time)
240
+ if (!pending.has(key)) continue
241
+ const frame = extractUsageEvent(event)
242
+ if (frame === null) continue
243
+ const previous = candidates.get(key)
244
+ if (previous === undefined || seq > previous.seq) candidates.set(key, { seq, signature: usageEventSignature(event) })
245
+ }
246
+ for (const [key, expected] of pending) {
247
+ const found = candidates.get(key)
248
+ if (found === undefined || found.seq < expected.seq || (found.seq === expected.seq && found.signature !== expected.signature)) return false
249
+ }
250
+ return true
251
+ }
252
+ function scheduleLiveResync(sid, wsId, generation) {
253
+ if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid) || state.liveResyncTimers.has(sid)) return
254
+ const attempt = state.liveResyncAttempts.get(sid) || 0
255
+ const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(attempt, 5)))
256
+ const timer = setTimeout(() => {
257
+ state.liveResyncTimers.delete(sid)
258
+ if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
259
+ void enqueue(sid, () => resyncLiveSession(sid, wsId, generation))
260
+ }, delay)
261
+ state.liveResyncTimers.set(sid, timer)
262
+ if (timer && typeof timer.unref === 'function') timer.unref()
263
+ }
264
+ function foldLiveFallback(sid, wsId, event) {
265
+ if (event === null || event === undefined) return
266
+ const seq = Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1
267
+ foldEvent(wsId, event.time, event.type, event.data, sid, seq, 'live')
268
+ if (seq >= 0) {
269
+ const current = state.sessionSeq.get(sid)
270
+ if (current === undefined || seq > current) state.sessionSeq.set(sid, seq)
271
+ }
272
+ state.sessionCount.add(sid)
273
+ }
274
+ async function syncLiveSession(sid, wsId, event, generation) {
275
+ let snap
276
+ try {
277
+ snap = await readSessionSnapshot(sid)
278
+ if (state.disposed || generation !== state.aggregationGeneration) return true
279
+ const events = snap === null || snap === undefined ? undefined : snap.events
280
+ if (Array.isArray(events)) {
281
+ const snapshotLast = lastSeqOf(events)
282
+ const snapshotProfile = sequenceProfile(events)
283
+ const eventSeq = event === null || event === undefined ? -1 : (Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1)
284
+ // The snapshot may replace the session only when it is provably
285
+ // complete AND it already contains the triggering event: a timer / full
286
+ // resync (no event), or an explicitly contained, monotonic event
287
+ // stream. A snapshot that merely has a higher tail than the event is
288
+ // NOT proof — the event could be a live append the snapshot missed (or
289
+ // the snapshot could have been rewritten without it), so the event is
290
+ // upserted and the session stays pending for a follow-up alignment.
291
+ // Events without a usable seq never trigger a destructive replacement.
292
+ const containsEvent = event !== null && event !== undefined && eventSeq >= 0 && events.some((candidate) => candidate && Number.isSafeInteger(candidate.seq) && candidate.seq === eventSeq)
293
+ const coversTriggers = snapshotCoversTriggers(sid, events)
294
+ const authoritative = (event === null || event === undefined ? true : eventSeq < 0 ? false : containsEvent) && !snapshotProfile.nonMonotonic && !snapshotProfile.hasInvalid && coversTriggers
295
+ // A pending usage trigger can only be settled by a complete, sequence-valid
296
+ // rebuild. Even a snapshot containing it is unsafe to fold as live data when
297
+ // unrelated sequence damage makes the snapshot ambiguous.
298
+ if (state.liveResyncTriggers.has(sid) && (!coversTriggers || snapshotProfile.nonMonotonic || snapshotProfile.hasInvalid)) return false
299
+ if (authoritative) {
300
+ host.aggregation.removeSession(sid)
301
+ // The authoritative snapshot is the new truth for both the in-memory
302
+ // aggregate and the durable ledger: rebuild the record from scratch
303
+ // and persist it, so a restart after a live history rewrite cannot
304
+ // resurrect usage that the snapshot deleted.
305
+ const rebuiltRecord = buildLedgerRecord({ id: sid, events }, wsId, 'scan', undefined, null, true)
306
+ if (rebuiltRecord !== null) {
307
+ const canonical = replaceLedgerRecord(rebuiltRecord)
308
+ if (canonical === rebuiltRecord) {
309
+ void persistLedgerRecord(rebuiltRecord).then(() => {
310
+ if (state.ledgerWriteFailedSessions && state.ledgerWriteFailedSessions.has(sid)) {
311
+ console.error('[all-usage] authoritative resync ledger persist failed; session stays dirty:', sid)
312
+ noteSyncError('resync-ledger-persist-failed')
313
+ }
314
+ })
315
+ }
316
+ }
317
+ }
318
+ // Complete persisted history may have reversed timestamps even when
319
+ // its sequences are canonical. Only incomplete fallback folds use the
320
+ // live replay heuristics.
321
+ foldEvents(wsId, events, undefined, sid, authoritative ? 'scan' : 'live')
322
+ let nextLast = snapshotLast
323
+ const needsFollowup = !authoritative && event !== null && event !== undefined
324
+ if (needsFollowup) {
325
+ foldLiveFallback(sid, wsId, event)
326
+ const current = state.sessionSeq.get(sid)
327
+ nextLast = Math.max(nextLast, current === undefined ? -1 : current)
328
+ }
329
+ state.sessionSeq.set(sid, nextLast)
330
+ state.sessionCount.add(sid)
331
+ if (needsFollowup) {
332
+ // The snapshot is behind: keep the pending flag so the follow-up
333
+ // resync realigns the cursor once the missing history arrives.
334
+ scheduleLiveResync(sid, wsId, generation)
335
+ } else {
336
+ state.liveResyncPending.delete(sid)
337
+ cancelLiveResync(sid)
338
+ }
339
+ return true
340
+ }
341
+ } catch (err) {
342
+ // Keep the event as a fallback and retry a complete session state.sync later.
343
+ } finally {
344
+ // The lease is held for the whole read — success, failure, or a generation
345
+ // change — and released only once this call is completely done. A legacy
346
+ // readSession snapshot carries no disposal protocol.
347
+ releaseSessionSnapshot(snap)
348
+ }
349
+ return false
350
+ }
351
+ async function resyncLiveSession(sid, wsId, generation) {
352
+ if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
353
+ if (await syncLiveSession(sid, wsId, null, generation)) return
354
+ state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
355
+ scheduleLiveResync(sid, wsId, generation)
356
+ }
357
+ async function processLiveEvent(sid, wsId, event, generation = state.aggregationGeneration) {
358
+ if (state.disposed || generation !== state.aggregationGeneration) return
359
+ const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
360
+ const last = state.sessionSeq.get(sid)
361
+ const frame = extractUsageEvent(event)
362
+ const key = frame === null ? null : usageStepKey(sid, frame.data, seq, event.time)
363
+ // A known key can be replaced only after authoritative alignment once its
364
+ // detail is evicted. A replay-shaped new key also needs proof before the live
365
+ // replay fence can discard it, so preserve either trigger through retries.
366
+ const digest = state.perSession.get(sid)
367
+ const knownKey = key !== null && digest?.keys.has(key)
368
+ const evictedCorrection = knownKey && !state.usageByStep.has(key)
369
+ const replayShapedAppend = key !== null && !knownKey && digest !== undefined && (event.time < digest.newestTime || state.turnRecords.has(sid + ':turn:' + frame.turn))
370
+ // A fresh sequence can still look like a replay after a clock correction or
371
+ // late usage delivery. Prove it against persisted history before rejecting it.
372
+ const needsUsageSync = seq > (last ?? -1) && (evictedCorrection || replayShapedAppend)
373
+ const needsSync = last === undefined || state.liveResyncPending.has(sid) || (seq >= 0 && seq > last + 1) || needsUsageSync
374
+ if (needsSync) {
375
+ if (needsUsageSync) retainLiveResyncTrigger(sid, key, event)
376
+ state.liveResyncPending.add(sid)
377
+ if (await syncLiveSession(sid, wsId, event, generation)) return
378
+ // Keep sync-triggering usage out of the cube until history proves whether
379
+ // it is an addition or a replacement for an evicted contribution.
380
+ if (!evictedCorrection) foldLiveFallback(sid, wsId, event)
381
+ state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
382
+ scheduleLiveResync(sid, wsId, generation)
383
+ return
384
+ }
385
+ if (seq < 0) {
386
+ foldLiveFallback(sid, wsId, event)
387
+ return
388
+ }
389
+ if (seq <= last) return
390
+ foldLiveFallback(sid, wsId, event)
391
+ }
392
+
393
+ // ---------- durable usage ledger ----------
394
+ function scheduleNativeBaselineRetry(generation, delay) {
395
+ if (state.disposed || state.baselineFallbackTimer !== null) return
396
+ state.baselineFallbackTimer = setTimeout(() => {
397
+ state.baselineFallbackTimer = null
398
+ if (!state.disposed && generation === state.aggregationGeneration && !state.scan.started && !state.scan.done) void runBaseline(generation)
399
+ }, delay)
400
+ if (state.baselineFallbackTimer && typeof state.baselineFallbackTimer.unref === 'function') state.baselineFallbackTimer.unref()
401
+ }
402
+ function scheduleBaselineRetry(generation = state.aggregationGeneration) {
403
+ if (state.disposed || state.baselineRetryScheduled || state.scan.done || generation !== state.aggregationGeneration) return
404
+ state.baselineRetryScheduled = true
405
+ const delay = state.baselineRetryDelay
406
+ state.baselineRetryDelay = Math.min(state.baselineRetryDelay * 2, 30000)
407
+ void safeContextTimeout(delay).then((ready) => {
408
+ if (generation !== state.aggregationGeneration) return undefined
409
+ state.baselineRetryScheduled = false
410
+ if (ready && !state.scan.started && !state.scan.done) return runBaseline(generation)
411
+ if (!ready && !state.disposed) scheduleNativeBaselineRetry(generation, delay)
412
+ return undefined
413
+ })
414
+ }
415
+ function scanOneSession(sid, record, wsId, generation, snapshots) {
416
+ return enqueue(sid, async () => {
417
+ if (state.disposed || generation !== state.aggregationGeneration) return
418
+ // Held across the awaits below: the resident cache may drop the record while
419
+ // the session log is being read, and the recovery path still needs it.
420
+ let loadedRecord
421
+ let snap
422
+ try {
423
+ if (state.sessionSeq.has(sid) && !state.liveResyncPending.has(sid)) return
424
+ // v1.0.8: when the persisted log revision is unchanged since the last ledger
425
+ // write, the whole readSession (full event transfer) is skipped — the ledger
426
+ // record is applied directly and the live feed keeps catching new events.
427
+ // The record is not resident any more: a cold start enumerates the ledger
428
+ // keys and every session reads its own document here, on demand.
429
+ if (state.ledgerRecords.get(sid) === undefined) await host.ledger.recordFor(sid)
430
+ const previousRecord = state.ledgerRecords.get(sid)
431
+ loadedRecord = previousRecord
432
+ const revision = snapshots === null ? undefined : snapshots.get(sid)
433
+ 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) {
434
+ state.sync.sessionsSkippedByRevision += 1
435
+ applyLedgerRecord(previousRecord, 'ledger-reuse')
436
+ state.sessionSeq.set(sid, previousRecord.lastSeq)
437
+ state.sessionCount.add(sid)
438
+ markStatsChanged('scan')
439
+ return
440
+ }
441
+ state.sync.sessionsRead += 1
442
+ markStatsChanged('scan')
443
+ snap = await readSessionSnapshot(sid)
444
+ if (state.disposed || generation !== state.aggregationGeneration) return
445
+ if (snap && Array.isArray(snap.events)) {
446
+ if (state.liveResyncTriggers.has(sid) && !snapshotCoversTriggers(sid, snap.events)) {
447
+ state.liveResyncPending.add(sid)
448
+ const saved = state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : previousRecord
449
+ if (!state.sessionSeq.has(sid) && saved !== undefined) {
450
+ applyLedgerRecord(saved, 'ledger-recovery')
451
+ state.sessionSeq.set(sid, saved.lastSeq)
452
+ state.sessionCount.add(sid)
453
+ }
454
+ scheduleLiveResync(sid, wsId, generation)
455
+ return
456
+ }
457
+ // v1.0.7: incremental seed — the durable ledger doubles as a per-session
458
+ // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
459
+ // session applies its canonical record directly and never re-folds;
460
+ // a changed session seeds the previous record once, then folds only the
461
+ // new tail (previously every listed session was re-read and fully rebuilt).
462
+ const sequence = sequenceProfile(snap.events)
463
+ const currentLastSeq = sequence.lastSeq
464
+ const hasPendingTrigger = state.liveResyncTriggers.has(sid)
465
+ const previous = hasPendingTrigger ? null : state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : previousRecord
466
+ if (hasPendingTrigger) host.aggregation.removeSession(sid)
467
+ const canFoldTail = !hasPendingTrigger && previous !== undefined && previous.needsUpgrade !== true && previous.rebuildRequired === undefined && previous.workspaceId === wsId && !sequence.nonMonotonic && !sequence.hasInvalid && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
468
+ if (canFoldTail) {
469
+ applyLedgerRecord(previous, 'ledger-reuse')
470
+ foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
471
+ } else {
472
+ // A changed revision with no new tail may still contain a replacement;
473
+ // rebuild from the complete read instead of trusting lastSeq alone.
474
+ foldEvents(wsId, snap.events, undefined, sid, 'scan')
475
+ }
476
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous, !sequence.hasInvalid)
477
+ const canonical = ledger === null ? (state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : previousRecord) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
478
+ if (canonical === ledger) {
479
+ void persistLedgerRecord(ledger)
480
+ }
481
+ const observedLastSeq = state.sessionSeq.get(sid)
482
+ const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
483
+ state.sessionSeq.set(sid, nextLastSeq)
484
+ state.sessionCount.add(sid)
485
+ if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
486
+ else scheduleLiveResync(sid, wsId, generation)
487
+ }
488
+ } catch (err) {
489
+ if (generation !== state.aggregationGeneration) return
490
+ state.sync.sessionsFailed += 1
491
+ state.scan.failed += 1
492
+ noteSyncError('session-read-failed')
493
+ const saved = state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : loadedRecord
494
+ if (saved !== undefined) {
495
+ applyLedgerRecord(saved, 'ledger-recovery')
496
+ if (saved.turns.length > 0 || saved.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
497
+ state.sessionSeq.set(sid, -1)
498
+ state.sessionCount.add(sid)
499
+ } else {
500
+ state.sessionSeq.set(sid, -1)
501
+ }
502
+ } finally {
503
+ // The lease is released once the read and the fold are over; the scan
504
+ // bookkeeping still has to run even if the lease disposal does not.
505
+ try {
506
+ releaseSessionSnapshot(snap)
507
+ } finally {
508
+ if (generation === state.aggregationGeneration) {
509
+ state.scan.scanned += 1
510
+ markStatsChanged('scan')
511
+ }
512
+ }
513
+ }
514
+ })
515
+ }
516
+
517
+ async function runBaseline(generation = state.aggregationGeneration) {
518
+ if (state.scan.started || state.disposed || generation !== state.aggregationGeneration) return
519
+ state.scan.started = true
520
+ beginSync()
521
+ await Promise.all([state.ledgerReady, state.pricingReady])
522
+ if (state.disposed || generation !== state.aggregationGeneration) return
523
+ let setupFailed = false
524
+ try {
525
+ const workspaces = ctx.workspaceRegistry.list()
526
+ installWorkspaceSnapshot(buildWorkspaceSnapshot(workspaces))
527
+ } catch (err) {
528
+ console.error('[all-usage] workspace list failed:', err)
529
+ setupFailed = true
530
+ noteSyncError('workspace-list-failed')
531
+ }
532
+ let records = null
533
+ try {
534
+ records = await ctx.sessionQuery.listSessions()
535
+ } catch (err) {
536
+ console.error('[all-usage] session list failed:', err)
537
+ if (state.disposed || generation !== state.aggregationGeneration) return
538
+ noteSyncError('session-list-failed')
539
+ }
540
+ if (state.disposed || generation !== state.aggregationGeneration) return
541
+ // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
542
+ let snapshots = null
543
+ try {
544
+ const revisions = await loadSessionRevisions()
545
+ if (state.disposed || generation !== state.aggregationGeneration) return
546
+ if (revisions !== null) {
547
+ state.sync.persistenceSnapshotsAvailable = true
548
+ snapshots = revisions
549
+ }
550
+ } catch (err) {
551
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
552
+ if (state.disposed || generation !== state.aggregationGeneration) return
553
+ state.sync.persistenceSnapshotsAvailable = false
554
+ markStatsChanged('scan')
555
+ }
556
+ if (state.disposed || generation !== state.aggregationGeneration) return
557
+ if (setupFailed || !Array.isArray(records)) {
558
+ // A transient registry failure must not be reported as a completed empty state.scan.
559
+ state.scan.started = false
560
+ markStatsChanged('scan')
561
+ scheduleBaselineRetry(generation)
562
+ return
563
+ }
564
+ state.scan.total = records.length
565
+ state.sync.sessionsTotal = records.length
566
+ markStatsChanged('scan')
567
+ const listedSessionIds = new Set()
568
+ for (const record of records) {
569
+ if (record === undefined || record === null || record.header === undefined) continue
570
+ const sid = record.header.id
571
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
572
+ const wsId = workspaceForCwd(cwd)
573
+ if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
574
+ }
575
+ const ledgerKeyList = Array.from(state.ledgerKeys)
576
+ for (let keyIndex = 0; keyIndex < ledgerKeyList.length; keyIndex += 1) {
577
+ if (keyIndex % 32 === 0) await host.ledger.loadRecords(ledgerKeyList.slice(keyIndex, keyIndex + 32))
578
+ const sid = ledgerKeyList[keyIndex]
579
+ if (listedSessionIds.has(sid)) continue
580
+ const record = await host.ledger.recordFor(sid)
581
+ if (record !== undefined && record !== null) {
582
+ // Recorded usage survives deletion. A session whose workspace, cwd or
583
+ // log is gone still counts from its ledger row; only legacy synthetic
584
+ // rows, which never belonged to a registered workspace, stay excluded.
585
+ if (typeof record.workspaceId === 'string' && record.workspaceId.startsWith('unregistered:')) continue
586
+ // Usage from a workspace that is no longer registered is summed into the
587
+ // shared deleted bucket, so history survives without a row per dead
588
+ // workspace. The stored row keeps its original workspace id and cwd.
589
+ const retired = typeof record.workspaceId !== 'string' || !state.wsMeta.has(record.workspaceId)
590
+ const carriesUsage = record.turns.length > 0 || record.usage.length > 0
591
+ // An empty row must not advertise a bucket that holds nothing.
592
+ if (retired && carriesUsage) state.retiredBucketUsed = true
593
+ applyLedgerRecord(retired ? retargetLedgerRecord(record) : record, retired ? 'ledger-retired' : 'ledger-recovery')
594
+ if (carriesUsage) state.sync.sessionsRestoredFromLedger += 1
595
+ }
596
+ }
597
+ if (state.sync.sessionsRestoredFromLedger > 0) markStatsChanged('scan')
598
+ // Prefetch a small window of documents so the reads of the sessions the scan is
599
+ // about to visit overlap the folding work (kept aligned with the records array).
600
+ const scanIds = records.map((entry) => (entry !== null && entry !== undefined && entry.header !== undefined && typeof entry.header.id === 'string' ? entry.header.id : null))
601
+ for (let scanIndex = 0; scanIndex < records.length; scanIndex += 1) {
602
+ const record = records[scanIndex]
603
+ if (scanIndex % 24 === 0) await host.ledger.loadRecords(scanIds.slice(scanIndex, scanIndex + 24))
604
+ if (state.disposed || generation !== state.aggregationGeneration) return
605
+ if (record === undefined || record === null || record.header === undefined) {
606
+ state.scan.scanned += 1
607
+ markStatsChanged('scan')
608
+ continue
609
+ }
610
+ const sid = record.header.id
611
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
612
+ const wsId = workspaceForCwd(cwd)
613
+ if (sid === undefined || wsId === undefined) {
614
+ state.scan.scanned += 1
615
+ markStatsChanged('scan')
616
+ continue
617
+ }
618
+ listedSessionIds.add(sid)
619
+ await scanOneSession(sid, record, wsId, generation, snapshots)
620
+ if (!(await safeContextTimeout(0))) {
621
+ state.scan.started = false
622
+ noteSyncError('baseline-yield-unavailable')
623
+ scheduleBaselineRetry(generation)
624
+ return
625
+ }
626
+ }
627
+ if (state.disposed || generation !== state.aggregationGeneration) return
628
+ await drainLedgerWrites()
629
+ if (state.disposed || generation !== state.aggregationGeneration) return
630
+ const pricingReconciliation = await host.pricing.reconcileUsageCosts({ backfill: true })
631
+ if (pricingReconciliation.backfill.priced > 0 || pricingReconciliation.temporal.reconciled > 0) {
632
+ await drainLedgerWrites()
633
+ markStatsChanged('pricing')
634
+ }
635
+ if (state.disposed || generation !== state.aggregationGeneration) return
636
+ // Everything above (folding, backfill, temporal reconcile) needs the rows; from
637
+ // here on they are only needed to render logs, so the settled sessions hand
638
+ // theirs back to the ledger and are read again on demand.
639
+ const evicted = host.aggregation.evictColdRows()
640
+ if (evicted.rows > 0) console.info('[all-usage] dropped ' + evicted.rows + ' resident usage rows from ' + evicted.sessions + ' settled sessions')
641
+ state.knownSessionIds.clear()
642
+ for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
643
+ state.scan.done = true
644
+ if (state.workspaceSyncDeferred) {
645
+ state.workspaceSyncDeferred = false
646
+ scheduleWorkspaceSync()
647
+ }
648
+ state.sync.lastCompletedAt = Date.now()
649
+ if (state.sync.sessionsFailed === 0) {
650
+ state.sync.lastErrorAt = 0
651
+ state.sync.lastErrorCode = null
652
+ }
653
+ markStatsChanged('scan')
654
+ if (state.reconcilePending) scheduleReconcileHint()
655
+ }
656
+
657
+ function scheduleWorkspaceSync() {
658
+ if (state.disposed) return
659
+ if (state.workspaceSyncTimer !== null) return
660
+ state.workspaceSyncTimer = setTimeout(() => {
661
+ state.workspaceSyncTimer = null
662
+ if (!state.disposed) void runWorkspaceSync()
663
+ }, WORKSPACE_SYNC_DEBOUNCE_MS)
664
+ if (state.workspaceSyncTimer && typeof state.workspaceSyncTimer.unref === 'function') state.workspaceSyncTimer.unref()
665
+ }
666
+ async function runWorkspaceSync() {
667
+ if (state.disposed) return
668
+ if (state.workspaceSyncInFlight) {
669
+ state.workspaceSyncPending = true
670
+ return
671
+ }
672
+ state.workspaceSyncInFlight = true
673
+ state.sync.workspaceSyncRuns += 1
674
+ try {
675
+ await synchronizeWorkspaceRegistry()
676
+ } catch (err) {
677
+ console.error('[all-usage] workspace synchronize failed:', err)
678
+ noteSyncError('workspace-sync-failed')
679
+ } finally {
680
+ state.workspaceSyncInFlight = false
681
+ if (state.workspaceSyncPending) {
682
+ state.workspaceSyncPending = false
683
+ scheduleWorkspaceSync()
684
+ }
685
+ }
686
+ }
687
+ async function synchronizeWorkspaceRegistry() {
688
+ if (state.disposed) return
689
+ // A registry change that lands while a baseline is running used to be
690
+ // dropped outright, which is why a new workspace could stay invisible until
691
+ // the next restart. Remember it and re-run once the scan settles.
692
+ if (!state.scan.done) {
693
+ state.workspaceSyncDeferred = true
694
+ state.sync.workspaceSyncDeferred += 1
695
+ return
696
+ }
697
+ let workspaces
698
+ try {
699
+ workspaces = ctx.workspaceRegistry.list()
700
+ } catch (err) {
701
+ console.error('[all-usage] workspace list failed:', err)
702
+ noteSyncError('workspace-list-failed')
703
+ return
704
+ }
705
+ const next = buildWorkspaceSnapshot(workspaces)
706
+ const removedWs = new Set()
707
+ for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
708
+ const addedWs = new Set()
709
+ for (const id of next.wsMeta.keys()) if (!state.wsMeta.has(id)) addedWs.add(id)
710
+ for (const id of removedWs) state.retiringWorkspaceIds.add(id)
711
+ state.sync.workspaceSyncAdded += addedWs.size
712
+ state.sync.workspaceSyncRemoved += removedWs.size
713
+ // Reuse path: no membership change — metadata only, zero rescan.
714
+ if (removedWs.size === 0 && addedWs.size === 0) {
715
+ installWorkspaceSnapshot(next)
716
+ return
717
+ }
718
+ // Retire path: a deregistered workspace keeps everything it recorded. The
719
+ // ledger's promise is that flushed usage survives deletion, so sessions are
720
+ // re-applied under the shared deleted bucket instead of being subtracted and
721
+ // dropped. New sessions still cannot enter the workspace: pathIndex only
722
+ // ever carries registered workspaces, so workspaceForCwd() no longer
723
+ // resolves its cwd and the scan skips it.
724
+ if (removedWs.size > 0) {
725
+ const retiredSids = new Set()
726
+ for (const sid of state.ledgerKeys) {
727
+ const record = await host.ledger.recordFor(sid)
728
+ if (record === null || record === undefined || !removedWs.has(record.workspaceId)) continue
729
+ retiredSids.add(sid)
730
+ }
731
+ for (const [sid, id] of state.memberOf) if (removedWs.has(id)) retiredSids.add(sid)
732
+ let moved = 0
733
+ for (const sid of retiredSids) {
734
+ if (typeof sid !== 'string' || sid === '') continue
735
+ // retargetSession() walks the session's rows: bring back any that were
736
+ // dropped from memory, or the workspace split would keep the old bucket.
737
+ if (typeof host.ledger.loadRowsForSession === 'function') await host.ledger.loadRowsForSession(sid)
738
+ // Re-key the live aggregate first: usage folded from the feed but not yet
739
+ // persisted has no ledger row to replay, and a session the registry no
740
+ // longer lists is never re-scanned afterwards.
741
+ moved += host.aggregation.retargetSession(sid, RETIRED_WORKSPACE_ID)
742
+ const record = state.ledgerRecords.get(sid)
743
+ // Keep the in-memory record aligned with the aggregate; the persisted row
744
+ // keeps its original workspace id and cwd, so recovery re-derives this.
745
+ if (record !== undefined && (record.turns.length > 0 || record.usage.length > 0)) moved += 1
746
+ if (record !== undefined) state.ledgerRecords.set(sid, retargetLedgerRecord(record))
747
+ }
748
+ if (moved > 0) state.retiredBucketUsed = true
749
+ }
750
+ installWorkspaceSnapshot(next)
751
+ // Add path: scan only the sessions that belong to newly registered workspaces.
752
+ if (addedWs.size === 0) return
753
+ let records = null
754
+ try {
755
+ records = await ctx.sessionQuery.listSessions()
756
+ } catch (err) {
757
+ console.error('[all-usage] session list failed:', err)
758
+ noteSyncError('session-list-failed')
759
+ }
760
+ if (!Array.isArray(records)) return
761
+ state.sync.sessionsTotal = records.length
762
+ markStatsChanged('scan')
763
+ let snapshots = null
764
+ try {
765
+ snapshots = await loadSessionRevisions()
766
+ } catch (err) {
767
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
768
+ }
769
+ for (const record of records) {
770
+ if (state.disposed) return
771
+ if (record === undefined || record === null || record.header === undefined) continue
772
+ const sid = record.header.id
773
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
774
+ const wsId = workspaceForCwd(cwd)
775
+ if (sid === undefined || wsId === undefined) continue
776
+ if (!addedWs.has(wsId)) continue
777
+ state.knownSessionIds.add(sid)
778
+ scanOneSession(sid, record, wsId, state.aggregationGeneration, snapshots)
779
+ }
780
+ }
781
+
782
+ function sessionIdsFromRecords(records) {
783
+ const ids = new Set()
784
+ for (const record of records) {
785
+ if (record === undefined || record === null || record.header === undefined) continue
786
+ const sid = record.header.id
787
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
788
+ const wsId = workspaceForCwd(cwd)
789
+ if (sid !== undefined && wsId !== undefined) ids.add(sid)
790
+ }
791
+ return ids
792
+ }
793
+ async function reconcileSessions() {
794
+ if (state.disposed || state.reconcileInFlight || !state.scan.done) return
795
+ state.reconcilePending = false
796
+ state.reconcileInFlight = true
797
+ try {
798
+ const records = await ctx.sessionQuery.listSessions()
799
+ if (state.disposed || !Array.isArray(records)) return
800
+ const currentIds = sessionIdsFromRecords(records)
801
+ let removed = false
802
+ for (const sid of state.knownSessionIds) {
803
+ if (!currentIds.has(sid)) { removed = true; break }
804
+ }
805
+ if (removed && !state.disposed && state.scan.done) {
806
+ console.info('[all-usage] session removal detected; rebuilding usage index')
807
+ const generation = resetAggregationState()
808
+ void runBaseline(generation)
809
+ return
810
+ }
811
+ state.knownSessionIds.clear()
812
+ for (const sid of currentIds) state.knownSessionIds.add(sid)
813
+ } catch (err) {
814
+ console.error('[all-usage] session reconciliation failed:', err)
815
+ noteSyncError('session-reconcile-failed')
816
+ } finally {
817
+ state.reconcileInFlight = false
818
+ if (state.reconcilePending && !state.disposed) scheduleReconcileHint()
819
+ }
820
+ }
821
+ function scheduleReconcileHint() {
822
+ if (state.disposed) return
823
+ state.reconcilePending = true
824
+ if (state.reconcileHintScheduled || state.reconcileInFlight) return
825
+ state.reconcileHintScheduled = true
826
+ const generation = state.aggregationGeneration
827
+ void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
828
+ if (generation !== state.aggregationGeneration) return
829
+ state.reconcileHintScheduled = false
830
+ if (ready && !state.disposed) void reconcileSessions()
831
+ }, () => {
832
+ if (generation === state.aggregationGeneration) state.reconcileHintScheduled = false
833
+ })
834
+ }
835
+ function scheduleReconcileTimer() {
836
+ if (state.disposed || state.reconcileTimer !== null) return
837
+ state.reconcileTimer = setTimeout(() => {
838
+ state.reconcileTimer = null
839
+ if (!state.disposed) {
840
+ void reconcileSessions()
841
+ scheduleReconcileTimer()
842
+ }
843
+ }, RECONCILE_INTERVAL_MS)
844
+ if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
845
+ }
846
+ // Belt and braces for the registry probe: the event fast path can be missed
847
+ // (dropped while scanning, or never delivered), and unlike the event this
848
+ // cannot silently leave the workspace list stale until a restart.
849
+ function scheduleWorkspaceProbeTimer() {
850
+ if (state.disposed || state.workspaceProbeTimer !== null) return
851
+ state.workspaceProbeTimer = setTimeout(() => {
852
+ state.workspaceProbeTimer = null
853
+ if (!state.disposed) {
854
+ void runWorkspaceSync()
855
+ scheduleWorkspaceProbeTimer()
856
+ }
857
+ }, WORKSPACE_PROBE_INTERVAL_MS)
858
+ if (state.workspaceProbeTimer && typeof state.workspaceProbeTimer.unref === 'function') state.workspaceProbeTimer.unref()
859
+ }
860
+
861
+ // ---------- live feed ----------
862
+ ctx.on('session/event', (session, event) => {
863
+ if (state.disposed) return
864
+ if (event === undefined || event === null) return
865
+ const type = event.type
866
+ if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'assistant/chunk' && type !== 'request/context' && type !== 'request/header') return
867
+ const sid = session && session.id
868
+ if (typeof sid !== 'string') return
869
+ markLedgerDirty(sid)
870
+ const wsId = wsForLiveSession(session, sid)
871
+ if (wsId === undefined) return
872
+ const generation = state.aggregationGeneration
873
+ state.knownSessionIds.add(sid)
874
+ enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
875
+ })
876
+ ctx.on('session/flush', async (session) => {
877
+ if (state.disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
878
+ const sid = session.id
879
+ // Flush changes the same record as live resync and pricing. Serialize the
880
+ // complete read/build/write checkpoint with those mutations.
881
+ return enqueue(sid, async () => {
882
+ if (state.disposed) return
883
+ const previous = state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : await host.ledger.recordFor(sid)
884
+ const eventList = sessionEvents(session)
885
+ // DSH sequences events with seq === log index: the tail event gives the
886
+ // latest sequence in O(1) without scanning a 500K-event session twice.
887
+ const tailEvent = eventList.length > 0 ? eventList[eventList.length - 1] : null
888
+ const currentLastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(eventList)
889
+ const hasNewEvents = state.ledgerDirtySessions.has(sid) || state.ledgerWriteFailedSessions.has(sid) || (previous === undefined && currentLastSeq >= 0) || (previous !== undefined && currentLastSeq > previous.lastSeq)
890
+ if (!hasNewEvents) return
891
+ const dirtyEpoch = state.ledgerDirtyEpochs.get(sid) || 0
892
+ await Promise.all([state.ledgerReady, state.pricingReady])
893
+ if (state.disposed) return
894
+ const wsId = wsForLiveSession(session, sid)
895
+ if (wsId === undefined) return
896
+ // Any dirty flush whose log contains invalid sequences may have rewritten
897
+ // history (even when a new tail was appended) or carries contract-external
898
+ // events; refuse to fold it and rebuild the whole usage index instead, so
899
+ // the in-memory aggregate and the persisted ledger cannot disagree.
900
+ if (previous !== undefined && eventList.length > 0) {
901
+ const sequence = sequenceProfile(eventList)
902
+ if (sequence.hasInvalid) {
903
+ if (state.scan.done && !state.disposed) {
904
+ console.info('[all-usage] invalid sequences in flushed log; rebuilding usage index')
905
+ // Persist the rebuild flag so the baseline cannot fast-path the stale
906
+ // record (the persistence revision may still match a lagging log).
907
+ const current = state.ledgerRecords.get(sid)
908
+ if (current !== undefined) {
909
+ current.rebuildRequired = 'invalid-flush-sequence'
910
+ current.needsUpgrade = true
911
+ void persistLedgerRecord(current)
912
+ }
913
+ const generation = resetAggregationState()
914
+ void runBaseline(generation)
915
+ }
916
+ return
917
+ }
918
+ }
919
+ const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, previous)
920
+ if (ledger === null) return
921
+ const canonical = storeLedgerRecord(ledger)
922
+ if (canonical === ledger) {
923
+ const persisted = persistLedgerRecord(ledger)
924
+ // This is an explicit checkpoint; bypass the background debounce.
925
+ await drainLedgerWrites()
926
+ await persisted
927
+ }
928
+ if (state.ledgerDirtyEpochs.get(sid) === dirtyEpoch) {
929
+ state.ledgerDirtySessions.delete(sid)
930
+ state.ledgerDirtyEpochs.delete(sid)
931
+ }
932
+ })
933
+ })
934
+ ctx.on('session/disposed', () => {
935
+ if (!state.disposed) scheduleReconcileHint()
936
+ })
937
+ // Workspace registry probe: DSH emits domain/changed after every durable
938
+ // workspace-domain write (create/delete/rename/reorder/archive/attach/detach).
939
+ // On any such change we re-read workspaceRegistry.list() and, when membership
940
+ // changed, rescan only the affected workspaces — unchanged ones keep their
941
+ // computed aggregates and ledger untouched.
942
+ ctx.on('domain/changed', (change) => {
943
+ if (state.disposed) return
944
+ if (change === undefined || change === null) return
945
+ if (change.domain !== 'workspace') return
946
+ if (change.table !== 'workspaces' && change.table !== '') return
947
+ state.sync.workspaceProbeEvents += 1
948
+ scheduleWorkspaceSync()
949
+ })
950
+
951
+ return {
952
+ foldEvent,
953
+ foldEvents,
954
+ lastSeqOf,
955
+ sequenceProfile,
956
+ enqueue,
957
+ wsForLiveSession,
958
+ cancelLiveResync,
959
+ scheduleLiveResync,
960
+ foldLiveFallback,
961
+ syncLiveSession,
962
+ resyncLiveSession,
963
+ processLiveEvent,
964
+ scheduleNativeBaselineRetry,
965
+ scheduleBaselineRetry,
966
+ runBaseline,
967
+ synchronizeWorkspaceRegistry,
968
+ scheduleWorkspaceSync,
969
+ sessionIdsFromRecords,
970
+ reconcileSessions,
971
+ scheduleReconcileHint,
972
+ scheduleReconcileTimer,
973
+ scheduleWorkspaceProbeTimer,
974
+ markLedgerDirty
975
+ }
976
+ }