dsh-all-usage 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +12 -8
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/lib/client.js +149 -11
- package/lib/index.js +179 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `dsh-all-usage` are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.0.9] - 2026-08-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a lightweight, guarded `/api/all-usage/status` contract with a per-Host instance ID, monotonic stats revision, scan progress, and non-sensitive sync-health counters.
|
|
10
|
+
- Added sync-health visibility for revision-skipped sessions, actual log reads, ledger-only recoveries, failures, persistence snapshot availability, and last completed baseline time.
|
|
11
|
+
- Added revision-driven Client refresh: after the initial scan, the dashboard polls only lightweight status and fetches the full historical snapshot only when the Host instance or stats revision changes.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Preserved the last successful usage view after status or full-snapshot failures, exposed a retry action, and added bounded retry backoff instead of silently discarding refresh errors.
|
|
16
|
+
|
|
17
|
+
## [1.0.8] - 2026-08-23
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Added a restart read-avoidance cursor: on startup the plugin uses the persisted log revision (via `sessionPersistence.listSnapshots()`, a header-line + stat read) as a per-session change signal. Sessions whose log is unchanged since the last ledger write are applied straight from the durable ledger without reading their full event log; only changed or new sessions are read (incrementally).
|
|
22
|
+
- Stored the log revision in each durable ledger record (`lastRevision`); existing rows without it are re-read once and backfilled, so behavior is backward compatible.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Eliminated the per-session full-event read at restart for unchanged persisted sessions, so DSH restarts no longer re-read every session's history from scratch.
|
|
27
|
+
|
|
5
28
|
## [1.0.7] - 2026-08-23
|
|
6
29
|
|
|
7
30
|
### Added
|
|
@@ -51,6 +74,8 @@ All notable changes to `dsh-all-usage` are documented here.
|
|
|
51
74
|
- Allowed same-origin browser balance GET requests that omit `Origin` while retaining token protection.
|
|
52
75
|
- Standardized English date buckets, range filters, streaks, heatmap dates, and export timestamps on UTC.
|
|
53
76
|
|
|
77
|
+
[1.0.9]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.9
|
|
78
|
+
[1.0.8]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.8
|
|
54
79
|
[1.0.7]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.7
|
|
55
80
|
[1.0.6]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.6
|
|
56
81
|
[1.0.5]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.5
|
package/README.md
CHANGED
|
@@ -18,15 +18,16 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
18
18
|
- **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all_usage_aliases`
|
|
19
19
|
- **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
|
|
20
20
|
- **完整历史与增量重建**:基线扫描全部可读历史会话;独立用量账本同时作为每会话游标——未变化的会话直接复用账本,新增事件只增量回填,长历史重启不再全量重建
|
|
21
|
+
- **重启免读**:用持久化日志的 revision 作为每会话的变更信号(只读头部行 + stat,不读全量)——日志未变的会话重启时连事件都不读,直接从账本复用;仅日志变化(新增/修改)的会话才做增量读取
|
|
22
|
+
- **数据健康与按需刷新**:扫描完成后浏览器只检查轻量状态版本,只有用量、别名或同步状态变化时才拉完整历史;显示本次数据更新时间、历史扫描健康、revision 免读、实际读取、账本恢复和失败,网络异常保留上次成功数据并可重试
|
|
21
23
|
- **Token 口径**:输入按「未含缓存命中」计,缓存命中 / 写入与推理独立成桶;全 0 用量的重放事件不会覆盖已记录的真实用量,仅缓存命中的请求也会计入
|
|
22
24
|
|
|
23
25
|
### 最近更新
|
|
24
26
|
|
|
25
|
-
**v1.0.
|
|
27
|
+
**v1.0.9**
|
|
26
28
|
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
- 结构化语义声明:API 返回机读的 token 口径(输入不含缓存命中,缓存与推理独立成桶)
|
|
29
|
+
- 按需刷新:扫描完成后只轮询轻量状态,Host instance 或统计 revision 变化时才拉完整历史快照
|
|
30
|
+
- 数据健康:显示本次数据更新时间与历史扫描健康,以及 revision 免读、实际读取、账本恢复、失败与优化可用性;刷新失败保留上次成功数据并支持重试
|
|
30
31
|
|
|
31
32
|
完整版本记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
32
33
|
|
|
@@ -81,6 +82,7 @@ dsh plugin --profile web add github:ParticleLight/dsh-all-usage
|
|
|
81
82
|
- 会话删除后,已成功 flush 的用量仍从独立账本恢复;会话销毁提示和周期对账只负责触发重建,不会删除账本记录
|
|
82
83
|
- 同一会话的同一 `turn / step` 只保留一份最终 usage;重试或替换消息会替换旧贡献,不重复累计
|
|
83
84
|
- 输入 Token 按「未含缓存命中」计(缓存命中 / 写入独立成桶);全 0 用量的重放事件不会覆盖已记录的真实用量,纯缓存命中的请求仍会计入
|
|
85
|
+
- 轻量状态接口只公开 Host 实例、统计 revision、扫描进度与同步计数,不公开会话 ID、工作区路径、提示词或回复正文;完整快照仅在状态变化或手动刷新时获取
|
|
84
86
|
- 看板中的总处理量 = 输入 + 输出 + 缓存读写 + 推理;缓存命中表示复用的上下文 Token,不等于新生成 Token 或实际费用
|
|
85
87
|
- 余额查询走 DeepSeek 官方 `/user/balance` 接口;未配置 API Key 时卡片显示引导文案
|
|
86
88
|
- 仅统计能归属到已注册工作区(按会话 cwd 匹配)的会话
|
|
@@ -104,15 +106,16 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
|
|
|
104
106
|
- **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all_usage_aliases`
|
|
105
107
|
- **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
|
|
106
108
|
- **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
|
|
109
|
+
- **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
|
|
110
|
+
- **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
|
|
107
111
|
- **Token accounting semantics**: input tokens are fresh (exclude cache hits/writes, which sit in separate buckets along with reasoning); all-zero usage replays never overwrite recorded usage, while cache-only requests still count
|
|
108
112
|
|
|
109
113
|
### Latest Update
|
|
110
114
|
|
|
111
|
-
**v1.0.
|
|
115
|
+
**v1.0.9**
|
|
112
116
|
|
|
113
|
-
-
|
|
114
|
-
-
|
|
115
|
-
- Structured token semantics: the API now declares a machine-readable accounting contract (input excludes cache; cache and reasoning are separate buckets)
|
|
117
|
+
- On-demand refresh: after the scan completes, the dashboard polls lightweight status and fetches full history only when the Host instance or stats revision changes
|
|
118
|
+
- Data health: latest full-data update, historical scan health, revision skips, rereads, ledger recovery, failures, and optimization availability are visible; failed refreshes keep last-good data and expose retry
|
|
116
119
|
|
|
117
120
|
See [CHANGELOG.md](CHANGELOG.md) for the complete version history.
|
|
118
121
|
|
|
@@ -161,6 +164,7 @@ The profile patch layer hot-reloads; save the file and refresh the page.
|
|
|
161
164
|
- After a session is deleted, successfully flushed usage is restored from the separate ledger; disposal hints and periodic reconciliation trigger rebuilds without deleting ledger rows
|
|
162
165
|
- For each session and logical `turn / step`, only the final usage contribution is kept; retries or replaced messages do not double-count
|
|
163
166
|
- Input tokens are fresh (exclude cache hits/writes, which sit in their own buckets); all-zero usage replays do not overwrite recorded usage and pure cache-read requests still count
|
|
167
|
+
- The lightweight status endpoint exposes only Host instance, stats revision, scan progress, and sync counters. It does not expose session IDs, workspace paths, prompts, or reply bodies; full snapshots are fetched only after status changes or a manual refresh
|
|
164
168
|
- Processed tokens = input + output + cache read/write + reasoning; a cache hit means reused context, not newly generated tokens or actual cost
|
|
165
169
|
- Balance data comes from DeepSeek’s official `/user/balance` endpoint; the card shows guidance when no API key is configured
|
|
166
170
|
- English mode uses UTC for date buckets, range filters, streaks, heatmap dates, and export timestamps; Chinese mode uses local time
|
package/assets/screenshot-1.png
CHANGED
|
Binary file
|
package/assets/screenshot-2.png
CHANGED
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -58,6 +58,25 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
isCurrent(seq) { return seq === latest },
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
function snapshotVersion(data) {
|
|
62
|
+
if (data === null || typeof data !== 'object') return null
|
|
63
|
+
const instanceId = typeof data.instanceId === 'string' ? data.instanceId : ''
|
|
64
|
+
const revision = typeof data.revision === 'number' && Number.isFinite(data.revision) ? data.revision : null
|
|
65
|
+
return instanceId === '' || revision === null ? null : instanceId + ':' + revision
|
|
66
|
+
}
|
|
67
|
+
function statusRequiresFullSnapshot(status, snapshot) {
|
|
68
|
+
if (status === null || typeof status !== 'object' || snapshot === null || typeof snapshot !== 'object') return true
|
|
69
|
+
const statusVersion = snapshotVersion(status)
|
|
70
|
+
const snapshotVersionValue = snapshotVersion(snapshot)
|
|
71
|
+
if (statusVersion === null || snapshotVersionValue === null || statusVersion !== snapshotVersionValue) return true
|
|
72
|
+
const statusScan = status.scan
|
|
73
|
+
const snapshotScan = snapshot.scan
|
|
74
|
+
return !!(statusScan && snapshotScan && !!statusScan.done !== !!snapshotScan.done)
|
|
75
|
+
}
|
|
76
|
+
function retryDelayFor(failures) {
|
|
77
|
+
const count = Math.max(1, Math.min(4, typeof failures === 'number' && Number.isFinite(failures) ? failures : 1))
|
|
78
|
+
return 5000 * Math.pow(2, count - 1)
|
|
79
|
+
}
|
|
61
80
|
function rangeFilenamePart(range, customRange, utc) {
|
|
62
81
|
if (range !== 'custom') return range
|
|
63
82
|
const normalized = normalizeCustomRange(customRange, utc)
|
|
@@ -327,6 +346,9 @@ window.__ModuleLoader__.load({
|
|
|
327
346
|
.uh-alias-ok:active { transform:scale(.96); }
|
|
328
347
|
.uh-anim-panel { animation:uh-panel-in .28s ease both; }
|
|
329
348
|
.uh-progress { font-size:12px; color:var(--dsw-alias-label-secondary); display:flex; align-items:center; gap:10px; }
|
|
349
|
+
.uh-sync-health { margin-top:8px; padding:8px 12px; display:flex; align-items:center; flex-wrap:wrap; gap:6px; border:1px solid var(--dsw-alias-border-l1); border-radius:10px; color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-1); font-size:11px; line-height:1.45; }
|
|
350
|
+
.uh-sync-health.uh-stale { color:var(--dsw-alias-warning, #a55b00); border-color:color-mix(in srgb, var(--dsw-alias-warning, #d9822b) 45%, var(--dsw-alias-border-l1)); }
|
|
351
|
+
.uh-sync-retry { border:0; background:transparent; color:inherit; font:inherit; text-decoration:underline; cursor:pointer; padding:0 2px; }
|
|
330
352
|
.uh-bar { flex:1; height:6px; border-radius:3px; background:var(--dsw-alias-bg-layer-2); overflow:hidden; max-width:340px; }
|
|
331
353
|
.uh-fill { height:100%; background:var(--dsw-alias-brand-primary); border-radius:3px; transition:width .3s ease; }
|
|
332
354
|
.uh-cards { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px; }
|
|
@@ -496,6 +518,10 @@ window.__ModuleLoader__.load({
|
|
|
496
518
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
497
519
|
return r.json()
|
|
498
520
|
})
|
|
521
|
+
const getStatus = () => fetch('/api/all-usage/status', { headers: { accept: 'application/json' } }).then((r) => {
|
|
522
|
+
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
523
|
+
return r.json()
|
|
524
|
+
})
|
|
499
525
|
const getBalance = (force, requestToken) => fetch('/api/all-usage/balance' + (force ? '?force=1' : ''), { headers: { accept: 'application/json', 'x-all-usage-request-token': requestToken } }).then((r) => {
|
|
500
526
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
501
527
|
return r.json()
|
|
@@ -525,6 +551,9 @@ window.__ModuleLoader__.load({
|
|
|
525
551
|
const calendarNow = new Date()
|
|
526
552
|
const latestCalendarDate = fmtDate(calendarNow, useUtc)
|
|
527
553
|
const [stats, setStats] = React.useState(null)
|
|
554
|
+
const [status, setStatus] = React.useState(null)
|
|
555
|
+
const [statsError, setStatsError] = React.useState('')
|
|
556
|
+
const [lastStatsAt, setLastStatsAt] = React.useState(0)
|
|
528
557
|
const [balance, setBalance] = React.useState(null)
|
|
529
558
|
const [range, setRange] = React.useState('90d')
|
|
530
559
|
const [customRange, setCustomRange] = React.useState({ start: '', end: '' })
|
|
@@ -539,9 +568,12 @@ window.__ModuleLoader__.load({
|
|
|
539
568
|
const languageMenuRef = React.useRef(null)
|
|
540
569
|
const statsGateRef = React.useRef(null)
|
|
541
570
|
if (statsGateRef.current === null) statsGateRef.current = createRequestGate()
|
|
571
|
+
const statusGateRef = React.useRef(null)
|
|
572
|
+
if (statusGateRef.current === null) statusGateRef.current = createRequestGate()
|
|
542
573
|
const balanceGateRef = React.useRef(null)
|
|
543
574
|
if (balanceGateRef.current === null) balanceGateRef.current = createRequestGate()
|
|
544
575
|
const statsGate = statsGateRef.current
|
|
576
|
+
const statusGate = statusGateRef.current
|
|
545
577
|
const balanceGate = balanceGateRef.current
|
|
546
578
|
const refreshRef = React.useRef(() => {})
|
|
547
579
|
const setLanguage = (next) => { if (typeof props.onLanguageChange === 'function') props.onLanguageChange(next === 'en' ? 'en' : 'zh') }
|
|
@@ -551,6 +583,16 @@ window.__ModuleLoader__.load({
|
|
|
551
583
|
let alive = true
|
|
552
584
|
let scanDone = false
|
|
553
585
|
let requestToken = ''
|
|
586
|
+
let appliedSnapshot = null
|
|
587
|
+
let fullFailures = 0
|
|
588
|
+
let statusFailures = 0
|
|
589
|
+
let retryTimer = null
|
|
590
|
+
const clearRetry = () => {
|
|
591
|
+
if (retryTimer !== null) {
|
|
592
|
+
clearTimeout(retryTimer)
|
|
593
|
+
retryTimer = null
|
|
594
|
+
}
|
|
595
|
+
}
|
|
554
596
|
const refreshBalance = (force) => {
|
|
555
597
|
if (requestToken === '') return
|
|
556
598
|
const seq = balanceGate.next()
|
|
@@ -559,24 +601,84 @@ window.__ModuleLoader__.load({
|
|
|
559
601
|
if (data) setBalance(data)
|
|
560
602
|
}, () => {})
|
|
561
603
|
}
|
|
604
|
+
const scheduleRetry = (kind) => {
|
|
605
|
+
if (!alive || retryTimer !== null) return
|
|
606
|
+
const failures = kind === 'full' ? (fullFailures += 1) : (statusFailures += 1)
|
|
607
|
+
retryTimer = setTimeout(() => {
|
|
608
|
+
retryTimer = null
|
|
609
|
+
if (!alive) return
|
|
610
|
+
if (kind === 'full') refreshStats()
|
|
611
|
+
else refreshStatus()
|
|
612
|
+
}, retryDelayFor(failures))
|
|
613
|
+
}
|
|
562
614
|
const refreshStats = () => {
|
|
615
|
+
statusGate.next()
|
|
563
616
|
const seq = statsGate.next()
|
|
564
617
|
getStats().then((data) => {
|
|
565
618
|
if (!alive || !statsGate.isCurrent(seq)) return
|
|
566
|
-
if (data
|
|
567
|
-
|
|
619
|
+
if (data === null || typeof data !== 'object') {
|
|
620
|
+
setStatsError('full')
|
|
621
|
+
scheduleRetry('full')
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
appliedSnapshot = data
|
|
625
|
+
if (data.scan) scanDone = !!data.scan.done
|
|
626
|
+
const nextToken = typeof data.requestToken === 'string' ? data.requestToken : ''
|
|
568
627
|
const tokenChanged = nextToken !== '' && nextToken !== requestToken
|
|
569
628
|
requestToken = nextToken
|
|
629
|
+
fullFailures = 0
|
|
630
|
+
clearRetry()
|
|
631
|
+
setStatsError('')
|
|
632
|
+
setLastStatsAt(Date.now())
|
|
633
|
+
setStatus(data)
|
|
570
634
|
setStats(data)
|
|
571
635
|
if (tokenChanged) refreshBalance(false)
|
|
572
|
-
}, () => {
|
|
636
|
+
}, () => {
|
|
637
|
+
if (!alive || !statsGate.isCurrent(seq)) return
|
|
638
|
+
setStatsError('full')
|
|
639
|
+
scheduleRetry('full')
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
const refreshStatus = () => {
|
|
643
|
+
if (appliedSnapshot === null) { refreshStats(); return }
|
|
644
|
+
const seq = statusGate.next()
|
|
645
|
+
getStatus().then((data) => {
|
|
646
|
+
if (!alive || !statusGate.isCurrent(seq)) return
|
|
647
|
+
if (data === null || typeof data !== 'object') {
|
|
648
|
+
setStatsError('status')
|
|
649
|
+
scheduleRetry('status')
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
statusFailures = 0
|
|
653
|
+
const requiresFullSnapshot = statusRequiresFullSnapshot(data, appliedSnapshot)
|
|
654
|
+
if (data.scan) scanDone = !!data.scan.done
|
|
655
|
+
// Do not render a newer status beside an older full snapshot. A full
|
|
656
|
+
// fetch owns the state transition when instance/revision diverges.
|
|
657
|
+
if (requiresFullSnapshot) {
|
|
658
|
+
refreshStats()
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
setStatus(data)
|
|
662
|
+
clearRetry()
|
|
663
|
+
setStatsError('')
|
|
664
|
+
}, () => {
|
|
665
|
+
if (!alive || !statusGate.isCurrent(seq)) return
|
|
666
|
+
setStatsError('status')
|
|
667
|
+
scheduleRetry('status')
|
|
668
|
+
})
|
|
573
669
|
}
|
|
574
670
|
refreshStats()
|
|
575
|
-
const fast = timer.interval(() => { if (!scanDone) refreshStats() }, 2000)
|
|
576
|
-
const slow = timer.interval(() => { if (scanDone)
|
|
671
|
+
const fast = timer.interval(() => { if (!scanDone && retryTimer === null) refreshStats() }, 2000)
|
|
672
|
+
const slow = timer.interval(() => { if (scanDone && retryTimer === null) refreshStatus() }, 15000)
|
|
577
673
|
const bal = timer.interval(() => { refreshBalance(false) }, 60000)
|
|
578
|
-
refreshRef.current = () => {
|
|
579
|
-
|
|
674
|
+
refreshRef.current = () => {
|
|
675
|
+
clearRetry()
|
|
676
|
+
fullFailures = 0
|
|
677
|
+
statusFailures = 0
|
|
678
|
+
refreshStats()
|
|
679
|
+
refreshBalance(true)
|
|
680
|
+
}
|
|
681
|
+
return () => { alive = false; clearRetry(); fast(); slow(); bal() }
|
|
580
682
|
}, [])
|
|
581
683
|
|
|
582
684
|
React.useEffect(() => {
|
|
@@ -622,12 +724,23 @@ window.__ModuleLoader__.load({
|
|
|
622
724
|
const animatedTurns = useCountUp(agg.totals.turns, timer)
|
|
623
725
|
|
|
624
726
|
if (stats === null) {
|
|
727
|
+
const failed = statsError !== ''
|
|
625
728
|
return React.createElement('div', { className: 'uh-page' },
|
|
626
|
-
React.createElement('div', { className: 'uh-panel' },
|
|
729
|
+
React.createElement('div', { className: 'uh-panel' },
|
|
730
|
+
React.createElement('div', { className: 'uh-empty' }, failed
|
|
731
|
+
? tr('无法加载用量统计。请重试。', 'Unable to load usage statistics. Please retry.')
|
|
732
|
+
: tr('正在加载用量统计…', 'Loading usage statistics…'),
|
|
733
|
+
),
|
|
734
|
+
failed ? React.createElement('div', { style: { textAlign: 'center' } },
|
|
735
|
+
React.createElement('button', { className: 'uh-refresh', onClick: onRefresh }, React.createElement(LineIcon, { name: 'refresh', size: 14 }), tr('重试', 'Retry')),
|
|
736
|
+
) : null,
|
|
737
|
+
),
|
|
627
738
|
)
|
|
628
739
|
}
|
|
629
740
|
|
|
630
|
-
const
|
|
741
|
+
const statusPayload = status !== null && typeof status === 'object' ? status : stats
|
|
742
|
+
const scan = statusPayload && statusPayload.scan ? statusPayload.scan : (stats.scan || { done: true, started: true, scanned: 0, total: 0, failed: 0 })
|
|
743
|
+
const sync = statusPayload && statusPayload.sync ? statusPayload.sync : (stats.sync || {})
|
|
631
744
|
const workspaces = Array.isArray(stats.workspaces) ? stats.workspaces : []
|
|
632
745
|
const aliases = stats.aliases && typeof stats.aliases === 'object' ? stats.aliases : {}
|
|
633
746
|
const wsById = new Map()
|
|
@@ -985,6 +1098,25 @@ window.__ModuleLoader__.load({
|
|
|
985
1098
|
const scanning = !scan.done
|
|
986
1099
|
const pct = scan.total > 0 ? Math.min(100, Math.round((scan.scanned / scan.total) * 100)) : 40
|
|
987
1100
|
const isEmpty = scan.done && dayRows.length === 0 && stats.totals.turns === 0
|
|
1101
|
+
const syncCompletedAt = typeof sync.lastCompletedAt === 'number' && sync.lastCompletedAt > 0 ? new Date(sync.lastCompletedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN') : ''
|
|
1102
|
+
const lastStatsText = lastStatsAt > 0 ? new Date(lastStatsAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN') : ''
|
|
1103
|
+
const healthTitle = syncCompletedAt === '' ? undefined : (language === 'en' ? 'Historical scan completed ' + syncCompletedAt : '历史扫描完成于 ' + syncCompletedAt)
|
|
1104
|
+
const healthText = language === 'en'
|
|
1105
|
+
? (lastStatsText !== '' ? 'Updated ' + lastStatsText : (scanning ? 'Refreshing data' : 'Update state pending'))
|
|
1106
|
+
+ ' · ' + (sync.sessionsSkippedByRevision || 0) + ' revision reused'
|
|
1107
|
+
+ ' · ' + (sync.sessionsRead || 0) + ' read'
|
|
1108
|
+
+ ((sync.sessionsRestoredFromLedger || 0) > 0 ? ' · ' + sync.sessionsRestoredFromLedger + ' ledger restored' : '')
|
|
1109
|
+
+ ((sync.sessionsFailed || 0) > 0 ? ' · ' + sync.sessionsFailed + ' failed' : '')
|
|
1110
|
+
+ ' · ' + (sync.persistenceSnapshotsAvailable === true ? 'revision optimization on' : 'full-read fallback')
|
|
1111
|
+
: (lastStatsText !== '' ? '已更新 ' + lastStatsText : (scanning ? '正在更新数据' : '数据更新准备中'))
|
|
1112
|
+
+ ' · revision 复用 ' + (sync.sessionsSkippedByRevision || 0)
|
|
1113
|
+
+ ' · 实际读取 ' + (sync.sessionsRead || 0)
|
|
1114
|
+
+ ((sync.sessionsRestoredFromLedger || 0) > 0 ? ' · 账本恢复 ' + sync.sessionsRestoredFromLedger : '')
|
|
1115
|
+
+ ((sync.sessionsFailed || 0) > 0 ? ' · 失败 ' + sync.sessionsFailed : '')
|
|
1116
|
+
+ ' · ' + (sync.persistenceSnapshotsAvailable === true ? '免读优化已启用' : '全量读取回退')
|
|
1117
|
+
const staleText = statsError === '' ? '' : (language === 'en'
|
|
1118
|
+
? 'Usage data may be stale' + (lastStatsText !== '' ? '; last full update ' + lastStatsText : '')
|
|
1119
|
+
: '用量数据可能已过期' + (lastStatsText !== '' ? ';上次完整更新 ' + lastStatsText : ''))
|
|
988
1120
|
|
|
989
1121
|
return React.createElement('div', { className: 'uh-page' },
|
|
990
1122
|
React.createElement('div', { className: 'uh-head' },
|
|
@@ -1049,6 +1181,11 @@ window.__ModuleLoader__.load({
|
|
|
1049
1181
|
React.createElement('span', {}, language === 'en' ? 'Scanning historical sessions: ' + scan.scanned + ' / ' + scan.total + (scan.failed > 0 ? ' (' + scan.failed + ' failed to read)' : '') : '正在统计历史会话 ' + scan.scanned + ' / ' + scan.total + (scan.failed > 0 ? '(' + scan.failed + ' 个读取失败)' : '')),
|
|
1050
1182
|
React.createElement('div', { className: 'uh-bar' }, React.createElement('div', { className: 'uh-fill', style: { width: pct + '%' } })),
|
|
1051
1183
|
) : null,
|
|
1184
|
+
React.createElement('div', { className: 'uh-sync-health' + (staleText !== '' ? ' uh-stale' : ''), title: staleText !== '' ? undefined : healthTitle },
|
|
1185
|
+
React.createElement(LineIcon, { name: staleText !== '' ? 'refresh' : 'clock', size: 14 }),
|
|
1186
|
+
React.createElement('span', {}, staleText !== '' ? staleText : healthText),
|
|
1187
|
+
staleText !== '' ? React.createElement('button', { className: 'uh-sync-retry', onClick: onRefresh }, tr('重试', 'Retry')) : null,
|
|
1188
|
+
),
|
|
1052
1189
|
isEmpty ? React.createElement('div', { className: 'uh-panel' },
|
|
1053
1190
|
React.createElement('div', { className: 'uh-empty' }, tr('还没有使用记录。开始对话后,这里会点亮。', 'No usage recorded yet. This area will light up after you start a conversation.')),
|
|
1054
1191
|
) : React.createElement(React.Fragment, null,
|
|
@@ -1189,10 +1326,11 @@ window.__ModuleLoader__.load({
|
|
|
1189
1326
|
exports.inject = ['timer', 'slots']
|
|
1190
1327
|
exports.apply = (ctx) => {
|
|
1191
1328
|
const slots = ctx.get('slots')
|
|
1192
|
-
|
|
1329
|
+
const timer = ctx.get('timer')
|
|
1330
|
+
if (slots === undefined || timer === undefined) return
|
|
1193
1331
|
slots.inject('sidebar.footer.action', () => slots.register(
|
|
1194
1332
|
{ name: 'sidebar.footer.action', id: 'all-usage', order: 10 },
|
|
1195
|
-
(props) => React.createElement(UsageSidebarEntry, { wide: props.wide, timerCtx:
|
|
1333
|
+
(props) => React.createElement(UsageSidebarEntry, { wide: props.wide, timerCtx: timer }),
|
|
1196
1334
|
))
|
|
1197
1335
|
}
|
|
1198
1336
|
return module.exports;
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
4
|
|
|
5
5
|
const name = 'dsh-all-usage'
|
|
6
|
-
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'storage']
|
|
6
|
+
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'sessionPersistence', 'storage']
|
|
7
7
|
|
|
8
8
|
// webServer route handlers do not inherit the connection API fence; keep this plugin
|
|
9
9
|
// local and require a browser-originated capability for state-changing reads/writes.
|
|
@@ -75,6 +75,10 @@ function apply(ctx) {
|
|
|
75
75
|
const settings = ctx.get('settings')
|
|
76
76
|
const storage = ctx.get('storage')
|
|
77
77
|
const webServer = ctx.get('webServer')
|
|
78
|
+
// v1.0.8: optional sessionPersistence exposes listSnapshots() — a cheap per-session
|
|
79
|
+
// revision (header line + stat, no full-log read) that lets the baseline skip
|
|
80
|
+
// re-reading unchanged sessions after a DSH restart.
|
|
81
|
+
const sessionPersistence = ctx.get('sessionPersistence')
|
|
78
82
|
|
|
79
83
|
// ---------- owned aggregation state ----------
|
|
80
84
|
const wsMeta = new Map()
|
|
@@ -98,6 +102,23 @@ function apply(ctx) {
|
|
|
98
102
|
let aliasWriteChain = Promise.resolve()
|
|
99
103
|
let balanceCache = { fetchedAt: 0, payload: null }
|
|
100
104
|
const requestToken = randomBytes(32).toString('base64url')
|
|
105
|
+
// A non-secret identity distinguishes HMR/restart revision resets from a stale page.
|
|
106
|
+
const instanceId = randomBytes(12).toString('base64url')
|
|
107
|
+
let statsRevision = 0
|
|
108
|
+
let statsUpdatedAt = Date.now()
|
|
109
|
+
let statsDirtyScheduled = false
|
|
110
|
+
const sync = {
|
|
111
|
+
lastStartedAt: 0,
|
|
112
|
+
lastCompletedAt: 0,
|
|
113
|
+
lastErrorAt: 0,
|
|
114
|
+
lastErrorCode: null,
|
|
115
|
+
persistenceSnapshotsAvailable: false,
|
|
116
|
+
sessionsTotal: 0,
|
|
117
|
+
sessionsRead: 0,
|
|
118
|
+
sessionsSkippedByRevision: 0,
|
|
119
|
+
sessionsRestoredFromLedger: 0,
|
|
120
|
+
sessionsFailed: 0,
|
|
121
|
+
}
|
|
101
122
|
const ledgerRecords = new Map()
|
|
102
123
|
let ledgerUnit = null
|
|
103
124
|
let ledgerReady = Promise.resolve()
|
|
@@ -128,6 +149,55 @@ function apply(ctx) {
|
|
|
128
149
|
function num(v) {
|
|
129
150
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
130
151
|
}
|
|
152
|
+
// Coalesce synchronous aggregation writes without adding a timer lifecycle.
|
|
153
|
+
function markStatsChanged() {
|
|
154
|
+
if (disposed || statsDirtyScheduled) return
|
|
155
|
+
statsDirtyScheduled = true
|
|
156
|
+
const flush = () => {
|
|
157
|
+
statsDirtyScheduled = false
|
|
158
|
+
if (disposed) return
|
|
159
|
+
statsRevision += 1
|
|
160
|
+
statsUpdatedAt = Date.now()
|
|
161
|
+
}
|
|
162
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(flush)
|
|
163
|
+
else Promise.resolve().then(flush)
|
|
164
|
+
}
|
|
165
|
+
function resetSyncState() {
|
|
166
|
+
sync.lastStartedAt = 0
|
|
167
|
+
sync.lastCompletedAt = 0
|
|
168
|
+
sync.lastErrorAt = 0
|
|
169
|
+
sync.lastErrorCode = null
|
|
170
|
+
sync.persistenceSnapshotsAvailable = false
|
|
171
|
+
sync.sessionsTotal = 0
|
|
172
|
+
sync.sessionsRead = 0
|
|
173
|
+
sync.sessionsSkippedByRevision = 0
|
|
174
|
+
sync.sessionsRestoredFromLedger = 0
|
|
175
|
+
sync.sessionsFailed = 0
|
|
176
|
+
}
|
|
177
|
+
function beginSync() {
|
|
178
|
+
resetSyncState()
|
|
179
|
+
sync.lastStartedAt = Date.now()
|
|
180
|
+
markStatsChanged()
|
|
181
|
+
}
|
|
182
|
+
function noteSyncError(code) {
|
|
183
|
+
sync.lastErrorAt = Date.now()
|
|
184
|
+
sync.lastErrorCode = code
|
|
185
|
+
markStatsChanged()
|
|
186
|
+
}
|
|
187
|
+
function syncSnapshot() {
|
|
188
|
+
return {
|
|
189
|
+
lastStartedAt: sync.lastStartedAt,
|
|
190
|
+
lastCompletedAt: sync.lastCompletedAt,
|
|
191
|
+
lastErrorAt: sync.lastErrorAt,
|
|
192
|
+
lastErrorCode: sync.lastErrorCode,
|
|
193
|
+
persistenceSnapshotsAvailable: sync.persistenceSnapshotsAvailable,
|
|
194
|
+
sessionsTotal: sync.sessionsTotal,
|
|
195
|
+
sessionsRead: sync.sessionsRead,
|
|
196
|
+
sessionsSkippedByRevision: sync.sessionsSkippedByRevision,
|
|
197
|
+
sessionsRestoredFromLedger: sync.sessionsRestoredFromLedger,
|
|
198
|
+
sessionsFailed: sync.sessionsFailed,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
131
201
|
async function safeContextTimeout(ms) {
|
|
132
202
|
if (disposed) return false
|
|
133
203
|
try {
|
|
@@ -164,7 +234,9 @@ function apply(ctx) {
|
|
|
164
234
|
scan.scanned = 0
|
|
165
235
|
scan.total = 0
|
|
166
236
|
scan.failed = 0
|
|
237
|
+
resetSyncState()
|
|
167
238
|
baselineRetryDelay = 1000
|
|
239
|
+
markStatsChanged()
|
|
168
240
|
return aggregationGeneration
|
|
169
241
|
}
|
|
170
242
|
function ensureDay(dayMap, date) {
|
|
@@ -271,6 +343,7 @@ function apply(ctx) {
|
|
|
271
343
|
const next = { seq: eventSeq, wsId, time, values, modelId, sid }
|
|
272
344
|
usageByStep.set(key, next)
|
|
273
345
|
adjustUsage(wsId, time, values, modelId, 1, sid)
|
|
346
|
+
markStatsChanged()
|
|
274
347
|
}
|
|
275
348
|
function addDayTurn(dayMap, date, wsId, sid) {
|
|
276
349
|
const day = ensureDay(dayMap, date)
|
|
@@ -283,6 +356,7 @@ function apply(ctx) {
|
|
|
283
356
|
totals.turns += 1
|
|
284
357
|
addDayTurn(byDay, dayKey(time), wsId, sid)
|
|
285
358
|
addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
|
|
359
|
+
markStatsChanged()
|
|
286
360
|
}
|
|
287
361
|
function routeLabel(route) {
|
|
288
362
|
if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
|
|
@@ -301,7 +375,7 @@ function apply(ctx) {
|
|
|
301
375
|
function ledgerEventKey(event, index) {
|
|
302
376
|
return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
|
|
303
377
|
}
|
|
304
|
-
function buildLedgerRecord(session, workspaceId, source = 'scan') {
|
|
378
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan', revision) {
|
|
305
379
|
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
306
380
|
const events = session && Array.isArray(session.events) ? session.events : []
|
|
307
381
|
if (sid === '' || workspaceId === undefined) return null
|
|
@@ -335,7 +409,7 @@ function apply(ctx) {
|
|
|
335
409
|
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
|
|
336
410
|
usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
|
|
337
411
|
}
|
|
338
|
-
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
412
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), lastRevision: typeof revision === 'string' ? revision : undefined, turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
339
413
|
}
|
|
340
414
|
function normalizeLedgerRecord(raw, key) {
|
|
341
415
|
if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
@@ -353,13 +427,16 @@ function apply(ctx) {
|
|
|
353
427
|
}
|
|
354
428
|
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
355
429
|
ledgerRevision = Math.max(ledgerRevision, updatedAt)
|
|
356
|
-
return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
430
|
+
return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, lastRevision: typeof raw.lastRevision === 'string' ? raw.lastRevision : undefined, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
357
431
|
}
|
|
358
432
|
function applyLedgerRecord(record) {
|
|
359
433
|
if (record === null || record === undefined) return
|
|
360
434
|
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
|
|
361
435
|
for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
|
|
362
|
-
if (record.turns.length > 0 || record.usage.length > 0)
|
|
436
|
+
if (record.turns.length > 0 || record.usage.length > 0) {
|
|
437
|
+
sessionCount.add(record.sessionId)
|
|
438
|
+
if (record.usage.length > 0) markStatsChanged()
|
|
439
|
+
}
|
|
363
440
|
}
|
|
364
441
|
function ledgerRank(record) {
|
|
365
442
|
return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
|
|
@@ -504,6 +581,7 @@ function apply(ctx) {
|
|
|
504
581
|
async function runBaseline(generation = aggregationGeneration) {
|
|
505
582
|
if (scan.started || disposed || generation !== aggregationGeneration) return
|
|
506
583
|
scan.started = true
|
|
584
|
+
beginSync()
|
|
507
585
|
await ledgerReady
|
|
508
586
|
if (disposed || generation !== aggregationGeneration) return
|
|
509
587
|
let setupFailed = false
|
|
@@ -523,21 +601,49 @@ function apply(ctx) {
|
|
|
523
601
|
} catch (err) {
|
|
524
602
|
console.error('[all-usage] workspace list failed:', err)
|
|
525
603
|
setupFailed = true
|
|
604
|
+
noteSyncError('workspace-list-failed')
|
|
526
605
|
}
|
|
527
606
|
let records = null
|
|
528
607
|
try {
|
|
529
608
|
records = await ctx.sessionQuery.listSessions()
|
|
530
609
|
} catch (err) {
|
|
531
610
|
console.error('[all-usage] session list failed:', err)
|
|
611
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
612
|
+
noteSyncError('session-list-failed')
|
|
613
|
+
}
|
|
614
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
615
|
+
// v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
|
|
616
|
+
let snapshots = null
|
|
617
|
+
if (sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function') {
|
|
618
|
+
try {
|
|
619
|
+
const rows = await sessionPersistence.listSnapshots()
|
|
620
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
621
|
+
if (Array.isArray(rows)) {
|
|
622
|
+
sync.persistenceSnapshotsAvailable = true
|
|
623
|
+
snapshots = new Map()
|
|
624
|
+
for (const row of rows) {
|
|
625
|
+
const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
|
|
626
|
+
if (rid !== undefined && row && typeof row.revision === 'string') snapshots.set(rid, row.revision)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
631
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
632
|
+
sync.persistenceSnapshotsAvailable = false
|
|
633
|
+
markStatsChanged()
|
|
634
|
+
}
|
|
532
635
|
}
|
|
533
636
|
if (disposed || generation !== aggregationGeneration) return
|
|
534
637
|
if (setupFailed || !Array.isArray(records)) {
|
|
535
638
|
// A transient registry failure must not be reported as a completed empty scan.
|
|
536
639
|
scan.started = false
|
|
640
|
+
markStatsChanged()
|
|
537
641
|
scheduleBaselineRetry(generation)
|
|
538
642
|
return
|
|
539
643
|
}
|
|
540
644
|
scan.total = records.length
|
|
645
|
+
sync.sessionsTotal = records.length
|
|
646
|
+
markStatsChanged()
|
|
541
647
|
const listedSessionIds = new Set()
|
|
542
648
|
for (const record of records) {
|
|
543
649
|
if (record === undefined || record === null || record.header === undefined) continue
|
|
@@ -547,12 +653,17 @@ function apply(ctx) {
|
|
|
547
653
|
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
548
654
|
}
|
|
549
655
|
for (const [sid, record] of ledgerRecords) {
|
|
550
|
-
if (!listedSessionIds.has(sid))
|
|
656
|
+
if (!listedSessionIds.has(sid)) {
|
|
657
|
+
applyLedgerRecord(record)
|
|
658
|
+
if (record.turns.length > 0 || record.usage.length > 0) sync.sessionsRestoredFromLedger += 1
|
|
659
|
+
}
|
|
551
660
|
}
|
|
661
|
+
if (sync.sessionsRestoredFromLedger > 0) markStatsChanged()
|
|
552
662
|
for (const record of records) {
|
|
553
663
|
if (disposed || generation !== aggregationGeneration) return
|
|
554
664
|
if (record === undefined || record === null || record.header === undefined) {
|
|
555
665
|
scan.scanned += 1
|
|
666
|
+
markStatsChanged()
|
|
556
667
|
continue
|
|
557
668
|
}
|
|
558
669
|
const sid = record.header.id
|
|
@@ -560,6 +671,7 @@ function apply(ctx) {
|
|
|
560
671
|
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
561
672
|
if (sid === undefined || wsId === undefined) {
|
|
562
673
|
scan.scanned += 1
|
|
674
|
+
markStatsChanged()
|
|
563
675
|
continue
|
|
564
676
|
}
|
|
565
677
|
listedSessionIds.add(sid)
|
|
@@ -567,6 +679,24 @@ function apply(ctx) {
|
|
|
567
679
|
if (disposed || generation !== aggregationGeneration) return
|
|
568
680
|
try {
|
|
569
681
|
if (sessionSeq.has(sid)) return
|
|
682
|
+
// v1.0.8: when the persisted log revision is unchanged since the last ledger
|
|
683
|
+
// write, the whole readSession (full event transfer) is skipped — the ledger
|
|
684
|
+
// record is applied directly and the live feed keeps catching new events.
|
|
685
|
+
const previousRecord = ledgerRecords.get(sid)
|
|
686
|
+
const revision = snapshots === null ? undefined : snapshots.get(sid)
|
|
687
|
+
if (previousRecord !== undefined && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
688
|
+
sync.sessionsSkippedByRevision += 1
|
|
689
|
+
applyLedgerRecord(previousRecord)
|
|
690
|
+
for (const item of previousRecord.usage) {
|
|
691
|
+
usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
|
|
692
|
+
}
|
|
693
|
+
sessionSeq.set(sid, previousRecord.lastSeq)
|
|
694
|
+
sessionCount.add(sid)
|
|
695
|
+
markStatsChanged()
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
sync.sessionsRead += 1
|
|
699
|
+
markStatsChanged()
|
|
570
700
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
571
701
|
if (disposed || generation !== aggregationGeneration) return
|
|
572
702
|
if (snap && Array.isArray(snap.events)) {
|
|
@@ -585,6 +715,12 @@ function apply(ctx) {
|
|
|
585
715
|
usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
|
|
586
716
|
}
|
|
587
717
|
if (previous.lastSeq >= currentLastSeq) {
|
|
718
|
+
// Content unchanged despite a revision change (rare: ctime-only churn):
|
|
719
|
+
// refresh the stored revision so future restarts can skip the read.
|
|
720
|
+
if (revision !== undefined && previous.lastRevision !== revision) {
|
|
721
|
+
previous.lastRevision = revision
|
|
722
|
+
void persistLedgerRecord(previous)
|
|
723
|
+
}
|
|
588
724
|
sessionSeq.set(sid, previous.lastSeq)
|
|
589
725
|
sessionCount.add(sid)
|
|
590
726
|
return
|
|
@@ -593,7 +729,7 @@ function apply(ctx) {
|
|
|
593
729
|
} else {
|
|
594
730
|
foldEvents(wsId, snap.events, undefined, sid)
|
|
595
731
|
}
|
|
596
|
-
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
|
|
732
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision)
|
|
597
733
|
const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
|
|
598
734
|
if (canonical === ledger) {
|
|
599
735
|
void persistLedgerRecord(ledger)
|
|
@@ -604,21 +740,29 @@ function apply(ctx) {
|
|
|
604
740
|
sessionCount.add(sid)
|
|
605
741
|
}
|
|
606
742
|
} catch (err) {
|
|
743
|
+
if (generation !== aggregationGeneration) return
|
|
744
|
+
sync.sessionsFailed += 1
|
|
745
|
+
noteSyncError('session-read-failed')
|
|
607
746
|
const saved = ledgerRecords.get(sid)
|
|
608
747
|
if (saved !== undefined) {
|
|
609
748
|
applyLedgerRecord(saved)
|
|
749
|
+
if (saved.turns.length > 0 || saved.usage.length > 0) sync.sessionsRestoredFromLedger += 1
|
|
610
750
|
sessionSeq.set(sid, -1)
|
|
611
751
|
sessionCount.add(sid)
|
|
612
|
-
} else
|
|
752
|
+
} else {
|
|
613
753
|
sessionSeq.set(sid, -1)
|
|
614
754
|
scan.failed += 1
|
|
615
755
|
}
|
|
616
756
|
} finally {
|
|
617
|
-
if (generation === aggregationGeneration)
|
|
757
|
+
if (generation === aggregationGeneration) {
|
|
758
|
+
scan.scanned += 1
|
|
759
|
+
markStatsChanged()
|
|
760
|
+
}
|
|
618
761
|
}
|
|
619
762
|
})
|
|
620
763
|
if (!(await safeContextTimeout(0))) {
|
|
621
764
|
scan.started = false
|
|
765
|
+
noteSyncError('baseline-yield-unavailable')
|
|
622
766
|
scheduleBaselineRetry(generation)
|
|
623
767
|
return
|
|
624
768
|
}
|
|
@@ -629,6 +773,12 @@ function apply(ctx) {
|
|
|
629
773
|
knownSessionIds.clear()
|
|
630
774
|
for (const sid of listedSessionIds) knownSessionIds.add(sid)
|
|
631
775
|
scan.done = true
|
|
776
|
+
sync.lastCompletedAt = Date.now()
|
|
777
|
+
if (sync.sessionsFailed === 0) {
|
|
778
|
+
sync.lastErrorAt = 0
|
|
779
|
+
sync.lastErrorCode = null
|
|
780
|
+
}
|
|
781
|
+
markStatsChanged()
|
|
632
782
|
if (reconcilePending) scheduleReconcileHint()
|
|
633
783
|
}
|
|
634
784
|
|
|
@@ -665,6 +815,7 @@ function apply(ctx) {
|
|
|
665
815
|
for (const sid of currentIds) knownSessionIds.add(sid)
|
|
666
816
|
} catch (err) {
|
|
667
817
|
console.error('[all-usage] session reconciliation failed:', err)
|
|
818
|
+
noteSyncError('session-reconcile-failed')
|
|
668
819
|
} finally {
|
|
669
820
|
reconcileInFlight = false
|
|
670
821
|
if (reconcilePending && !disposed) scheduleReconcileHint()
|
|
@@ -738,6 +889,7 @@ function apply(ctx) {
|
|
|
738
889
|
const value = g[key]
|
|
739
890
|
if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
|
|
740
891
|
}
|
|
892
|
+
markStatsChanged()
|
|
741
893
|
}
|
|
742
894
|
} catch (err) {
|
|
743
895
|
console.error('[all-usage] alias storage unavailable:', err)
|
|
@@ -759,6 +911,7 @@ function apply(ctx) {
|
|
|
759
911
|
if (alias === '') delete aliases[wsId]
|
|
760
912
|
else aliases[wsId] = alias
|
|
761
913
|
persistAliases()
|
|
914
|
+
markStatsChanged()
|
|
762
915
|
return { ok: true, aliases: Object.assign({}, aliases) }
|
|
763
916
|
}
|
|
764
917
|
ctx.effect(() => () => {
|
|
@@ -785,6 +938,12 @@ function apply(ctx) {
|
|
|
785
938
|
})
|
|
786
939
|
|
|
787
940
|
// ---------- snapshot for the client ----------
|
|
941
|
+
function scanSnapshot() {
|
|
942
|
+
return { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed }
|
|
943
|
+
}
|
|
944
|
+
function statusSnapshot() {
|
|
945
|
+
return { instanceId, revision: statsRevision, updatedAt: statsUpdatedAt, scan: scanSnapshot(), sync: syncSnapshot() }
|
|
946
|
+
}
|
|
788
947
|
function serializeDays(dayMap) {
|
|
789
948
|
const result = []
|
|
790
949
|
for (const pair of dayMap) {
|
|
@@ -806,7 +965,7 @@ function apply(ctx) {
|
|
|
806
965
|
}
|
|
807
966
|
function snapshot() {
|
|
808
967
|
return {
|
|
809
|
-
|
|
968
|
+
...statusSnapshot(),
|
|
810
969
|
generatedAt: Date.now(),
|
|
811
970
|
requestToken,
|
|
812
971
|
workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
|
|
@@ -940,6 +1099,16 @@ function apply(ctx) {
|
|
|
940
1099
|
// ---------- HTTP data routes for the client half ----------
|
|
941
1100
|
if (webServer !== undefined) {
|
|
942
1101
|
const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
|
|
1102
|
+
ctx.effect(() => webServer.register({
|
|
1103
|
+
kind: 'exact',
|
|
1104
|
+
path: '/api/all-usage/status',
|
|
1105
|
+
handler: (req, res) => {
|
|
1106
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
1107
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
1108
|
+
if (!scan.started) void runBaseline()
|
|
1109
|
+
sendJson(res, 200, statusSnapshot())
|
|
1110
|
+
},
|
|
1111
|
+
}))
|
|
943
1112
|
ctx.effect(() => webServer.register({
|
|
944
1113
|
kind: 'exact',
|
|
945
1114
|
path: '/api/all-usage',
|