dsh-all-usage 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ledger.js CHANGED
@@ -1,6 +1,24 @@
1
1
  import { calculateCost, normalizeCostSnapshot } from './pricing.js'
2
2
  import { billingInstantOf, contextTimeKey, extractUsageEvent, normalizeUsageValues as usageValues, pickPricingTime, touchContextTimes, upsertUsageSample as upsertUsageSampleState, usageStepKey } from './usage-core.js'
3
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
+
4
22
  const LEDGER_VERSION = 3
5
23
  const PREVIOUS_LEDGER_VERSION = 2
6
24
  const LEGACY_LEDGER_VERSION = 1
@@ -495,6 +513,7 @@ export function createLedger(host) {
495
513
  nextLedgerRevision,
496
514
  ledgerEventKey,
497
515
  sessionEvents,
516
+ retargetLedgerRecord,
498
517
  buildLedgerRecord,
499
518
  normalizeLedgerRecord,
500
519
  prepareLedgerRecord,
@@ -509,4 +528,4 @@ export function createLedger(host) {
509
528
  }
510
529
  }
511
530
 
512
- export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, ledgerShardIndex }
531
+ export { LEDGER_VERSION, PREVIOUS_LEDGER_VERSION, LEGACY_LEDGER_VERSION, LEDGER_SHARD_COUNT, RETIRED_WORKSPACE_ID, ledgerShardIndex, retargetLedgerRecord }
package/lib/plugin.js CHANGED
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'
2
2
  import { createAggregation } from './aggregation.js'
3
3
  import { createBalance } from './balance.js'
4
4
  import { registerRoutes } from './http.js'
5
- import { createLedger } from './ledger.js'
5
+ import { RETIRED_WORKSPACE_ID, createLedger } from './ledger.js'
6
6
  import { createPricingRuntime } from './pricing-runtime.js'
7
7
  import { createSessionSync } from './session-sync.js'
8
8
  import { createCostAccumulator, createEmptyPricingState } from './pricing.js'
@@ -20,6 +20,10 @@ function apply(ctx) {
20
20
  }
21
21
  const state = {
22
22
  wsMeta: new Map(),
23
+ // True once recorded usage from a vanished workspace was moved into the
24
+ // shared "deleted" bucket; the bucket row is only advertised when it holds
25
+ // something.
26
+ retiredBucketUsed: false,
23
27
  pathIndex: new Map(),
24
28
  memberOf: new Map(),
25
29
  byDay: new Map(),
@@ -66,6 +70,14 @@ function apply(ctx) {
66
70
  sessionsSkippedByRevision: 0,
67
71
  sessionsRestoredFromLedger: 0,
68
72
  sessionsFailed: 0,
73
+ // Cumulative registry-probe diagnostics. They deliberately survive a
74
+ // baseline reset so the status endpoint can show whether the
75
+ // `domain/changed` fast path fires at all on this host.
76
+ workspaceProbeEvents: 0,
77
+ workspaceSyncRuns: 0,
78
+ workspaceSyncAdded: 0,
79
+ workspaceSyncRemoved: 0,
80
+ workspaceSyncDeferred: 0,
69
81
  },
70
82
  ledgerRecords: new Map(),
71
83
  queryCache: new Map(),
@@ -101,6 +113,8 @@ function apply(ctx) {
101
113
  reconcileInFlight: false,
102
114
  reconcileTimer: null,
103
115
  workspaceSyncTimer: null,
116
+ workspaceSyncDeferred: false,
117
+ workspaceProbeTimer: null,
104
118
  workspaceSyncInFlight: false,
105
119
  workspaceSyncPending: false,
106
120
  }
@@ -168,6 +182,11 @@ function apply(ctx) {
168
182
  sessionsSkippedByRevision: state.sync.sessionsSkippedByRevision,
169
183
  sessionsRestoredFromLedger: state.sync.sessionsRestoredFromLedger,
170
184
  sessionsFailed: state.sync.sessionsFailed,
185
+ workspaceProbeEvents: state.sync.workspaceProbeEvents,
186
+ workspaceSyncRuns: state.sync.workspaceSyncRuns,
187
+ workspaceSyncAdded: state.sync.workspaceSyncAdded,
188
+ workspaceSyncRemoved: state.sync.workspaceSyncRemoved,
189
+ workspaceSyncDeferred: state.sync.workspaceSyncDeferred,
171
190
  }
172
191
  }
