dsh-all-usage 1.1.17 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +453 -386
  2. package/LICENSE +21 -21
  3. package/README.en.md +270 -0
  4. package/README.md +145 -273
  5. package/assets/model-icons/ai2-color.svg +1 -0
  6. package/assets/model-icons/ai21-brand-color.svg +1 -0
  7. package/assets/model-icons/aionlabs.svg +1 -0
  8. package/assets/model-icons/alephalpha.svg +1 -0
  9. package/assets/model-icons/baai.svg +1 -0
  10. package/assets/model-icons/baichuan-color.svg +1 -0
  11. package/assets/model-icons/baidu-color.svg +1 -0
  12. package/assets/model-icons/bytedance-color.svg +1 -0
  13. package/assets/model-icons/cohere-color.svg +1 -0
  14. package/assets/model-icons/hunyuan-color.svg +1 -0
  15. package/assets/model-icons/ibm.svg +1 -0
  16. package/assets/model-icons/iflytekcloud-color.svg +1 -0
  17. package/assets/model-icons/internlm-color.svg +1 -0
  18. package/assets/model-icons/kling-color.svg +1 -0
  19. package/assets/model-icons/liquid.svg +1 -0
  20. package/assets/model-icons/longcat-color.svg +1 -0
  21. package/assets/model-icons/manifest.json +739 -213
  22. package/assets/model-icons/microsoft-color.svg +1 -0
  23. package/assets/model-icons/mistral-color.svg +1 -0
  24. package/assets/model-icons/nousresearch.svg +1 -0
  25. package/assets/model-icons/nova-color.svg +1 -0
  26. package/assets/model-icons/nvidia-color.svg +1 -0
  27. package/assets/model-icons/perplexity-color.svg +1 -0
  28. package/assets/model-icons/sensenova-color.svg +1 -0
  29. package/assets/model-icons/skywork-color.svg +1 -0
  30. package/assets/model-icons/snowflake-color.svg +1 -0
  31. package/assets/model-icons/stepfun-color.svg +1 -0
  32. package/assets/model-icons/tencent-color.svg +1 -0
  33. package/assets/model-icons/tii-color.svg +1 -0
  34. package/assets/model-icons/upstage-color.svg +1 -0
  35. package/assets/model-icons/voyage-color.svg +1 -0
  36. package/assets/model-icons/xiaomimimo.svg +1 -0
  37. package/assets/model-icons/yandex.svg +1 -0
  38. package/assets/readme-architecture-en.svg +95 -0
  39. package/assets/readme-architecture-zh.svg +95 -0
  40. package/assets/readme-sync-en.svg +81 -0
  41. package/assets/readme-sync-zh.svg +81 -0
  42. package/assets/screenshots/en/01-overview.png +0 -0
  43. package/assets/screenshots/en/02-heatmap.png +0 -0
  44. package/assets/screenshots/en/03-model-usage.png +0 -0
  45. package/assets/screenshots/en/04-request-logs.png +0 -0
  46. package/assets/screenshots/en/05-cost-settings.png +0 -0
  47. package/assets/screenshots/zh/01-overview.png +0 -0
  48. package/assets/screenshots/zh/02-heatmap.png +0 -0
  49. package/assets/screenshots/zh/03-model-usage.png +0 -0
  50. package/assets/screenshots/zh/04-request-logs.png +0 -0
  51. package/assets/screenshots/zh/05-cost-settings.png +0 -0
  52. package/cordis.patch.yml +5 -5
  53. package/lib/aggregation.js +1842 -1112
  54. package/lib/client.js +1 -1
  55. package/lib/http.js +417 -370
  56. package/lib/icons.js +40 -0
  57. package/lib/ledger.js +1000 -531
  58. package/lib/plugin.js +347 -323
  59. package/lib/pricing-runtime.js +615 -551
  60. package/lib/pricing.js +1463 -1412
  61. package/lib/records-cache.js +113 -0
  62. package/lib/session-sync.js +976 -841
  63. package/lib/usage-core.js +33 -10
  64. package/lib/version-check.js +133 -0
  65. package/package.json +8 -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/plugin.js CHANGED
