dsh-all-usage 1.0.5 → 1.0.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/README.md +6 -4
- package/lib/index.js +328 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
15
15
|
- **摘要与工作区**:Token 用量、缓存命中、账户余额、连续使用、工作区 Token 分布和明细
|
|
16
16
|
- **导出**:按当前时间范围和模型聚合方式导出 CSV
|
|
17
17
|
- **时间范围**:今日、近 30 天、近 90 天、全部,或在全部可扫描历史日数据中自定义起止日期;热力图始终展示最近 53 周
|
|
18
|
-
- **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `
|
|
18
|
+
- **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all_usage_aliases`
|
|
19
19
|
- **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
|
|
20
20
|
|
|
21
21
|
### 截图 / Screenshots
|
|
@@ -64,8 +64,9 @@ dsh plugin --profile web add github:ParticleLight/dsh-all-usage
|
|
|
64
64
|
|
|
65
65
|
### 数据说明
|
|
66
66
|
|
|
67
|
-
- 使用次数与 Token
|
|
67
|
+
- 使用次数与 Token 来自 DSH 会话日志,并在 `session/flush` 时写入独立用量账本;插件激活时会回填日志与账本历史,插件卸载/重启后已成功持久化的数据不丢
|
|
68
68
|
- 按日范围统计会保留全部可读取历史会话的有使用记录日期;热力图仅作为最近 53 周的固定视图窗口
|
|
69
|
+
- 会话删除后,已成功 flush 的用量仍从独立账本恢复;会话销毁提示和周期对账只负责触发重建,不会删除账本记录
|
|
69
70
|
- 同一会话的同一 `turn / step` 只保留一份最终 usage;重试或替换消息会替换旧贡献,不重复累计
|
|
70
71
|
- 看板中的总处理量 = 输入 + 输出 + 缓存读写 + 推理;缓存命中表示复用的上下文 Token,不等于新生成 Token 或实际费用
|
|
71
72
|
- 余额查询走 DeepSeek 官方 `/user/balance` 接口;未配置 API Key 时卡片显示引导文案
|
|
@@ -87,7 +88,7 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
|
|
|
87
88
|
- **Summary and workspaces**: processed tokens, cache hits, account balance, usage streaks, workspace distribution, and details
|
|
88
89
|
- **CSV export**: export data using the selected time range and aggregation mode
|
|
89
90
|
- **Time ranges**: today, last 30 days, last 90 days, all time, or a custom start/end date across all available historical daily data; the heatmap always shows the latest 53 weeks
|
|
90
|
-
- **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `
|
|
91
|
+
- **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all_usage_aliases`
|
|
91
92
|
- **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
|
|
92
93
|
|
|
93
94
|
### Installation
|
|
@@ -130,8 +131,9 @@ The profile patch layer hot-reloads; save the file and refresh the page.
|
|
|
130
131
|
|
|
131
132
|
### Data semantics
|
|
132
133
|
|
|
133
|
-
-
|
|
134
|
+
- Calls and tokens come from DSH session logs and a separate usage ledger written at `session/flush`; readable logs and ledger history are backfilled when the plugin activates, so successfully persisted data survives reloads or session deletion
|
|
134
135
|
- Day-level range data retains every readable historical session date with tracked usage; the heatmap is only a fixed latest-53-week view
|
|
136
|
+
- 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
|
|
135
137
|
- For each session and logical `turn / step`, only the final usage contribution is kept; retries or replaced messages do not double-count
|
|
136
138
|
- Processed tokens = input + output + cache read/write + reasoning; a cache hit means reused context, not newly generated tokens or actual cost
|
|
137
139
|
- Balance data comes from DeepSeek’s official `/user/balance` endpoint; the card shows guidance when no API key is configured
|
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']
|
|
6
|
+
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', '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.
|
|
@@ -98,9 +98,24 @@ function apply(ctx) {
|
|
|
98
98
|
let aliasWriteChain = Promise.resolve()
|
|
99
99
|
let balanceCache = { fetchedAt: 0, payload: null }
|
|
100
100
|
const requestToken = randomBytes(32).toString('base64url')
|
|
101
|
+
const ledgerRecords = new Map()
|
|
102
|
+
let ledgerUnit = null
|
|
103
|
+
let ledgerReady = Promise.resolve()
|
|
104
|
+
let ledgerWriteChain = Promise.resolve()
|
|
105
|
+
const LEDGER_VERSION = 1
|
|
106
|
+
let ledgerRevision = Date.now()
|
|
101
107
|
let disposed = false
|
|
102
108
|
let baselineRetryDelay = 1000
|
|
103
109
|
let baselineRetryScheduled = false
|
|
110
|
+
let baselineFallbackTimer = null
|
|
111
|
+
const knownSessionIds = new Set()
|
|
112
|
+
let aggregationGeneration = 0
|
|
113
|
+
let reconcileHintScheduled = false
|
|
114
|
+
let reconcilePending = false
|
|
115
|
+
let reconcileInFlight = false
|
|
116
|
+
let reconcileTimer = null
|
|
117
|
+
const RECONCILE_INTERVAL_MS = 120000
|
|
118
|
+
const RECONCILE_HINT_DELAY_MS = 3000
|
|
104
119
|
|
|
105
120
|
function dayKey(ms) {
|
|
106
121
|
const d = new Date(ms)
|
|
@@ -113,6 +128,45 @@ function apply(ctx) {
|
|
|
113
128
|
function num(v) {
|
|
114
129
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
115
130
|
}
|
|
131
|
+
async function safeContextTimeout(ms) {
|
|
132
|
+
if (disposed) return false
|
|
133
|
+
try {
|
|
134
|
+
await ctx.timeout(ms)
|
|
135
|
+
return !disposed
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (!disposed) console.error('[all-usage] context timer unavailable:', err)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resetAggregationState() {
|
|
142
|
+
aggregationGeneration += 1
|
|
143
|
+
wsMeta.clear()
|
|
144
|
+
pathIndex.clear()
|
|
145
|
+
memberOf.clear()
|
|
146
|
+
byDay.clear()
|
|
147
|
+
byDayUtc.clear()
|
|
148
|
+
perWorkspace.clear()
|
|
149
|
+
perModel.clear()
|
|
150
|
+
usageByStep.clear()
|
|
151
|
+
sessionModel.clear()
|
|
152
|
+
sessionCount.clear()
|
|
153
|
+
sessionSeq.clear()
|
|
154
|
+
chains.clear()
|
|
155
|
+
knownSessionIds.clear()
|
|
156
|
+
totals.turns = 0
|
|
157
|
+
totals.input = 0
|
|
158
|
+
totals.output = 0
|
|
159
|
+
totals.cacheRead = 0
|
|
160
|
+
totals.cacheWrite = 0
|
|
161
|
+
totals.reasoning = 0
|
|
162
|
+
scan.started = false
|
|
163
|
+
scan.done = false
|
|
164
|
+
scan.scanned = 0
|
|
165
|
+
scan.total = 0
|
|
166
|
+
scan.failed = 0
|
|
167
|
+
baselineRetryDelay = 1000
|
|
168
|
+
return aggregationGeneration
|
|
169
|
+
}
|
|
116
170
|
function ensureDay(dayMap, date) {
|
|
117
171
|
let day = dayMap.get(date)
|
|
118
172
|
if (day === undefined) {
|
|
@@ -236,6 +290,93 @@ function apply(ctx) {
|
|
|
236
290
|
function modelFromMessage(data, fallback) {
|
|
237
291
|
return routeLabel(data && data.message && data.message.source) || fallback
|
|
238
292
|
}
|
|
293
|
+
function nextLedgerRevision() {
|
|
294
|
+
ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
|
|
295
|
+
return ledgerRevision
|
|
296
|
+
}
|
|
297
|
+
function ledgerEventKey(event, index) {
|
|
298
|
+
return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
|
|
299
|
+
}
|
|
300
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan') {
|
|
301
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
302
|
+
const events = session && Array.isArray(session.events) ? session.events : []
|
|
303
|
+
if (sid === '' || workspaceId === undefined) return null
|
|
304
|
+
const turns = new Map()
|
|
305
|
+
const usage = new Map()
|
|
306
|
+
let currentModel
|
|
307
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
308
|
+
const event = events[index]
|
|
309
|
+
if (event === null || typeof event !== 'object') continue
|
|
310
|
+
const data = event.data
|
|
311
|
+
if (event.type === 'request/context' || event.type === 'request/header') {
|
|
312
|
+
const model = modelFromRoute(data)
|
|
313
|
+
if (model !== undefined) currentModel = model
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if (event.type === 'turn/end') {
|
|
317
|
+
const key = ledgerEventKey(event, index)
|
|
318
|
+
turns.set(key, { key, time: event.time, workspaceId })
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
|
|
322
|
+
const values = usageValues(data.usage)
|
|
323
|
+
const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
|
|
324
|
+
? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
|
|
325
|
+
const eventSeq = typeof event.seq === 'number' ? event.seq : -1
|
|
326
|
+
const key = usageStepKey(sid, data, event.seq)
|
|
327
|
+
const previous = usage.get(key)
|
|
328
|
+
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
|
|
329
|
+
usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
|
|
330
|
+
}
|
|
331
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
332
|
+
}
|
|
333
|
+
function normalizeLedgerRecord(raw, key) {
|
|
334
|
+
if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
335
|
+
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
336
|
+
const turnMap = new Map()
|
|
337
|
+
for (const turn of raw.turns) {
|
|
338
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
|
|
339
|
+
}
|
|
340
|
+
const usageMap = new Map()
|
|
341
|
+
for (const item of raw.usage) {
|
|
342
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
343
|
+
const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
344
|
+
const previous = usageMap.get(normalized.key)
|
|
345
|
+
if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
|
|
346
|
+
}
|
|
347
|
+
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
348
|
+
ledgerRevision = Math.max(ledgerRevision, updatedAt)
|
|
349
|
+
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()) }
|
|
350
|
+
}
|
|
351
|
+
function applyLedgerRecord(record) {
|
|
352
|
+
if (record === null || record === undefined) return
|
|
353
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
|
|
354
|
+
for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
|
|
355
|
+
if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
|
|
356
|
+
}
|
|
357
|
+
function ledgerRank(record) {
|
|
358
|
+
return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
|
|
359
|
+
}
|
|
360
|
+
function storeLedgerRecord(record) {
|
|
361
|
+
const current = ledgerRecords.get(record.sessionId)
|
|
362
|
+
if (current !== undefined) {
|
|
363
|
+
const nextRank = ledgerRank(record)
|
|
364
|
+
const currentRank = ledgerRank(current)
|
|
365
|
+
if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
|
|
366
|
+
}
|
|
367
|
+
ledgerRecords.set(record.sessionId, record)
|
|
368
|
+
return record
|
|
369
|
+
}
|
|
370
|
+
function persistLedgerRecord(record) {
|
|
371
|
+
if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
|
|
372
|
+
const write = ledgerWriteChain.then(async () => {
|
|
373
|
+
if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
|
|
374
|
+
})
|
|
375
|
+
ledgerWriteChain = write.catch((err) => {
|
|
376
|
+
console.error('[all-usage] usage ledger write failed:', err)
|
|
377
|
+
})
|
|
378
|
+
return ledgerWriteChain
|
|
379
|
+
}
|
|
239
380
|
function foldEvent(wsId, time, type, data, sid, seq) {
|
|
240
381
|
if (type === 'request/context' || type === 'request/header') {
|
|
241
382
|
const model = modelFromRoute(data)
|
|
@@ -276,13 +417,14 @@ function apply(ctx) {
|
|
|
276
417
|
if (wsId !== undefined) memberOf.set(sid, wsId)
|
|
277
418
|
return wsId
|
|
278
419
|
}
|
|
279
|
-
async function processLiveEvent(sid, wsId, event) {
|
|
280
|
-
if (disposed) return
|
|
420
|
+
async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
|
|
421
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
281
422
|
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
282
423
|
const last = sessionSeq.get(sid)
|
|
283
424
|
if (last === undefined) {
|
|
284
425
|
try {
|
|
285
426
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
427
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
286
428
|
if (snap && Array.isArray(snap.events)) {
|
|
287
429
|
foldEvents(wsId, snap.events, undefined, sid)
|
|
288
430
|
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
@@ -295,6 +437,7 @@ function apply(ctx) {
|
|
|
295
437
|
if (seq > last + 1) {
|
|
296
438
|
try {
|
|
297
439
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
440
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
298
441
|
if (snap && Array.isArray(snap.events)) {
|
|
299
442
|
foldEvents(wsId, snap.events, last, sid)
|
|
300
443
|
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
@@ -307,21 +450,55 @@ function apply(ctx) {
|
|
|
307
450
|
sessionCount.add(sid)
|
|
308
451
|
}
|
|
309
452
|
|
|
453
|
+
// ---------- durable usage ledger ----------
|
|
454
|
+
async function loadLedger() {
|
|
455
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
456
|
+
try {
|
|
457
|
+
const backend = storage.backend.get('json')
|
|
458
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
459
|
+
const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
|
|
460
|
+
if (disposed) { await unit.close().catch(() => {}); return }
|
|
461
|
+
ledgerUnit = unit
|
|
462
|
+
const snapshot = await unit.loadAll()
|
|
463
|
+
const rows = snapshot && snapshot.tables && snapshot.tables.sessions
|
|
464
|
+
if (rows !== null && rows !== undefined && typeof rows === 'object') {
|
|
465
|
+
for (const [key, raw] of Object.entries(rows)) {
|
|
466
|
+
const record = normalizeLedgerRecord(raw, key)
|
|
467
|
+
if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
|
|
468
|
+
else ledgerRecords.set(key, record)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
console.error('[all-usage] usage ledger unavailable:', err)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
310
476
|
// ---------- baseline scan over durable logs ----------
|
|
311
|
-
function
|
|
312
|
-
if (disposed ||
|
|
477
|
+
function scheduleNativeBaselineRetry(generation, delay) {
|
|
478
|
+
if (disposed || baselineFallbackTimer !== null) return
|
|
479
|
+
baselineFallbackTimer = setTimeout(() => {
|
|
480
|
+
baselineFallbackTimer = null
|
|
481
|
+
if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
|
|
482
|
+
}, delay)
|
|
483
|
+
if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
|
|
484
|
+
}
|
|
485
|
+
function scheduleBaselineRetry(generation = aggregationGeneration) {
|
|
486
|
+
if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
|
|
313
487
|
baselineRetryScheduled = true
|
|
314
488
|
const delay = baselineRetryDelay
|
|
315
489
|
baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
|
|
316
|
-
void
|
|
490
|
+
void safeContextTimeout(delay).then((ready) => {
|
|
317
491
|
baselineRetryScheduled = false
|
|
318
|
-
if (
|
|
492
|
+
if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
|
|
493
|
+
if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
|
|
319
494
|
return undefined
|
|
320
495
|
})
|
|
321
496
|
}
|
|
322
|
-
async function runBaseline() {
|
|
323
|
-
if (scan.started || disposed) return
|
|
497
|
+
async function runBaseline(generation = aggregationGeneration) {
|
|
498
|
+
if (scan.started || disposed || generation !== aggregationGeneration) return
|
|
324
499
|
scan.started = true
|
|
500
|
+
await ledgerReady
|
|
501
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
325
502
|
let setupFailed = false
|
|
326
503
|
try {
|
|
327
504
|
const workspaces = ctx.workspaceRegistry.list()
|
|
@@ -346,16 +523,27 @@ function apply(ctx) {
|
|
|
346
523
|
} catch (err) {
|
|
347
524
|
console.error('[all-usage] session list failed:', err)
|
|
348
525
|
}
|
|
349
|
-
if (disposed) return
|
|
526
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
350
527
|
if (setupFailed || !Array.isArray(records)) {
|
|
351
528
|
// A transient registry failure must not be reported as a completed empty scan.
|
|
352
529
|
scan.started = false
|
|
353
|
-
scheduleBaselineRetry()
|
|
530
|
+
scheduleBaselineRetry(generation)
|
|
354
531
|
return
|
|
355
532
|
}
|
|
356
533
|
scan.total = records.length
|
|
534
|
+
const listedSessionIds = new Set()
|
|
357
535
|
for (const record of records) {
|
|
358
|
-
if (
|
|
536
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
537
|
+
const sid = record.header.id
|
|
538
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
539
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
540
|
+
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
541
|
+
}
|
|
542
|
+
for (const [sid, record] of ledgerRecords) {
|
|
543
|
+
if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
|
|
544
|
+
}
|
|
545
|
+
for (const record of records) {
|
|
546
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
359
547
|
if (record === undefined || record === null || record.header === undefined) {
|
|
360
548
|
scan.scanned += 1
|
|
361
549
|
continue
|
|
@@ -367,26 +555,114 @@ function apply(ctx) {
|
|
|
367
555
|
scan.scanned += 1
|
|
368
556
|
continue
|
|
369
557
|
}
|
|
558
|
+
listedSessionIds.add(sid)
|
|
370
559
|
await enqueue(sid, async () => {
|
|
371
|
-
if (disposed) return
|
|
560
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
372
561
|
try {
|
|
373
562
|
if (sessionSeq.has(sid)) return
|
|
374
563
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
564
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
375
565
|
if (snap && Array.isArray(snap.events)) {
|
|
376
|
-
|
|
566
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
|
|
567
|
+
const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
|
|
568
|
+
if (canonical === ledger) {
|
|
569
|
+
void persistLedgerRecord(ledger)
|
|
570
|
+
foldEvents(wsId, snap.events, undefined, sid)
|
|
571
|
+
} else if (canonical !== undefined) {
|
|
572
|
+
applyLedgerRecord(canonical)
|
|
573
|
+
}
|
|
377
574
|
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
378
575
|
sessionCount.add(sid)
|
|
379
576
|
}
|
|
380
577
|
} catch (err) {
|
|
381
|
-
|
|
382
|
-
|
|
578
|
+
const saved = ledgerRecords.get(sid)
|
|
579
|
+
if (saved !== undefined) {
|
|
580
|
+
applyLedgerRecord(saved)
|
|
581
|
+
sessionSeq.set(sid, -1)
|
|
582
|
+
sessionCount.add(sid)
|
|
583
|
+
} else if (generation === aggregationGeneration) {
|
|
584
|
+
sessionSeq.set(sid, -1)
|
|
585
|
+
scan.failed += 1
|
|
586
|
+
}
|
|
383
587
|
} finally {
|
|
384
|
-
scan.scanned += 1
|
|
588
|
+
if (generation === aggregationGeneration) scan.scanned += 1
|
|
385
589
|
}
|
|
386
590
|
})
|
|
387
|
-
await
|
|
591
|
+
if (!(await safeContextTimeout(0))) {
|
|
592
|
+
scan.started = false
|
|
593
|
+
scheduleBaselineRetry(generation)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
388
596
|
}
|
|
597
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
598
|
+
await ledgerWriteChain
|
|
599
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
600
|
+
knownSessionIds.clear()
|
|
601
|
+
for (const sid of listedSessionIds) knownSessionIds.add(sid)
|
|
389
602
|
scan.done = true
|
|
603
|
+
if (reconcilePending) scheduleReconcileHint()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function sessionIdsFromRecords(records) {
|
|
607
|
+
const ids = new Set()
|
|
608
|
+
for (const record of records) {
|
|
609
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
610
|
+
const sid = record.header.id
|
|
611
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
612
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
613
|
+
if (sid !== undefined && wsId !== undefined) ids.add(sid)
|
|
614
|
+
}
|
|
615
|
+
return ids
|
|
616
|
+
}
|
|
617
|
+
async function reconcileSessions() {
|
|
618
|
+
if (disposed || reconcileInFlight || !scan.done) return
|
|
619
|
+
reconcilePending = false
|
|
620
|
+
reconcileInFlight = true
|
|
621
|
+
try {
|
|
622
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
623
|
+
if (disposed || !Array.isArray(records)) return
|
|
624
|
+
const currentIds = sessionIdsFromRecords(records)
|
|
625
|
+
let removed = false
|
|
626
|
+
for (const sid of knownSessionIds) {
|
|
627
|
+
if (!currentIds.has(sid)) { removed = true; break }
|
|
628
|
+
}
|
|
629
|
+
if (removed && !disposed && scan.done) {
|
|
630
|
+
console.info('[all-usage] session removal detected; rebuilding usage index')
|
|
631
|
+
const generation = resetAggregationState()
|
|
632
|
+
void runBaseline(generation)
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
knownSessionIds.clear()
|
|
636
|
+
for (const sid of currentIds) knownSessionIds.add(sid)
|
|
637
|
+
} catch (err) {
|
|
638
|
+
console.error('[all-usage] session reconciliation failed:', err)
|
|
639
|
+
} finally {
|
|
640
|
+
reconcileInFlight = false
|
|
641
|
+
if (reconcilePending && !disposed) scheduleReconcileHint()
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function scheduleReconcileHint() {
|
|
645
|
+
if (disposed) return
|
|
646
|
+
reconcilePending = true
|
|
647
|
+
if (reconcileHintScheduled || reconcileInFlight) return
|
|
648
|
+
reconcileHintScheduled = true
|
|
649
|
+
void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
|
|
650
|
+
reconcileHintScheduled = false
|
|
651
|
+
if (ready && !disposed) void reconcileSessions()
|
|
652
|
+
}, () => {
|
|
653
|
+
reconcileHintScheduled = false
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
function scheduleReconcileTimer() {
|
|
657
|
+
if (disposed || reconcileTimer !== null) return
|
|
658
|
+
reconcileTimer = setTimeout(() => {
|
|
659
|
+
reconcileTimer = null
|
|
660
|
+
if (!disposed) {
|
|
661
|
+
void reconcileSessions()
|
|
662
|
+
scheduleReconcileTimer()
|
|
663
|
+
}
|
|
664
|
+
}, RECONCILE_INTERVAL_MS)
|
|
665
|
+
if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
|
|
390
666
|
}
|
|
391
667
|
|
|
392
668
|
// ---------- live feed ----------
|
|
@@ -399,7 +675,23 @@ function apply(ctx) {
|
|
|
399
675
|
if (typeof sid !== 'string') return
|
|
400
676
|
const wsId = wsForLiveSession(session, sid)
|
|
401
677
|
if (wsId === undefined) return
|
|
402
|
-
|
|
678
|
+
const generation = aggregationGeneration
|
|
679
|
+
knownSessionIds.add(sid)
|
|
680
|
+
enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
|
|
681
|
+
})
|
|
682
|
+
ctx.on('session/flush', async (session) => {
|
|
683
|
+
if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
684
|
+
await ledgerReady
|
|
685
|
+
if (disposed) return
|
|
686
|
+
const wsId = wsForLiveSession(session, session.id)
|
|
687
|
+
if (wsId === undefined) return
|
|
688
|
+
const ledger = buildLedgerRecord(session, wsId, 'flush')
|
|
689
|
+
if (ledger === null) return
|
|
690
|
+
const canonical = storeLedgerRecord(ledger)
|
|
691
|
+
if (canonical === ledger) await persistLedgerRecord(ledger)
|
|
692
|
+
})
|
|
693
|
+
ctx.on('session/disposed', () => {
|
|
694
|
+
if (!disposed) scheduleReconcileHint()
|
|
403
695
|
})
|
|
404
696
|
|
|
405
697
|
// ---------- workspace aliases (durable, schema-free KV unit) ----------
|
|
@@ -408,7 +700,7 @@ function apply(ctx) {
|
|
|
408
700
|
try {
|
|
409
701
|
const backend = storage.backend.get('json')
|
|
410
702
|
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
411
|
-
const unit = await backend.kv.open({ name: '
|
|
703
|
+
const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
|
|
412
704
|
kvUnit = unit
|
|
413
705
|
const snap = await unit.loadAll()
|
|
414
706
|
const g = snap && snap.global
|
|
@@ -442,12 +734,26 @@ function apply(ctx) {
|
|
|
442
734
|
}
|
|
443
735
|
ctx.effect(() => () => {
|
|
444
736
|
disposed = true
|
|
737
|
+
if (reconcileTimer !== null) {
|
|
738
|
+
clearTimeout(reconcileTimer)
|
|
739
|
+
reconcileTimer = null
|
|
740
|
+
}
|
|
741
|
+
if (baselineFallbackTimer !== null) {
|
|
742
|
+
clearTimeout(baselineFallbackTimer)
|
|
743
|
+
baselineFallbackTimer = null
|
|
744
|
+
}
|
|
445
745
|
})
|
|
446
746
|
ctx.effect(() => () => {
|
|
447
747
|
const unit = kvUnit
|
|
448
748
|
kvUnit = null
|
|
449
749
|
if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
|
|
450
750
|
})
|
|
751
|
+
ctx.effect(() => async () => {
|
|
752
|
+
const unit = ledgerUnit
|
|
753
|
+
await ledgerWriteChain
|
|
754
|
+
if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
|
|
755
|
+
if (ledgerUnit === unit) ledgerUnit = null
|
|
756
|
+
})
|
|
451
757
|
|
|
452
758
|
// ---------- snapshot for the client ----------
|
|
453
759
|
function serializeDays(dayMap) {
|
|
@@ -642,7 +948,9 @@ function apply(ctx) {
|
|
|
642
948
|
}
|
|
643
949
|
|
|
644
950
|
// ---------- start the historical backfill immediately ----------
|
|
951
|
+
ledgerReady = loadLedger()
|
|
645
952
|
void runBaseline()
|
|
953
|
+
scheduleReconcileTimer()
|
|
646
954
|
void loadAliases()
|
|
647
955
|
}
|
|
648
956
|
|