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