dsh-all-usage 1.1.18 → 1.2.0

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