dsh-all-usage 1.1.4 → 1.1.6
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 +25 -3
- package/README.md +34 -8
- package/lib/ledger.js +24 -3
- package/lib/plugin.js +3 -0
- package/lib/session-sync.js +269 -113
- package/lib/usage-core.js +7 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `dsh-all-usage` are documented here.
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [1.1.6] - 2026-09-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- DSH 0.1.5 compatibility: that runtime replaced the Session `events` getter with `snapshotEvents()`, so the live flush path read an empty event log and folded every flushed session into an empty ledger record (`lastSeq: -1`, no turns, no usage). The dashboard stayed correct because live totals come from the `session/event` hook, but the durable "usage survives session deletion" guarantee silently degraded and restart lost the zero-rescan fast path. Event access now prefers `session.events` and falls back to `session.snapshotEvents()`, leaving 0.1.1 behaviour unchanged.
|
|
10
|
+
- The runtime smoke test now asserts the persisted ledger keeps the flushed session's sequence and usage, and covers 0.1.5-rc.1 alongside the 0.1.1 line, so this regression cannot return unnoticed.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Declared DSH runtime compatibility widened to `>=0.1.1-rc.1 <0.1.6`, with `0.1.5-rc.1` added to the verified list; the runtime smoke test pins the Cordis/loader/timer lines per runtime version.
|
|
15
|
+
|
|
16
|
+
## [1.1.5] - 2026-09-07
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Workspace registries are now kept fresh by an automatic **probe**: all-usage listens to DSH's `domain/changed` event for the workspace domain and, on any durable registry write (create/delete/rename/reorder/archive/attach/detach), rereads `workspaceRegistry.list()`. The sync is incremental — only added workspaces are scanned (the sessions that belong to them) and removed workspaces have their sessions subtracted via inverse folding, while unchanged workspaces reuse their computed aggregates and ledger with zero rescan. The manual Refresh workspaces control, its `POST /api/all-usage/workspaces/refresh` route, and the refresh-workspaces client button were removed. Usage remains intentionally restricted to registered workspaces; unregistered cwds are ignored.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Usage is strictly limited to DSH-registered workspaces: sessions whose cwd does not map to `workspaceRegistry.list()` (including existing directories absent from the registry) are ignored, and legacy `unregistered:` ledger rows are never resurrected as history.
|
|
25
|
+
- Ledger rows whose persisted `sourceCwd` no longer maps to a registered workspace (for example the session directory was removed) are skipped during recovery instead of being restored as stale data; the workspace source path is now persisted with every ledger record so restart recovery can validate ownership.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## [1.1.4] - 2026-09-03
|
|
6
29
|
|
|
7
30
|
### Added
|
|
8
31
|
|
|
@@ -38,8 +61,6 @@ All notable changes to `dsh-all-usage` are documented here.
|
|
|
38
61
|
- Build-time SVG validation now decodes XML entities before checking, rejects unquoted URI attributes, external CSS url()/image-set() and @import targets (including escaped/commented/CDO-CDC/namespaced <svg:style> forms), DOCTYPE/CDATA/processing instructions and every external-resource element; the CSS checks run only on style attributes and <style> bodies so icon text and comments stay inert. It lives in scripts/svg-guard.mjs so the tests exercise the exact same guard with adversarial fixtures.
|
|
39
62
|
- Bundle the icon provenance required for release: the manifest pins the upstream commit, records per-file upstream paths and modification flags, and assets/model-icons/LICENSE.upstream-lobe-icons.txt ships the MIT notice, copyright, trademark disclaimer and the local modifications. The build fails if the pinned revision, licence file or per-file attribution is missing.
|
|
40
63
|
|
|
41
|
-
## [1.1.4] - 2026-09-03
|
|
42
|
-
|
|
43
64
|
### Fixed
|
|
44
65
|
|
|
45
66
|
- Hardened the model-icon build guard after an independent audit: the CSS checks now reject CDO/CDC-adjacent `@import`, external `@import` inside namespaced `<svg:style>` elements, CSS-escaped `url(` identifiers and external `image-set()` targets, all matching Blink's own tokenizer. Style-attribute `url(...)` references are checked on both the raw and entity-decoded document so `"` quoting cannot split a payload while encoded `@import` is still caught after decoding.
|
|
@@ -235,6 +256,7 @@ This release reduces historical scan work, storage write amplification, and Dash
|
|
|
235
256
|
- Allowed same-origin browser balance GET requests that omit `Origin` while retaining token protection.
|
|
236
257
|
- Standardized English date buckets, range filters, streaks, heatmap dates, and export timestamps on UTC.
|
|
237
258
|
|
|
259
|
+
[1.1.5]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.1.5
|
|
238
260
|
[1.1.4]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.1.4
|
|
239
261
|
[1.1.3]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.1.3
|
|
240
262
|
[1.1.2]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.1.2
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
11
11
|
### 功能
|
|
12
12
|
|
|
13
13
|
- **热力图**:53 周使用热力图;按工作区筛选并查看每日回合与 Token 明细
|
|
14
|
-
- **模型统计**:支持混合查看、按模型合并、按供应商汇总三种维度,展示调用次数、各类 Token
|
|
14
|
+
- **模型统计**:支持混合查看、按模型合并、按供应商汇总三种维度,展示调用次数、各类 Token 与缓存命中率;模型行与筛选下拉显示真实厂商品牌 SVG 图标(未知/混牌保持中性)
|
|
15
15
|
- **摘要与工作区**:Token 用量、缓存命中、估算成本、账户余额、连续使用、工作区 Token 分布和明细
|
|
16
16
|
- **成本统计**:从 models.dev 同步模型价格;按输入、输出、缓存读取和缓存写入四个桶计算,保存价格快照,明确区分已计价、免费模型和未计价调用
|
|
17
17
|
- **导出**:按当前时间范围和模型聚合方式导出 CSV
|
|
@@ -21,6 +21,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
21
21
|
- **完整历史与增量重建**:基线扫描全部可读历史会话;独立用量账本同时作为每会话游标——未变化的会话直接复用账本,新增事件只增量回填,长历史重启不再全量重建
|
|
22
22
|
- **重启免读**:用持久化日志的 revision 作为每会话的变更信号(只读头部行 + stat,不读全量)——日志未变的会话重启时连事件都不读,直接从账本复用;仅日志变化(新增/修改)的会话才做增量读取
|
|
23
23
|
- **数据健康与按需刷新**:扫描完成后浏览器只检查轻量状态版本,只有用量、别名或同步状态变化时才拉完整历史;显示本次数据更新时间、历史扫描健康、revision 免读、实际读取、账本恢复和失败,网络异常保留上次成功数据并可重试
|
|
24
|
+
- **工作区注册同步**:监听 DSH 的 `domain/changed` 事件自动跟随工作区注册表——注册表一有改动(创建/删除/重命名/重排/归档/成员变化)就重读 `workspaceRegistry.list()`;只对新增/删除的工作区做增量处理,未变化的已有工作区直接复用已计算账本(零重扫)。统计严格限定已注册工作区:未注册 cwd(含存在但未登记目录)一律忽略
|
|
24
25
|
- **性能优化**:Host 在 ingest 时维护 local/UTC 的日期、工作区、模型身份日级 cube 与单日小时桶;scope 查询按 bucket 合并,成本使用精确 BigInt 小数累加,53 周热力图只生成实际需要的字段,并继续使用 revision-scoped snapshot/records 缓存和可回收的实时事件队列;Client 将热力图、tooltip、趋势、环形图、请求日志和定价对话框隔离为 memoized 边界,指针坐标通过 ref + requestAnimationFrame 更新,不再触发整页重渲染;浏览器入口在打包前确定性压缩
|
|
25
26
|
- **趋势折线图**:按当前范围、时区、工作区、供应商和模型显示输入、缓存读写、输出、推理及总处理量;单日范围按小时聚合并显示小时轴,跨日范围按日聚合;使用平滑单调曲线与入场动画,悬停查看精确值,图例可切换曲线,点击点位进入当日明细
|
|
26
27
|
- **统一筛选与审计**:工作区、供应商、模型和日期筛选贯穿摘要、热力图、趋势、表格与 CSV;工作区、供应商、模型三个筛选维度可独立自由组合,工作区、供应商和模型选项只展示当前日期范围内实际使用过的值;切换范围后失效筛选会自动清除;请求日志以紧凑分页表常驻显示,选择单条后查看分组 Token 详情
|
|
@@ -30,11 +31,12 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
30
31
|
### 兼容性与已知限制
|
|
31
32
|
|
|
32
33
|
- **运行环境**:需要 Node.js `>=22 <25`;CI 会在 Node 22 和 Node 24 上运行测试、语法检查和 npm 包内容检查。
|
|
33
|
-
- **DSH 兼容**:`package.json` 声明 DSH runtime `>=0.1.1-rc.1 <0.1.2`,已使用 `0.1.1-rc.2` 和 `0.1.1-rc.1` 的真实 Cordis
|
|
34
|
+
- **DSH 兼容**:`package.json` 声明 DSH runtime `>=0.1.1-rc.1 <0.1.2`,已使用 `0.1.1-rc.2` 和 `0.1.1-rc.1` 的真实 Cordis 服务链验证;`0.1.2-rc.1` 已通过本人实际使用验证兼容,但尚未纳入 CI smoke 矩阵。
|
|
34
35
|
- **Web 服务依赖**:Host 将 `webServer` 声明为必需依赖,确保服务晚挂载时由 DSH 等待后再执行插件;该包面向 DSH Web profile,不提供无 WebServer 的 headless 路由。HTTP 守卫还会检查真实 socket peer,反向代理只有在连接本身来自 loopback 时才会被接受。
|
|
35
36
|
|
|
36
37
|
| DSH runtime | Node.js 支持 | 真实 Cordis smoke | 结论 |
|
|
37
38
|
| --- | --- | --- | --- |
|
|
39
|
+
| `0.1.2-rc.1` | `>=22 <25` | 通过(本人实际使用验证,未纳入 CI) | 已实际验证兼容 |
|
|
38
40
|
| `0.1.1-rc.2` | `>=22 <25`,CI 覆盖 22/24 | 通过(当前 Node 24) | 已声明、已验证 |
|
|
39
41
|
| `0.1.1-rc.1` | `>=22 <25`,CI 覆盖 22/24 | 通过(当前 Node 24) | 已声明、已验证 |
|
|
40
42
|
| 其他版本 | `>=22 <25` | 未测试 | 不在已验证矩阵内 |
|
|
@@ -44,7 +46,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
44
46
|
- **中断请求**:上游请求被中断时可能只有 `assistant/chunk` 的 usage,没有最终 `assistant/message`;本插件会保留该 chunk 用量。同一 `turn / step` 后续出现最终 message 时,message 会替换 chunk。若上游完全没有 usage 事件,则无法从响应内容精确恢复 Token。
|
|
45
47
|
- **估算成本**:成本是基于 models.dev 价格和 DSH usage 桶的估算,不是供应商账单;目录不可用或模型没有官方匹配时不会猜测价格,而是显示未计价。缓存读取、缓存写入和 reasoning 的口径取决于 DSH 上游事件。
|
|
46
48
|
- **分层价格**:models.dev 的 tiered/context-dependent 价格按本次请求的输入上下文(fresh input + cache read + cache write)选择对应档位;阈值边界遵循目录定义,无法验证的异常 tier 仍显示为 unsupported。
|
|
47
|
-
-
|
|
49
|
+
- **工作区边界**:只有 cwd 能映射到 DSH 已注册工作区的会话才进入统计;未注册 cwd(包括已存在但未在 registry 中登记的目录)会被忽略。工作区注册列表通过 DSH 的 `domain/changed` 探针自动同步:注册表一有改动就重读并只对新增/删除的工作区做增量处理,未变化的已有工作区直接复用已计算账本,不会全量重扫。会话尚未成功 flush 前删除或损坏的日志无法由独立账本恢复。
|
|
48
50
|
|
|
49
51
|
### 本地统计与官方账单
|
|
50
52
|
|
|
@@ -53,7 +55,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
53
55
|
- 本地统计读取 DSH 的 `assistant/chunk`、最终 `assistant/message` 和其他会话事件,按同一 `turn / step` 去重和替换;官方账单可能按供应商自己的请求、分词器、舍入、折扣、免费额度和结算周期计算。
|
|
54
56
|
- 失败请求只要留下 usage chunk,就会进入本地统计;供应商是否对该失败请求收费,应以官方账单为准。
|
|
55
57
|
- 价格来自 models.dev 的公开模型目录和本地显式覆盖;目录价格、供应商实际价格、区域费率和账单折扣可能不同。成本字段应理解为估算值。
|
|
56
|
-
-
|
|
58
|
+
- 本地统计只包含已注册工作区;未注册 cwd 与已删除目录的旧 ledger 不进入统计,并可能因日志损坏、清理或上游没有发出 usage 而少于官方账单。
|
|
57
59
|
|
|
58
60
|
### 可复现事件示例
|
|
59
61
|
|
|
@@ -102,6 +104,17 @@ node scripts/replay-fixture.mjs fixtures/usage-events.json
|
|
|
102
104
|
|
|
103
105
|
### 最近更新
|
|
104
106
|
|
|
107
|
+
**v1.1.5**
|
|
108
|
+
|
|
109
|
+
- **工作区注册探针**:自动跟随 DSH 工作区注册表(`domain/changed` 事件);只对新增/删除的工作区增量重扫,未变化工作区直接复用账本,零全量重扫。移除了手动“刷新工作区”按钮与其 `POST /api/all-usage/workspaces/refresh` 路由。
|
|
110
|
+
- **严格注册边界**:统计只包含 cwd 能映射到已注册工作区的会话;未登记目录(即使存在)与历史 `unregistered:` 账本行不再进入统计,已删除目录的旧账本行在恢复时跳过(`sourceCwd` 现在随每条账本持久化以校验归属)。
|
|
111
|
+
|
|
112
|
+
**v1.1.4**
|
|
113
|
+
|
|
114
|
+
- **模型品牌图标**:请求日志、选中调用详情、模型统计表、成本设置匹配表、环形图图例与模型筛选下拉现在显示真实厂商品牌 SVG(DeepSeek、OpenAI、Claude、Gemini、Meta、Zhipu、xAI、Qwen、Doubao、Kimi、MiniMax);模型命名空间优先于 DSH 供应商名,未知或混牌行保持中性。图标在构建期以 data URI 内嵌并由 scripts/svg-guard.mjs 校验(实体解码、CSS 转义/CDO/命名空间、url()/image-set() 均覆盖,注释与文本不误杀),运行时零网络请求。
|
|
115
|
+
- **构建安全加固**:补齐 CDO/CDC 邻接 at-import、命名空间 style 元素、CSS 转义 url() 与外部 image-set() 四类绕过,并把 CSS 检查收窄到 style 属性与 style 体;对抗用例现在断言拒绝原因。
|
|
116
|
+
- 三张用量仪表盘截图刷新为当前 UI。
|
|
117
|
+
|
|
105
118
|
**v1.1.3**
|
|
106
119
|
|
|
107
120
|
- 成本统计支持经验证的 context-tiered 官方费率、可展开费率表和显式价格覆盖。
|
|
@@ -197,7 +210,7 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, est
|
|
|
197
210
|
### Features
|
|
198
211
|
|
|
199
212
|
- **Heatmap**: a 53-week activity heatmap with workspace filters and daily turn/token details
|
|
200
|
-
- **Model analytics**: mixed view, model-merged view, and provider summary with calls, token categories, and cache hit rate
|
|
213
|
+
- **Model analytics**: mixed view, model-merged view, and provider summary with calls, token categories, and cache hit rate; model rows and the model filter dropdown render vendor brand SVG icons (neutral for unknown or mixed brands)
|
|
201
214
|
- **Summary and workspaces**: processed tokens, cache hits, estimated cost, account balance, usage streaks, workspace distribution, and details
|
|
202
215
|
- **Cost statistics**: sync model prices from models.dev, calculate four cost buckets, persist price snapshots, and distinguish priced, free, ambiguous, and unpriced calls
|
|
203
216
|
- **CSV export**: export data using the selected time range and aggregation mode
|
|
@@ -206,6 +219,7 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, est
|
|
|
206
219
|
- **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
|
|
207
220
|
- **Full history & incremental rebuild**: the baseline scans every readable historical session; the durable usage ledger doubles as a per-session cursor, so unchanged sessions are reused straight from the ledger and only newly appended events are folded — long histories restart without a full rebuild
|
|
208
221
|
- **Restart with no re-read**: the persisted log revision (a header-line + stat via `sessionPersistence.listSnapshots()`) acts as a per-session change signal — sessions whose log is unchanged are applied from the ledger on restart without reading their events at all; only changed/new sessions are read incrementally
|
|
222
|
+
- **Workspace registry sync**: follows DSH's `domain/changed` event so the workspace registry stays fresh automatically — any durable registry write (create/delete/rename/reorder/archive/membership) triggers a reread of `workspaceRegistry.list()`, with only added/removed workspaces reprocessed incrementally while unchanged workspaces reuse their computed aggregates and ledger (zero rescan). Usage is strictly limited to registered workspaces: unregistered cwds, including existing directories absent from the registry, are ignored.
|
|
209
223
|
- **Data health and on-demand refresh**: after a scan completes, the browser polls only a lightweight status revision and fetches full history only after usage, alias, or sync state changes; it shows the latest full-data update, historical scan health, revision skips, rereads, ledger recovery, and failures while preserving last-good data on network errors
|
|
210
224
|
- **Performance**: Host maintains ingest-time local/UTC day, workspace, model-identity cubes and single-day hour buckets; scope queries merge buckets, exact costs use BigInt decimal accumulators, and the 53-week heatmap emits only the fields it consumes, while revision-scoped snapshot/records caches and recyclable live-event queues remain in place. Client isolates the heatmap, tooltip, trend, donuts, request records, and pricing dialog behind memoized boundaries; pointer coordinates update through refs plus requestAnimationFrame instead of rerendering the page, and the browser entry is deterministically minified before packing
|
|
211
225
|
- **Trend line chart**: show input, cache read/write, output, reasoning, and total processed tokens for the active range, timezone, workspace, provider, and model scope; use hourly buckets for a single-day scope and daily buckets for cross-day scopes, with smooth monotone curves, staged entrance animation, hover for exact values, and click a point to inspect that day
|
|
@@ -216,11 +230,12 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, est
|
|
|
216
230
|
### Compatibility and Known Limitations
|
|
217
231
|
|
|
218
232
|
- **Runtime**: Node.js `>=22 <25` is required. CI runs the test suite, syntax checks, and package-content checks on Node 22 and Node 24.
|
|
219
|
-
- **DSH compatibility**: `package.json` declares DSH runtime `>=0.1.1-rc.1 <0.1.2`; the real Cordis service chain is verified on `0.1.1-rc.2` and `0.1.1-rc.1`.
|
|
233
|
+
- **DSH compatibility**: `package.json` declares DSH runtime `>=0.1.1-rc.1 <0.1.2`; the real Cordis service chain is verified on `0.1.1-rc.2` and `0.1.1-rc.1`. `0.1.2-rc.1` has also been verified compatible through the maintainer's real-world use, but is not yet covered by the CI smoke matrix.
|
|
220
234
|
- **Web service dependency**: the Host declares `webServer` as a required dependency, so DSH waits for a late-mounted service before applying the plugin; this package targets the DSH Web profile and does not expose routes without WebServer. The HTTP guard also checks the actual socket peer, so a reverse proxy is accepted only when the connection itself is loopback.
|
|
221
235
|
|
|
222
236
|
| DSH runtime | Node.js support | Real Cordis smoke | Conclusion |
|
|
223
237
|
| --- | --- | --- | --- |
|
|
238
|
+
| `0.1.2-rc.1` | `>=22 <25` | Passed through maintainer use (not in CI) | Verified compatible in real-world use |
|
|
224
239
|
| `0.1.1-rc.2` | `>=22 <25`, CI covers 22/24 | Passed (current Node 24) | Declared and verified |
|
|
225
240
|
| `0.1.1-rc.1` | `>=22 <25`, CI covers 22/24 | Passed (current Node 24) | Declared and verified |
|
|
226
241
|
| Other versions | `>=22 <25` | Not tested | Outside the verified matrix |
|
|
@@ -230,7 +245,7 @@ An unlisted DSH version is not necessarily incompatible. Include the DSH, Node.j
|
|
|
230
245
|
- **Interrupted requests**: an interrupted upstream request may emit only `assistant/chunk` usage and never produce a final `assistant/message`; that chunk is retained. A later final message for the same turn/step replaces it. If the upstream emits no usage event at all, exact token usage cannot be reconstructed from response text.
|
|
231
246
|
- **Estimated cost**: cost is an estimate based on models.dev rates and DSH usage buckets, not a provider invoice. Unavailable catalogs and unmatched models remain unpriced instead of receiving guessed rates. Cache reads, cache writes, and reasoning follow the buckets reported by the upstream DSH event.
|
|
232
247
|
- **Tiered prices**: models.dev context-tiered entries select the applicable rate from the request input context (fresh input plus cache read/write tokens); malformed schedules remain unsupported.
|
|
233
|
-
- **
|
|
248
|
+
- **Workspace boundary**: only sessions whose cwd maps to a DSH-registered workspace are included. Unregistered cwds, including existing directories absent from the registry, are ignored. The registry is kept fresh by a `domain/changed` probe: any durable workspace-domain write triggers a reread, and only added/removed workspaces are reprocessed incrementally — unchanged workspaces reuse their computed aggregates and ledger. Data deleted or corrupted before a successful session flush cannot be recovered from the separate ledger.
|
|
234
249
|
|
|
235
250
|
### Local Statistics vs Official Billing
|
|
236
251
|
|
|
@@ -239,7 +254,7 @@ This plugin reports replayable statistics from local DSH event logs; it is not a
|
|
|
239
254
|
- Local statistics read DSH `assistant/chunk`, final `assistant/message`, and related session events, then deduplicate and replace samples by logical `turn / step`. Official billing may use a provider tokenizer, rounding rules, discounts, free quotas, and billing periods.
|
|
240
255
|
- A failed request is included locally whenever it leaves a usage chunk; whether the provider charged for that failed request must be checked against the official bill.
|
|
241
256
|
- Prices come from the public models.dev catalog and local explicit overrides. Catalog prices can differ from provider prices, regional rates, and invoice discounts, so the cost field is an estimate.
|
|
242
|
-
- Local statistics include only
|
|
257
|
+
- Local statistics include registered workspaces only; unregistered cwds and old ledger rows for deleted directories are excluded, and totals can still be lower than the official bill when logs are damaged, cleaned up, or the upstream emits no usage event.
|
|
243
258
|
|
|
244
259
|
### Reproducible Event Examples
|
|
245
260
|
|
|
@@ -288,6 +303,17 @@ The command loads the real plugin Host, calls its compatible APIs, checks the do
|
|
|
288
303
|
|
|
289
304
|
### Latest Update
|
|
290
305
|
|
|
306
|
+
**v1.1.5**
|
|
307
|
+
|
|
308
|
+
- **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.
|
|
309
|
+
- **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, and ledger rows for deleted directories are skipped on recovery (`sourceCwd` is now persisted with every ledger record so ownership can be validated after a restart).
|
|
310
|
+
|
|
311
|
+
**v1.1.4**
|
|
312
|
+
|
|
313
|
+
- **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.
|
|
314
|
+
- **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.
|
|
315
|
+
- Refreshed the three usage-dashboard screenshots to the current UI.
|
|
316
|
+
|
|
291
317
|
**v1.1.3**
|
|
292
318
|
|
|
293
319
|
- Added validated context-tiered official pricing, expandable rate schedules, and explicit price overrides.
|
package/lib/ledger.js
CHANGED
|
@@ -9,6 +9,24 @@ const LEDGER_UNIT_NAME = 'all_usage_ledger'
|
|
|
9
9
|
const LEDGER_META_KEY = '__all_usage_ledger_meta__'
|
|
10
10
|
const LEDGER_WRITE_DEBOUNCE_MS = 25
|
|
11
11
|
|
|
12
|
+
// DSH 0.1.5 replaced the Session `events` getter with `snapshotEvents()` while
|
|
13
|
+
// 0.1.1 exposed `events`; both return the log in seq order. Read whichever the
|
|
14
|
+
// running runtime provides, because falling back to an empty log would fold a
|
|
15
|
+
// flushed session into an empty ledger record.
|
|
16
|
+
function sessionEvents(session) {
|
|
17
|
+
if (session === null || session === undefined) return []
|
|
18
|
+
if (Array.isArray(session.events)) return session.events
|
|
19
|
+
if (typeof session.snapshotEvents === 'function') {
|
|
20
|
+
try {
|
|
21
|
+
const snapshot = session.snapshotEvents()
|
|
22
|
+
if (Array.isArray(snapshot)) return snapshot
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
// Ledger sequences follow the DSH contract: -1 marks a missing/unavailable
|
|
13
31
|
// sequence, otherwise a non-negative safe integer is required.
|
|
14
32
|
function validLedgerSeq(value) {
|
|
@@ -173,7 +191,7 @@ export function createLedger(host) {
|
|
|
173
191
|
}
|
|
174
192
|
function buildLedgerRecord(session, workspaceId, source = 'scan', revision, previousRecord, sequenceClean = true) {
|
|
175
193
|
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
176
|
-
const events =
|
|
194
|
+
const events = sessionEvents(session)
|
|
177
195
|
if (sid === '' || workspaceId === undefined) return null
|
|
178
196
|
// DSH sequences every persisted event with seq === log index, so the last
|
|
179
197
|
// event's seq is the record's lastSeq in O(1); fall back to a full scan only
|
|
@@ -238,7 +256,8 @@ export function createLedger(host) {
|
|
|
238
256
|
}, { costPrevious: previousUsage.get(key) })
|
|
239
257
|
if (result.accepted && usageEvent.kind === 'message') currentIdentity = identity
|
|
240
258
|
}
|
|
241
|
-
|
|
259
|
+
const sourceCwd = session && session.header && typeof session.header.cwd === 'string' ? session.header.cwd : undefined
|
|
260
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq, source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, sourceRevision: typeof revision === 'string' ? revision : undefined, lastIdentity: currentIdentity, contextTimes: Array.from(contextTimes.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
242
261
|
}
|
|
243
262
|
function normalizeLedgerRecord(raw, key) {
|
|
244
263
|
if (raw === null || typeof raw !== 'object' || (raw.version !== LEDGER_VERSION && raw.version !== PREVIOUS_LEDGER_VERSION && raw.version !== LEGACY_LEDGER_VERSION) || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
@@ -279,7 +298,8 @@ export function createLedger(host) {
|
|
|
279
298
|
}
|
|
280
299
|
const lastUsage = Array.from(usageMap.values()).at(-1)
|
|
281
300
|
const lastIdentity = coerceIdentity(raw.lastIdentity || raw.sourceRevisionIdentity || (lastUsage && lastUsage.identity))
|
|
282
|
-
const
|
|
301
|
+
const sourceCwd = typeof raw.sourceCwd === 'string' && raw.sourceCwd.trim() !== '' ? raw.sourceCwd : undefined
|
|
302
|
+
const normalizedRecord = { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, ...(sourceCwd === undefined ? {} : { sourceCwd }), lastSeq: validLedgerSeq(raw.lastSeq) ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : (typeof raw.sourceRevision === 'string' ? raw.sourceRevision : undefined), sourceRevision: typeof raw.sourceRevision === 'string' ? raw.sourceRevision : (typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined), lastIdentity, contextTimes: Array.from(contextMap.entries()).map(([key, time]) => ({ key, time })), turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
283
303
|
if (rebuildReason !== undefined) normalizedRecord.rebuildRequired = rebuildReason
|
|
284
304
|
Object.defineProperty(normalizedRecord, 'needsUpgrade', { value: needsUpgrade, enumerable: false, writable: true })
|
|
285
305
|
return normalizedRecord
|
|
@@ -474,6 +494,7 @@ export function createLedger(host) {
|
|
|
474
494
|
return {
|
|
475
495
|
nextLedgerRevision,
|
|
476
496
|
ledgerEventKey,
|
|
497
|
+
sessionEvents,
|
|
477
498
|
buildLedgerRecord,
|
|
478
499
|
normalizeLedgerRecord,
|
|
479
500
|
prepareLedgerRecord,
|
package/lib/plugin.js
CHANGED
|
@@ -100,6 +100,9 @@ function apply(ctx) {
|
|
|
100
100
|
reconcilePending: false,
|
|
101
101
|
reconcileInFlight: false,
|
|
102
102
|
reconcileTimer: null,
|
|
103
|
+
workspaceSyncTimer: null,
|
|
104
|
+
workspaceSyncInFlight: false,
|
|
105
|
+
workspaceSyncPending: false,
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
const host = { ctx, services, state, webServer: services.webServer, aggregation: null, ledger: null, pricing: null, balance: null, sessionSync: null, aliases: null }
|
package/lib/session-sync.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes } from './usage-core.js'
|
|
1
|
+
import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes, workspacePathKey } from './usage-core.js'
|
|
2
2
|
|
|
3
3
|
const RECONCILE_INTERVAL_MS = 120000
|
|
4
4
|
const RECONCILE_HINT_DELAY_MS = 3000
|
|
5
|
+
const WORKSPACE_SYNC_DEBOUNCE_MS = 300
|
|
5
6
|
|
|
6
7
|
export function createSessionSync(host) {
|
|
7
8
|
const { ctx, state } = host
|
|
@@ -17,6 +18,7 @@ export function createSessionSync(host) {
|
|
|
17
18
|
} = host.aggregation
|
|
18
19
|
const { beginSync, noteSyncError, markStatsChanged } = host
|
|
19
20
|
const {
|
|
21
|
+
sessionEvents,
|
|
20
22
|
buildLedgerRecord,
|
|
21
23
|
applyLedgerRecord,
|
|
22
24
|
storeLedgerRecord,
|
|
@@ -107,13 +109,56 @@ export function createSessionSync(host) {
|
|
|
107
109
|
state.ledgerDirtySessions.add(sid)
|
|
108
110
|
state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
|
|
109
111
|
}
|
|
112
|
+
function buildWorkspaceSnapshot(workspaces) {
|
|
113
|
+
const wsMeta = new Map()
|
|
114
|
+
const pathIndex = new Map()
|
|
115
|
+
const memberOf = new Map()
|
|
116
|
+
for (const w of workspaces) {
|
|
117
|
+
const id = w && w.id
|
|
118
|
+
const path = w && typeof w.path === 'string' ? w.path : ''
|
|
119
|
+
const title = w && typeof w.title === 'string' ? w.title : ''
|
|
120
|
+
if (id === undefined) continue
|
|
121
|
+
wsMeta.set(id, { id, title, path })
|
|
122
|
+
if (path !== '') pathIndex.set(workspacePathKey(path), id)
|
|
123
|
+
if (w && Array.isArray(w.sessionIds)) {
|
|
124
|
+
for (const sid of w.sessionIds) memberOf.set(sid, id)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { wsMeta, pathIndex, memberOf }
|
|
128
|
+
}
|
|
129
|
+
function installWorkspaceSnapshot(next) {
|
|
130
|
+
let metadataChanged = state.wsMeta.size !== next.wsMeta.size || state.pathIndex.size !== next.pathIndex.size || state.memberOf.size !== next.memberOf.size
|
|
131
|
+
if (!metadataChanged) {
|
|
132
|
+
for (const [id, value] of next.wsMeta) {
|
|
133
|
+
const previous = state.wsMeta.get(id)
|
|
134
|
+
if (previous === undefined || previous.title !== value.title || previous.path !== value.path) { metadataChanged = true; break }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!metadataChanged) {
|
|
138
|
+
for (const [path, id] of next.pathIndex) if (state.pathIndex.get(path) !== id) { metadataChanged = true; break }
|
|
139
|
+
}
|
|
140
|
+
if (!metadataChanged) {
|
|
141
|
+
for (const [sid, id] of next.memberOf) if (state.memberOf.get(sid) !== id) { metadataChanged = true; break }
|
|
142
|
+
}
|
|
143
|
+
state.wsMeta.clear()
|
|
144
|
+
state.pathIndex.clear()
|
|
145
|
+
state.memberOf.clear()
|
|
146
|
+
for (const [id, value] of next.wsMeta) state.wsMeta.set(id, value)
|
|
147
|
+
for (const [path, id] of next.pathIndex) state.pathIndex.set(path, id)
|
|
148
|
+
for (const [sid, id] of next.memberOf) state.memberOf.set(sid, id)
|
|
149
|
+
if (metadataChanged) markStatsChanged('metadata')
|
|
150
|
+
return metadataChanged
|
|
151
|
+
}
|
|
152
|
+
function workspaceForCwd(cwd) {
|
|
153
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined
|
|
154
|
+
return state.pathIndex.get(workspacePathKey(cwd))
|
|
155
|
+
}
|
|
110
156
|
function wsForLiveSession(session, sid) {
|
|
111
157
|
let wsId = state.memberOf.get(sid)
|
|
112
158
|
if (wsId !== undefined) return wsId
|
|
113
159
|
const header = session && session.header
|
|
114
160
|
const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
|
|
115
|
-
|
|
116
|
-
wsId = state.pathIndex.get(cwd)
|
|
161
|
+
wsId = workspaceForCwd(cwd)
|
|
117
162
|
if (wsId !== undefined) state.memberOf.set(sid, wsId)
|
|
118
163
|
return wsId
|
|
119
164
|
}
|
|
@@ -259,6 +304,81 @@ export function createSessionSync(host) {
|
|
|
259
304
|
return undefined
|
|
260
305
|
})
|
|
261
306
|
}
|
|
307
|
+
function scanOneSession(sid, record, wsId, generation, snapshots) {
|
|
308
|
+
return enqueue(sid, async () => {
|
|
309
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
310
|
+
try {
|
|
311
|
+
if (state.sessionSeq.has(sid) && !state.liveResyncPending.has(sid)) return
|
|
312
|
+
// v1.0.8: when the persisted log revision is unchanged since the last ledger
|
|
313
|
+
// write, the whole readSession (full event transfer) is skipped — the ledger
|
|
314
|
+
// record is applied directly and the live feed keeps catching new events.
|
|
315
|
+
const previousRecord = state.ledgerRecords.get(sid)
|
|
316
|
+
const revision = snapshots === null ? undefined : snapshots.get(sid)
|
|
317
|
+
if (!state.liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && previousRecord.rebuildRequired === undefined && previousRecord.workspaceId === wsId && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
318
|
+
state.sync.sessionsSkippedByRevision += 1
|
|
319
|
+
applyLedgerRecord(previousRecord, 'ledger-reuse')
|
|
320
|
+
state.sessionSeq.set(sid, previousRecord.lastSeq)
|
|
321
|
+
state.sessionCount.add(sid)
|
|
322
|
+
markStatsChanged('scan')
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
state.sync.sessionsRead += 1
|
|
326
|
+
markStatsChanged('scan')
|
|
327
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
328
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
329
|
+
if (snap && Array.isArray(snap.events)) {
|
|
330
|
+
// v1.0.7: incremental seed — the durable ledger doubles as a per-session
|
|
331
|
+
// cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
|
|
332
|
+
// session applies its canonical record directly and never re-folds;
|
|
333
|
+
// a changed session seeds the previous record once, then folds only the
|
|
334
|
+
// new tail (previously every listed session was re-read and fully rebuilt).
|
|
335
|
+
const sequence = sequenceProfile(snap.events)
|
|
336
|
+
const currentLastSeq = sequence.lastSeq
|
|
337
|
+
const previous = state.ledgerRecords.get(sid)
|
|
338
|
+
const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && previous.rebuildRequired === undefined && previous.workspaceId === wsId && !sequence.nonMonotonic && !sequence.hasInvalid && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
|
|
339
|
+
if (canFoldTail) {
|
|
340
|
+
applyLedgerRecord(previous, 'ledger-reuse')
|
|
341
|
+
foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
|
|
342
|
+
} else {
|
|
343
|
+
// A changed revision with no new tail may still contain a replacement;
|
|
344
|
+
// rebuild from the complete read instead of trusting lastSeq alone.
|
|
345
|
+
foldEvents(wsId, snap.events, undefined, sid, 'scan')
|
|
346
|
+
}
|
|
347
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous, !sequence.hasInvalid)
|
|
348
|
+
const canonical = ledger === null ? state.ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
|
|
349
|
+
if (canonical === ledger) {
|
|
350
|
+
void persistLedgerRecord(ledger)
|
|
351
|
+
}
|
|
352
|
+
const observedLastSeq = state.sessionSeq.get(sid)
|
|
353
|
+
const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
|
|
354
|
+
state.sessionSeq.set(sid, nextLastSeq)
|
|
355
|
+
state.sessionCount.add(sid)
|
|
356
|
+
if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
|
|
357
|
+
else scheduleLiveResync(sid, wsId, generation)
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (generation !== state.aggregationGeneration) return
|
|
361
|
+
state.sync.sessionsFailed += 1
|
|
362
|
+
state.scan.failed += 1
|
|
363
|
+
noteSyncError('session-read-failed')
|
|
364
|
+
const saved = state.ledgerRecords.get(sid)
|
|
365
|
+
if (saved !== undefined) {
|
|
366
|
+
applyLedgerRecord(saved, 'ledger-recovery')
|
|
367
|
+
if (saved.turns.length > 0 || saved.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
|
|
368
|
+
state.sessionSeq.set(sid, -1)
|
|
369
|
+
state.sessionCount.add(sid)
|
|
370
|
+
} else {
|
|
371
|
+
state.sessionSeq.set(sid, -1)
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
if (generation === state.aggregationGeneration) {
|
|
375
|
+
state.scan.scanned += 1
|
|
376
|
+
markStatsChanged('scan')
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
262
382
|
async function runBaseline(generation = state.aggregationGeneration) {
|
|
263
383
|
if (state.scan.started || state.disposed || generation !== state.aggregationGeneration) return
|
|
264
384
|
state.scan.started = true
|
|
@@ -268,40 +388,7 @@ export function createSessionSync(host) {
|
|
|
268
388
|
let setupFailed = false
|
|
269
389
|
try {
|
|
270
390
|
const workspaces = ctx.workspaceRegistry.list()
|
|
271
|
-
|
|
272
|
-
const nextPathIndex = new Map()
|
|
273
|
-
const nextMemberOf = new Map()
|
|
274
|
-
for (const w of workspaces) {
|
|
275
|
-
const id = w && w.id
|
|
276
|
-
const path = w && typeof w.path === 'string' ? w.path : ''
|
|
277
|
-
const title = w && typeof w.title === 'string' ? w.title : ''
|
|
278
|
-
if (id === undefined) continue
|
|
279
|
-
nextWsMeta.set(id, { id, title, path })
|
|
280
|
-
if (path !== '') nextPathIndex.set(path, id)
|
|
281
|
-
if (w && Array.isArray(w.sessionIds)) {
|
|
282
|
-
for (const sid of w.sessionIds) nextMemberOf.set(sid, id)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
let metadataChanged = state.wsMeta.size !== nextWsMeta.size || state.pathIndex.size !== nextPathIndex.size || state.memberOf.size !== nextMemberOf.size
|
|
286
|
-
if (!metadataChanged) {
|
|
287
|
-
for (const [id, value] of nextWsMeta) {
|
|
288
|
-
const previous = state.wsMeta.get(id)
|
|
289
|
-
if (previous === undefined || previous.title !== value.title || previous.path !== value.path) { metadataChanged = true; break }
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
if (!metadataChanged) {
|
|
293
|
-
for (const [path, id] of nextPathIndex) if (state.pathIndex.get(path) !== id) { metadataChanged = true; break }
|
|
294
|
-
}
|
|
295
|
-
if (!metadataChanged) {
|
|
296
|
-
for (const [sid, id] of nextMemberOf) if (state.memberOf.get(sid) !== id) { metadataChanged = true; break }
|
|
297
|
-
}
|
|
298
|
-
state.wsMeta.clear()
|
|
299
|
-
state.pathIndex.clear()
|
|
300
|
-
state.memberOf.clear()
|
|
301
|
-
for (const [id, value] of nextWsMeta) state.wsMeta.set(id, value)
|
|
302
|
-
for (const [path, id] of nextPathIndex) state.pathIndex.set(path, id)
|
|
303
|
-
for (const [sid, id] of nextMemberOf) state.memberOf.set(sid, id)
|
|
304
|
-
if (metadataChanged) markStatsChanged('metadata')
|
|
391
|
+
installWorkspaceSnapshot(buildWorkspaceSnapshot(workspaces))
|
|
305
392
|
} catch (err) {
|
|
306
393
|
console.error('[all-usage] workspace list failed:', err)
|
|
307
394
|
setupFailed = true
|
|
@@ -353,11 +440,18 @@ export function createSessionSync(host) {
|
|
|
353
440
|
if (record === undefined || record === null || record.header === undefined) continue
|
|
354
441
|
const sid = record.header.id
|
|
355
442
|
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
356
|
-
const wsId =
|
|
443
|
+
const wsId = workspaceForCwd(cwd)
|
|
357
444
|
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
358
445
|
}
|
|
359
446
|
for (const [sid, record] of state.ledgerRecords) {
|
|
360
447
|
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
|
|
454
|
+
if (typeof record.workspaceId === 'string' && record.workspaceId.startsWith('unregistered:')) continue
|
|
361
455
|
applyLedgerRecord(record, 'ledger-recovery')
|
|
362
456
|
if (record.turns.length > 0 || record.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
|
|
363
457
|
}
|
|
@@ -372,85 +466,14 @@ export function createSessionSync(host) {
|
|
|
372
466
|
}
|
|
373
467
|
const sid = record.header.id
|
|
374
468
|
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
375
|
-
const wsId =
|
|
469
|
+
const wsId = workspaceForCwd(cwd)
|
|
376
470
|
if (sid === undefined || wsId === undefined) {
|
|
377
471
|
state.scan.scanned += 1
|
|
378
472
|
markStatsChanged('scan')
|
|
379
473
|
continue
|
|
380
474
|
}
|
|
381
475
|
listedSessionIds.add(sid)
|
|
382
|
-
await
|
|
383
|
-
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
384
|
-
try {
|
|
385
|
-
if (state.sessionSeq.has(sid) && !state.liveResyncPending.has(sid)) return
|
|
386
|
-
// v1.0.8: when the persisted log revision is unchanged since the last ledger
|
|
387
|
-
// write, the whole readSession (full event transfer) is skipped — the ledger
|
|
388
|
-
// record is applied directly and the live feed keeps catching new events.
|
|
389
|
-
const previousRecord = state.ledgerRecords.get(sid)
|
|
390
|
-
const revision = snapshots === null ? undefined : snapshots.get(sid)
|
|
391
|
-
if (!state.liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && previousRecord.rebuildRequired === undefined && previousRecord.workspaceId === wsId && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
392
|
-
state.sync.sessionsSkippedByRevision += 1
|
|
393
|
-
applyLedgerRecord(previousRecord, 'ledger-reuse')
|
|
394
|
-
state.sessionSeq.set(sid, previousRecord.lastSeq)
|
|
395
|
-
state.sessionCount.add(sid)
|
|
396
|
-
markStatsChanged('scan')
|
|
397
|
-
return
|
|
398
|
-
}
|
|
399
|
-
state.sync.sessionsRead += 1
|
|
400
|
-
markStatsChanged('scan')
|
|
401
|
-
const snap = await ctx.sessionQuery.readSession(sid)
|
|
402
|
-
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
403
|
-
if (snap && Array.isArray(snap.events)) {
|
|
404
|
-
// v1.0.7: incremental seed — the durable ledger doubles as a per-session
|
|
405
|
-
// cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
|
|
406
|
-
// session applies its canonical record directly and never re-folds;
|
|
407
|
-
// a changed session seeds the previous record once, then folds only the
|
|
408
|
-
// new tail (previously every listed session was re-read and fully rebuilt).
|
|
409
|
-
const sequence = sequenceProfile(snap.events)
|
|
410
|
-
const currentLastSeq = sequence.lastSeq
|
|
411
|
-
const previous = state.ledgerRecords.get(sid)
|
|
412
|
-
const canFoldTail = previous !== undefined && previous.needsUpgrade !== true && previous.rebuildRequired === undefined && previous.workspaceId === wsId && !sequence.nonMonotonic && !sequence.hasInvalid && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
|
|
413
|
-
if (canFoldTail) {
|
|
414
|
-
applyLedgerRecord(previous, 'ledger-reuse')
|
|
415
|
-
foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
|
|
416
|
-
} else {
|
|
417
|
-
// A changed revision with no new tail may still contain a replacement;
|
|
418
|
-
// rebuild from the complete read instead of trusting lastSeq alone.
|
|
419
|
-
foldEvents(wsId, snap.events, undefined, sid, 'scan')
|
|
420
|
-
}
|
|
421
|
-
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous, !sequence.hasInvalid)
|
|
422
|
-
const canonical = ledger === null ? state.ledgerRecords.get(sid) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
|
|
423
|
-
if (canonical === ledger) {
|
|
424
|
-
void persistLedgerRecord(ledger)
|
|
425
|
-
}
|
|
426
|
-
const observedLastSeq = state.sessionSeq.get(sid)
|
|
427
|
-
const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
|
|
428
|
-
state.sessionSeq.set(sid, nextLastSeq)
|
|
429
|
-
state.sessionCount.add(sid)
|
|
430
|
-
if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
|
|
431
|
-
else scheduleLiveResync(sid, wsId, generation)
|
|
432
|
-
}
|
|
433
|
-
} catch (err) {
|
|
434
|
-
if (generation !== state.aggregationGeneration) return
|
|
435
|
-
state.sync.sessionsFailed += 1
|
|
436
|
-
state.scan.failed += 1
|
|
437
|
-
noteSyncError('session-read-failed')
|
|
438
|
-
const saved = state.ledgerRecords.get(sid)
|
|
439
|
-
if (saved !== undefined) {
|
|
440
|
-
applyLedgerRecord(saved, 'ledger-recovery')
|
|
441
|
-
if (saved.turns.length > 0 || saved.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
|
|
442
|
-
state.sessionSeq.set(sid, -1)
|
|
443
|
-
state.sessionCount.add(sid)
|
|
444
|
-
} else {
|
|
445
|
-
state.sessionSeq.set(sid, -1)
|
|
446
|
-
}
|
|
447
|
-
} finally {
|
|
448
|
-
if (generation === state.aggregationGeneration) {
|
|
449
|
-
state.scan.scanned += 1
|
|
450
|
-
markStatsChanged('scan')
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
})
|
|
476
|
+
await scanOneSession(sid, record, wsId, generation, snapshots)
|
|
454
477
|
if (!(await safeContextTimeout(0))) {
|
|
455
478
|
state.scan.started = false
|
|
456
479
|
noteSyncError('baseline-yield-unavailable')
|
|
@@ -485,13 +508,132 @@ export function createSessionSync(host) {
|
|
|
485
508
|
if (state.reconcilePending) scheduleReconcileHint()
|
|
486
509
|
}
|
|
487
510
|
|
|
511
|
+
function scheduleWorkspaceSync() {
|
|
512
|
+
if (state.disposed) return
|
|
513
|
+
if (state.workspaceSyncTimer !== null) return
|
|
514
|
+
state.workspaceSyncTimer = setTimeout(() => {
|
|
515
|
+
state.workspaceSyncTimer = null
|
|
516
|
+
if (!state.disposed) void runWorkspaceSync()
|
|
517
|
+
}, WORKSPACE_SYNC_DEBOUNCE_MS)
|
|
518
|
+
if (state.workspaceSyncTimer && typeof state.workspaceSyncTimer.unref === 'function') state.workspaceSyncTimer.unref()
|
|
519
|
+
}
|
|
520
|
+
async function runWorkspaceSync() {
|
|
521
|
+
if (state.disposed) return
|
|
522
|
+
if (state.workspaceSyncInFlight) {
|
|
523
|
+
state.workspaceSyncPending = true
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
state.workspaceSyncInFlight = true
|
|
527
|
+
try {
|
|
528
|
+
await synchronizeWorkspaceRegistry()
|
|
529
|
+
} catch (err) {
|
|
530
|
+
console.error('[all-usage] workspace synchronize failed:', err)
|
|
531
|
+
noteSyncError('workspace-sync-failed')
|
|
532
|
+
} finally {
|
|
533
|
+
state.workspaceSyncInFlight = false
|
|
534
|
+
if (state.workspaceSyncPending) {
|
|
535
|
+
state.workspaceSyncPending = false
|
|
536
|
+
scheduleWorkspaceSync()
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
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
|
|
544
|
+
let workspaces
|
|
545
|
+
try {
|
|
546
|
+
workspaces = ctx.workspaceRegistry.list()
|
|
547
|
+
} catch (err) {
|
|
548
|
+
console.error('[all-usage] workspace list failed:', err)
|
|
549
|
+
noteSyncError('workspace-list-failed')
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
const next = buildWorkspaceSnapshot(workspaces)
|
|
553
|
+
const removedWs = new Set()
|
|
554
|
+
for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
|
|
555
|
+
const addedWs = new Set()
|
|
556
|
+
for (const id of next.wsMeta.keys()) if (!state.wsMeta.has(id)) addedWs.add(id)
|
|
557
|
+
// Reuse path: no membership change — metadata only, zero rescan.
|
|
558
|
+
if (removedWs.size === 0 && addedWs.size === 0) {
|
|
559
|
+
installWorkspaceSnapshot(next)
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
// Remove path: subtract every session that belonged to a deregistered workspace.
|
|
563
|
+
if (removedWs.size > 0) {
|
|
564
|
+
const removedSids = new Set()
|
|
565
|
+
for (const [sid, record] of state.ledgerRecords) {
|
|
566
|
+
if (record !== null && record !== undefined && typeof record.workspaceId === 'string' && removedWs.has(record.workspaceId)) removedSids.add(sid)
|
|
567
|
+
}
|
|
568
|
+
for (const [sid, id] of state.memberOf) if (removedWs.has(id)) removedSids.add(sid)
|
|
569
|
+
for (const sid of removedSids) {
|
|
570
|
+
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)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
installWorkspaceSnapshot(next)
|
|
589
|
+
// Add path: scan only the sessions that belong to newly registered workspaces.
|
|
590
|
+
if (addedWs.size === 0) return
|
|
591
|
+
let records = null
|
|
592
|
+
try {
|
|
593
|
+
records = await ctx.sessionQuery.listSessions()
|
|
594
|
+
} catch (err) {
|
|
595
|
+
console.error('[all-usage] session list failed:', err)
|
|
596
|
+
noteSyncError('session-list-failed')
|
|
597
|
+
}
|
|
598
|
+
if (!Array.isArray(records)) return
|
|
599
|
+
state.sync.sessionsTotal = records.length
|
|
600
|
+
markStatsChanged('scan')
|
|
601
|
+
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
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const record of records) {
|
|
618
|
+
if (state.disposed) return
|
|
619
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
620
|
+
const sid = record.header.id
|
|
621
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
622
|
+
const wsId = workspaceForCwd(cwd)
|
|
623
|
+
if (sid === undefined || wsId === undefined) continue
|
|
624
|
+
if (!addedWs.has(wsId)) continue
|
|
625
|
+
state.knownSessionIds.add(sid)
|
|
626
|
+
scanOneSession(sid, record, wsId, state.aggregationGeneration, snapshots)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
488
630
|
function sessionIdsFromRecords(records) {
|
|
489
631
|
const ids = new Set()
|
|
490
632
|
for (const record of records) {
|
|
491
633
|
if (record === undefined || record === null || record.header === undefined) continue
|
|
492
634
|
const sid = record.header.id
|
|
493
635
|
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
494
|
-
const wsId =
|
|
636
|
+
const wsId = workspaceForCwd(cwd)
|
|
495
637
|
if (sid !== undefined && wsId !== undefined) ids.add(sid)
|
|
496
638
|
}
|
|
497
639
|
return ids
|
|
@@ -569,7 +711,7 @@ export function createSessionSync(host) {
|
|
|
569
711
|
if (state.disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
570
712
|
const sid = session.id
|
|
571
713
|
const previous = state.ledgerRecords.get(sid)
|
|
572
|
-
const eventList =
|
|
714
|
+
const eventList = sessionEvents(session)
|
|
573
715
|
// DSH sequences events with seq === log index: the tail event gives the
|
|
574
716
|
// latest sequence in O(1) without scanning a 500K-event session twice.
|
|
575
717
|
const tailEvent = eventList.length > 0 ? eventList[eventList.length - 1] : null
|
|
@@ -616,6 +758,18 @@ export function createSessionSync(host) {
|
|
|
616
758
|
ctx.on('session/disposed', () => {
|
|
617
759
|
if (!state.disposed) scheduleReconcileHint()
|
|
618
760
|
})
|
|
761
|
+
// Workspace registry probe: DSH emits domain/changed after every durable
|
|
762
|
+
// workspace-domain write (create/delete/rename/reorder/archive/attach/detach).
|
|
763
|
+
// On any such change we re-read workspaceRegistry.list() and, when membership
|
|
764
|
+
// changed, rescan only the affected workspaces — unchanged ones keep their
|
|
765
|
+
// computed aggregates and ledger untouched.
|
|
766
|
+
ctx.on('domain/changed', (change) => {
|
|
767
|
+
if (state.disposed) return
|
|
768
|
+
if (change === undefined || change === null) return
|
|
769
|
+
if (change.domain !== 'workspace') return
|
|
770
|
+
if (change.table !== 'workspaces' && change.table !== '') return
|
|
771
|
+
scheduleWorkspaceSync()
|
|
772
|
+
})
|
|
619
773
|
|
|
620
774
|
return {
|
|
621
775
|
foldEvent,
|
|
@@ -633,6 +787,8 @@ export function createSessionSync(host) {
|
|
|
633
787
|
scheduleNativeBaselineRetry,
|
|
634
788
|
scheduleBaselineRetry,
|
|
635
789
|
runBaseline,
|
|
790
|
+
synchronizeWorkspaceRegistry,
|
|
791
|
+
scheduleWorkspaceSync,
|
|
636
792
|
sessionIdsFromRecords,
|
|
637
793
|
reconcileSessions,
|
|
638
794
|
scheduleReconcileHint,
|
package/lib/usage-core.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/** Canonical comparison key for Windows workspace paths. */
|
|
2
|
+
export function workspacePathKey(value) {
|
|
3
|
+
if (typeof value !== 'string') return ''
|
|
4
|
+
const normalized = value.trim().replaceAll('\\', '/').toLowerCase()
|
|
5
|
+
return normalized.length > 3 ? normalized.replace(/\/+$/, '') : normalized
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
const TOKEN_FIELDS = Object.freeze({
|
|
2
9
|
input: 'inputTokens',
|
|
3
10
|
output: 'outputTokens',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-all-usage",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dsh": {
|
|
33
33
|
"compatibility": {
|
|
34
|
-
"runtime": ">=0.1.1-rc.1 <0.1.
|
|
35
|
-
"verified": ["0.1.1-rc.2", "0.1.1-rc.1"]
|
|
34
|
+
"runtime": ">=0.1.1-rc.1 <0.1.6",
|
|
35
|
+
"verified": ["0.1.5-rc.1", "0.1.1-rc.2", "0.1.1-rc.1"]
|
|
36
36
|
},
|
|
37
37
|
"bundle": {
|
|
38
38
|
"patch": "./cordis.patch.yml"
|