@@ -1,323 +1,347 @@
1
- import { createHash, randomBytes } from 'node:crypto'
2
- import { readFileSync } from 'node:fs'
3
- import { createAggregation } from './aggregation.js'
4
- import { createBalance } from './balance.js'
5
- import { registerRoutes } from './http.js'
6
- import { RETIRED_WORKSPACE_ID, createLedger } from './ledger.js'
7
- import { createPricingRuntime } from './pricing-runtime.js'
8
- import { createSessionSync } from './session-sync.js'
9
- import { createCostAccumulator, createEmptyPricingState } from './pricing.js'
10
-
11
- const name = 'dsh-all-usage'
12
- // The page is served from this package, so a version mismatch between the
13
- // running host and the page means DSH is still executing an older plugin (it
14
- // only imports at startup). Publishing it lets the panel say so instead of
15
- // leaving the user to guess why a new panel talks to an old payload.
16
- const pluginVersion = (() => {
17
- try {
18
- const meta = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
19
- return typeof meta.version === 'string' ? meta.version : ''
20
- } catch (error) {
21
- return ''
22
- }
23
- })()
24
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage', 'webServer']
25
-
26
- function apply(ctx) {
27
- const services = {
28
- credentials: ctx.get('credentials'),
29
- settings: ctx.get('settings'),
30
- storage: ctx.get('storage'),
31
- webServer: ctx.get('webServer'),
32
- sessionPersistence: ctx.get('sessionPersistence'),
33
- }
34
- const requestToken = randomBytes(32).toString('base64url')
35
- const state = {
36
- wsMeta: new Map(),
37
- // True once recorded usage from a vanished workspace was moved into the
38
- // shared "deleted" bucket; the bucket row is only advertised when it holds
39
- // something.
40
- retiredBucketUsed: false,
41
- // Last client-side environment report (diagnostics only): the desktop host
42
- // cannot be inspected from outside the app.
43
- clientEnv: null,
44
- pathIndex: new Map(),
45
- memberOf: new Map(),
46
- byDay: new Map(),
47
- byDayUtc: new Map(),
48
- perWorkspace: new Map(),
49
- perModel: new Map(),
50
- usageByStep: new Map(),
51
- turnRecords: new Map(),
52
- usageByLocalDate: new Map(),
53
- usageByUtcDate: new Map(),
54
- sessionModel: new Map(),
55
- sessionContextTimes: new Map(),
56
- totals: { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() },
57
- sessionCount: new Set(),
58
- sessionSeq: new Map(),
59
- chains: new Map(),
60
- liveResyncPending: new Set(),
61
- liveResyncTimers: new Map(),
62
- liveResyncAttempts: new Map(),
63
- scan: { started: false, done: false, scanned: 0, total: 0, failed: 0 },
64
- aliases: {},
65
- kvUnit: null,
66
- aliasWriteChain: Promise.resolve(),
67
- aliasesReady: Promise.resolve(),
68
- balanceCache: { fetchedAt: 0, payload: null },
69
- requestToken,
70
- // The status poll must be able to notice that the capability rotated without
71
- // being handed the capability itself: this id is derived from the token, so it
72
- // changes exactly when the token does and reveals nothing about it.
73
- capabilityId: createHash('sha256').update(requestToken).digest('base64url').slice(0, 16),
74
- instanceId: randomBytes(12).toString('base64url'),
75
- pluginVersion,
76
- statsRevision: 0,
77
- dataRevision: 0,
78
- metadataRevision: 0,
79
- scanRevision: 0,
80
- pricingRevision: 0,
81
- statsUpdatedAt: Date.now(),
82
- statsDirtyScheduled: false,
83
- statsDirtyKinds: new Set(),
84
- sync: {
85
- lastStartedAt: 0,
86
- lastCompletedAt: 0,
87
- lastErrorAt: 0,
88
- lastErrorCode: null,
89
- persistenceSnapshotsAvailable: false,
90
- sessionsTotal: 0,
91
- sessionsRead: 0,
92
- sessionsSkippedByRevision: 0,
93
- sessionsRestoredFromLedger: 0,
94
- sessionsFailed: 0,
95
- // Cumulative registry-probe diagnostics. They deliberately survive a
96
- // baseline reset so the status endpoint can show whether the
97
- // `domain/changed` fast path fires at all on this host.
98
- workspaceProbeEvents: 0,
99
- workspaceSyncRuns: 0,
100
- workspaceSyncAdded: 0,
101
- workspaceSyncRemoved: 0,
102
- workspaceSyncDeferred: 0,
103
- },
104
- ledgerRecords: new Map(),
105
- queryCache: new Map(),
106
- recordsQueryCache: new Map(),
107
- snapshotCache: null,
108
- ledgerRevision: Date.now(),
109
- ledgerUnit: null,
110
- ledgerReady: Promise.resolve(),
111
- ledgerWriteChain: Promise.resolve(),
112
- ledgerPending: new Map(),
113
- ledgerWriteWaiters: new Map(),
114
- ledgerWriteRunning: false,
115
- ledgerWriteScheduled: false,
116
- ledgerWriteTimer: null,
117
- ledgerDirtySessions: new Set(),
118
- ledgerDirtyEpochs: new Map(),
119
- ledgerWriteFailedSessions: new Set(),
120
- pricingState: createEmptyPricingState(),
121
- pricingResolutionCache: new Map(),
122
- holidayCache: new Map(),
123
- pricingUnit: null,
124
- pricingReady: Promise.resolve(),
125
- pricingWriteChain: Promise.resolve(),
126
- pricingSyncInFlight: false,
127
- pricingSyncTimer: null,
128
- aggregationGeneration: 0,
129
- disposed: false,
130
- baselineRetryDelay: 1000,
131
- baselineRetryScheduled: false,
132
- baselineFallbackTimer: null,
133
- knownSessionIds: new Set(),
134
- reconcileHintScheduled: false,
135
- reconcilePending: false,
136
- reconcileInFlight: false,
137
- reconcileTimer: null,
138
- workspaceSyncTimer: null,
139
- workspaceSyncDeferred: false,
140
- workspaceProbeTimer: null,
141
- workspaceSyncInFlight: false,
142
- workspaceSyncPending: false,
143
- }
144
-
145
- const host = { ctx, services, state, webServer: services.webServer, aggregation: null, ledger: null, pricing: null, balance: null, sessionSync: null, aliases: null }
146
- function commitPendingStats() {
147
- if (!state.statsDirtyScheduled) return
148
- state.statsDirtyScheduled = false
149
- const kinds = state.statsDirtyKinds
150
- state.statsDirtyKinds = new Set()
151
- if (state.disposed) return
152
- state.statsRevision += 1
153
- if (kinds.has('data')) state.dataRevision += 1
154
- if (kinds.has('metadata')) state.metadataRevision += 1
155
- if (kinds.has('scan')) state.scanRevision += 1
156
- if (kinds.has('pricing')) state.pricingRevision += 1
157
- state.statsUpdatedAt = Date.now()
158
- }
159
- function markStatsChanged(kind = 'data') {
160
- const kinds = Array.isArray(kind) ? kind : [kind]
161
- let recordsChanged = false
162
- for (const value of kinds) {
163
- if (value !== 'data' && value !== 'metadata' && value !== 'scan' && value !== 'pricing') continue
164
- state.statsDirtyKinds.add(value)
165
- if (value === 'data' || value === 'pricing') recordsChanged = true
166
- }
167
- state.snapshotCache = null
168
- if (recordsChanged) state.recordsQueryCache.clear()
169
- if (state.disposed || state.statsDirtyScheduled) return
170
- state.statsDirtyScheduled = true
171
- if (typeof queueMicrotask === 'function') queueMicrotask(commitPendingStats)
172
- else Promise.resolve().then(commitPendingStats)
173
- }
174
- function resetSyncState() {
175
- state.sync.lastStartedAt = 0
176
- state.sync.lastCompletedAt = 0
177
- state.sync.lastErrorAt = 0
178
- state.sync.lastErrorCode = null
179
- state.sync.persistenceSnapshotsAvailable = false
180
- state.sync.sessionsTotal = 0
181
- state.sync.sessionsRead = 0
182
- state.sync.sessionsSkippedByRevision = 0
183
- state.sync.sessionsRestoredFromLedger = 0
184
- state.sync.sessionsFailed = 0
185
- }
186
- function beginSync() {
187
- resetSyncState()
188
- state.sync.lastStartedAt = Date.now()
189
- markStatsChanged('scan')
190
- }
191
- function noteSyncError(code) {
192
- state.sync.lastErrorAt = Date.now()
193
- state.sync.lastErrorCode = code
194
- markStatsChanged('scan')
195
- }
196
- function syncSnapshot() {
197
- return {
198
- lastStartedAt: state.sync.lastStartedAt,
199
- lastCompletedAt: state.sync.lastCompletedAt,
200
- lastErrorAt: state.sync.lastErrorAt,
201
- lastErrorCode: state.sync.lastErrorCode,
202
- persistenceSnapshotsAvailable: state.sync.persistenceSnapshotsAvailable,
203
- sessionsTotal: state.sync.sessionsTotal,
204
- sessionsRead: state.sync.sessionsRead,
205
- sessionsSkippedByRevision: state.sync.sessionsSkippedByRevision,
206
- sessionsRestoredFromLedger: state.sync.sessionsRestoredFromLedger,
207
- sessionsFailed: state.sync.sessionsFailed,
208
- workspaceProbeEvents: state.sync.workspaceProbeEvents,
209
- workspaceSyncRuns: state.sync.workspaceSyncRuns,
210
- workspaceSyncAdded: state.sync.workspaceSyncAdded,
211
- workspaceSyncRemoved: state.sync.workspaceSyncRemoved,
212
- workspaceSyncDeferred: state.sync.workspaceSyncDeferred,
213
- }
214
- }
215
- Object.assign(host, { commitPendingStats, markStatsChanged, resetSyncState, beginSync, noteSyncError, syncSnapshot })
216
-
217
- host.pricingSnapshot = (...args) => host.pricing.pricingSnapshot(...args)
218
- host.syncSnapshot = (...args) => syncSnapshot(...args)
219
- host.aggregation = createAggregation(host)
220
- host.ledger = createLedger(host)
221
- host.pricing = createPricingRuntime(host)
222
- host.balance = createBalance(host)
223
- host.sessionSync = createSessionSync(host)
224
-
225
- async function loadAliases() {
226
- const storage = services.storage
227
- if (storage === undefined || storage === null) return
228
- try {
229
- const backend = storage.backend.get('json')
230
- if (backend === undefined || backend === null || backend.kv === undefined) return
231
- const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
232
- if (state.disposed) { await unit.close().catch(() => {}); return }
233
- state.kvUnit = unit
234
- const snap = await unit.loadAll()
235
- const global = snap && snap.global
236
- if (global !== null && global !== undefined && typeof global === 'object') {
237
- let changed = false
238
- for (const key of Object.keys(global)) {
239
- const value = global[key]
240
- const next = typeof value === 'string' ? value.trim() : ''
241
- if (next !== '' && state.aliases[key] !== next) {
242
- state.aliases[key] = next
243
- changed = true
244
- }
245
- }
246
- if (changed) markStatsChanged('metadata')
247
- }
248
- } catch (err) {
249
- console.error('[all-usage] alias storage unavailable:', err)
250
- }
251
- }
252
- function persistAliases() {
253
- if (state.disposed) return state.aliasWriteChain
254
- const snapshotAliases = {}
255
- for (const key of Object.keys(state.aliases)) snapshotAliases[key] = state.aliases[key]
256
- state.aliasWriteChain = state.aliasWriteChain.then(() => {
257
- if (state.kvUnit === null || state.kvUnit === undefined) return undefined
258
- return state.kvUnit.setGlobal(snapshotAliases).catch((err) => {
259
- console.error('[all-usage] alias persist failed:', err)
260
- })
261
- })
262
- return state.aliasWriteChain
263
- }
264
- function setAlias(wsId, raw) {
265
- if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, state.aliases) }
266
- if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, state.aliases) }
267
- const alias = raw.trim().slice(0, 80)
268
- if (!state.wsMeta.has(wsId) && !(state.retiredBucketUsed && wsId === RETIRED_WORKSPACE_ID)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
269
- const previous = typeof state.aliases[wsId] === 'string' ? state.aliases[wsId] : ''
270
- if (alias === previous) return { ok: true, aliases: Object.assign({}, state.aliases) }
271
- if (alias === '') delete state.aliases[wsId]
272
- else state.aliases[wsId] = alias
273
- persistAliases()
274
- markStatsChanged('metadata')
275
- return { ok: true, aliases: Object.assign({}, state.aliases) }
276
- }
277
- host.aliases = { loadAliases, persistAliases, setAlias }
278
-
279
- ctx.effect(() => async () => {
280
- state.disposed = true
281
- if (state.reconcileTimer !== null) {
282
- clearTimeout(state.reconcileTimer)
283
- state.reconcileTimer = null
284
- }
285
- if (state.baselineFallbackTimer !== null) {
286
- clearTimeout(state.baselineFallbackTimer)
287
- state.baselineFallbackTimer = null
288
- }
289
- if (state.pricingSyncTimer !== null) {
290
- clearTimeout(state.pricingSyncTimer)
291
- state.pricingSyncTimer = null
292
- }
293
- for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
294
- state.liveResyncTimers.clear()
295
- state.liveResyncAttempts.clear()
296
- state.liveResyncPending.clear()
297
- state.baselineRetryScheduled = false
298
- if (state.ledgerWriteTimer !== null) {
299
- clearTimeout(state.ledgerWriteTimer)
300
- state.ledgerWriteTimer = null
301
- }
302
- state.ledgerWriteScheduled = false
303
- state.chains.clear()
304
- await Promise.all([state.aliasesReady, state.ledgerReady, state.pricingReady, state.aliasWriteChain, state.pricingWriteChain])
305
- await host.ledger.drainLedgerWrites()
306
- const units = [state.kvUnit, state.ledgerUnit, state.pricingUnit]
307
- state.kvUnit = null
308
- state.ledgerUnit = null
309
- state.pricingUnit = null
310
- await Promise.all(units.map((unit) => unit === null || unit === undefined ? undefined : unit.close().catch(() => {})))
311
- })
312
-
313
- registerRoutes(host)
314
- state.ledgerReady = host.ledger.loadLedger()
315
- state.pricingReady = host.pricing.loadPricing().then(() => { host.pricing.schedulePricingSync() })
316
- void host.sessionSync.runBaseline()
317
- host.sessionSync.scheduleReconcileTimer()
318
- host.sessionSync.scheduleWorkspaceProbeTimer()
319
- state.aliasesReady = loadAliases()
320
- }
321
-
322
- export { name, inject, apply }
323
- export default { name, inject, apply }
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { createAggregation } from './aggregation.js'
4
+ import { createBalance } from './balance.js'
5
+ import { registerRoutes } from './http.js'
6
+ import { RETIRED_WORKSPACE_ID, createLedger } from './ledger.js'
7
+ import { createPricingRuntime } from './pricing-runtime.js'
8
+ import { createSessionSync } from './session-sync.js'
9
+ import { createCostAccumulator, createEmptyPricingState } from './pricing.js'
10
+ import { createVersionCheck } from './version-check.js'
11
+
12
+ const name = 'dsh-all-usage'
13
+ // The page is served from this package, so a version mismatch between the
14
+ // running host and the page means DSH is still executing an older plugin (it
15
+ // only imports at startup). Publishing it lets the panel say so instead of
16
+ // leaving the user to guess why a new panel talks to an old payload.
17
+ const pluginVersion = (() => {
18
+ try {
19
+ const meta = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
20
+ return typeof meta.version === 'string' ? meta.version : ''
21
+ } catch (error) {
22
+ return ''
23
+ }
24
+ })()
25
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage', 'webServer']
26
+
27
+ function apply(ctx) {
28
+ const services = {
29
+ credentials: ctx.get('credentials'),
30
+ settings: ctx.get('settings'),
31
+ storage: ctx.get('storage'),
32
+ webServer: ctx.get('webServer'),
33
+ sessionPersistence: ctx.get('sessionPersistence'),
34
+ }
35
+ const requestToken = randomBytes(32).toString('base64url')
36
+ const state = {
37
+ wsMeta: new Map(),
38
+ // True once recorded usage from a vanished workspace was moved into the
39
+ // shared "deleted" bucket; the bucket row is only advertised when it holds
40
+ // something.
41
+ retiredBucketUsed: false,
42
+ // Last client-side environment report (diagnostics only): the desktop host
43
+ // cannot be inspected from outside the app.
44
+ clientEnv: null,
45
+ pathIndex: new Map(),
46
+ memberOf: new Map(),
47
+ byDay: new Map(),
48
+ byDayUtc: new Map(),
49
+ perWorkspace: new Map(),
50
+ perModel: new Map(),
51
+ // One digest per session (day/workspace/model buckets) so a session can be
52
+ // unwound from the cubes without keeping its detail rows in memory.
53
+ perSession: new Map(),
54
+ usageByStep: new Map(),
55
+ turnRecords: new Map(),
56
+ usageByLocalDate: new Map(),
57
+ usageByUtcDate: new Map(),
58
+ sessionModel: new Map(),
59
+ sessionContextTimes: new Map(),
60
+ totals: { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: createCostAccumulator() },
61
+ sessionCount: new Set(),
62
+ sessionSeq: new Map(),
63
+ chains: new Map(),
64
+ liveResyncPending: new Set(),
65
+ liveResyncTriggers: new Map(),
66
+ liveResyncTimers: new Map(),
67
+ liveResyncAttempts: new Map(),
68
+ scan: { started: false, done: false, scanned: 0, total: 0, failed: 0 },
69
+ aliases: {},
70
+ kvUnit: null,
71
+ aliasWriteChain: Promise.resolve(),
72
+ aliasesReady: Promise.resolve(),
73
+ balanceCache: { fetchedAt: 0, payload: null },
74
+ requestToken,
75
+ // The status poll must be able to notice that the capability rotated without
76
+ // being handed the capability itself: this id is derived from the token, so it
77
+ // changes exactly when the token does and reveals nothing about it.
78
+ capabilityId: createHash('sha256').update(requestToken).digest('base64url').slice(0, 16),
79
+ instanceId: randomBytes(12).toString('base64url'),
80
+ pluginVersion,
81
+ statsRevision: 0,
82
+ dataRevision: 0,
83
+ metadataRevision: 0,
84
+ scanRevision: 0,
85
+ pricingRevision: 0,
86
+ statsUpdatedAt: Date.now(),
87
+ statsDirtyScheduled: false,
88
+ statsDirtyKinds: new Set(),
89
+ sync: {
90
+ lastStartedAt: 0,
91
+ lastCompletedAt: 0,
92
+ lastErrorAt: 0,
93
+ lastErrorCode: null,
94
+ persistenceSnapshotsAvailable: false,
95
+ sessionsTotal: 0,
96
+ sessionsRead: 0,
97
+ sessionsSkippedByRevision: 0,
98
+ sessionsRestoredFromLedger: 0,
99
+ sessionsFailed: 0,
100
+ // Cumulative registry-probe diagnostics. They deliberately survive a
101
+ // baseline reset so the status endpoint can show whether the
102
+ // `domain/changed` fast path fires at all on this host.
103
+ workspaceProbeEvents: 0,
104
+ workspaceSyncRuns: 0,
105
+ workspaceSyncAdded: 0,
106
+ workspaceSyncRemoved: 0,
107
+ workspaceSyncDeferred: 0,
108
+ },
109
+ ledgerRecords: new Map(),
110
+ // The shard filenames are the index; a record is read (and cached, LRU by size)
111
+ // only when something asks for it, so the whole ledger is never resident.
112
+ ledgerKeys: new Set(),
113
+ // date -> { sessionId -> rows on that date }: how /records finds the sessions a
114
+ // range needs without the detail rows staying resident.
115
+ sessionsByLocalDate: new Map(),
116
+ sessionsByUtcDate: new Map(),
117
+ rowTouch: new Map(),
118
+ ledgerRecordSizes: new Map(),
119
+ ledgerRecordBytes: 0,
120
+ queryCache: new Map(),
121
+ recordsQueryCache: new Map(),
122
+ snapshotCache: null,
123
+ ledgerRevision: Date.now(),
124
+ ledgerUnit: null,
125
+ ledgerReady: Promise.resolve(),
126
+ ledgerWriteChain: Promise.resolve(),
127
+ ledgerPending: new Map(),
128
+ ledgerWriting: new Set(),
129
+ ledgerWriteWaiters: new Map(),
130
+ ledgerWriteRunning: false,
131
+ ledgerWriteScheduled: false,
132
+ ledgerWriteTimer: null,
133
+ ledgerDirtySessions: new Set(),
134
+ ledgerDirtyEpochs: new Map(),
135
+ ledgerWriteFailedSessions: new Set(),
136
+ pricingState: createEmptyPricingState(),
137
+ pricingResolutionCache: new Map(),
138
+ holidayCache: new Map(),
139
+ pricingUnit: null,
140
+ pricingReady: Promise.resolve(),
141
+ pricingWriteChain: Promise.resolve(),
142
+ pricingSyncInFlight: false,
143
+ pricingSyncTimer: null,
144
+ aggregationGeneration: 0,
145
+ disposed: false,
146
+ baselineRetryDelay: 1000,
147
+ baselineRetryScheduled: false,
148
+ baselineFallbackTimer: null,
149
+ knownSessionIds: new Set(),
150
+ reconcileHintScheduled: false,
151
+ reconcilePending: false,
152
+ reconcileInFlight: false,
153
+ reconcileTimer: null,
154
+ workspaceSyncTimer: null,
155
+ workspaceSyncDeferred: false,
156
+ workspaceProbeTimer: null,
157
+ workspaceSyncInFlight: false,
158
+ workspaceSyncPending: false,
159
+ }
160
+
161
+ const host = { ctx, services, state, webServer: services.webServer, aggregation: null, ledger: null, pricing: null, balance: null, sessionSync: null, aliases: null }
162
+ function commitPendingStats() {
163
+ if (!state.statsDirtyScheduled) return
164
+ state.statsDirtyScheduled = false
165
+ const kinds = state.statsDirtyKinds
166
+ state.statsDirtyKinds = new Set()
167
+ if (state.disposed) return
168
+ state.statsRevision += 1
169
+ if (kinds.has('data')) state.dataRevision += 1
170
+ if (kinds.has('metadata')) state.metadataRevision += 1
171
+ if (kinds.has('scan')) state.scanRevision += 1
172
+ if (kinds.has('pricing')) state.pricingRevision += 1
173
+ state.statsUpdatedAt = Date.now()
174
+ }
175
+ function markStatsChanged(kind = 'data') {
176
+ const kinds = Array.isArray(kind) ? kind : [kind]
177
+ let recordsChanged = false
178
+ for (const value of kinds) {
179
+ if (value !== 'data' && value !== 'metadata' && value !== 'scan' && value !== 'pricing') continue
180
+ state.statsDirtyKinds.add(value)
181
+ if (value === 'data' || value === 'pricing') recordsChanged = true
182
+ }
183
+ state.snapshotCache = null
184
+ // Scope results are keyed by the revision, so a superseded entry can never be
185
+ // read again — dropping it here keeps the cache at "live revisions only"
186
+ // instead of letting old 30-115 KB results sit until the size cap evicts them.
187
+ if (recordsChanged) state.queryCache.clear()
188
+ if (recordsChanged) state.recordsQueryCache.clear()
189
+ if (state.disposed || state.statsDirtyScheduled) return
190
+ state.statsDirtyScheduled = true
191
+ if (typeof queueMicrotask === 'function') queueMicrotask(commitPendingStats)
192
+ else Promise.resolve().then(commitPendingStats)
193
+ }
194
+ function resetSyncState() {
195
+ state.sync.lastStartedAt = 0
196
+ state.sync.lastCompletedAt = 0
197
+ state.sync.lastErrorAt = 0
198
+ state.sync.lastErrorCode = null
199
+ state.sync.persistenceSnapshotsAvailable = false
200
+ state.sync.sessionsTotal = 0
201
+ state.sync.sessionsRead = 0
202
+ state.sync.sessionsSkippedByRevision = 0
203
+ state.sync.sessionsRestoredFromLedger = 0
204
+ state.sync.sessionsFailed = 0
205
+ }
206
+ function beginSync() {
207
+ resetSyncState()
208
+ state.sync.lastStartedAt = Date.now()
209
+ markStatsChanged('scan')
210
+ }
211
+ function noteSyncError(code) {
212
+ state.sync.lastErrorAt = Date.now()
213
+ state.sync.lastErrorCode = code
214
+ markStatsChanged('scan')
215
+ }
216
+ function syncSnapshot() {
217
+ return {
218
+ lastStartedAt: state.sync.lastStartedAt,
219
+ lastCompletedAt: state.sync.lastCompletedAt,
220
+ lastErrorAt: state.sync.lastErrorAt,
221
+ lastErrorCode: state.sync.lastErrorCode,
222
+ persistenceSnapshotsAvailable: state.sync.persistenceSnapshotsAvailable,
223
+ sessionsTotal: state.sync.sessionsTotal,
224
+ sessionsRead: state.sync.sessionsRead,
225
+ sessionsSkippedByRevision: state.sync.sessionsSkippedByRevision,
226
+ sessionsRestoredFromLedger: state.sync.sessionsRestoredFromLedger,
227
+ sessionsFailed: state.sync.sessionsFailed,
228
+ workspaceProbeEvents: state.sync.workspaceProbeEvents,
229
+ workspaceSyncRuns: state.sync.workspaceSyncRuns,
230
+ workspaceSyncAdded: state.sync.workspaceSyncAdded,
231
+ workspaceSyncRemoved: state.sync.workspaceSyncRemoved,
232
+ workspaceSyncDeferred: state.sync.workspaceSyncDeferred,
233
+ }
234
+ }
235
+ Object.assign(host, { commitPendingStats, markStatsChanged, resetSyncState, beginSync, noteSyncError, syncSnapshot })
236
+
237
+ host.pricingSnapshot = (...args) => host.pricing.pricingSnapshot(...args)
238
+ host.syncSnapshot = (...args) => syncSnapshot(...args)
239
+ host.aggregation = createAggregation(host)
240
+ host.ledger = createLedger(host)
241
+ host.pricing = createPricingRuntime(host)
242
+ host.balance = createBalance(host)
243
+ host.sessionSync = createSessionSync(host)
244
+ // The header asks the host for this, so the page never talks to the registry
245
+ // itself (the desktop shell would not allow it) and the answer is cached here.
246
+ host.versionCheck = createVersionCheck({ currentVersion: pluginVersion })
247
+
248
+ async function loadAliases() {
249
+ const storage = services.storage
250
+ if (storage === undefined || storage === null) return
251
+ try {
252
+ const backend = storage.backend.get('json')
253
+ if (backend === undefined || backend === null || backend.kv === undefined) return
254
+ const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
255
+ if (state.disposed) { await unit.close().catch(() => {}); return }
256
+ state.kvUnit = unit
257
+ const snap = await unit.loadAll()
258
+ const global = snap && snap.global
259
+ if (global !== null && global !== undefined && typeof global === 'object') {
260
+ let changed = false
261
+ for (const key of Object.keys(global)) {
262
+ const value = global[key]
263
+ const next = typeof value === 'string' ? value.trim() : ''
264
+ if (next !== '' && state.aliases[key] !== next) {
265
+ state.aliases[key] = next
266
+ changed = true
267
+ }
268
+ }
269
+ if (changed) markStatsChanged('metadata')
270
+ }
271
+ } catch (err) {
272
+ console.error('[all-usage] alias storage unavailable:', err)
273
+ }
274
+ }
275
+ function persistAliases() {
276
+ if (state.disposed) return state.aliasWriteChain
277
+ const snapshotAliases = {}
278
+ for (const key of Object.keys(state.aliases)) snapshotAliases[key] = state.aliases[key]
279
+ state.aliasWriteChain = state.aliasWriteChain.then(() => {
280
+ if (state.kvUnit === null || state.kvUnit === undefined) return undefined
281
+ return state.kvUnit.setGlobal(snapshotAliases).catch((err) => {
282
+ console.error('[all-usage] alias persist failed:', err)
283
+ })
284
+ })
285
+ return state.aliasWriteChain
286
+ }
287
+ function setAlias(wsId, raw) {
288
+ if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, state.aliases) }
289
+ if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, state.aliases) }
290
+ const alias = raw.trim().slice(0, 80)
291
+ if (!state.wsMeta.has(wsId) && !(state.retiredBucketUsed && wsId === RETIRED_WORKSPACE_ID)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
292
+ const previous = typeof state.aliases[wsId] === 'string' ? state.aliases[wsId] : ''
293
+ if (alias === previous) return { ok: true, aliases: Object.assign({}, state.aliases) }
294
+ if (alias === '') delete state.aliases[wsId]
295
+ else state.aliases[wsId] = alias
296
+ persistAliases()
297
+ markStatsChanged('metadata')
298
+ return { ok: true, aliases: Object.assign({}, state.aliases) }
299
+ }
300
+ host.aliases = { loadAliases, persistAliases, setAlias }
301
+
302
+ ctx.effect(() => async () => {
303
+ state.disposed = true
304
+ if (state.reconcileTimer !== null) {
305
+ clearTimeout(state.reconcileTimer)
306
+ state.reconcileTimer = null
307
+ }
308
+ if (state.baselineFallbackTimer !== null) {
309
+ clearTimeout(state.baselineFallbackTimer)
310
+ state.baselineFallbackTimer = null
311
+ }
312
+ if (state.pricingSyncTimer !== null) {
313
+ clearTimeout(state.pricingSyncTimer)
314
+ state.pricingSyncTimer = null
315
+ }
316
+ for (const timer of state.liveResyncTimers.values()) clearTimeout(timer)
317
+ state.liveResyncTimers.clear()
318
+ state.liveResyncAttempts.clear()
319
+ state.liveResyncPending.clear()
320
+ state.liveResyncTriggers.clear()
321
+ state.baselineRetryScheduled = false
322
+ if (state.ledgerWriteTimer !== null) {
323
+ clearTimeout(state.ledgerWriteTimer)
324
+ state.ledgerWriteTimer = null
325
+ }
326
+ state.ledgerWriteScheduled = false
327
+ state.chains.clear()
328
+ await Promise.all([state.aliasesReady, state.ledgerReady, state.pricingReady, state.aliasWriteChain, state.pricingWriteChain])
329
+ await host.ledger.drainLedgerWrites()
330
+ const units = [state.kvUnit, state.ledgerUnit, state.pricingUnit]
331
+ state.kvUnit = null
332
+ state.ledgerUnit = null
333
+ state.pricingUnit = null
334
+ await Promise.all(units.map((unit) => unit === null || unit === undefined ? undefined : unit.close().catch(() => {})))
335
+ })
336
+
337
+ registerRoutes(host)
338
+ state.ledgerReady = host.ledger.loadLedger()
339
+ state.pricingReady = host.pricing.loadPricing().then(() => { host.pricing.schedulePricingSync() })
340
+ void host.sessionSync.runBaseline()
341
+ host.sessionSync.scheduleReconcileTimer()
342
+ host.sessionSync.scheduleWorkspaceProbeTimer()
343
+ state.aliasesReady = loadAliases()
344
+ }
345
+
346
+ export { name, inject, apply }
347
+ export default { name, inject, apply }