dsh-all-usage 1.1.8 → 1.1.9

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(),
@@ -223,7 +227,7 @@ function apply(ctx) {
223
227
  if (typeof wsId !== 'string' || wsId.length === 0 || wsId.length > 256) return { ok: false, message: 'invalid-workspace', aliases: Object.assign({}, state.aliases) }
224
228
  if (typeof raw !== 'string') return { ok: false, message: 'invalid-alias', aliases: Object.assign({}, state.aliases) }
225
229
  const alias = raw.trim().slice(0, 80)
226
- if (!state.wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
230
+ if (!state.wsMeta.has(wsId) && !(state.retiredBucketUsed && wsId === RETIRED_WORKSPACE_ID)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, state.aliases) }
227
231
  const previous = typeof state.aliases[wsId] === 'string' ? state.aliases[wsId] : ''
228
232
  if (alias === previous) return { ok: true, aliases: Object.assign({}, state.aliases) }
229
233
  if (alias === '') delete state.aliases[wsId]
@@ -1,3 +1,4 @@
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
@@ -445,15 +446,19 @@ export function createSessionSync(host) {
445
446
  }
446
447
  for (const [sid, record] of state.ledgerRecords) {
447
448
  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
449
+ // Recorded usage survives deletion. A session whose workspace, cwd or
450
+ // log is gone still counts from its ledger row; only legacy synthetic
451
+ // rows, which never belonged to a registered workspace, stay excluded.
454
452
  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
453
+ // Usage from a workspace that is no longer registered is summed into the
454
+ // shared deleted bucket, so history survives without a row per dead
455
+ // workspace. The stored row keeps its original workspace id and cwd.
456
+ const retired = typeof record.workspaceId !== 'string' || !state.wsMeta.has(record.workspaceId)
457
+ const carriesUsage = record.turns.length > 0 || record.usage.length > 0
458
+ // An empty row must not advertise a bucket that holds nothing.
459
+ if (retired && carriesUsage) state.retiredBucketUsed = true
460
+ applyLedgerRecord(retired ? retargetLedgerRecord(record) : record, retired ? 'ledger-retired' : 'ledger-recovery')
461
+ if (carriesUsage) state.sync.sessionsRestoredFromLedger += 1
457
462
  }
458
463
  }
459
464
  if (state.sync.sessionsRestoredFromLedger > 0) markStatsChanged('scan')
@@ -559,31 +564,33 @@ export function createSessionSync(host) {
559
564
  installWorkspaceSnapshot(next)
560
565
  return
561
566
  }
562
- // Remove path: subtract every session that belonged to a deregistered workspace.
567
+ // Retire path: a deregistered workspace keeps everything it recorded. The
568
+ // ledger's promise is that flushed usage survives deletion, so sessions are
569
+ // re-applied under the shared deleted bucket instead of being subtracted and
570
+ // dropped. New sessions still cannot enter the workspace: pathIndex only
571
+ // ever carries registered workspaces, so workspaceForCwd() no longer
572
+ // resolves its cwd and the scan skips it.
563
573
  if (removedWs.size > 0) {
564
- const removedSids = new Set()
574
+ const retiredSids = new Set()
565
575
  for (const [sid, record] of state.ledgerRecords) {
566
- if (record !== null && record !== undefined && typeof record.workspaceId === 'string' && removedWs.has(record.workspaceId)) removedSids.add(sid)
576
+ if (record === null || record === undefined || !removedWs.has(record.workspaceId)) continue
577
+ retiredSids.add(sid)
567
578
  }
568
- for (const [sid, id] of state.memberOf) if (removedWs.has(id)) removedSids.add(sid)
569
- for (const sid of removedSids) {
579
+ for (const [sid, id] of state.memberOf) if (removedWs.has(id)) retiredSids.add(sid)
580
+ let moved = 0
581
+ for (const sid of retiredSids) {
570
582
  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)
583
+ // Re-key the live aggregate first: usage folded from the feed but not yet
584
+ // persisted has no ledger row to replay, and a session the registry no
585
+ // longer lists is never re-scanned afterwards.
586
+ moved += host.aggregation.retargetSession(sid, RETIRED_WORKSPACE_ID)
587
+ const record = state.ledgerRecords.get(sid)
588
+ // Keep the in-memory record aligned with the aggregate; the persisted row
589
+ // keeps its original workspace id and cwd, so recovery re-derives this.
590
+ if (record !== undefined && (record.turns.length > 0 || record.usage.length > 0)) moved += 1
591
+ if (record !== undefined) state.ledgerRecords.set(sid, retargetLedgerRecord(record))
586
592
  }
593
+ if (moved > 0) state.retiredBucketUsed = true
587
594
  }
588
595
  installWorkspaceSnapshot(next)
589
596
  // Add path: scan only the sessions that belong to newly registered workspaces.
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.9",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",