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 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.5**
108
+ **v1.1.10**
109
109
 
110
- - **删除工作区不再丢数据**:工作区被删除后,已记录的用量不再从统计中移除,而是与其它已删工作区一起汇总为一行「已删除」(含尚未落账的实时用量);磁盘账本行保留原工作区 id 与 cwd,因此可逆、可在重启后重建同一个桶。
111
- - **工作区注册探针**:自动跟随 DSH 工作区注册表(`domain/changed` 事件);只对新增/删除的工作区增量重扫,未变化工作区直接复用账本,零全量重扫。移除了手动“刷新工作区”按钮与其 `POST /api/all-usage/workspaces/refresh` 路由。
112
- - **严格注册边界**:统计只包含 cwd 能映射到已注册工作区的会话;未登记目录(即使存在)与历史 `unregistered:` 账本行不再进入统计。已删除工作区的**历史**用量保留(汇总为「已删除」行),但其目录不会再接纳新会话。
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.5**
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
- - Added validated context-tiered official pricing, expandable rate schedules, and explicit price overrides.
324
- - **Performance optimizations**: the durable ledger uses stable shards, dirty-flush coalescing, and revision reuse; scoped queries, trends, and the fixed 53-week heatmap use ingest-time aggregates to reduce historical scans, storage write amplification, and Dashboard rerenders.
325
- - Hardened deterministic pricing catalogs, pricing sync, official-model search, and mapping refresh races.
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
 
@@ -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
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
410
- try {
411
- const rows = await sessionPersistence.listSnapshots()
412
- if (state.disposed || generation !== state.aggregationGeneration) return
413
- if (Array.isArray(rows)) {
414
- state.sync.persistenceSnapshotsAvailable = true
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
- // The first baseline reads the registry with the latest list, so a probe
547
- // arriving before it completes has nothing to add.
548
- 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
+ }
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
- if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
610
- try {
611
- const rows = await sessionPersistence.listSnapshots()
612
- if (Array.isArray(rows)) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.1.9",
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",