dsh-all-usage 1.1.9 → 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/CHANGELOG.md +16 -0
- package/README.md +8 -36
- package/lib/plugin.js +16 -0
- package/lib/session-sync.js +70 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `dsh-all-usage` are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.1.10] - 2026-09-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The ledger fast path is back.** DSH 0.1.5 renamed `sessionPersistence.listSnapshots()` to `list()`, so the plugin never obtained a per-session revision: `sessionsSkippedByRevision` stayed at 0 and every baseline re-read every session log (431 reads / ~13 minutes for 479 sessions on the maintainer's host). Both spellings are now accepted; against a real 0.1.5 service the plugin reports `persistenceSnapshotsAvailable: true` and skips unchanged sessions again.
|
|
10
|
+
- **A registry change during a baseline is no longer dropped.** `synchronizeWorkspaceRegistry()` returned early while `scan.done` was false, so a workspace added during the (previously very long) scan stayed invisible until the next restart. Such a change is now remembered and replayed as soon as the scan settles.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The workspace registry is additionally polled every 30 seconds, so a missed `domain/changed` event can no longer leave the workspace list stale until a restart.
|
|
15
|
+
- `/api/all-usage/status` exposes `workspaceProbeEvents`, `workspaceSyncRuns`, `workspaceSyncAdded`, `workspaceSyncRemoved` and `workspaceSyncDeferred` (cumulative, not reset by a baseline) so the probe can be verified on a live host.
|
|
16
|
+
|
|
17
|
+
### Note
|
|
18
|
+
|
|
19
|
+
- The first start after upgrading still reads every log once: ledger records written before this release carry no `lastRevision`, so the revision comparison has nothing to match against. From the next start on, unchanged sessions are applied from the ledger without re-reading their logs.
|
|
20
|
+
|
|
5
21
|
## [1.1.9] - 2026-09-12
|
|
6
22
|
|
|
7
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -105,25 +105,11 @@ node scripts/replay-fixture.mjs fixtures/usage-events.json
|
|
|
105
105
|
|
|
106
106
|
### 最近更新
|
|
107
107
|
|
|
108
|
-
**v1.1.
|
|
108
|
+
**v1.1.10**
|
|
109
109
|
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
|
|
114
|
-
**v1.1.4**
|
|
115
|
-
|
|
116
|
-
- **模型品牌图标**:请求日志、选中调用详情、模型统计表、成本设置匹配表、环形图图例与模型筛选下拉现在显示真实厂商品牌 SVG(DeepSeek、OpenAI、Claude、Gemini、Meta、Zhipu、xAI、Qwen、Doubao、Kimi、MiniMax);模型命名空间优先于 DSH 供应商名,未知或混牌行保持中性。图标在构建期以 data URI 内嵌并由 scripts/svg-guard.mjs 校验(实体解码、CSS 转义/CDO/命名空间、url()/image-set() 均覆盖,注释与文本不误杀),运行时零网络请求。
|
|
117
|
-
- **构建安全加固**:补齐 CDO/CDC 邻接 at-import、命名空间 style 元素、CSS 转义 url() 与外部 image-set() 四类绕过,并把 CSS 检查收窄到 style 属性与 style 体;对抗用例现在断言拒绝原因。
|
|
118
|
-
- 三张用量仪表盘截图刷新为当前 UI。
|
|
119
|
-
|
|
120
|
-
**v1.1.3**
|
|
121
|
-
|
|
122
|
-
- 成本统计支持经验证的 context-tiered 官方费率、可展开费率表和显式价格覆盖。
|
|
123
|
-
- **性能优化**:账本采用稳定分片、dirty flush 合并和 revision 快路径;查询、趋势和固定 53 周热力图改用写入时聚合索引,减少历史扫描、存储写放大和 Dashboard 重渲染。
|
|
124
|
-
- 定价目录、价格同步、模型检索和映射编辑强化确定性与并发刷新边界。
|
|
125
|
-
- 加强非法序列、旧账本、工作区重建、滞后 persistence revision 与复合账本键的恢复保护。
|
|
126
|
-
- 增加 Node 22/24、DSH rc.1/rc.2 runtime smoke、脱敏 fixture replay 和包内容发布门禁。
|
|
110
|
+
- **恢复账本的 revision 快路径**:DSH 0.1.5 把 `sessionPersistence.listSnapshots()` 改名为 `list()`,插件因此拿不到每会话 revision,`sessionsSkippedByRevision` 恒为 0、每次启动都重读全部会话日志(实测 479 个会话读了 431 个、约 13 分钟)。现在两种写法都支持,未变化的会话重新从账本复用。
|
|
111
|
+
- **扫描期间的工作区变更不再被丢弃**:以前基线运行中到达的注册表变更会被直接忽略,导致新增工作区必须重启才出现;现在会被记住并在扫描结束后立即补跑,另有每 30 秒的注册表轮询兜底。
|
|
112
|
+
- 升级后**首次**启动仍会全量读一次(旧账本记录没有 `lastRevision` 字段),此后启动走快路径。
|
|
127
113
|
|
|
128
114
|
完整版本记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
129
115
|
|
|
@@ -306,25 +292,11 @@ The command loads the real plugin Host, calls its compatible APIs, checks the do
|
|
|
306
292
|
|
|
307
293
|
### Latest Update
|
|
308
294
|
|
|
309
|
-
**v1.1.
|
|
310
|
-
|
|
311
|
-
- **Deleting a workspace no longer loses data**: usage already recorded for a removed workspace is no longer subtracted; it is summed with every other removed workspace into one "Deleted" row (including usage folded from the live feed that had not been persisted yet). Persisted ledger rows keep their original workspace id and cwd, so the mapping stays reversible and the same bucket is rebuilt after a restart.
|
|
312
|
-
- **Workspace registry probe**: all-usage follows DSH's workspace registry automatically through the `domain/changed` event; only added/removed workspaces are reprocessed incrementally while unchanged workspaces reuse their ledger with zero rescan. The manual Refresh workspaces control and its `POST /api/all-usage/workspaces/refresh` route were removed.
|
|
313
|
-
- **Strict registration boundary**: only sessions whose cwd maps to a registered workspace are counted; unregistered directories (even existing ones) and legacy `unregistered:` ledger rows never re-enter statistics. Usage already recorded for a deleted workspace is kept in the "Deleted" row, but its directory never accepts new sessions again.
|
|
314
|
-
|
|
315
|
-
**v1.1.4**
|
|
316
|
-
|
|
317
|
-
- **Vendor brand icons**: request logs, selected-call details, the model summary table, the cost-settings match table, donut legends, and the model filter dropdown now render real vendor brand SVGs (DeepSeek, OpenAI, Claude, Gemini, Meta, Zhipu, xAI, Qwen, Doubao, Kimi, MiniMax); the model namespace wins over the DSH provider name, and unknown or mixed-brand rows stay neutral. Icons are embedded as data URIs at build time and validated by scripts/svg-guard.mjs (entity decoding, CSS escapes/CDO/namespaced styles, url()/image-set() all covered, comments and text stay inert), with zero runtime network access.
|
|
318
|
-
- **Build hardening**: per the independent audit, closed four bypass classes (CDO/CDC-adjacent at-import, namespaced style elements, CSS-escaped url(), external image-set()) and scoped the CSS checks to style attributes and bodies; adversarial fixtures now assert the rejection reason.
|
|
319
|
-
- Refreshed the three usage-dashboard screenshots to the current UI.
|
|
320
|
-
|
|
321
|
-
**v1.1.3**
|
|
295
|
+
**v1.1.10**
|
|
322
296
|
|
|
323
|
-
-
|
|
324
|
-
- **
|
|
325
|
-
-
|
|
326
|
-
- Strengthened recovery for invalid sequences, legacy ledgers, recreated workspaces, lagging persistence revisions, and composite ledger keys.
|
|
327
|
-
- Added Node 22/24, DSH rc.1/rc.2 runtime smoke, redacted fixture replay, and package-content release gates.
|
|
297
|
+
- **The ledger fast path is back**: DSH 0.1.5 renamed `sessionPersistence.listSnapshots()` to `list()`, so no per-session revision was ever obtained — `sessionsSkippedByRevision` stayed at 0 and every start re-read every session log (431 reads / ~13 minutes for 479 sessions). Both spellings are accepted now, and unchanged sessions are applied from the ledger again.
|
|
298
|
+
- **A registry change during a baseline is no longer dropped**: a change arriving mid-scan used to be ignored, so a newly added workspace only appeared after a restart. It is now replayed as soon as the scan settles, with a 30-second registry poll as a safety net.
|
|
299
|
+
- The first start after upgrading still reads every log once (older ledger records carry no `lastRevision`); later starts take the fast path.
|
|
328
300
|
|
|
329
301
|
See [CHANGELOG.md](CHANGELOG.md) for the complete version history.
|
|
330
302
|
|
package/lib/plugin.js
CHANGED
|
@@ -70,6 +70,14 @@ function apply(ctx) {
|
|
|
70
70
|
sessionsSkippedByRevision: 0,
|
|
71
71
|
sessionsRestoredFromLedger: 0,
|
|
72
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,
|
|
73
81
|
},
|
|
74
82
|
ledgerRecords: new Map(),
|
|
75
83
|
queryCache: new Map(),
|
|
@@ -105,6 +113,8 @@ function apply(ctx) {
|
|
|
105
113
|
reconcileInFlight: false,
|
|
106
114
|
reconcileTimer: null,
|
|
107
115
|
workspaceSyncTimer: null,
|
|
116
|
+
workspaceSyncDeferred: false,
|
|
117
|
+
workspaceProbeTimer: null,
|
|
108
118
|
workspaceSyncInFlight: false,
|
|
109
119
|
workspaceSyncPending: false,
|
|
110
120
|
}
|
|
@@ -172,6 +182,11 @@ function apply(ctx) {
|
|
|
172
182
|
sessionsSkippedByRevision: state.sync.sessionsSkippedByRevision,
|
|
173
183
|
sessionsRestoredFromLedger: state.sync.sessionsRestoredFromLedger,
|
|
174
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,
|
|
175
190
|
}
|
|
176
191
|
}
|
|
177
192
|
Object.assign(host, { commitPendingStats, markStatsChanged, resetSyncState, beginSync, noteSyncError, syncSnapshot })
|
|
@@ -277,6 +292,7 @@ function apply(ctx) {
|
|
|
277
292
|
state.pricingReady = host.pricing.loadPricing().then(() => { host.pricing.schedulePricingSync() })
|
|
278
293
|
void host.sessionSync.runBaseline()
|
|
279
294
|
host.sessionSync.scheduleReconcileTimer()
|
|
295
|
+
host.sessionSync.scheduleWorkspaceProbeTimer()
|
|
280
296
|
state.aliasesReady = loadAliases()
|
|
281
297
|
}
|
|
282
298
|
|
package/lib/session-sync.js
CHANGED
|
@@ -4,6 +4,10 @@ import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes,
|
|
|
4
4
|
const RECONCILE_INTERVAL_MS = 120000
|
|
5
5
|
const RECONCILE_HINT_DELAY_MS = 3000
|
|
6
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
|
|
7
11
|
|
|
8
12
|
export function createSessionSync(host) {
|
|
9
13
|
const { ctx, state } = host
|
|
@@ -154,6 +158,25 @@ export function createSessionSync(host) {
|
|
|
154
158
|
if (typeof cwd !== 'string' || cwd === '') return undefined
|
|
155
159
|
return state.pathIndex.get(workspacePathKey(cwd))
|
|
156
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
|
+
}
|
|
157
180
|
function wsForLiveSession(session, sid) {
|
|
158
181
|
let wsId = state.memberOf.get(sid)
|
|
159
182
|
if (wsId !== undefined) return wsId
|
|
@@ -406,24 +429,18 @@ export function createSessionSync(host) {
|
|
|
406
429
|
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
407
430
|
// v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
|
|
408
431
|
let snapshots = null
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
snapshots = new Map()
|
|
416
|
-
for (const row of rows) {
|
|
417
|
-
const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
|
|
418
|
-
if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
} catch (err) {
|
|
422
|
-
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
423
|
-
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
424
|
-
state.sync.persistenceSnapshotsAvailable = false
|
|
425
|
-
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
|
|
426
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')
|
|
427
444
|
}
|
|
428
445
|
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
429
446
|
if (setupFailed || !Array.isArray(records)) {
|
|
@@ -504,6 +521,10 @@ export function createSessionSync(host) {
|
|
|
504
521
|
state.knownSessionIds.clear()
|
|
505
522
|
for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
|
|
506
523
|
state.scan.done = true
|
|
524
|
+
if (state.workspaceSyncDeferred) {
|
|
525
|
+
state.workspaceSyncDeferred = false
|
|
526
|
+
scheduleWorkspaceSync()
|
|
527
|
+
}
|
|
507
528
|
state.sync.lastCompletedAt = Date.now()
|
|
508
529
|
if (state.sync.sessionsFailed === 0) {
|
|
509
530
|
state.sync.lastErrorAt = 0
|
|
@@ -529,6 +550,7 @@ export function createSessionSync(host) {
|
|
|
529
550
|
return
|
|
530
551
|
}
|
|
531
552
|
state.workspaceSyncInFlight = true
|
|
553
|
+
state.sync.workspaceSyncRuns += 1
|
|
532
554
|
try {
|
|
533
555
|
await synchronizeWorkspaceRegistry()
|
|
534
556
|
} catch (err) {
|
|
@@ -543,9 +565,15 @@ export function createSessionSync(host) {
|
|
|
543
565
|
}
|
|
544
566
|
}
|
|
545
567
|
async function synchronizeWorkspaceRegistry() {
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
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
|
+
}
|
|
549
577
|
let workspaces
|
|
550
578
|
try {
|
|
551
579
|
workspaces = ctx.workspaceRegistry.list()
|
|
@@ -559,6 +587,8 @@ export function createSessionSync(host) {
|
|
|
559
587
|
for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
|
|
560
588
|
const addedWs = new Set()
|
|
561
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
|
|
562
592
|
// Reuse path: no membership change — metadata only, zero rescan.
|
|
563
593
|
if (removedWs.size === 0 && addedWs.size === 0) {
|
|
564
594
|
installWorkspaceSnapshot(next)
|
|
@@ -606,20 +636,10 @@ export function createSessionSync(host) {
|
|
|
606
636
|
state.sync.sessionsTotal = records.length
|
|
607
637
|
markStatsChanged('scan')
|
|
608
638
|
let snapshots = null
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
snapshots = new Map()
|
|
614
|
-
for (const row of rows) {
|
|
615
|
-
const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
|
|
616
|
-
if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
} catch (err) {
|
|
620
|
-
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
621
|
-
markStatsChanged('scan')
|
|
622
|
-
}
|
|
639
|
+
try {
|
|
640
|
+
snapshots = await loadSessionRevisions()
|
|
641
|
+
} catch (err) {
|
|
642
|
+
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
623
643
|
}
|
|
624
644
|
for (const record of records) {
|
|
625
645
|
if (state.disposed) return
|
|
@@ -698,6 +718,20 @@ export function createSessionSync(host) {
|
|
|
698
718
|
}, RECONCILE_INTERVAL_MS)
|
|
699
719
|
if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
|
|
700
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
|
+
}
|
|
701
735
|
|
|
702
736
|
// ---------- live feed ----------
|
|
703
737
|
ctx.on('session/event', (session, event) => {
|
|
@@ -775,6 +809,7 @@ export function createSessionSync(host) {
|
|
|
775
809
|
if (change === undefined || change === null) return
|
|
776
810
|
if (change.domain !== 'workspace') return
|
|
777
811
|
if (change.table !== 'workspaces' && change.table !== '') return
|
|
812
|
+
state.sync.workspaceProbeEvents += 1
|
|
778
813
|
scheduleWorkspaceSync()
|
|
779
814
|
})
|
|
780
815
|
|
|
@@ -800,6 +835,7 @@ export function createSessionSync(host) {
|
|
|
800
835
|
reconcileSessions,
|
|
801
836
|
scheduleReconcileHint,
|
|
802
837
|
scheduleReconcileTimer,
|
|
838
|
+
scheduleWorkspaceProbeTimer,
|
|
803
839
|
markLedgerDirty
|
|
804
840
|
}
|
|
805
841
|
}
|