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