173
192
  Object.assign(host, { commitPendingStats, markStatsChanged, resetSyncState, beginSync, noteSyncError, syncSnapshot })
@@ -223,7 +242,7 @@ function apply(ctx) {
223
242
  if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, state.aliases) }
224
243
  if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, state.aliases) }
225
244
  const alias = raw.trim().slice(0, 80)
226
- if (!state.wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
245
+ if (!state.wsMeta.has(wsId) && !(state.retiredBucketUsed && wsId === RETIRED_WORKSPACE_ID)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
227
246
  const previous = typeof state.aliases[wsId] === 'string' ? state.aliases[wsId] : ''
228
247
  if (alias === previous) return { ok: true, aliases: Object.assign({}, state.aliases) }
229
248
  if (alias === '') delete state.aliases[wsId]
@@ -273,6 +292,7 @@ function apply(ctx) {
273
292
  state.pricingReady = host.pricing.loadPricing().then(() => { host.pricing.schedulePricingSync() })
274
293
  void host.sessionSync.runBaseline()
275
294
  host.sessionSync.scheduleReconcileTimer()
295
+ host.sessionSync.scheduleWorkspaceProbeTimer()
276
296
  state.aliasesReady = loadAliases()
277
297
  }
278
298
 
@@ -1,8 +1,13 @@
1
+ import { RETIRED_WORKSPACE_ID, retargetLedgerRecord } from './ledger.js'
1
2
  import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes, workspacePathKey } from './usage-core.js'
2
3
 
3
4
  const RECONCILE_INTERVAL_MS = 120000
4
5
  const RECONCILE_HINT_DELAY_MS = 3000
5
6
  const WORKSPACE_SYNC_DEBOUNCE_MS = 300
7
+ // The registry event is a fast path, not a guarantee: a change that lands while a
8
+ // baseline runs used to be dropped outright, and a missed event left the plugin
9
+ // stale until the next restart. The (in-memory) registry list is polled as well.
10
+ const WORKSPACE_PROBE_INTERVAL_MS = 30000
6
11
 
7
12
  export function createSessionSync(host) {
8
13
  const { ctx, state } = host
@@ -153,6 +158,25 @@ export function createSessionSync(host) {
153
158
  if (typeof cwd !== 'string' || cwd === '') return undefined
154
159
  return state.pathIndex.get(workspacePathKey(cwd))
155
160
  }
161
+ // DSH 0.1.5 replaced \`sessionPersistence.listSnapshots()\` with \`list()\`; both
162
+ // return per-session \`{ header, revision }\` rows. Without a revision the
163
+ // ledger-reuse fast path never fires, so every baseline re-reads every log.
164
+ async function loadSessionRevisions() {
165
+ const source = sessionPersistence
166
+ if (source === undefined || source === null) return null
167
+ const read = typeof source.list === 'function'
168
+ ? () => source.list()
169
+ : (typeof source.listSnapshots === 'function' ? () => source.listSnapshots() : null)
170
+ if (read === null) return null
171
+ const rows = await read()
172
+ if (!Array.isArray(rows)) return null
173
+ const revisions = new Map()
174
+ for (const row of rows) {
175
+ const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
176
+ if (rid !== undefined && row && typeof row.revision === 'string') revisions.set(rid, row.revision)
177
+ }
178
+ return revisions
179
+ }
156
180
  function wsForLiveSession(session, sid) {
157
181
  let wsId = state.memberOf.get(sid)
158
182
  if (wsId !== undefined) return wsId
@@ -405,24 +429,18 @@ export function createSessionSync(host) {
405
429
  if (state.disposed || generation !== state.aggregationGeneration) return
406
430
  // v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
407
431
  let snapshots = null
408
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
409
- try {
410
- const rows = await sessionPersistence.listSnapshots()
411
- if (state.disposed || generation !== state.aggregationGeneration) return
412
- if (Array.isArray(rows)) {
413
- state.sync.persistenceSnapshotsAvailable = true
414
- snapshots = new Map()
415
- for (const row of rows) {
416
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
417
- if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
418
- }
419
- }
420
- } catch (err) {
421
- console.error('[all-usage] session persistence snapshots unavailable:', err)
422
- if (state.disposed || generation !== state.aggregationGeneration) return
423
- state.sync.persistenceSnapshotsAvailable = false
424
- markStatsChanged('scan')
432
+ try {
433
+ const revisions = await loadSessionRevisions()
434
+ if (state.disposed || generation !== state.aggregationGeneration) return
435
+ if (revisions !== null) {
436
+ state.sync.persistenceSnapshotsAvailable = true
437
+ snapshots = revisions
425
438
  }
439
+ } catch (err) {
440
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
441
+ if (state.disposed || generation !== state.aggregationGeneration) return
442
+ state.sync.persistenceSnapshotsAvailable = false
443
+ markStatsChanged('scan')
426
444
  }
427
445
  if (state.disposed || generation !== state.aggregationGeneration) return
428
446
  if (setupFailed || !Array.isArray(records)) {
@@ -445,15 +463,19 @@ export function createSessionSync(host) {
445
463
  }
446
464
  for (const [sid, record] of state.ledgerRecords) {
447
465
  if (!listedSessionIds.has(sid)) {
448
- // A ledger row from a deleted cwd is stale historical data, not an
449
- // active workspace. Registered records remain recoverable; unknown
450
- // records are recovered only while their source cwd still exists. Old
451
- // synthetic rows predate sourceCwd persistence and cannot be validated,
452
- // so they are fail-closed rather than resurrected after a restart.
453
- if (record.sourceCwd !== undefined && workspaceForCwd(record.sourceCwd) === undefined) continue
466
+ // Recorded usage survives deletion. A session whose workspace, cwd or
467
+ // log is gone still counts from its ledger row; only legacy synthetic
468
+ // rows, which never belonged to a registered workspace, stay excluded.
454
469
  if (typeof record.workspaceId === 'string' && record.workspaceId.startsWith('unregistered:')) continue
455
- applyLedgerRecord(record, 'ledger-recovery')
456
- if (record.turns.length > 0 || record.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
470
+ // Usage from a workspace that is no longer registered is summed into the
471
+ // shared deleted bucket, so history survives without a row per dead
472
+ // workspace. The stored row keeps its original workspace id and cwd.
473
+ const retired = typeof record.workspaceId !== 'string' || !state.wsMeta.has(record.workspaceId)
474
+ const carriesUsage = record.turns.length > 0 || record.usage.length > 0
475
+ // An empty row must not advertise a bucket that holds nothing.
476
+ if (retired && carriesUsage) state.retiredBucketUsed = true
477
+ applyLedgerRecord(retired ? retargetLedgerRecord(record) : record, retired ? 'ledger-retired' : 'ledger-recovery')
478
+ if (carriesUsage) state.sync.sessionsRestoredFromLedger += 1
457
479
  }
458
480
  }
459
481
  if (state.sync.sessionsRestoredFromLedger > 0) markStatsChanged('scan')
@@ -499,6 +521,10 @@ export function createSessionSync(host) {
499
521
  state.knownSessionIds.clear()
500
522
  for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
501
523
  state.scan.done = true
524
+ if (state.workspaceSyncDeferred) {
525
+ state.workspaceSyncDeferred = false
526
+ scheduleWorkspaceSync()
527
+ }
502
528
  state.sync.lastCompletedAt = Date.now()
503
529
  if (state.sync.sessionsFailed === 0) {
504
530
  state.sync.lastErrorAt = 0
@@ -524,6 +550,7 @@ export function createSessionSync(host) {
524
550
  return
525
551
  }
526
552
  state.workspaceSyncInFlight = true
553
+ state.sync.workspaceSyncRuns += 1
527
554
  try {
528
555
  await synchronizeWorkspaceRegistry()
529
556
  } catch (err) {
@@ -538,9 +565,15 @@ export function createSessionSync(host) {
538
565
  }
539
566
  }
540
567
  async function synchronizeWorkspaceRegistry() {
541
- // The first baseline reads the registry with the latest list, so a probe
542
- // arriving before it completes has nothing to add.
543
- if (state.disposed || !state.scan.done) return
568
+ if (state.disposed) return
569
+ // A registry change that lands while a baseline is running used to be
570
+ // dropped outright, which is why a new workspace could stay invisible until
571
+ // the next restart. Remember it and re-run once the scan settles.
572
+ if (!state.scan.done) {
573
+ state.workspaceSyncDeferred = true
574
+ state.sync.workspaceSyncDeferred += 1
575
+ return
576
+ }
544
577
  let workspaces
545
578
  try {
546
579
  workspaces = ctx.workspaceRegistry.list()
@@ -554,36 +587,40 @@ export function createSessionSync(host) {
554
587
  for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
555
588
  const addedWs = new Set()
556
589
  for (const id of next.wsMeta.keys()) if (!state.wsMeta.has(id)) addedWs.add(id)
590
+ state.sync.workspaceSyncAdded += addedWs.size
591
+ state.sync.workspaceSyncRemoved += removedWs.size
557
592
  // Reuse path: no membership change — metadata only, zero rescan.
558
593
  if (removedWs.size === 0 && addedWs.size === 0) {
559
594
  installWorkspaceSnapshot(next)
560
595
  return
561
596
  }
562
- // Remove path: subtract every session that belonged to a deregistered workspace.
597
+ // Retire path: a deregistered workspace keeps everything it recorded. The
598
+ // ledger's promise is that flushed usage survives deletion, so sessions are
599
+ // re-applied under the shared deleted bucket instead of being subtracted and
600
+ // dropped. New sessions still cannot enter the workspace: pathIndex only
601
+ // ever carries registered workspaces, so workspaceForCwd() no longer
602
+ // resolves its cwd and the scan skips it.
563
603
  if (removedWs.size > 0) {
564
- const removedSids = new Set()
604
+ const retiredSids = new Set()
565
605
  for (const [sid, record] of state.ledgerRecords) {
566
- if (record !== null && record !== undefined && typeof record.workspaceId === 'string' && removedWs.has(record.workspaceId)) removedSids.add(sid)
606
+ if (record === null || record === undefined || !removedWs.has(record.workspaceId)) continue
607
+ retiredSids.add(sid)
567
608
  }
568
- for (const [sid, id] of state.memberOf) if (removedWs.has(id)) removedSids.add(sid)
569
- for (const sid of removedSids) {
609
+ for (const [sid, id] of state.memberOf) if (removedWs.has(id)) retiredSids.add(sid)
610
+ let moved = 0
611
+ for (const sid of retiredSids) {
570
612
  if (typeof sid !== 'string' || sid === '') continue
571
- host.aggregation.removeSession(sid)
572
- state.ledgerRecords.delete(sid)
573
- state.knownSessionIds.delete(sid)
574
- state.sessionCount.delete(sid)
575
- state.sessionSeq.delete(sid)
576
- state.sessionModel.delete(sid)
577
- state.sessionContextTimes.delete(sid)
578
- state.memberOf.delete(sid)
579
- state.liveResyncPending.delete(sid)
580
- cancelLiveResync(sid)
581
- }
582
- for (const id of removedWs) {
583
- state.perWorkspace.delete(id)
584
- for (const day of state.byDay.values()) day.byWs.delete(id)
585
- for (const day of state.byDayUtc.values()) day.byWs.delete(id)
613
+ // Re-key the live aggregate first: usage folded from the feed but not yet
614
+ // persisted has no ledger row to replay, and a session the registry no
615
+ // longer lists is never re-scanned afterwards.
616
+ moved += host.aggregation.retargetSession(sid, RETIRED_WORKSPACE_ID)
617
+ const record = state.ledgerRecords.get(sid)
618
+ // Keep the in-memory record aligned with the aggregate; the persisted row
619
+ // keeps its original workspace id and cwd, so recovery re-derives this.
620
+ if (record !== undefined && (record.turns.length > 0 || record.usage.length > 0)) moved += 1
621
+ if (record !== undefined) state.ledgerRecords.set(sid, retargetLedgerRecord(record))
586
622
  }
623
+ if (moved > 0) state.retiredBucketUsed = true
587
624
  }
588
625
  installWorkspaceSnapshot(next)
589
626
  // Add path: scan only the sessions that belong to newly registered workspaces.
@@ -599,20 +636,10 @@ export function createSessionSync(host) {
599
636
  state.sync.sessionsTotal = records.length
600
637
  markStatsChanged('scan')
601
638
  let snapshots = null
602
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
603
- try {
604
- const rows = await sessionPersistence.listSnapshots()
605
- if (Array.isArray(rows)) {
606
- snapshots = new Map()
607
- for (const row of rows) {
608
- const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
609
- if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
610
- }
611
- }
612
- } catch (err) {
613
- console.error('[all-usage] session persistence snapshots unavailable:', err)
614
- markStatsChanged('scan')
615
- }
639
+ try {
640
+ snapshots = await loadSessionRevisions()
641
+ } catch (err) {
642
+ console.error('[all-usage] session persistence snapshots unavailable:', err)
616
643
  }
617
644
  for (const record of records) {
618
645
  if (state.disposed) return
@@ -691,6 +718,20 @@ export function createSessionSync(host) {
691
718
  }, RECONCILE_INTERVAL_MS)
692
719
  if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
693
720
  }
721
+ // Belt and braces for the registry probe: the event fast path can be missed
722
+ // (dropped while scanning, or never delivered), and unlike the event this
723
+ // cannot silently leave the workspace list stale until a restart.
724
+ function scheduleWorkspaceProbeTimer() {
725
+ if (state.disposed || state.workspaceProbeTimer !== null) return
726
+ state.workspaceProbeTimer = setTimeout(() => {
727
+ state.workspaceProbeTimer = null
728
+ if (!state.disposed) {
729
+ void runWorkspaceSync()
730
+ scheduleWorkspaceProbeTimer()
731
+ }
732
+ }, WORKSPACE_PROBE_INTERVAL_MS)
733
+ if (state.workspaceProbeTimer && typeof state.workspaceProbeTimer.unref === 'function') state.workspaceProbeTimer.unref()
734
+ }
694
735
 
695
736
  // ---------- live feed ----------
696
737
  ctx.on('session/event', (session, event) => {
@@ -768,6 +809,7 @@ export function createSessionSync(host) {
768
809
  if (change === undefined || change === null) return
769
810
  if (change.domain !== 'workspace') return
770
811
  if (change.table !== 'workspaces' && change.table !== '') return
812
+ state.sync.workspaceProbeEvents += 1
771
813
  scheduleWorkspaceSync()
772
814
  })
773
815
 
@@ -793,6 +835,7 @@ export function createSessionSync(host) {
793
835
  reconcileSessions,
794
836
  scheduleReconcileHint,
795
837
  scheduleReconcileTimer,
838
+ scheduleWorkspaceProbeTimer,
796
839
  markLedgerDirty
797
840
  }
798
841
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",