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/CHANGELOG.md +12 -0
- package/README.md +9 -7
- package/lib/aggregation.js +50 -1
- package/lib/client.js +1 -1
- package/lib/ledger.js +20 -1
- package/lib/plugin.js +6 -2
- package/lib/session-sync.js +35 -28
- package/package.json +1 -1
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]
|
package/lib/session-sync.js
CHANGED
|
@@ -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
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
//
|
|
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
|
|
574
|
+
const retiredSids = new Set()
|
|
565
575
|
for (const [sid, record] of state.ledgerRecords) {
|
|
566
|
-
if (record
|
|
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))
|
|
569
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
state.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
state.
|
|
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.
|