dsh-all-usage 1.1.18 → 1.2.1

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 +469 -394
  2. package/LICENSE +21 -21
  3. package/README.en.md +273 -0
  4. package/README.md +145 -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 +1850 -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 +348 -327
  59. package/lib/pricing-runtime.js +645 -551
  60. package/lib/pricing.js +1488 -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
package/lib/ledger.js CHANGED
@@ -1,531 +1,1000 @@
1
- import { calculateCost, normalizeCostSnapshot } from './pricing.js'
2
- import { billingInstantOf, contextTimeKey, extractUsageEvent, normalizeUsageValues as usageValues, pickPricingTime, touchContextTimes, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
3
-
4
- // Usage recorded for workspaces that left the registry is summed into one
5
- // bucket. The ledger's promise is that flushed usage survives deletion, and a
6
- // row per dead workspace would grow the dashboard without adding information.
7
- const RETIRED_WORKSPACE_ID = 'retired:deleted'
8
-
9
- // Re-attribute a record (and its turns/usage items) to another workspace id.
10
- // Only the in-memory copy is retargeted: the persisted row keeps its original
11
- // workspace id and cwd, so the mapping stays reversible.
12
- function retargetLedgerRecord(record, workspaceId = RETIRED_WORKSPACE_ID) {
13
- if (record === null || record === undefined) return record
14
- return {
15
- ...record,
16
- workspaceId,
17
- turns: Array.isArray(record.turns) ? record.turns.map((turn) => ({ ...turn, workspaceId })) : record.turns,
18
- usage: Array.isArray(record.usage) ? record.usage.map((item) => ({ ...item, workspaceId })) : record.usage,
19
- }
20
- }
21
-
22
- const LEDGER_VERSION = 3
23
- const PREVIOUS_LEDGER_VERSION = 2
24
- const LEGACY_LEDGER_VERSION = 1
25
- const LEDGER_SHARD_COUNT = 32
26
- const LEDGER_UNIT_NAME = 'all_usage_ledger'
27
- const LEDGER_META_KEY = '__all_usage_ledger_meta__'
28
- const LEDGER_WRITE_DEBOUNCE_MS = 25
29
-
30
- // DSH 0.1.5 replaced the Session `events` getter with `snapshotEvents()` while
31
- // 0.1.1 exposed `events`; both return the log in seq order. Read whichever the
32
- // running runtime provides, because falling back to an empty log would fold a
33
- // flushed session into an empty ledger record.
34
- function sessionEvents(session) {
35
- if (session === null || session === undefined) return []
36
- if (Array.isArray(session.events)) return session.events
37
- if (typeof session.snapshotEvents === 'function') {
38
- try {
39
- const snapshot = session.snapshotEvents()
40
- if (Array.isArray(snapshot)) return snapshot
41
- } catch (error) {
42
- return []
43
- }
44
- }
45
- return []
46
- }
47
-
48
- // Ledger sequences follow the DSH contract: -1 marks a missing/unavailable
49
- // sequence, otherwise a non-negative safe integer is required.
50
- function validLedgerSeq(value) {
51
- return value === -1 || (Number.isSafeInteger(value) && value >= 0)
52
- }
53
-
54
- function pollutedLedgerKey(key) {
55
- // Older builds stored raw String(seq) in event keys. Only keys that are
56
- // entirely numeric text (leading zeros, signs, whitespace, decimals,
57
- // exponents, huge values) are treated as bare sequence keys and rebuilt;
58
- // composite keys such as positional event:N and usageStepKey forms that
59
- // contain colons, letters, or hyphens are never flagged.
60
- if (typeof key !== 'string') return true
61
- // Bare whole values only: composite keys such as 'Infinity-session:step:1:1'
62
- // are valid usageStepKey output and must keep the revision fast path.
63
- if (key === 'Infinity' || key === '-Infinity' || key === 'NaN' || key === '-NaN') return true
64
- if (!/^[+\-0-9.eE \t]+$/.test(key)) return false
65
- if (!/^(?:0|[1-9]\d*)$/.test(key)) return true
66
- return !Number.isSafeInteger(Number(key))
67
- }
68
-
69
- function ledgerShardIndex(sessionId) {
70
- let hash = 2166136261
71
- const value = typeof sessionId === 'string' ? sessionId : String(sessionId)
72
- for (let index = 0; index < value.length; index += 1) {
73
- hash ^= value.charCodeAt(index)
74
- hash = Math.imul(hash, 16777619)
75
- }
76
- return (hash >>> 0) % LEDGER_SHARD_COUNT
77
- }
78
-
79
- function ledgerShardName(index) {
80
- return LEDGER_UNIT_NAME + '_' + String(index).padStart(2, '0')
81
- }
82
-
83
- function sessionRows(snapshot) {
84
- const rows = snapshot && snapshot.tables && snapshot.tables.sessions
85
- return rows !== null && rows !== undefined && typeof rows === 'object' ? rows : {}
86
- }
87
-
88
- function uniqueUnits(units) {
89
- return Array.from(new Set(units.filter((unit) => unit !== null && unit !== undefined)))
90
- }
91
-
92
- function createLedgerUnitStore(legacyUnit, shardUnits) {
93
- const shards = uniqueUnits(shardUnits)
94
- const allUnits = uniqueUnits([legacyUnit, ...shards])
95
- return {
96
- async loadAll() {
97
- const snapshots = await Promise.all(shards.map((unit) => unit.loadAll()))
98
- const rows = Object.create(null)
99
- for (const snapshot of snapshots) {
100
- for (const [key, value] of Object.entries(sessionRows(snapshot))) {
101
- if (key !== LEDGER_META_KEY) rows[key] = value
102
- }
103
- }
104
- return { global: {}, tables: { sessions: rows } }
105
- },
106
- async hasMigrationMarker() {
107
- if (shardUnits[0] === undefined) return false
108
- return Object.hasOwn(sessionRows(await shardUnits[0].loadAll()), LEDGER_META_KEY)
109
- },
110
- async putRecord(table, key, value) {
111
- if (table !== 'sessions') throw new Error('usage ledger only supports the sessions table')
112
- const index = key === LEDGER_META_KEY ? 0 : ledgerShardIndex(key)
113
- const unit = shardUnits[index]
114
- if (unit === undefined) throw new Error('usage ledger shard is unavailable: ' + index)
115
- return unit.putRecord(table, key, value)
116
- },
117
- async close() {
118
- await Promise.all(allUnits.map((unit) => unit.close().catch(() => {})))
119
- },
120
- shardCount: LEDGER_SHARD_COUNT,
121
- }
122
- }
123
-
124
- async function openLedgerUnitStore(backend) {
125
- const shardUnits = []
126
- let legacyUnit
127
- try {
128
- for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
129
- shardUnits.push(await backend.kv.open({ name: ledgerShardName(index), version: 0, tables: ['sessions'], hasGlobal: false }))
130
- }
131
- const shardStore = createLedgerUnitStore(undefined, shardUnits)
132
- const shardRows = sessionRows(await shardStore.loadAll())
133
- if (await shardStore.hasMigrationMarker()) return { store: shardStore, rows: shardRows }
134
-
135
- // Read the legacy monolith only during the one-time migration. Once shard 00
136
- // carries the marker, startup never parses the old multi-megabyte file again.
137
- legacyUnit = await backend.kv.open({ name: LEDGER_UNIT_NAME, version: 0, tables: ['sessions'], hasGlobal: false })
138
- const store = createLedgerUnitStore(legacyUnit, shardUnits)
139
- const rows = Object.assign(Object.create(null), shardRows)
140
- const legacyRows = sessionRows(await legacyUnit.loadAll())
141
- for (const [key, value] of Object.entries(legacyRows)) {
142
- if (key === LEDGER_META_KEY || Object.hasOwn(rows, key)) continue
143
- await store.putRecord('sessions', key, value)
144
- rows[key] = value
145
- }
146
- await store.putRecord('sessions', LEDGER_META_KEY, { version: 1, migratedAt: Date.now() })
147
- return { store, rows }
148
- } catch (error) {
149
- await Promise.all(uniqueUnits([legacyUnit, ...shardUnits]).map((unit) => unit.close().catch(() => {})))
150
- throw error
151
- }
152
- }
153
-
154
- export function createLedger(host) {
155
- const { state, markStatsChanged } = host
156
- const { storage } = host.services
157
- state.ledgerPending = state.ledgerPending || new Map()
158
- state.ledgerWriteWaiters = state.ledgerWriteWaiters || new Map()
159
- state.ledgerWriteRunning = state.ledgerWriteRunning === true
160
- state.ledgerWriteScheduled = state.ledgerWriteScheduled === true
161
- state.ledgerWriteTimer = state.ledgerWriteTimer || null
162
- state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
163
- const {
164
- addTurn,
165
- coerceIdentity,
166
- identityFromLegacy,
167
- identityFromMessage,
168
- identityFromRoute,
169
- makeIdentity,
170
- costForUsage,
171
- resolveCurrentPricing,
172
- dateKeys,
173
- validEventTime,
174
- upsertUsageSample,
175
- } = host.aggregation
176
- const lastSeqOf = (...args) => host.sessionSync.lastSeqOf(...args)
177
- function isLegacyTieredCost(cost) {
178
- const selectedTier = cost && cost.selectedTier
179
- const hasSelectedTier = selectedTier && selectedTier.type === 'context' && Number.isSafeInteger(selectedTier.size) && selectedTier.size >= 0
180
- return cost !== null && cost.tiered === true && cost.status === 'priced' && !hasSelectedTier
181
- }
182
-
183
- function nextLedgerRevision() {
184
- const current = Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0
185
- state.ledgerRevision = Math.max(current + 1, Date.now())
186
- return state.ledgerRevision
187
- }
188
- function ledgerEventKey(event, index) {
189
- // Only finite safe-integer sequences are canonical event ids; anything else
190
- // falls back to the positional index so two invalid events cannot overwrite
191
- // each other in the ledger.
192
- return Number.isSafeInteger(event.seq) && event.seq >= 0 ? String(event.seq) : 'event:' + index
193
- }
194
- function canFoldLedgerTail(previousRecord, events, workspaceId) {
195
- if (previousRecord === null || previousRecord === undefined || previousRecord.needsUpgrade === true || previousRecord.rebuildRequired !== undefined || !Number.isSafeInteger(previousRecord.lastSeq) || previousRecord.lastSeq < 0 || !Array.isArray(events) || events.length === 0) return false
196
- // A changed workspace identity (deleted and re-added workspace gets a new id)
197
- // assigns every historical event to a different bucket, so the previous
198
- // record can never be reused as a tail seed for the new workspace.
199
- if (workspaceId !== undefined && previousRecord.workspaceId !== undefined && previousRecord.workspaceId !== workspaceId) return false
200
- const startIndex = previousRecord.lastSeq + 1
201
- if (startIndex >= events.length || events[startIndex] === null || typeof events[startIndex] !== 'object' || events[startIndex].seq !== startIndex) return false
202
- const previousTurns = new Set(Array.isArray(previousRecord.turns) ? previousRecord.turns.map((turn) => turn && turn.turn).filter((turn) => Number.isSafeInteger(turn)) : [])
203
- for (let index = startIndex, expectedSeq = startIndex; index < events.length; index += 1, expectedSeq += 1) {
204
- const event = events[index]
205
- if (event === null || typeof event !== 'object' || event.seq !== expectedSeq) return false
206
- if (event.type === 'turn/end' && event.data && Number.isSafeInteger(event.data.turn) && previousTurns.has(event.data.turn)) return false
207
- }
208
- return true
209
- }
210
- function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord, sequenceClean = true) {
211
- const sid = session && typeof session.id === 'string' ? session.id : ''
212
- const events = sessionEvents(session)
213
- if (sid === '' || workspaceId === undefined) return null
214
- // DSH sequences every persisted event with seq === log index, so the last
215
- // event's seq is the record's lastSeq in O(1); fall back to a full scan only
216
- // when the tail carries no seq (legacy or truncated logs).
217
- const tailEvent = events.length > 0 ? events[events.length - 1] : null
218
- const lastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(events)
219
- // A changed incremental log that contains invalid sequences anywhere must
220
- // be rebuilt wholesale: the fold check only validates the tail, so a bad
221
- // event before the fold start would otherwise be skipped in memory while
222
- // the persisted record still contains everything.
223
- const canFoldTail = sequenceClean === true && canFoldLedgerTail(previousRecord, events, workspaceId)
224
- const turns = canFoldTail ? new Map((previousRecord.turns || []).map((item) => [item.key, { ...item }])) : new Map()
225
- const usage = canFoldTail ? new Map((previousRecord.usage || []).map((item) => [item.key, { ...item }])) : new Map()
226
- const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
227
- let currentIdentity = canFoldTail ? coerceIdentity(previousRecord.lastIdentity) : makeIdentity(null, null, null, null)
228
- // Request-context instants are kept per turn/step so parallel requests each
229
- // carry their own billing time while scanning the persisted log. When the
230
- // tail is folded onto a persisted record, the previous context archive seeds
231
- // the map so an open request whose context arrived in the previous batch
232
- // still bills its usage with the matching request instant after a restart.
233
- const contextTimes = canFoldTail ? new Map(Array.isArray(previousRecord.contextTimes) ? previousRecord.contextTimes.map((entry) => [entry.key, entry.time]) : []) : new Map()
234
- const startIndex = canFoldTail ? previousRecord.lastSeq + 1 : 0
235
- for (let index = startIndex; index < events.length; index += 1) {
236
- const event = events[index]
237
- if (event === null || typeof event !== 'object') continue
238
- if (canFoldTail && event.seq <= previousRecord.lastSeq) continue
239
- const data = event.data
240
- if (event.type === 'request/context') {
241
- currentIdentity = identityFromRoute(data, currentIdentity)
242
- if (validEventTime(event.time)) touchContextTimes(contextTimes, contextTimeKey(data && data.turn, data && data.step), event.time)
243
- continue
244
- }
245
- if (event.type === 'request/header') {
246
- currentIdentity = identityFromRoute(data, currentIdentity)
247
- continue
248
- }
249
- if (event.type === 'turn/end') {
250
- if (!validEventTime(event.time)) continue
251
- const key = ledgerEventKey(event, index)
252
- const turn = data && typeof data.turn === 'number' ? data.turn : null
253
- turns.set(key, { key, seq: validLedgerSeq(event.seq) ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
254
- continue
255
- }
256
- if (!validEventTime(event.time)) continue
257
- const usageEvent = extractUsageEvent(event)
258
- if (usageEvent === null) continue
259
- const identity = usageEvent.kind === 'message' ? identityFromMessage(data, currentIdentity) : currentIdentity
260
- const key = usageStepKey(sid, data, event.seq)
261
- const pricing = pickPricingTime(contextTimes, event.time, usageEvent.turn, usageEvent.step)
262
- const result = upsertUsageSample(usage, {
263
- key,
264
- seq: validLedgerSeq(event.seq) ? event.seq : -1,
265
- time: event.time,
266
- workspaceId,
267
- identity,
268
- modelId: identity.label,
269
- turn: usageEvent.turn,
270
- step: usageEvent.step,
271
- values: usageEvent.values,
272
- pricingAt: pricing.time,
273
- pricingTimeSource: pricing.source,
274
- }, { costPrevious: previousUsage.get(key) })
275
- if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
276
- }
277
- const sourceCwd = session && session.header && typeof session.header.cwd === 'string' ? session.header.cwd : undefined
278
- return { version: LEDGER_VERSION, sessionId: sid, workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq, source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, contextTimes: Array.from(contextTimes.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
279
- }
280
- function normalizeLedgerRecord(raw, key) {
281
- if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
282
- if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
283
- const invalidTurn = raw.turns.some((turn) => turn === null || typeof turn !== 'object' || typeof turn.key !== 'string' || turn.workspaceId === undefined || !validEventTime(turn.time) || pollutedLedgerKey(turn.key) || (turn.seq !== undefined && !validLedgerSeq(turn.seq)))
284
- const invalidUsage = raw.usage.some((item) => { const cost = normalizeCostSnapshot(item && item.cost); return item === null || typeof item !== 'object' || !validEventTime(item.time) || item.identity === undefined || pollutedLedgerKey(item.key) || (item.seq !== undefined && !validLedgerSeq(item.seq)) || cost === null || cost.pricingMode !== 'official-model' || isLegacyTieredCost(cost) })
285
- // Older versions could write records whose top-level workspaceId belonged to
286
- // a recreated workspace while the historical items kept the previous one;
287
- // such mixed records must never be reused as a fast path.
288
- const mixedWorkspace = raw.turns.some((turn) => turn && turn.workspaceId !== raw.workspaceId) || raw.usage.some((item) => item && item.workspaceId !== raw.workspaceId)
289
- // The rebuild reason is persisted (enumerable) so the flag survives a
290
- // failure-and-restart cycle even when normalized content alone no longer
291
- // carries the evidence (e.g. lastSeq -1 after an invalid non-safe value).
292
- const persistedReason = typeof raw.rebuildRequired === 'string' && raw.rebuildRequired !== '' ? raw.rebuildRequired : undefined
293
- const computedReason = invalidTurn ? 'invalid-turn' : invalidUsage ? 'invalid-usage' : mixedWorkspace ? 'mixed-workspace' : (raw.lastSeq !== undefined && !validLedgerSeq(raw.lastSeq)) ? 'invalid-last-seq' : raw.version !== LEDGER_VERSION ? 'legacy-version' : !Number.isFinite(raw.updatedAt) ? 'invalid-updated-at' : undefined
294
- const rebuildReason = persistedReason !== undefined ? persistedReason : computedReason
295
- const needsUpgrade = rebuildReason !== undefined || !Number.isFinite(raw.updatedAt) || raw.version !== LEDGER_VERSION
296
- const turnMap = new Map()
297
- for (const turn of raw.turns) {
298
- if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && validEventTime(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: Number.isSafeInteger(turn.seq) ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
299
- }
300
- const usageMap = new Map()
301
- for (const item of raw.usage) {
302
- if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !validEventTime(item.time) || item.values === null || typeof item.values !== 'object') continue
303
- const identity = coerceIdentity(item.identity || item.modelId)
304
- const normalizedCost = normalizeCostSnapshot(item.cost)
305
- const normalized = { key: item.key, seq: Number.isSafeInteger(item.seq) ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
306
- upsertUsageSampleState(usageMap, normalized)
307
- }
308
- const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
309
- state.ledgerRevision = Math.max(Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0, updatedAt)
310
- const contextMap = new Map()
311
- if (Array.isArray(raw.contextTimes)) {
312
- for (const entry of raw.contextTimes) {
313
- if (entry === null || typeof entry !== 'object' || typeof entry.key !== 'string' || !entry.key.startsWith('context:') || entry.key.length > 128 || !validEventTime(entry.time)) continue
314
- touchContextTimes(contextMap, entry.key, entry.time)
315
- }
316
- }
317
- const lastUsage = Array.from(usageMap.values()).at(-1)
318
- const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
319
- const sourceCwd = typeof raw.sourceCwd === 'string' && raw.sourceCwd.trim() !== '' ? raw.sourceCwd : undefined
320
- const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq: validLedgerSeq(raw.lastSeq) ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, contextTimes: Array.from(contextMap.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
321
- if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
322
- Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
323
- return normalizedRecord
324
- }
325
- function prepareLedgerRecord(record) {
326
- if (record === null || record === undefined) return record
327
- let changed = record.needsUpgrade === true || record.version !== LEDGER_VERSION
328
- for (const item of record.usage) {
329
- const cost = normalizeCostSnapshot(item.cost)
330
- if (isLegacyTieredCost(cost)) {
331
- item.cost = calculateCost(item.values, { ...cost, status: 'unsupported', reason: 'tiered-pricing-not-modeled' })
332
- changed = true
333
- continue
334
- }
335
- if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
336
- const identity = item.identity || identityFromLegacy(item.modelId)
337
- const billing = billingInstantOf(item, cost)
338
- item.cost = calculateCost(item.values, resolveCurrentPricing(identity), billing.at, billing.source)
339
- changed = true
340
- }
341
- if (changed) {
342
- // Repacking costs must never clear the rebuild flag: only a complete
343
- // rebuild from the source log produces a record that is safe to reuse.
344
- record.version = LEDGER_VERSION
345
- record.updatedAt = nextLedgerRevision()
346
- state.ledgerRecords.set(record.sessionId, record)
347
- void persistLedgerRecord(record)
348
- }
349
- return record
350
- }
351
- function applyLedgerRecord(record, materialization = 'ledger-reuse') {
352
- if (record === null || record === undefined) return
353
- prepareLedgerRecord(record)
354
- if (record.lastIdentity !== undefined) state.sessionModel.set(record.sessionId, record.lastIdentity)
355
- if (Array.isArray(record.contextTimes)) {
356
- const times = new Map()
357
- for (const entry of record.contextTimes) if (entry && typeof entry.key === 'string' && Number.isFinite(entry.time)) times.set(entry.key, entry.time)
358
- state.sessionContextTimes.set(record.sessionId, times)
359
- }
360
- for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
361
- for (const item of record.usage) {
362
- const identity = item.identity || identityFromLegacy(item.modelId)
363
- const dates = dateKeys(item.time)
364
- upsertUsageSample(state.usageByStep, {
365
- key: item.key,
366
- seq: item.seq,
367
- wsId: item.workspaceId,
368
- time: item.time,
369
- date: dates.local,
370
- dateUtc: dates.utc,
371
- values: item.values,
372
- identity,
373
- modelId: identity.label,
374
- cost: item.cost,
375
- turn: item.turn,
376
- step: item.step,
377
- materialization,
378
- sid: record.sessionId,
379
- })
380
- }
381
- if (record.turns.length > 0 || record.usage.length > 0) {
382
- state.sessionCount.add(record.sessionId)
383
- if (record.usage.length > 0) markStatsChanged('scan')
384
- }
385
- }
386
- function ledgerRank(record) {
387
- return [Number.isFinite(record.lastSeq) ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, Number.isFinite(record.updatedAt) ? record.updatedAt : 0]
388
- }
389
- function replaceLedgerRecord(record) {
390
- assertLedgerRecord(record)
391
- state.ledgerRecords.set(record.sessionId, record)
392
- return record
393
- }
394
- function assertLedgerRecord(record) {
395
- if (record === null || typeof record !== 'object' || !Number.isFinite(record.updatedAt)) throw new TypeError('usage ledger updatedAt must be a finite number')
396
- return record
397
- }
398
- function storeLedgerRecord(record) {
399
- assertLedgerRecord(record)
400
- const current = state.ledgerRecords.get(record.sessionId)
401
- if (current !== undefined) {
402
- const nextRank = ledgerRank(record)
403
- const currentRank = ledgerRank(current)
404
- if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
405
- }
406
- state.ledgerRecords.set(record.sessionId, record)
407
- return record
408
- }
409
- function resolveLedgerWaiters(sid) {
410
- const waiters = state.ledgerWriteWaiters.get(sid)
411
- if (waiters === undefined) return
412
- state.ledgerWriteWaiters.delete(sid)
413
- for (const resolve of waiters) resolve()
414
- }
415
- async function drainPendingLedgerWrites() {
416
- while (state.ledgerPending.size > 0) {
417
- const entry = state.ledgerPending.entries().next().value
418
- if (entry === undefined) break
419
- const [sid, record] = entry
420
- state.ledgerPending.delete(sid)
421
- if (state.ledgerUnit === null || state.ledgerRecords.get(sid) !== record) {
422
- if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
423
- continue
424
- }
425
- try {
426
- await state.ledgerUnit.putRecord('sessions', sid, record)
427
- state.ledgerWriteFailedSessions.delete(sid)
428
- } catch (err) {
429
- state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
430
- state.ledgerWriteFailedSessions.add(sid)
431
- state.ledgerDirtySessions = state.ledgerDirtySessions || new Set()
432
- state.ledgerDirtyEpochs = state.ledgerDirtyEpochs || new Map()
433
- state.ledgerDirtySessions.add(sid)
434
- state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
435
- console.error('[all-usage] usage ledger write failed:', err)
436
- }
437
- if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
438
- }
439
- }
440
- function startLedgerWrite() {
441
- if (state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
442
- state.ledgerWriteRunning = true
443
- const write = state.ledgerWriteChain.then(() => drainPendingLedgerWrites(), () => drainPendingLedgerWrites())
444
- state.ledgerWriteChain = write.catch((err) => {
445
- console.error('[all-usage] usage ledger queue failed:', err)
446
- }).finally(() => {
447
- state.ledgerWriteRunning = false
448
- if (state.ledgerPending.size > 0 && state.ledgerUnit !== null) scheduleLedgerWrite()
449
- })
450
- }
451
- function scheduleLedgerWrite() {
452
- if (state.ledgerWriteScheduled || state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
453
- state.ledgerWriteScheduled = true
454
- const run = () => {
455
- state.ledgerWriteScheduled = false
456
- state.ledgerWriteTimer = null
457
- startLedgerWrite()
458
- }
459
- state.ledgerWriteTimer = setTimeout(run, LEDGER_WRITE_DEBOUNCE_MS)
460
- // Keep a reference while write waiters are pending: the debounce timer is
461
- // what eventually resolves persistLedgerRecord promises, and an unreferenced
462
- // timer lets the event loop (and test runs) settle before the write happens.
463
- if (state.ledgerWriteTimer && typeof state.ledgerWriteTimer.unref === 'function' && state.ledgerWriteWaiters.size === 0) state.ledgerWriteTimer.unref()
464
- }
465
- function persistLedgerRecord(record) {
466
- if (record !== null && record !== undefined) assertLedgerRecord(record)
467
- if (record === null || record === undefined || state.disposed || state.ledgerUnit === null) return Promise.resolve()
468
- if (state.ledgerRecords.get(record.sessionId) !== record) return Promise.resolve()
469
- const waiter = new Promise((resolve) => {
470
- const waiters = state.ledgerWriteWaiters.get(record.sessionId) || []
471
- waiters.push(resolve)
472
- state.ledgerWriteWaiters.set(record.sessionId, waiters)
473
- })
474
- state.ledgerPending.set(record.sessionId, record)
475
- scheduleLedgerWrite()
476
- return waiter
477
- }
478
- async function drainLedgerWrites() {
479
- if (state.ledgerWriteTimer !== null) {
480
- clearTimeout(state.ledgerWriteTimer)
481
- state.ledgerWriteTimer = null
482
- }
483
- state.ledgerWriteScheduled = false
484
- if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) startLedgerWrite()
485
- await state.ledgerWriteChain
486
- if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) {
487
- startLedgerWrite()
488
- await state.ledgerWriteChain
489
- }
490
- for (const sid of Array.from(state.ledgerWriteWaiters.keys())) {
491
- if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
492
- }
493
- }
494
- async function loadLedger() {
495
- if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
496
- try {
497
- const backend = storage.backend.get('json')
498
- if (backend === undefined || backend === null || backend.kv === undefined) return
499
- const opened = await openLedgerUnitStore(backend)
500
- if (state.disposed) { await opened.store.close(); return }
501
- state.ledgerUnit = opened.store
502
- for (const [key, raw] of Object.entries(opened.rows)) {
503
- const record = normalizeLedgerRecord(raw, key)
504
- if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
505
- else state.ledgerRecords.set(key, record)
506
- }
507
- } catch (err) {
508
- console.error('[all-usage] usage ledger unavailable:', err)
509
- }
510
- }
511
-
512
- return {
513
- nextLedgerRevision,
514
- ledgerEventKey,
515
- sessionEvents,
516
- retargetLedgerRecord,
517
- buildLedgerRecord,
518
- normalizeLedgerRecord,
519
- prepareLedgerRecord,
520
- applyLedgerRecord,
521
- ledgerRank,
522
- replaceLedgerRecord,
523
- storeLedgerRecord,
524
- persistLedgerRecord,
525
- drainLedgerWrites,
526
- assertLedgerRecord,
527
- loadLedger
528
- }
529
- }
530
-
531
- export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, RETIRED_WORKSPACE_ID, ledgerShardIndex, retargetLedgerRecord }
1
+ import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { recordKeyHash, recordKeyLocator } from './records-cache.js'
4
+ import { calculateCost, normalizeCostSnapshot } from './pricing.js'
5
+ import { billingInstantOf, contextTimeKey, extractUsageEvent, normalizeUsageValues as usageValues, pickPricingTime, touchContextTimes, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
6
+
7
+ // Usage recorded for workspaces that left the registry is summed into one
8
+ // bucket. The ledger's promise is that flushed usage survives deletion, and a
9
+ // row per dead workspace would grow the dashboard without adding information.
10
+ const RETIRED_WORKSPACE_ID = 'retired:deleted'
11
+
12
+ // Re-attribute a record (and its turns/usage items) to another workspace id.
13
+ // Only the in-memory copy is retargeted: the persisted row keeps its original
14
+ // workspace id and cwd, so the mapping stays reversible.
15
+ function retargetLedgerRecord(record, workspaceId = RETIRED_WORKSPACE_ID) {
16
+ if (record === null || record === undefined) return record
17
+ const retargeted = {
18
+ ...record,
19
+ workspaceId,
20
+ turns: Array.isArray(record.turns) ? record.turns.map((turn) => ({ ...turn, workspaceId })) : record.turns,
21
+ usage: Array.isArray(record.usage) ? record.usage.map((item) => ({ ...item, workspaceId })) : record.usage,
22
+ }
23
+ // Pricing may later write a resident retired copy. Keep a non-enumerable pointer
24
+ // to the original durable shape so that write-back changes costs without erasing
25
+ // the historical workspace attribution.
26
+ Object.defineProperty(retargeted, '__durableRecord', {
27
+ value: record.__durableRecord || record,
28
+ enumerable: false,
29
+ configurable: true,
30
+ })
31
+ return retargeted
32
+ }
33
+
34
+ const LEDGER_VERSION = 3
35
+ const PREVIOUS_LEDGER_VERSION = 2
36
+ const LEGACY_LEDGER_VERSION = 1
37
+ const LEDGER_SHARD_COUNT = 32
38
+ const LEDGER_UNIT_NAME = 'all_usage_ledger'
39
+ const LEDGER_META_KEY = '__all_usage_ledger_meta__'
40
+ const LEDGER_WRITE_DEBOUNCE_MS = 25
41
+
42
+ // DSH 0.1.5 replaced the Session `events` getter with `snapshotEvents()` while
43
+ // 0.1.1 exposed `events`; both return the log in seq order. Read whichever the
44
+ // running runtime provides, because falling back to an empty log would fold a
45
+ // flushed session into an empty ledger record.
46
+ function sessionEvents(session) {
47
+ if (session === null || session === undefined) return []
48
+ if (Array.isArray(session.events)) return session.events
49
+ if (typeof session.snapshotEvents === 'function') {
50
+ try {
51
+ const snapshot = session.snapshotEvents()
52
+ if (Array.isArray(snapshot)) return snapshot
53
+ } catch (error) {
54
+ return []
55
+ }
56
+ }
57
+ return []
58
+ }
59
+
60
+ // Ledger sequences follow the DSH contract: -1 marks a missing/unavailable
61
+ // sequence, otherwise a non-negative safe integer is required.
62
+ function validLedgerSeq(value) {
63
+ return value === -1 || (Number.isSafeInteger(value) && value >= 0)
64
+ }
65
+
66
+ function pollutedLedgerKey(key) {
67
+ // Older builds stored raw String(seq) in event keys. Only keys that are
68
+ // entirely numeric text (leading zeros, signs, whitespace, decimals,
69
+ // exponents, huge values) are treated as bare sequence keys and rebuilt;
70
+ // composite keys such as positional event:N and usageStepKey forms that
71
+ // contain colons, letters, or hyphens are never flagged.
72
+ if (typeof key !== 'string') return true
73
+ // Bare whole values only: composite keys such as 'Infinity-session:step:1:1'
74
+ // are valid usageStepKey output and must keep the revision fast path.
75
+ if (key === 'Infinity' || key === '-Infinity' || key === 'NaN' || key === '-NaN') return true
76
+ if (!/^[+\-0-9.eE \t]+$/.test(key)) return false
77
+ if (!/^(?:0|[1-9]\d*)$/.test(key)) return true
78
+ return !Number.isSafeInteger(Number(key))
79
+ }
80
+
81
+ function ledgerShardIndex(sessionId) {
82
+ let hash = 2166136261
83
+ const value = typeof sessionId === 'string' ? sessionId : String(sessionId)
84
+ for (let index = 0; index < value.length; index += 1) {
85
+ hash ^= value.charCodeAt(index)
86
+ hash = Math.imul(hash, 16777619)
87
+ }
88
+ return (hash >>> 0) % LEDGER_SHARD_COUNT
89
+ }
90
+
91
+ function ledgerShardName(index) {
92
+ return LEDGER_UNIT_NAME + '_' + String(index).padStart(2, '0')
93
+ }
94
+
95
+ function sessionRows(snapshot) {
96
+ const rows = snapshot && snapshot.tables && snapshot.tables.sessions
97
+ return rows !== null && rows !== undefined && typeof rows === 'object' ? rows : {}
98
+ }
99
+
100
+ function uniqueUnits(units) {
101
+ return Array.from(new Set(units.filter((unit) => unit !== null && unit !== undefined)))
102
+ }
103
+
104
+ function createLedgerUnitStore(legacyUnit, shardUnits, root = '') {
105
+ const shards = uniqueUnits(shardUnits)
106
+ const allUnits = uniqueUnits([legacyUnit, ...shards])
107
+ return {
108
+
109
+ async loadAll() {
110
+ const snapshots = await Promise.all(shards.map((unit) => unit.loadAll()))
111
+ const rows = Object.create(null)
112
+ for (const snapshot of snapshots) {
113
+ for (const [key, value] of Object.entries(sessionRows(snapshot))) {
114
+ if (key !== LEDGER_META_KEY) rows[key] = value
115
+ }
116
+ }
117
+ return { global: {}, tables: { sessions: rows } }
118
+ },
119
+ /**
120
+ * Every session key the shards hold, without parsing a single record: the
121
+ * per-record layout is a directory of documents, so the filenames are the
122
+ * index and a record is read later, on demand, with readRecord().
123
+ */
124
+ async listKeys() {
125
+ const keys = []
126
+ for (const unit of shards) {
127
+ let dir = ''
128
+ try {
129
+ dir = unit.tableDir('sessions')
130
+ } catch (err) {
131
+ continue
132
+ }
133
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
134
+ for (const entry of entries) {
135
+ if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('__')) continue
136
+ keys.push(entry.name.slice(0, -'.json'.length))
137
+ }
138
+ }
139
+ return keys
140
+ },
141
+ /** One document, parsed the way the storage unit parses it when it loads. */
142
+ async readRecord(key) {
143
+ const unit = shardUnits[ledgerShardIndex(key)]
144
+ if (unit === undefined) return undefined
145
+ let path = ''
146
+ try {
147
+ path = join(unit.tableDir('sessions'), key + '.json')
148
+ } catch (err) {
149
+ return undefined
150
+ }
151
+ try {
152
+ const doc = JSON.parse(await readFile(path, 'utf8'))
153
+ if (doc === null || typeof doc !== 'object' || doc.version !== 0 || doc.record === undefined) return undefined
154
+ return doc.record
155
+ } catch (err) {
156
+ return undefined
157
+ }
158
+ },
159
+ async hasMigrationMarker() {
160
+ if (shardUnits[0] === undefined) return false
161
+ return Object.hasOwn(sessionRows(await shardUnits[0].loadAll()), LEDGER_META_KEY)
162
+ },
163
+ async putRecord(table, key, value) {
164
+ if (table !== 'sessions') throw new Error('usage ledger only supports the sessions table')
165
+ const index = key === LEDGER_META_KEY ? 0 : ledgerShardIndex(key)
166
+ const unit = shardUnits[index]
167
+ if (unit === undefined) throw new Error('usage ledger shard is unavailable: ' + index)
168
+ return unit.putRecord(table, key, value)
169
+ },
170
+ async close() {
171
+ await Promise.all(allUnits.map((unit) => unit.close().catch(() => {})))
172
+ },
173
+ shardCount: LEDGER_SHARD_COUNT,
174
+ }
175
+ }
176
+
177
+ async function openLedgerUnitStore(backend) {
178
+ const shardUnits = []
179
+ let legacyUnit
180
+ try {
181
+ for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
182
+ // One file per session record: the single-file layout rewrote the whole
183
+ // shard (a 4.9 MB average, 13.2 MB worst case, ~50-240 ms) for every
184
+ // session update. The per-record layout publishes one document per write,
185
+ // and it migrates itself from the same-named single-file unit on open.
186
+ shardUnits.push(await backend.kv.open({ name: ledgerShardName(index), version: 0, tables: ['sessions'], hasGlobal: false, layout: 'per-record' }))
187
+ }
188
+ const root = typeof backend.root === 'string' ? backend.root : ''
189
+ const shardStore = createLedgerUnitStore(undefined, shardUnits, root)
190
+ // A per-record unit exposes tableDir(), so its documents can be listed and read
191
+ // one at a time. Older or fake units cannot, and there the whole table is read
192
+ // once (and stays resident) exactly as it always was.
193
+ const streaming = shardUnits.length > 0 && typeof shardUnits[0].tableDir === 'function'
194
+ const shardRows = streaming ? Object.create(null) : sessionRows(await shardStore.loadAll())
195
+ if (await shardStore.hasMigrationMarker()) {
196
+ // The trees are the record now, so any leftover whole-unit file is dead
197
+ // weight. This also runs on the fast path, because a previous start may
198
+ // have seeded the trees and exited before retiring them.
199
+ const retired = await retireLegacyLedgerFiles(backend, shardUnits).catch((error) => { console.error('[all-usage] usage ledger legacy retirement failed:', error && error.message); return [] })
200
+ return { store: shardStore, rows: shardRows, retired, streaming }
201
+ }
202
+
203
+ // Read the legacy monolith only during the one-time migration. Once shard 00
204
+ // carries the marker, startup never parses the old multi-megabyte file again.
205
+ legacyUnit = await backend.kv.open({ name: LEDGER_UNIT_NAME, version: 0, tables: ['sessions'], hasGlobal: false })
206
+ const store = createLedgerUnitStore(legacyUnit, shardUnits)
207
+ const rows = Object.assign(Object.create(null), shardRows)
208
+ const legacyRows = sessionRows(await legacyUnit.loadAll())
209
+ for (const [key, value] of Object.entries(legacyRows)) {
210
+ if (key === LEDGER_META_KEY || Object.hasOwn(rows, key)) continue
211
+ await store.putRecord('sessions', key, value)
212
+ rows[key] = value
213
+ }
214
+ await store.putRecord('sessions', LEDGER_META_KEY, { version: 1, migratedAt: Date.now() })
215
+ // Its records now live in the per-record trees (written above), so the
216
+ // legacy document is retired. A failure here leaves the file in place and
217
+ // simply tries again on the next start.
218
+ const retired = await retireLegacyLedgerFiles(backend, shardUnits).catch((error) => { console.error('[all-usage] usage ledger legacy retirement failed:', error && error.message); return [] })
219
+ return { store, rows, retired, streaming: false }
220
+ } catch (error) {
221
+ await Promise.all(uniqueUnits([legacyUnit, ...shardUnits]).map((unit) => unit.close().catch(() => {})))
222
+ throw error
223
+ }
224
+ }
225
+
226
+ // Once a legacy whole-unit file has seeded its per-record tree, the file itself
227
+ // is dead weight: every record lives in the tree now, while the old document
228
+ // keeps a full copy (155.9 MB across the 32 shards plus a 22.4 MB monolith on
229
+ // this machine). DSH's storage layer never removes it, so the ledger retires it
230
+ // itself — only after the tree is non-empty, only for files it owns, and never
231
+ // more than once per unit (a marker keeps a failed run from looping).
232
+ async function retireLegacyLedgerFiles(backend, shardUnits) {
233
+ if (process.env.DSH_ALL_USAGE_KEEP_LEGACY === '1') return []
234
+ const root = typeof backend.root === 'string' ? backend.root : ''
235
+ if (root === '') return []
236
+ const marker = join(root, LEDGER_UNIT_NAME + '.retired')
237
+ if (await stat(marker).then(() => true, () => false)) return []
238
+ const retired = []
239
+ const candidates = []
240
+ for (let index = 0; index < LEDGER_SHARD_COUNT; index += 1) {
241
+ const name = ledgerShardName(index)
242
+ if (shardUnits[index] === undefined || shardUnits[index] === null) continue
243
+ // The medium tells the truth: a shard has been migrated when its per-record
244
+ // tree holds documents.
245
+ const count = await readdir(join(root, name, 'sessions')).then((entries) => entries.filter((entry) => entry.endsWith('.json')).length, () => 0)
246
+ candidates.push({ name, count })
247
+ }
248
+ for (const candidate of candidates) {
249
+ if (candidate.count === 0) continue
250
+ const file = join(root, candidate.name + '.json')
251
+ const size = await stat(file).then((info) => info.size, () => null)
252
+ if (size === null) continue
253
+ const removed = await unlink(file).then(() => size, (error) => { console.error('[all-usage] usage ledger legacy file could not be retired:', file, error && error.message); return null })
254
+ if (removed !== null) retired.push({ name: candidate.name + '.json', bytes: removed })
255
+ }
256
+ const monolith = join(root, LEDGER_UNIT_NAME + '.json')
257
+ const monolithSize = await stat(monolith).then((info) => info.size, () => null)
258
+ if (monolithSize !== null && candidates.some((candidate) => candidate.count > 0)) {
259
+ const removed = await unlink(monolith).then(() => monolithSize, () => null)
260
+ if (removed !== null) retired.push({ name: LEDGER_UNIT_NAME + '.json', bytes: removed })
261
+ }
262
+ if (retired.length > 0) await writeFile(marker, JSON.stringify({ retiredAt: Date.now(), files: retired.length }), 'utf8').catch(() => {})
263
+ return retired
264
+ }
265
+
266
+ const IDENTITY_REF_FIELD = 'identityRef'
267
+
268
+ // The same route is used for thousands of requests, yet a ledger row used to
269
+ // repeat its identity object (key, provider, requested/actual model, label) on
270
+ // every usage item — 14.5 MB of the current 91 MB of records. Rows are written
271
+ // with one shared identity list and per-item indexes instead, and read back into
272
+ // the same shape as before, so nothing outside this file changes. Rows whose
273
+ // workspace differs from the record's keep their own workspaceId, and a row with
274
+ // no identity at all still normalizes through its modelId.
275
+ function serializeLedgerRecord(record) {
276
+ if (record === null || typeof record !== 'object' || !Array.isArray(record.usage)) return record
277
+ const identities = []
278
+ const identityIndex = new Map()
279
+ const usage = record.usage.map((item) => {
280
+ if (item === null || typeof item !== 'object') return item
281
+ const identity = item.identity
282
+ if (identity === undefined || identity === null) return item
283
+ const identityKey = JSON.stringify(identity)
284
+ let index = identityIndex.get(identityKey)
285
+ if (index === undefined) { index = identities.length; identities.push(identity); identityIndex.set(identityKey, index) }
286
+ // workspaceId stays on every item: it is what tells a mixed-workspace row
287
+ // apart, and a record retargeted to another workspace must not re-home its
288
+ // history.
289
+ const next = { ...item, [IDENTITY_REF_FIELD]: index }
290
+ delete next.identity
291
+ delete next.modelId
292
+ return next
293
+ })
294
+ if (identities.length === 0) return { ...record, usage }
295
+ return { ...record, usageIdentities: identities, usage }
296
+ }
297
+
298
+ function expandLedgerRecord(raw) {
299
+ if (raw === null || typeof raw !== 'object' || !Array.isArray(raw.usage) || !Array.isArray(raw.usageIdentities)) return raw
300
+ const identities = raw.usageIdentities
301
+ const usage = raw.usage.map((item) => {
302
+ if (item === null || typeof item !== 'object' || !Number.isInteger(item[IDENTITY_REF_FIELD])) return item
303
+ const identity = identities[item[IDENTITY_REF_FIELD]]
304
+ const next = { ...item, identity, modelId: identity === undefined || identity === null ? item.modelId : identity.label }
305
+ delete next[IDENTITY_REF_FIELD]
306
+ return next
307
+ })
308
+ const expanded = { ...raw, usage }
309
+ delete expanded.usageIdentities
310
+ return expanded
311
+ }
312
+
313
+ export function createLedger(host) {
314
+ const { state, markStatsChanged } = host
315
+ const { storage } = host.services
316
+ state.ledgerPending = state.ledgerPending || new Map()
317
+ state.ledgerReading = state.ledgerReading || new Map()
318
+ state.ledgerWriting = state.ledgerWriting || new Set()
319
+ state.ledgerWriteWaiters = state.ledgerWriteWaiters || new Map()
320
+ state.ledgerWriteRunning = state.ledgerWriteRunning === true
321
+ state.ledgerWriteScheduled = state.ledgerWriteScheduled === true
322
+ state.ledgerWriteTimer = state.ledgerWriteTimer || null
323
+ state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
324
+ const {
325
+ addTurn,
326
+ coerceIdentity,
327
+ identityFromLegacy,
328
+ identityFromMessage,
329
+ identityFromRoute,
330
+ makeIdentity,
331
+ costForUsage,
332
+ resolveCurrentPricing,
333
+ dateKeys,
334
+ validEventTime,
335
+ upsertUsageSample,
336
+ usageRowOf,
337
+ foldUsageRow,
338
+ adoptUsageRow,
339
+ sessionsForRange,
340
+ } = host.aggregation
341
+ const lastSeqOf = (...args) => host.sessionSync.lastSeqOf(...args)
342
+ function isLegacyTieredCost(cost) {
343
+ const selectedTier = cost && cost.selectedTier
344
+ const hasSelectedTier = selectedTier && selectedTier.type === 'context' && Number.isSafeInteger(selectedTier.size) && selectedTier.size >= 0
345
+ return cost !== null && cost.tiered === true && cost.status === 'priced' && !hasSelectedTier
346
+ }
347
+
348
+ function nextLedgerRevision() {
349
+ const current = Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0
350
+ state.ledgerRevision = Math.max(current + 1, Date.now())
351
+ return state.ledgerRevision
352
+ }
353
+ function ledgerEventKey(event, index) {
354
+ // Only finite safe-integer sequences are canonical event ids; anything else
355
+ // falls back to the positional index so two invalid events cannot overwrite
356
+ // each other in the ledger.
357
+ return Number.isSafeInteger(event.seq) && event.seq >= 0 ? String(event.seq) : 'event:' + index
358
+ }
359
+ function canFoldLedgerTail(previousRecord, events, workspaceId) {
360
+ if (previousRecord === null || previousRecord === undefined || previousRecord.needsUpgrade === true || previousRecord.rebuildRequired !== undefined || !Number.isSafeInteger(previousRecord.lastSeq) || previousRecord.lastSeq < 0 || !Array.isArray(events) || events.length === 0) return false
361
+ // A changed workspace identity (deleted and re-added workspace gets a new id)
362
+ // assigns every historical event to a different bucket, so the previous
363
+ // record can never be reused as a tail seed for the new workspace.
364
+ if (workspaceId !== undefined && previousRecord.workspaceId !== undefined && previousRecord.workspaceId !== workspaceId) return false
365
+ const startIndex = previousRecord.lastSeq + 1
366
+ if (startIndex >= events.length || events[startIndex] === null || typeof events[startIndex] !== 'object' || events[startIndex].seq !== startIndex) return false
367
+ const previousTurns = new Set(Array.isArray(previousRecord.turns) ? previousRecord.turns.map((turn) => turn && turn.turn).filter((turn) => Number.isSafeInteger(turn)) : [])
368
+ for (let index = startIndex, expectedSeq = startIndex; index < events.length; index += 1, expectedSeq += 1) {
369
+ const event = events[index]
370
+ if (event === null || typeof event !== 'object' || event.seq !== expectedSeq) return false
371
+ if (event.type === 'turn/end' && event.data && Number.isSafeInteger(event.data.turn) && previousTurns.has(event.data.turn)) return false
372
+ }
373
+ return true
374
+ }
375
+ function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord, sequenceClean = true) {
376
+ const sid = session && typeof session.id === 'string' ? session.id : ''
377
+ const events = sessionEvents(session)
378
+ if (sid === '' || workspaceId === undefined) return null
379
+ // DSH sequences every persisted event with seq === log index, so the last
380
+ // event's seq is the record's lastSeq in O(1); fall back to a full scan only
381
+ // when the tail carries no seq (legacy or truncated logs).
382
+ const tailEvent = events.length > 0 ? events[events.length - 1] : null
383
+ const lastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(events)
384
+ // A changed incremental log that contains invalid sequences anywhere must
385
+ // be rebuilt wholesale: the fold check only validates the tail, so a bad
386
+ // event before the fold start would otherwise be skipped in memory while
387
+ // the persisted record still contains everything.
388
+ const canFoldTail = sequenceClean === true && canFoldLedgerTail(previousRecord, events, workspaceId)
389
+ const turns = canFoldTail ? new Map((previousRecord.turns || []).map((item) => [item.key, { ...item }])) : new Map()
390
+ const usage = canFoldTail ? new Map((previousRecord.usage || []).map((item) => [item.key, { ...item }])) : new Map()
391
+ const previousUsage = new Map(Array.isArray(previousRecord && previousRecord.usage) ? previousRecord.usage.map((item) => [item.key, item]) : [])
392
+ let currentIdentity = canFoldTail ? coerceIdentity(previousRecord.lastIdentity) : makeIdentity(null, null, null, null)
393
+ // Request-context instants are kept per turn/step so parallel requests each
394
+ // carry their own billing time while scanning the persisted log. When the
395
+ // tail is folded onto a persisted record, the previous context archive seeds
396
+ // the map so an open request whose context arrived in the previous batch
397
+ // still bills its usage with the matching request instant after a restart.
398
+ const contextTimes = canFoldTail ? new Map(Array.isArray(previousRecord.contextTimes) ? previousRecord.contextTimes.map((entry) => [entry.key, entry.time]) : []) : new Map()
399
+ const startIndex = canFoldTail ? previousRecord.lastSeq + 1 : 0
400
+ for (let index = startIndex; index < events.length; index += 1) {
401
+ const event = events[index]
402
+ if (event === null || typeof event !== 'object') continue
403
+ if (canFoldTail && event.seq <= previousRecord.lastSeq) continue
404
+ const data = event.data
405
+ if (event.type === 'request/context') {
406
+ currentIdentity = identityFromRoute(data, currentIdentity)
407
+ if (validEventTime(event.time)) touchContextTimes(contextTimes, contextTimeKey(data && data.turn, data && data.step), event.time)
408
+ continue
409
+ }
410
+ if (event.type === 'request/header') {
411
+ currentIdentity = identityFromRoute(data, currentIdentity)
412
+ continue
413
+ }
414
+ if (event.type === 'turn/end') {
415
+ if (!validEventTime(event.time)) continue
416
+ const key = ledgerEventKey(event, index)
417
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
418
+ turns.set(key, { key, seq: validLedgerSeq(event.seq) ? event.seq : -1, time: event.time, workspaceId, turn, identity: currentIdentity })
419
+ continue
420
+ }
421
+ if (!validEventTime(event.time)) continue
422
+ const usageEvent = extractUsageEvent(event)
423
+ if (usageEvent === null) continue
424
+ const identity = usageEvent.kind === 'message' ? identityFromMessage(data, currentIdentity) : currentIdentity
425
+ const key = usageStepKey(sid, data, event.seq)
426
+ const pricing = pickPricingTime(contextTimes, event.time, usageEvent.turn, usageEvent.step)
427
+ const result = upsertUsageSample(usage, {
428
+ key,
429
+ seq: validLedgerSeq(event.seq) ? event.seq : -1,
430
+ time: event.time,
431
+ workspaceId,
432
+ identity,
433
+ modelId: identity.label,
434
+ turn: usageEvent.turn,
435
+ step: usageEvent.step,
436
+ values: usageEvent.values,
437
+ pricingAt: pricing.time,
438
+ pricingTimeSource: pricing.source,
439
+ }, { costPrevious: previousUsage.get(key) })
440
+ if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
441
+ }
442
+ const sourceCwd = session && session.header && typeof session.header.cwd === 'string' ? session.header.cwd : undefined
443
+ return { version: LEDGER_VERSION, sessionId: sid, workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq, source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, contextTimes: Array.from(contextTimes.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
444
+ }
445
+ function normalizeLedgerRecord(input, key) {
446
+ // Rows written with a shared identity list are expanded first: every check
447
+ // below then sees exactly the same item shape it always did.
448
+ const raw = expandLedgerRecord(input)
449
+ if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
450
+ if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
451
+ // One pass per list: validity, workspace mixing and the map share it, and a
452
+ // usage row normalizes its cost snapshot once instead of once per question
453
+ // (that call parses eleven decimal fields).
454
+ let invalidTurn = false
455
+ let invalidUsage = false
456
+ let mixedWorkspace = false
457
+ const turnMap = new Map()
458
+ for (const turn of raw.turns) {
459
+ if (turn === null || typeof turn !== 'object' || typeof turn.key !== 'string' || turn.workspaceId === undefined || !validEventTime(turn.time) || pollutedLedgerKey(turn.key) || (turn.seq !== undefined && !validLedgerSeq(turn.seq))) invalidTurn = true
460
+ if (turn && turn.workspaceId !== raw.workspaceId) mixedWorkspace = true
461
+ if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && validEventTime(turn.time)) turnMap.set(turn.key, { key: turn.key, seq: Number.isSafeInteger(turn.seq) ? turn.seq : -1, time: turn.time, workspaceId: turn.workspaceId, turn: typeof turn.turn === 'number' ? turn.turn : null, identity: coerceIdentity(turn.identity || turn.modelId) })
462
+ }
463
+ const usageMap = new Map()
464
+ for (const item of raw.usage) {
465
+ const normalizedCost = normalizeCostSnapshot(item === null || item === undefined ? null : item.cost)
466
+ if (item === null || typeof item !== 'object' || !validEventTime(item.time) || item.identity === undefined || pollutedLedgerKey(item.key) || (item.seq !== undefined && !validLedgerSeq(item.seq)) || normalizedCost === null || normalizedCost.pricingMode !== 'official-model' || isLegacyTieredCost(normalizedCost)) invalidUsage = true
467
+ // Older versions could write a record whose top-level workspaceId belonged
468
+ // to a recreated workspace while its historical items kept the previous one;
469
+ // such mixed records must never be reused as a fast path.
470
+ if (item && item.workspaceId !== raw.workspaceId) mixedWorkspace = true
471
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || !validEventTime(item.time) || item.values === null || typeof item.values !== 'object') continue
472
+ const identity = coerceIdentity(item.identity || item.modelId)
473
+ const normalized = { key: item.key, seq: Number.isSafeInteger(item.seq) ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, identity, modelId: identity.label, ...(normalizedCost === null ? {} : { cost: normalizedCost }), turn: typeof item.turn === 'number' ? item.turn : null, step: typeof item.step === 'number' ? item.step : null, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
474
+ upsertUsageSampleState(usageMap, normalized)
475
+ }
476
+ // The rebuild reason is persisted (enumerable) so the flag survives a
477
+ // failure-and-restart cycle even when normalized content alone no longer
478
+ // carries the evidence (e.g. lastSeq -1 after an invalid non-safe value).
479
+ const persistedReason = typeof raw.rebuildRequired === 'string' && raw.rebuildRequired !== '' ? raw.rebuildRequired : undefined
480
+ const computedReason = invalidTurn ? 'invalid-turn' : invalidUsage ? 'invalid-usage' : mixedWorkspace ? 'mixed-workspace' : (raw.lastSeq !== undefined && !validLedgerSeq(raw.lastSeq)) ? 'invalid-last-seq' : raw.version !== LEDGER_VERSION ? 'legacy-version' : !Number.isFinite(raw.updatedAt) ? 'invalid-updated-at' : undefined
481
+ const rebuildReason = persistedReason !== undefined ? persistedReason : computedReason
482
+ const needsUpgrade = rebuildReason !== undefined || !Number.isFinite(raw.updatedAt) || raw.version !== LEDGER_VERSION
483
+ const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0
484
+ state.ledgerRevision = Math.max(Number.isFinite(state.ledgerRevision) ? state.ledgerRevision : 0, updatedAt)
485
+ const contextMap = new Map()
486
+ if (Array.isArray(raw.contextTimes)) {
487
+ for (const entry of raw.contextTimes) {
488
+ if (entry === null || typeof entry !== 'object' || typeof entry.key !== 'string' || !entry.key.startsWith('context:') || entry.key.length > 128 || !validEventTime(entry.time)) continue
489
+ touchContextTimes(contextMap, entry.key, entry.time)
490
+ }
491
+ }
492
+ const lastUsage = Array.from(usageMap.values()).at(-1)
493
+ const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
494
+ const sourceCwd = typeof raw.sourceCwd === 'string' && raw.sourceCwd.trim() !== '' ? raw.sourceCwd : undefined
495
+ const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq: validLedgerSeq(raw.lastSeq) ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, contextTimes: Array.from(contextMap.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
496
+ if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
497
+ Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
498
+ return normalizedRecord
499
+ }
500
+ function prepareLedgerRecord(record) {
501
+ if (record === null || record === undefined) return record
502
+ // A current record was normalized on the way in, so every item already carries
503
+ // a canonical cost snapshot: re-parsing each one (eleven decimal fields apiece)
504
+ // would be a pass over the whole record that changes nothing.
505
+ if (record.needsUpgrade !== true && record.version === LEDGER_VERSION) return record
506
+ let changed = true
507
+ for (const item of record.usage) {
508
+ const cost = normalizeCostSnapshot(item.cost)
509
+ if (isLegacyTieredCost(cost)) {
510
+ item.cost = calculateCost(item.values, { ...cost, status: 'unsupported', reason: 'tiered-pricing-not-modeled' })
511
+ changed = true
512
+ continue
513
+ }
514
+ if (cost !== null && cost.pricingMode === 'official-model') { item.cost = cost; continue }
515
+ const identity = item.identity || identityFromLegacy(item.modelId)
516
+ const billing = billingInstantOf(item, cost)
517
+ item.cost = calculateCost(item.values, resolveCurrentPricing(identity), billing.at, billing.source)
518
+ changed = true
519
+ }
520
+ if (changed) {
521
+ // Repacking costs must never clear the rebuild flag: only a complete
522
+ // rebuild from the source log produces a record that is safe to reuse.
523
+ record.version = LEDGER_VERSION
524
+ record.updatedAt = nextLedgerRevision()
525
+ cacheLedgerRecord(record)
526
+ rememberLedgerKey(record.sessionId)
527
+ void persistLedgerRecord(record)
528
+ }
529
+ return record
530
+ }
531
+ function applyLedgerRecord(record, materialization = 'ledger-reuse') {
532
+ if (record === null || record === undefined) return
533
+ prepareLedgerRecord(record)
534
+ if (record.lastIdentity !== undefined) state.sessionModel.set(record.sessionId, record.lastIdentity)
535
+ if (Array.isArray(record.contextTimes)) {
536
+ const times = new Map()
537
+ for (const entry of record.contextTimes) if (entry && typeof entry.key === 'string' && Number.isFinite(entry.time)) times.set(entry.key, entry.time)
538
+ state.sessionContextTimes.set(record.sessionId, times)
539
+ }
540
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId, turn.turn, turn.identity, materialization, turn.seq)
541
+ for (const item of record.usage) {
542
+ foldUsageRow(usageRowOf(record, item, materialization), materialization)
543
+ }
544
+ if (record.turns.length > 0 || record.usage.length > 0) {
545
+ state.sessionCount.add(record.sessionId)
546
+ if (record.usage.length > 0) markStatsChanged('scan')
547
+ }
548
+ }
549
+ function ledgerRank(record) {
550
+ return [Number.isFinite(record.lastSeq) ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, Number.isFinite(record.updatedAt) ? record.updatedAt : 0]
551
+ }
552
+ function replaceLedgerRecord(record) {
553
+ assertLedgerRecord(record)
554
+ cacheLedgerRecord(record)
555
+ rememberLedgerKey(record.sessionId)
556
+ return record
557
+ }
558
+ function assertLedgerRecord(record) {
559
+ if (record === null || typeof record !== 'object' || !Number.isFinite(record.updatedAt)) throw new TypeError('usage ledger updatedAt must be a finite number')
560
+ return record
561
+ }
562
+ function storeLedgerRecord(record) {
563
+ assertLedgerRecord(record)
564
+ const current = state.ledgerRecords.get(record.sessionId)
565
+ if (current !== undefined) {
566
+ const nextRank = ledgerRank(record)
567
+ const currentRank = ledgerRank(current)
568
+ if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
569
+ }
570
+ cacheLedgerRecord(record)
571
+ rememberLedgerKey(record.sessionId)
572
+ return record
573
+ }
574
+ function resolveLedgerWaiters(sid) {
575
+ const waiters = state.ledgerWriteWaiters.get(sid)
576
+ if (waiters === undefined) return
577
+ state.ledgerWriteWaiters.delete(sid)
578
+ for (const resolve of waiters) resolve()
579
+ }
580
+ async function drainPendingLedgerWrites() {
581
+ while (state.ledgerPending.size > 0) {
582
+ const entry = state.ledgerPending.entries().next().value
583
+ if (entry === undefined) break
584
+ const [sid, record] = entry
585
+ state.ledgerPending.delete(sid)
586
+ const resident = state.ledgerRecords.get(sid)
587
+ if (state.ledgerUnit === null || (resident !== record && resident?.__durableRecord !== record)) {
588
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
589
+ continue
590
+ }
591
+ // Pending ends before I/O begins. Keep this session resident until the
592
+ // write settles, so a cold read cannot restore the older durable record.
593
+ state.ledgerWriting.add(sid)
594
+ try {
595
+ // The medium receives the compact row; everything in memory keeps the record.
596
+ await state.ledgerUnit.putRecord('sessions', sid, serializeLedgerRecord(record))
597
+ state.ledgerWriteFailedSessions.delete(sid)
598
+ } catch (err) {
599
+ state.ledgerWriteFailedSessions = state.ledgerWriteFailedSessions || new Set()
600
+ state.ledgerWriteFailedSessions.add(sid)
601
+ state.ledgerDirtySessions = state.ledgerDirtySessions || new Set()
602
+ state.ledgerDirtyEpochs = state.ledgerDirtyEpochs || new Map()
603
+ state.ledgerDirtySessions.add(sid)
604
+ state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
605
+ console.error('[all-usage] usage ledger write failed:', err)
606
+ } finally {
607
+ state.ledgerWriting.delete(sid)
608
+ }
609
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
610
+ }
611
+ }
612
+ function startLedgerWrite() {
613
+ if (state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
614
+ state.ledgerWriteRunning = true
615
+ const write = state.ledgerWriteChain.then(() => drainPendingLedgerWrites(), () => drainPendingLedgerWrites())
616
+ state.ledgerWriteChain = write.catch((err) => {
617
+ console.error('[all-usage] usage ledger queue failed:', err)
618
+ }).finally(() => {
619
+ state.ledgerWriteRunning = false
620
+ if (state.ledgerPending.size > 0 && state.ledgerUnit !== null) scheduleLedgerWrite()
621
+ })
622
+ }
623
+ function scheduleLedgerWrite() {
624
+ if (state.ledgerWriteScheduled || state.ledgerWriteRunning || state.ledgerPending.size === 0 || state.ledgerUnit === null) return
625
+ state.ledgerWriteScheduled = true
626
+ const run = () => {
627
+ state.ledgerWriteScheduled = false
628
+ state.ledgerWriteTimer = null
629
+ startLedgerWrite()
630
+ }
631
+ state.ledgerWriteTimer = setTimeout(run, LEDGER_WRITE_DEBOUNCE_MS)
632
+ // Keep a reference while write waiters are pending: the debounce timer is
633
+ // what eventually resolves persistLedgerRecord promises, and an unreferenced
634
+ // timer lets the event loop (and test runs) settle before the write happens.
635
+ if (state.ledgerWriteTimer && typeof state.ledgerWriteTimer.unref === 'function' && state.ledgerWriteWaiters.size === 0) state.ledgerWriteTimer.unref()
636
+ }
637
+ function persistLedgerRecord(record) {
638
+ if (record !== null && record !== undefined) assertLedgerRecord(record)
639
+ if (record === null || record === undefined || state.disposed || state.ledgerUnit === null) return Promise.resolve()
640
+ const resident = state.ledgerRecords.get(record.sessionId)
641
+ if (resident !== record && resident?.__durableRecord !== record) return Promise.resolve()
642
+ const waiter = new Promise((resolve) => {
643
+ const waiters = state.ledgerWriteWaiters.get(record.sessionId) || []
644
+ waiters.push(resolve)
645
+ state.ledgerWriteWaiters.set(record.sessionId, waiters)
646
+ })
647
+ state.ledgerPending.set(record.sessionId, record)
648
+ scheduleLedgerWrite()
649
+ return waiter
650
+ }
651
+ async function drainLedgerWrites() {
652
+ if (state.ledgerWriteTimer !== null) {
653
+ clearTimeout(state.ledgerWriteTimer)
654
+ state.ledgerWriteTimer = null
655
+ }
656
+ state.ledgerWriteScheduled = false
657
+ if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) startLedgerWrite()
658
+ await state.ledgerWriteChain
659
+ if (state.ledgerPending.size > 0 && !state.ledgerWriteRunning && state.ledgerUnit !== null) {
660
+ startLedgerWrite()
661
+ await state.ledgerWriteChain
662
+ }
663
+ for (const sid of Array.from(state.ledgerWriteWaiters.keys())) {
664
+ if (!state.ledgerPending.has(sid)) resolveLedgerWaiters(sid)
665
+ }
666
+ }
667
+ function recordCacheLimit() {
668
+ const megabytes = Number(process.env.DSH_ALL_USAGE_RECORD_CACHE_MB)
669
+ return Number.isFinite(megabytes) && megabytes >= 0 ? megabytes * 1024 * 1024 : 16 * 1024 * 1024
670
+ }
671
+ // Rough but allocation-free: the usage rows dominate a record's footprint, and
672
+ // the cap only has to be in the right ballpark.
673
+ function recordSizeOf(record) {
674
+ const usage = Array.isArray(record.usage) ? record.usage.length : 0
675
+ const turns = Array.isArray(record.turns) ? record.turns.length : 0
676
+ return 1024 + usage * 1024 + turns * 256
677
+ }
678
+ function rememberLedgerKey(sid) {
679
+ if (typeof sid === 'string' && sid !== '' && state.ledgerKeys instanceof Set) state.ledgerKeys.add(sid)
680
+ }
681
+ /**
682
+ * Put a record into the resident set, evicting the oldest clean ones when the
683
+ * cache is over budget. A dirty session (unwritten changes, a pending write or a
684
+ * live resync) is never evicted: its in-memory copy is the pending write.
685
+ */
686
+ function cacheLedgerRecord(record) {
687
+ if (record === null || record === undefined || typeof record.sessionId !== 'string') return
688
+ const sid = record.sessionId
689
+ if (state.ledgerRecords.has(sid)) {
690
+ state.ledgerRecordBytes = Math.max(0, (state.ledgerRecordBytes || 0) - (state.ledgerRecordSizes.get(sid) || 0))
691
+ state.ledgerRecords.delete(sid)
692
+ }
693
+ const size = recordSizeOf(record)
694
+ state.ledgerRecords.set(sid, record)
695
+ if (!(state.ledgerRecordSizes instanceof Map)) state.ledgerRecordSizes = new Map()
696
+ state.ledgerRecordSizes.set(sid, size)
697
+ state.ledgerRecordBytes = (state.ledgerRecordBytes || 0) + size
698
+ // A store that cannot read one record back has to keep them all.
699
+ if (state.ledgerRecordsComplete === true) return
700
+ const limit = recordCacheLimit()
701
+ while ((state.ledgerRecordBytes || 0) > limit && state.ledgerRecords.size > 1) {
702
+ const oldest = state.ledgerRecords.keys().next()
703
+ if (oldest.done) break
704
+ const candidate = oldest.value
705
+ if (candidate === sid) break
706
+ const protectedDirty = state.ledgerDirtySessions instanceof Set && state.ledgerDirtySessions.has(candidate)
707
+ const protectedPending = state.ledgerPending instanceof Map && state.ledgerPending.has(candidate)
708
+ const protectedWrite = state.ledgerWriting.has(candidate)
709
+ const protectedResync = state.liveResyncPending instanceof Set && state.liveResyncPending.has(candidate)
710
+ const protectedPrefetch = state.ledgerPrefetching instanceof Set && state.ledgerPrefetching.has(candidate)
711
+ const protectedRead = (state.ledgerReading.get(candidate) || 0) > 0
712
+ if (protectedDirty || protectedPending || protectedWrite || protectedResync || protectedPrefetch || protectedRead) {
713
+ // Rotate the protected entry to the back and stop: the resident set is
714
+ // dominated by sessions that are still being written.
715
+ const keep = state.ledgerRecords.get(candidate)
716
+ state.ledgerRecords.delete(candidate)
717
+ state.ledgerRecords.set(candidate, keep)
718
+ break
719
+ }
720
+ state.ledgerRecords.delete(candidate)
721
+ state.ledgerRecordBytes = Math.max(0, (state.ledgerRecordBytes || 0) - (state.ledgerRecordSizes.get(candidate) || 0))
722
+ state.ledgerRecordSizes.delete(candidate)
723
+ }
724
+ }
725
+ /**
726
+ * Read a window of session documents, a few files at a time. One file per read is
727
+ * what keeps memory flat, but doing that strictly one-at-a-time serialises the
728
+ * I/O behind the folding; a small parallel window restores the overlap while the
729
+ * resident set stays bounded (a record is dropped again once it is folded).
730
+ */
731
+ async function loadRecords(sids, concurrency = 16) {
732
+ if (state.ledgerUnit === null || typeof state.ledgerUnit.readRecord !== 'function') return
733
+ if (!(state.ledgerKeys instanceof Set)) return
734
+ const queue = []
735
+ for (const sid of sids) {
736
+ if (typeof sid !== 'string' || sid === '') continue
737
+ if (state.ledgerRecords.has(sid) || !state.ledgerKeys.has(sid)) continue
738
+ queue.push(sid)
739
+ }
740
+ if (!(state.ledgerPrefetching instanceof Set)) state.ledgerPrefetching = new Set()
741
+ const width = Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 8
742
+ for (let index = 0; index < queue.length; index += width) {
743
+ const batch = queue.slice(index, index + width)
744
+ await Promise.all(batch.map((sid) => {
745
+ state.ledgerPrefetching.add(sid)
746
+ return recordFor(sid).catch(() => undefined).finally(() => state.ledgerPrefetching.delete(sid))
747
+ }))
748
+ }
749
+ }
750
+ /**
751
+ * Read back the detail rows of every session that has usage in a date range, so a
752
+ * query over old history sees them without the rows having to stay resident. The
753
+ * rows are adopted, not folded: the cubes already hold their contribution.
754
+ */
755
+ /** A row read back from a record that no longer belongs to a live workspace is
756
+ * attributed to the shared retired bucket, exactly like the scan does. */
757
+ function adoptableRow(row) {
758
+ const published = typeof row.wsId === 'string' && row.wsId !== '' && state.wsMeta instanceof Map && state.wsMeta.has(row.wsId)
759
+ const digest = state.perSession?.get(row.sid)
760
+ const keyMeta = digest && digest.keys instanceof Map ? digest.keys.get(row.key) : undefined
761
+ const keyWorkspace = keyMeta && typeof keyMeta.wsId === 'string' && keyMeta.wsId !== '' ? keyMeta.wsId : undefined
762
+ if (keyWorkspace !== undefined) {
763
+ const keyWorkspacePublished = keyWorkspace === RETIRED_WORKSPACE_ID || (state.wsMeta instanceof Map && state.wsMeta.has(keyWorkspace))
764
+ if (keyWorkspace !== row.wsId && keyWorkspacePublished) return { ...row, wsId: keyWorkspace, ...(keyWorkspace === RETIRED_WORKSPACE_ID ? { retiredAtRead: true } : {}) }
765
+ if (keyWorkspace === row.wsId && keyWorkspacePublished) return row
766
+ }
767
+ // Legacy digests do not carry workspace ownership per key. Preserve the prior
768
+ // all-session fallback only for those; mixed digests are ambiguous by design.
769
+ const moved = keyWorkspace === undefined && published && state.retiringWorkspaceIds?.has(row.wsId) && digest !== undefined && digest.buckets.size > 0 && Array.from(digest.buckets.values()).every(bucket => bucket.wsId === RETIRED_WORKSPACE_ID)
770
+ return (published && !moved) || row.wsId === RETIRED_WORKSPACE_ID ? row : { ...row, wsId: RETIRED_WORKSPACE_ID, retiredAtRead: true }
771
+ }
772
+ /** Read back every detail row of one session (used before a retarget walks them). */
773
+ async function loadRowsForSession(sid) {
774
+ const record = await recordFor(sid)
775
+ if (record === undefined || record === null || !Array.isArray(record.usage)) return 0
776
+ let adopted = 0
777
+ for (const item of record.usage) if (adoptUsageRow(adoptableRow(usageRowOf(record, item, 'ledger-reuse')))) adopted += 1
778
+ return adopted
779
+ }
780
+ // An authoritative usage array owns this lookup weakly. Values contain only
781
+ // bounded locators and numeric positions, never a row/array/cost reference.
782
+ // Replacement and LRU eviction can therefore release the old payloads.
783
+ const usagePageIndices = new WeakMap()
784
+ function storedRowsForPage(record, selected) {
785
+ const requests = [
786
+ ...Array.from(selected.keys, ([key, positions]) => ({ key, positions, lookup: recordKeyLocator(record.sessionId, key) })),
787
+ ...Array.from(selected.hashes, ([keyHash, positions]) => ({ keyHash, positions, lookup: 'hash:' + keyHash })),
788
+ ]
789
+ const build = () => {
790
+ const index = { length: record.usage.length, positions: new Map() }
791
+ for (let position = 0; position < record.usage.length; position += 1) {
792
+ const stored = record.usage[position]
793
+ if (typeof stored?.key === 'string') index.positions.set(recordKeyLocator(record.sessionId, stored.key), position)
794
+ }
795
+ usagePageIndices.set(record.usage, index)
796
+ return index
797
+ }
798
+ let index = usagePageIndices.get(record.usage)
799
+ const fresh = index === undefined || index.length !== record.usage.length
800
+ if (fresh) index = build()
801
+ const matches = (stored, request) => stored !== undefined && (request.key === undefined ? recordKeyHash(stored.key) === request.keyHash : stored.key === request.key)
802
+ const resolve = () => requests.map(request => ({ request, stored: record.usage[index.positions.get(request.lookup)] }))
803
+ let rows = resolve()
804
+ // Same-length in-place rewrites/reorders can invalidate numeric positions.
805
+ // Verify selected keys and rebuild once before declaring a key missing.
806
+ if (!fresh && rows.some(({ stored, request }) => !matches(stored, request))) { index = build(); rows = resolve() }
807
+ return rows.filter(({ stored, request }) => matches(stored, request)).map(({ stored, request }) => [stored, request.positions])
808
+ }
809
+
810
+ // A compact result keeps only keys. Borrow just the sessions needed by this
811
+ // page, without adopting their unrelated rows back into the resident set.
812
+ async function readUsageRows(references, utc = false) {
813
+ const rows = new Array(references.length).fill(undefined)
814
+ const sessions = new Map()
815
+ const assign = (positions, item) => {
816
+ for (const index of positions) rows[index] = { item, date: utc ? item.dateUtc : item.date }
817
+ }
818
+ for (let index = 0; index < references.length; index += 1) {
819
+ const { sid, key, keyHash } = references[index]
820
+ const item = key === undefined ? undefined : state.usageByStep.get(key)
821
+ if (item !== undefined && item.sid === sid) { assign([index], item); continue }
822
+ let selected = sessions.get(sid)
823
+ if (selected === undefined) { selected = { keys: new Map(), hashes: new Map() }; sessions.set(sid, selected) }
824
+ const wanted = key === undefined ? selected.hashes : selected.keys
825
+ const locator = key === undefined ? keyHash : key
826
+ let positions = wanted.get(locator)
827
+ if (positions === undefined) { positions = []; wanted.set(locator, positions) }
828
+ positions.push(index)
829
+ }
830
+ // Hash lookups are only needed for noncanonical legacy keys. Ordinary pages
831
+ // use direct resident lookups and read each missing session at most once.
832
+ if (Array.from(sessions.values()).some(selected => selected.hashes.size > 0)) {
833
+ for (const item of state.usageByStep.values()) {
834
+ const selected = sessions.get(item.sid)
835
+ if (selected === undefined || selected.hashes.size === 0) continue
836
+ const hash = recordKeyHash(item.key)
837
+ const positions = selected.hashes.get(hash)
838
+ if (positions !== undefined) { assign(positions, item); selected.hashes.delete(hash) }
839
+ }
840
+ }
841
+ const queue = Array.from(sessions).filter(([, selected]) => selected.keys.size > 0 || selected.hashes.size > 0)
842
+ for (let offset = 0; offset < queue.length; offset += 8) {
843
+ const results = await Promise.allSettled(queue.slice(offset, offset + 8).map(async ([sid, selected]) => {
844
+ const releaseRecord = pinRecord(sid)
845
+ try {
846
+ const record = await recordFor(sid)
847
+ if (record === undefined || record === null || !Array.isArray(record.usage)) return
848
+ for (const [stored, positions] of storedRowsForPage(record, selected)) {
849
+ const resident = state.usageByStep.get(stored.key)
850
+ const item = resident !== undefined && resident.sid === sid ? resident : adoptableRow(usageRowOf(record, stored, 'ledger-reuse'))
851
+ assign(positions, item)
852
+ }
853
+ } finally { releaseRecord() }
854
+ }))
855
+ const failed = results.find(result => result.status === 'rejected')
856
+ if (failed !== undefined) throw failed.reason
857
+ }
858
+ return rows
859
+ }
860
+
861
+
862
+ async function loadRowsForRange(utc, start, end) {
863
+ if (typeof sessionsForRange !== 'function' || typeof adoptUsageRow !== 'function') return 0
864
+ const sessions = sessionsForRange(utc === true, start, end)
865
+ if (sessions.size === 0) return 0
866
+ let adopted = 0
867
+ // A day can own rows in dozens of sessions; reading their documents one at a
868
+ // time made a first open of an old day take hundreds of milliseconds, so the
869
+ // reads go in small parallel windows (the row is data, the order does not matter).
870
+ const queue = Array.from(sessions)
871
+ for (let index = 0; index < queue.length; index += 8) {
872
+ const batch = queue.slice(index, index + 8)
873
+ await Promise.all(batch.map(async (sid) => {
874
+ const record = await recordFor(sid)
875
+ if (record === undefined || record === null || !Array.isArray(record.usage)) return
876
+ for (const item of record.usage) {
877
+ const row = adoptableRow(usageRowOf(record, item, 'ledger-reuse'))
878
+ const date = utc === true ? row.dateUtc : row.date
879
+ if (typeof start === 'string' && start !== '' && date < start) continue
880
+ if (typeof end === 'string' && end !== '' && date > end) continue
881
+ if (adoptUsageRow(row)) adopted += 1
882
+ }
883
+ }))
884
+ }
885
+ return adopted
886
+ }
887
+ /** The record of one session: resident, else read from its own document. */
888
+ // A caller that borrows a record across awaits must keep the authoritative
889
+ // resident copy alive until its work (including any write-back) has settled.
890
+ function pinRecord(sid) {
891
+ state.ledgerReading.set(sid, (state.ledgerReading.get(sid) || 0) + 1)
892
+ let released = false
893
+ return () => {
894
+ if (released) return
895
+ released = true
896
+ const remaining = (state.ledgerReading.get(sid) || 1) - 1
897
+ if (remaining > 0) state.ledgerReading.set(sid, remaining)
898
+ else state.ledgerReading.delete(sid)
899
+ const current = state.ledgerRecords.get(sid)
900
+ if (!state.disposed && current !== undefined) cacheLedgerRecord(current)
901
+ }
902
+ }
903
+ async function recordFor(sid) {
904
+ if (typeof sid !== 'string' || sid === '') return undefined
905
+ const resident = state.ledgerRecords.get(sid)
906
+ if (resident !== undefined) {
907
+ cacheLedgerRecord(resident)
908
+ return resident
909
+ }
910
+ if (state.ledgerKeys instanceof Set && !state.ledgerKeys.has(sid)) return undefined
911
+ if (state.ledgerUnit === null || typeof state.ledgerUnit.readRecord !== 'function') return undefined
912
+ // Keep a concurrently installed authoritative record resident until every
913
+ // older disk read of this session has settled. Otherwise LRU pressure can
914
+ // remove the evidence that the old read must not restore deleted history.
915
+ const releaseRecord = pinRecord(sid)
916
+ try {
917
+ const raw = await state.ledgerUnit.readRecord(sid)
918
+ const current = state.ledgerRecords.get(sid)
919
+ if (current !== undefined) return current
920
+ if (raw === undefined) return undefined
921
+ const record = normalizeLedgerRecord(raw, sid)
922
+ if (record === null) {
923
+ console.warn('[all-usage] ignoring malformed usage ledger row:', sid)
924
+ return undefined
925
+ }
926
+ if (!state.disposed) cacheLedgerRecord(record)
927
+ return record
928
+ } catch (err) {
929
+ const current = state.ledgerRecords.get(sid)
930
+ if (current !== undefined) return current
931
+ throw err
932
+ } finally {
933
+ releaseRecord()
934
+ }
935
+ }
936
+ async function loadLedger() {
937
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
938
+ try {
939
+ const backend = storage.backend.get('json')
940
+ if (backend === undefined || backend === null || backend.kv === undefined) return
941
+ const opened = await openLedgerUnitStore(backend)
942
+ if (state.disposed) { await opened.store.close(); return }
943
+ state.ledgerUnit = opened.store
944
+ if (!(state.ledgerKeys instanceof Set)) state.ledgerKeys = new Set()
945
+ if (!(state.ledgerRecordSizes instanceof Map)) state.ledgerRecordSizes = new Map()
946
+ if (!Number.isFinite(state.ledgerRecordBytes)) state.ledgerRecordBytes = 0
947
+ // The per-record tree is its own index: enumerate the keys and read a record
948
+ // only when something asks for it, so a cold start no longer materialises
949
+ // every session document at once (123 MB on a 66k-row ledger).
950
+ if (opened.streaming !== true) {
951
+ // A unit that cannot read a single record has already handed us the whole
952
+ // table; keep the old behaviour there (tests, exotic backends).
953
+ state.ledgerRecordsComplete = true
954
+ for (const [key, raw] of Object.entries(opened.rows)) {
955
+ if (key.startsWith('__')) continue
956
+ const record = normalizeLedgerRecord(raw, key)
957
+ if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
958
+ else cacheLedgerRecord(record)
959
+ state.ledgerKeys.add(key)
960
+ }
961
+ }
962
+ if (typeof opened.store.listKeys === 'function') {
963
+ for (const key of await opened.store.listKeys()) state.ledgerKeys.add(key)
964
+ }
965
+ if (state.ledgerUnit !== null && state.ledgerUnit !== undefined && typeof state.ledgerUnit.readRecord !== 'function') state.ledgerRecordsComplete = true
966
+ } catch (err) {
967
+ console.error('[all-usage] usage ledger unavailable:', err)
968
+ }
969
+ }
970
+
971
+ return {
972
+ nextLedgerRevision,
973
+ ledgerEventKey,
974
+ sessionEvents,
975
+ retargetLedgerRecord,
976
+ buildLedgerRecord,
977
+ normalizeLedgerRecord,
978
+ prepareLedgerRecord,
979
+ applyLedgerRecord,
980
+ ledgerRank,
981
+ replaceLedgerRecord,
982
+ storeLedgerRecord,
983
+ persistLedgerRecord,
984
+ drainLedgerWrites,
985
+ // Exposed for the round-trip test: the on-disk shape is private otherwise.
986
+ serializeLedgerRecordForTest: serializeLedgerRecord,
987
+ expandLedgerRecordForTest: expandLedgerRecord,
988
+ assertLedgerRecord,
989
+ loadLedger,
990
+ recordFor,
991
+ pinRecord,
992
+ loadRecords,
993
+ adoptableRow,
994
+ loadRowsForRange,
995
+ readUsageRows,
996
+ loadRowsForSession
997
+ }
998
+ }
999
+
1000
+ export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, RETIRED_WORKSPACE_ID, ledgerShardIndex, retargetLedgerRecord, serializeLedgerRecord }