dsh-token-total 1.1.3 → 1.1.4
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 +4 -0
- package/lib/client.js +39 -7
- package/lib/index.js +166 -180
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +2 -0
- package/lib/types/session-tree.d.ts +16 -0
- package/lib/types/session-usage.d.ts +5 -33
- package/lib/types/wire.d.ts +16 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.tsx +6 -1
- package/src/client/StatsLineEnhanced.tsx +14 -5
- package/src/client/locales.ts +8 -0
- package/src/client/session-usage-core.ts +1 -0
- package/src/client/session-usage-locales.ts +3 -0
- package/src/session-detail.ts +7 -36
- package/src/session-tree.ts +146 -0
- package/src/session-usage.ts +32 -181
- package/src/wire.ts +18 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { UsageSessionEvent, UsageSessionHandle, UsageSessionHeader, UsageSessionPersistence, UsageSessionStore } from './context-types.ts';
|
|
2
|
+
import type { SessionUsageCoverage } from './wire.ts';
|
|
3
|
+
/** Shared membership, ownership and failure semantics for both usage APIs.
|
|
4
|
+
* A bad descendant is isolated; root, catalog and cancellation failures reject. */
|
|
5
|
+
export interface UsageSessionLog {
|
|
6
|
+
id: string;
|
|
7
|
+
header?: UsageSessionHeader;
|
|
8
|
+
live?: UsageSessionHandle;
|
|
9
|
+
events: readonly UsageSessionEvent[];
|
|
10
|
+
inheritedCut: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<{
|
|
13
|
+
root: UsageSessionLog;
|
|
14
|
+
descendants: UsageSessionLog[];
|
|
15
|
+
coverage: SessionUsageCoverage;
|
|
16
|
+
}>;
|
|
@@ -1,42 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-session usage aggregation, INCLUDING subagent sessions.
|
|
3
|
-
*
|
|
4
|
-
* Why this exists: dsh's native `tokenUsage` projection covers only the main
|
|
5
|
-
* agent of the current session — a session that delegates to subagents (whose
|
|
6
|
-
* own logs are separate persisted sessions, linked by `header.parentSession`)
|
|
7
|
-
* shows only a fraction of what it actually spent. This module answers "what
|
|
8
|
-
* did this whole session tree cost".
|
|
9
|
-
*
|
|
10
|
-
* The store has no per-session dimension (it accumulates per day x model), so
|
|
11
|
-
* this is computed ON DEMAND from the session logs: the root session plus every
|
|
12
|
-
* descendant whose `parentSession` chain leads back to it, each replayed
|
|
13
|
-
* through a fresh {@link UsageFold} (the same dedupe rules the backfill uses).
|
|
14
|
-
*
|
|
15
|
-
* Two correctness rules carried over from the backfill engine:
|
|
16
|
-
* - a forked log begins with the events it copied from its parent
|
|
17
|
-
* (`handle.inheritedEventCount`); that prefix is skipped, the parent owns it;
|
|
18
|
-
* - the fold dedupes per (turn, step) within one session, so a streaming
|
|
19
|
-
* sample followed by the final message counts once.
|
|
20
|
-
*
|
|
21
|
-
* Results are cached briefly: the composer pill polls, and replaying a long log
|
|
22
|
-
* per poll would be wasteful. The cache is invalidated for a session as soon as
|
|
23
|
-
* a new event lands (see the collector's live subscription).
|
|
24
|
-
*/
|
|
1
|
+
/** Per-session owned usage, including every readable descendant. */
|
|
25
2
|
import type { UsageSessionPersistence, UsageSessionStore } from './context-types.ts';
|
|
26
3
|
import type { SessionUsageTree } from './wire.ts';
|
|
27
4
|
export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
|
|
28
5
|
export declare class SessionUsageService {
|
|
29
|
-
/** Short-lived tree reads; any live event can invalidate tree membership. */
|
|
30
6
|
private cache;
|
|
31
|
-
/** Any event can introduce a new descendant while an async read is running. */
|
|
32
7
|
private generation;
|
|
33
|
-
/**
|
|
34
|
-
* of ANY cached root, so membership-only invalidation is not safe. */
|
|
8
|
+
/** New events can introduce descendants of any cached root. */
|
|
35
9
|
invalidate(_sessionId: string): void;
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* callers can retain their last good display while surfacing the error. */
|
|
10
|
+
/** A bad descendant leaves the readable totals available with coverage.
|
|
11
|
+
* Never cache incomplete results: a repaired log must recover on the next read.
|
|
12
|
+
* Root, catalog and cancellation failures still reject without publishing zero. */
|
|
40
13
|
tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
|
|
41
|
-
private listHeaders;
|
|
42
14
|
}
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -109,6 +109,20 @@ export interface SessionUsageTotals {
|
|
|
109
109
|
cacheReadTokens: number;
|
|
110
110
|
cacheWriteTokens: number;
|
|
111
111
|
}
|
|
112
|
+
/** A descendant that could not be read. Reasons are stable and sanitized;
|
|
113
|
+
* exception messages and filesystem paths never cross the wire. */
|
|
114
|
+
export interface SessionUsageReadIssue {
|
|
115
|
+
sessionId: string;
|
|
116
|
+
reason: 'corrupt' | 'missing' | 'unavailable';
|
|
117
|
+
}
|
|
118
|
+
/** A partial response is usable, but its totals are explicitly not the whole
|
|
119
|
+
* tree. The root is always readable; older hosts may omit this field. */
|
|
120
|
+
export interface SessionUsageCoverage {
|
|
121
|
+
complete: boolean;
|
|
122
|
+
totalSessions: number;
|
|
123
|
+
readableSessions: number;
|
|
124
|
+
unreadableSessions: SessionUsageReadIssue[];
|
|
125
|
+
}
|
|
112
126
|
/** What one session tree cost, split into the main agent and its subagents.
|
|
113
127
|
* The composer stats row renders this so a delegating session shows its whole
|
|
114
128
|
* spend (the native `tokenUsage` projection only covers the main agent). */
|
|
@@ -124,6 +138,7 @@ export interface SessionUsageTree {
|
|
|
124
138
|
total: SessionUsageTotals;
|
|
125
139
|
/** How many descendant sessions were counted. */
|
|
126
140
|
subagentSessions: number;
|
|
141
|
+
coverage?: SessionUsageCoverage;
|
|
127
142
|
}
|
|
128
143
|
/** Request body of POST /token-total/api/session. */
|
|
129
144
|
export interface SessionUsageRequest {
|
|
@@ -132,6 +147,7 @@ export interface SessionUsageRequest {
|
|
|
132
147
|
/** Detailed per-session usage view used by the Usage tab. */
|
|
133
148
|
export interface SessionUsageDetail {
|
|
134
149
|
sessionId: string;
|
|
150
|
+
coverage?: SessionUsageCoverage;
|
|
135
151
|
totals: SessionUsageTotals;
|
|
136
152
|
requests: number;
|
|
137
153
|
averageFirstTokenMs: number | null;
|
package/package.json
CHANGED
|
@@ -205,7 +205,12 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
|
|
|
205
205
|
/** Pure presentation export also used by offline visual regression fixtures. */
|
|
206
206
|
export function SessionUsageContent({ detail, t, updatedAt }: { detail: SessionUsageDetail; t: T; updatedAt?: number | null }): JSX.Element {
|
|
207
207
|
return <>
|
|
208
|
-
<p className={css.scope}>{t('scope')}</p
|
|
208
|
+
<p className={css.scope}>{t('scope')}</p>
|
|
209
|
+
{detail.coverage?.complete === false && <div className={css.error} role="status" data-session-usage-partial>
|
|
210
|
+
{t('partialCoverage', { count: String(detail.coverage.unreadableSessions.length) })}
|
|
211
|
+
<span title={detail.coverage.unreadableSessions.map(issue => issue.sessionId).join('\n')}>{t('partialTotals')}</span>
|
|
212
|
+
</div>}
|
|
213
|
+
<Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
|
|
209
214
|
{/* The two live cards sit between the per-subagent/model tables and the
|
|
210
215
|
request records: the left one describes what the next request carries
|
|
211
216
|
right now, the right one what the whole session spent, while everything
|
|
@@ -405,7 +405,8 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
405
405
|
const billed = display.total
|
|
406
406
|
// Same aggregate as the official pill total: every prompt-side billing bucket plus output.
|
|
407
407
|
const total = billedTotal(billed)
|
|
408
|
-
const
|
|
408
|
+
const incomplete = display.coverage?.complete === false
|
|
409
|
+
const totalText = (incomplete ? '≥ ' : '') + t('message.turnUsage.count', { count: formatTokensCompact(total) })
|
|
409
410
|
// Native formatting, one consistent scope: the ratio comes from the same
|
|
410
411
|
// aggregate the headline uses, so "33M tok · 87%" can never contradict the
|
|
411
412
|
// number printed beside it. The integer shape and its >99% guard are the
|
|
@@ -421,7 +422,8 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
421
422
|
className={css.pill}
|
|
422
423
|
aria-haspopup="dialog"
|
|
423
424
|
aria-expanded={open}
|
|
424
|
-
aria-label={cacheHitText === null ? totalText : totalText + ' · ' + cacheHitText}
|
|
425
|
+
aria-label={(incomplete ? t('stats.partial') + ' · ' : '') + (cacheHitText === null ? totalText : totalText + ' · ' + cacheHitText)}
|
|
426
|
+
title={incomplete ? t('stats.incomplete', { count: display.coverage!.unreadableSessions.length }) : undefined}
|
|
425
427
|
onClick={() => { setOpen(!open) }}
|
|
426
428
|
>
|
|
427
429
|
<DatabaseIcon />
|
|
@@ -451,9 +453,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
451
453
|
<DatabaseIcon />
|
|
452
454
|
{t('stats.dialog.usageTitle')}
|
|
453
455
|
</span>
|
|
454
|
-
<span className={css.dialogTitleValue}>{exactCount(total)}</span>
|
|
456
|
+
<span className={css.dialogTitleValue}>{(incomplete ? '≥ ' : '') + exactCount(total)}</span>
|
|
455
457
|
</div>
|
|
456
458
|
<div className={css.dialogTitleRule} aria-hidden />
|
|
459
|
+
{incomplete && (
|
|
460
|
+
<p role="status" data-session-usage-partial>
|
|
461
|
+
{t('stats.incomplete', { count: display.coverage!.unreadableSessions.length })}
|
|
462
|
+
</p>
|
|
463
|
+
)}
|
|
457
464
|
{/* Exactly the official dialog's rows, in the official order: the
|
|
458
465
|
takeover adds the tree to the TOTAL, not to the breakdown. */}
|
|
459
466
|
<dl className={css.dialogDetails} data-session-stats-usage>
|
|
@@ -509,7 +516,8 @@ export const StatsLineEnhanced = memo(function StatsLineEnhanced(
|
|
|
509
516
|
// Refresh on main-agent settlements and poll for child-only activity.
|
|
510
517
|
const tree = useSessionUsage(sessionId, usageSignature(usage, stats.steps))
|
|
511
518
|
// A delegated-only session must still show its usage pill.
|
|
512
|
-
const
|
|
519
|
+
const display = sessionUsageForDisplay(usage, tree.tree)
|
|
520
|
+
const hasTokens = hasAnyTokens(display.total) || display.coverage?.complete === false
|
|
513
521
|
if (stats.steps === 0 && !hasTokens) return null
|
|
514
522
|
// data-composer-stats: through host 0.1.6-alpha.1 InputBar tightened the
|
|
515
523
|
// composer bottom clearance while this row rendered (the official pills'
|
|
@@ -582,7 +590,8 @@ export const StatsUsageEntry = memo(function StatsUsageEntry(
|
|
|
582
590
|
) {
|
|
583
591
|
const usage = useProjection('tokenUsage')
|
|
584
592
|
const tree = useSessionUsage(sessionId, usageSignature(usage, usage === undefined ? 0 : 1))
|
|
585
|
-
const
|
|
593
|
+
const display = sessionUsageForDisplay(usage, tree.tree)
|
|
594
|
+
const hasTokens = hasAnyTokens(display.total) || display.coverage?.complete === false
|
|
586
595
|
if (!hasTokens) return null
|
|
587
596
|
return (
|
|
588
597
|
<UsagePill
|
package/src/client/locales.ts
CHANGED
|
@@ -82,6 +82,8 @@ export type TokenTotalKey =
|
|
|
82
82
|
| 'stats.cacheMiss'
|
|
83
83
|
| 'stats.dialog.title'
|
|
84
84
|
| 'stats.dialog.usageTitle'
|
|
85
|
+
| 'stats.partial'
|
|
86
|
+
| 'stats.incomplete'
|
|
85
87
|
| 'stats.dialog.llmTime'
|
|
86
88
|
| 'stats.dialog.toolTime'
|
|
87
89
|
| 'stats.dialog.ttft'
|
|
@@ -170,6 +172,8 @@ export const en: Record<TokenTotalKey, string> = {
|
|
|
170
172
|
'stats.cacheMiss': 'Cache miss',
|
|
171
173
|
'stats.dialog.title': 'Time & speed',
|
|
172
174
|
'stats.dialog.usageTitle': 'Token usage',
|
|
175
|
+
'stats.partial': 'Partial usage',
|
|
176
|
+
'stats.incomplete': '{count} subagent session(s) could not be read; totals are incomplete.',
|
|
173
177
|
'stats.dialog.llmTime': 'LLM time',
|
|
174
178
|
'stats.dialog.toolTime': 'Tool time',
|
|
175
179
|
'stats.dialog.ttft': 'Mean TTFT',
|
|
@@ -259,6 +263,8 @@ export const zh: Record<TokenTotalKey, string> = {
|
|
|
259
263
|
'stats.cacheMiss': '未命中缓存',
|
|
260
264
|
'stats.dialog.title': '用时与速度',
|
|
261
265
|
'stats.dialog.usageTitle': 'Token 用量',
|
|
266
|
+
'stats.partial': '部分用量',
|
|
267
|
+
'stats.incomplete': '有 {count} 个子会话暂不可读取,总计尚不完整。',
|
|
262
268
|
'stats.dialog.llmTime': 'LLM 用时',
|
|
263
269
|
'stats.dialog.toolTime': '工具用时',
|
|
264
270
|
'stats.dialog.ttft': '首 token 平均',
|
|
@@ -348,6 +354,8 @@ export const zhTW: Record<TokenTotalKey, string> = {
|
|
|
348
354
|
'stats.cacheMiss': '未命中快取',
|
|
349
355
|
'stats.dialog.title': '用時與速度',
|
|
350
356
|
'stats.dialog.usageTitle': 'Token 用量',
|
|
357
|
+
'stats.partial': '部分用量',
|
|
358
|
+
'stats.incomplete': '有 {count} 個子會話暫不可讀取,總計尚不完整。',
|
|
351
359
|
'stats.dialog.llmTime': 'LLM 用時',
|
|
352
360
|
'stats.dialog.toolTime': '工具用時',
|
|
353
361
|
'stats.dialog.ttft': '首 token 平均',
|
|
@@ -10,6 +10,7 @@ export const usageZh = {
|
|
|
10
10
|
empty: '本会话还没有请求记录', noChildren: '暂无子代理', noModels: '暂无模型用量',
|
|
11
11
|
scope: '本会话及所有子代理 · 历史日志累计',
|
|
12
12
|
note: '统计包含本会话与全部后代的独立请求、重试和压缩调用,不重复计算继承历史。首 token 与速度仅使用已记录的时间信息,缺失时显示 —。',
|
|
13
|
+
partialCoverage: '有 {count} 个子会话暂不可读取。', partialTotals: '当前仅显示已读取用量,未将缺失用量视为零。',
|
|
13
14
|
failedLoad: '用量读取失败,已保留上次结果。', retry: '重新读取', completed: '已完成', running: '进行中', failed: '失败', interrupted: '已中断',
|
|
14
15
|
main: '主会话', turn: '轮', step: '步', started: '开始', duration: '耗时', firstDetail: '首 token',
|
|
15
16
|
provider: '供应商', status: '状态', exact: '完整 token 数',
|
|
@@ -50,6 +51,7 @@ export const usageEn: Record<SessionUsageKey, string> = {
|
|
|
50
51
|
empty: 'No requests in this session yet', noChildren: 'No subagents yet', noModels: 'No model usage yet',
|
|
51
52
|
scope: 'This session and all subagents · Historical log totals',
|
|
52
53
|
note: 'Includes independent requests, retries and compaction calls from this session and all descendants, without counting inherited history twice. Timing uses recorded timestamps only; missing values appear as —.',
|
|
54
|
+
partialCoverage: '{count} subagent session(s) could not be read.', partialTotals: 'Only readable usage is shown; missing usage is not treated as zero.',
|
|
53
55
|
failedLoad: 'Could not read usage. The last successful result is preserved.', retry: 'Retry', completed: 'Completed', running: 'Running', failed: 'Failed', interrupted: 'Interrupted',
|
|
54
56
|
main: 'Main session', turn: 'Turn', step: 'Step', started: 'Started', duration: 'Duration', firstDetail: 'First token',
|
|
55
57
|
provider: 'Provider', status: 'Status', exact: 'Exact token count',
|
|
@@ -89,6 +91,7 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
|
89
91
|
loading: '正在讀取會話用量…', empty: '本會話還沒有請求記錄', noChildren: '暫無子代理', noModels: '暫無模型用量',
|
|
90
92
|
scope: '本會話及所有子代理 · 歷史日誌累計',
|
|
91
93
|
note: '統計包含本會話與全部後代的獨立請求、重試和壓縮呼叫,不重複計算繼承歷史。首 token 與速度僅使用已記錄的時間資訊,缺失時顯示 —。',
|
|
94
|
+
partialCoverage: '有 {count} 個子會話暫不可讀取。', partialTotals: '目前僅顯示已讀取用量,未將缺失用量視為零。',
|
|
92
95
|
failedLoad: '用量讀取失敗,已保留上次結果。', retry: '重新讀取', completed: '已完成', running: '進行中', failed: '失敗', interrupted: '已中斷',
|
|
93
96
|
main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
|
|
94
97
|
answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
package/src/session-detail.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
UsageSessionEvent,
|
|
4
|
-
UsageSessionHandle,
|
|
5
|
-
UsageSessionHeader,
|
|
6
|
-
UsageSessionPersistence,
|
|
7
|
-
UsageSessionSnapshot,
|
|
8
|
-
UsageSessionStore,
|
|
9
|
-
} from './context-types.ts'
|
|
1
|
+
import type { Context, UsageSessionHeader, UsageSessionPersistence, UsageSessionStore } from './context-types.ts'
|
|
2
|
+
import { readUsageTree, type UsageSessionLog } from './session-tree.ts'
|
|
10
3
|
import { UsageFold } from './collector.ts'
|
|
11
4
|
import { buildSessionContextWindow } from './context-window.ts'
|
|
12
5
|
import { buildSessionUsageComposition, mergeUsageCompositions } from './usage-composition.ts'
|
|
@@ -83,22 +76,6 @@ function usageOutputTokens(x: any): number | null {
|
|
|
83
76
|
const value = (x?.usage ?? x)?.outputTokens
|
|
84
77
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
|
85
78
|
}
|
|
86
|
-
function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
|
|
87
|
-
const x: any = item
|
|
88
|
-
if (x?.header?.id && typeof x.header.id === 'string') return x.header
|
|
89
|
-
return typeof x?.id === 'string' && x.id ? x : null
|
|
90
|
-
}
|
|
91
|
-
async function read(p: UsageSessionPersistence, id: string, live?: UsageSessionHandle): Promise<{ events: readonly UsageSessionEvent[]; cut: number }> {
|
|
92
|
-
if (live?.snapshotEvents && typeof live.inheritedEventCount === 'number') return { events: live.snapshotEvents(), cut: Math.max(0, live.inheritedEventCount) }
|
|
93
|
-
if (p.open) {
|
|
94
|
-
const h = await p.open(id, 'read'); const out: UsageSessionEvent[] = []
|
|
95
|
-
try { for (let off = 0;;) { const page = await h.read(off, 500); if (!page.events.length) break; out.push(...page.events); off += page.events.length } }
|
|
96
|
-
finally { await h.close().catch(() => {}) }
|
|
97
|
-
return { events: out, cut: Math.max(0, Number(h.inheritedEventCount) || 0) }
|
|
98
|
-
}
|
|
99
|
-
if (p.inspect) { const x = await p.inspect(id); return { events: x.events, cut: Math.max(0, Number(x.inheritedEventCount) || 0) } }
|
|
100
|
-
throw new Error('no readable persistence seam (need open or inspect)')
|
|
101
|
-
}
|
|
102
79
|
|
|
103
80
|
type MutableRecord = SessionUsageDetail['records'][number] & { _attempt: number; _lastUsage: SessionUsageTotals }
|
|
104
81
|
function nameOf(h: UsageSessionHeader | undefined, id: string): string {
|
|
@@ -109,8 +86,8 @@ function nameOf(h: UsageSessionHeader | undefined, id: string): string {
|
|
|
109
86
|
return id
|
|
110
87
|
}
|
|
111
88
|
|
|
112
|
-
|
|
113
|
-
const { events, cut } =
|
|
89
|
+
function one(log: UsageSessionLog) {
|
|
90
|
+
const { id, header, live, events, inheritedCut: cut } = log
|
|
114
91
|
const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
|
|
115
92
|
const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
|
|
116
93
|
let ordinal = 0
|
|
@@ -214,14 +191,8 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
|
|
|
214
191
|
}
|
|
215
192
|
|
|
216
193
|
export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const live = new Map((sessions?.list() ?? []).map(x => [x.id, x])); const children = new Map<string, string[]>()
|
|
220
|
-
for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...(children.get(h.parentSession) ?? []), h.id])
|
|
221
|
-
for (const h of live.values()) { const p = h.header?.parentSession ?? h.parentSession; if (p) children.set(p, [...(children.get(p) ?? []).filter(x => x !== h.id), h.id]) }
|
|
222
|
-
const ids: string[] = []; const seen = new Set([rootId]); const q = [rootId]
|
|
223
|
-
while (q.length) for (const c of children.get(q.shift()!) ?? []) if (!seen.has(c)) { seen.add(c); ids.push(c); q.push(c) }
|
|
224
|
-
const all = [rootId, ...ids]; const parts = await Promise.all(all.map(id => one(persistence, id, headers.find(h => h.id === id), live.get(id))))
|
|
194
|
+
const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId)
|
|
195
|
+
const parts = [root, ...descendants].map(one)
|
|
225
196
|
const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
|
|
226
197
|
for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
|
|
227
198
|
const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
|
|
@@ -248,5 +219,5 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
|
|
|
248
219
|
// The card is scoped to this session and every subagent, so its speed and
|
|
249
220
|
// first-token average fold the same records its token totals do.
|
|
250
221
|
const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
|
|
251
|
-
return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null, composition: mergeUsageCompositions(parts.map(p => p.composition)) }
|
|
222
|
+
return { sessionId: rootId, coverage, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null, composition: mergeUsageCompositions(parts.map(p => p.composition)) }
|
|
252
223
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UsageSessionEvent,
|
|
3
|
+
UsageSessionHandle,
|
|
4
|
+
UsageSessionHeader,
|
|
5
|
+
UsageSessionPersistence,
|
|
6
|
+
UsageSessionSnapshot,
|
|
7
|
+
UsageSessionStore,
|
|
8
|
+
} from './context-types.ts'
|
|
9
|
+
import type { SessionUsageCoverage, SessionUsageReadIssue } from './wire.ts'
|
|
10
|
+
|
|
11
|
+
/** Shared membership, ownership and failure semantics for both usage APIs.
|
|
12
|
+
* A bad descendant is isolated; root, catalog and cancellation failures reject. */
|
|
13
|
+
export interface UsageSessionLog {
|
|
14
|
+
id: string
|
|
15
|
+
header?: UsageSessionHeader
|
|
16
|
+
live?: UsageSessionHandle
|
|
17
|
+
events: readonly UsageSessionEvent[]
|
|
18
|
+
inheritedCut: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
|
|
22
|
+
if (!item || typeof item !== 'object') return null
|
|
23
|
+
const header = 'header' in item ? item.header : item
|
|
24
|
+
return header && typeof header.id === 'string' && header.id !== '' ? header : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function inheritedCount(value: unknown): number {
|
|
28
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function throwIfCanceled(error: unknown, signal?: AbortSignal): void {
|
|
32
|
+
signal?.throwIfAborted()
|
|
33
|
+
if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') throw error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Never send raw exceptions to the client: they may contain paths or secrets. */
|
|
37
|
+
function readIssue(sessionId: string, error: unknown): SessionUsageReadIssue {
|
|
38
|
+
const name = error && typeof error === 'object' && 'name' in error ? error.name : ''
|
|
39
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : ''
|
|
40
|
+
const reason = name === 'SessionPersistenceCorruptionError' || name === 'SessionFormatError'
|
|
41
|
+
? 'corrupt'
|
|
42
|
+
: name === 'SessionPersistenceNotFoundError' || code === 'ENOENT' ? 'missing' : 'unavailable'
|
|
43
|
+
return { sessionId, reason }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readSession(
|
|
47
|
+
persistence: UsageSessionPersistence,
|
|
48
|
+
id: string,
|
|
49
|
+
header: UsageSessionHeader | undefined,
|
|
50
|
+
live: UsageSessionHandle | undefined,
|
|
51
|
+
signal?: AbortSignal,
|
|
52
|
+
): Promise<UsageSessionLog> {
|
|
53
|
+
signal?.throwIfAborted()
|
|
54
|
+
if (typeof live?.snapshotEvents === 'function' && typeof live.inheritedEventCount === 'number') {
|
|
55
|
+
return { id, header, live, events: live.snapshotEvents(), inheritedCut: inheritedCount(live.inheritedEventCount) }
|
|
56
|
+
}
|
|
57
|
+
if (typeof persistence.open === 'function') {
|
|
58
|
+
const options = signal === undefined ? undefined : { signal }
|
|
59
|
+
const handle = await persistence.open(id, 'read', options)
|
|
60
|
+
try {
|
|
61
|
+
const events: UsageSessionEvent[] = []
|
|
62
|
+
for (let offset = 0;;) {
|
|
63
|
+
signal?.throwIfAborted()
|
|
64
|
+
const page = await handle.read(offset, 500, options)
|
|
65
|
+
if (!page.events.length) break
|
|
66
|
+
for (const event of page.events) events.push(event)
|
|
67
|
+
offset += page.events.length
|
|
68
|
+
}
|
|
69
|
+
return { id, header: header ?? handle.header, live, events, inheritedCut: inheritedCount(handle.inheritedEventCount) }
|
|
70
|
+
} finally {
|
|
71
|
+
await handle.close().catch(() => {})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (typeof persistence.inspect === 'function') {
|
|
75
|
+
const inspection = await persistence.inspect(id, signal)
|
|
76
|
+
signal?.throwIfAborted()
|
|
77
|
+
return { id, header: header ?? inspection.meta, live, events: inspection.events, inheritedCut: inheritedCount(inspection.inheritedEventCount) }
|
|
78
|
+
}
|
|
79
|
+
throw new Error('no readable persistence seam (need open or inspect)')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function readUsageTree(
|
|
83
|
+
persistence: UsageSessionPersistence,
|
|
84
|
+
sessions: UsageSessionStore | undefined,
|
|
85
|
+
rootId: string,
|
|
86
|
+
signal?: AbortSignal,
|
|
87
|
+
): Promise<{ root: UsageSessionLog; descendants: UsageSessionLog[]; coverage: SessionUsageCoverage }> {
|
|
88
|
+
signal?.throwIfAborted()
|
|
89
|
+
const rows = await persistence.list(typeof persistence.open === 'function'
|
|
90
|
+
? signal === undefined ? undefined : { signal }
|
|
91
|
+
: signal)
|
|
92
|
+
const headers = new Map<string, UsageSessionHeader>()
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const header = headerOf(row)
|
|
95
|
+
if (header) headers.set(header.id, header)
|
|
96
|
+
}
|
|
97
|
+
const live = new Map((sessions?.list() ?? []).map(handle => [handle.id, handle]))
|
|
98
|
+
const children = new Map<string, Set<string>>()
|
|
99
|
+
const link = (id: string, parent: unknown): void => {
|
|
100
|
+
if (typeof parent !== 'string' || parent === '' || parent === id) return
|
|
101
|
+
const bucket = children.get(parent) ?? new Set<string>()
|
|
102
|
+
bucket.add(id)
|
|
103
|
+
children.set(parent, bucket)
|
|
104
|
+
}
|
|
105
|
+
for (const header of headers.values()) link(header.id, header.parentSession)
|
|
106
|
+
for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession)
|
|
107
|
+
const ids: string[] = []
|
|
108
|
+
const seen = new Set([rootId])
|
|
109
|
+
const queue = [rootId]
|
|
110
|
+
for (let index = 0; index < queue.length; index++) {
|
|
111
|
+
for (const id of children.get(queue[index]!) ?? []) {
|
|
112
|
+
if (seen.has(id)) continue
|
|
113
|
+
seen.add(id)
|
|
114
|
+
ids.push(id)
|
|
115
|
+
queue.push(id)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal)
|
|
119
|
+
const logs: Array<UsageSessionLog | undefined> = new Array(ids.length)
|
|
120
|
+
const issues: Array<SessionUsageReadIssue | undefined> = new Array(ids.length)
|
|
121
|
+
let next = 0
|
|
122
|
+
// Bound full-log reads; preserve catalog order regardless of completion order.
|
|
123
|
+
const workers = await Promise.allSettled(Array.from({ length: Math.min(4, ids.length) }, async () => {
|
|
124
|
+
for (;;) {
|
|
125
|
+
signal?.throwIfAborted()
|
|
126
|
+
const index = next++
|
|
127
|
+
if (index >= ids.length) return
|
|
128
|
+
const id = ids[index]!
|
|
129
|
+
try {
|
|
130
|
+
logs[index] = await readSession(persistence, id, headers.get(id), live.get(id), signal)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throwIfCanceled(error, signal)
|
|
133
|
+
issues[index] = readIssue(id, error)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}))
|
|
137
|
+
for (const worker of workers) if (worker.status === 'rejected') throw worker.reason
|
|
138
|
+
signal?.throwIfAborted()
|
|
139
|
+
const descendants = logs.filter((log): log is UsageSessionLog => log !== undefined)
|
|
140
|
+
const unreadableSessions = issues.filter((issue): issue is SessionUsageReadIssue => issue !== undefined)
|
|
141
|
+
return {
|
|
142
|
+
root,
|
|
143
|
+
descendants,
|
|
144
|
+
coverage: { complete: unreadableSessions.length === 0, totalSessions: ids.length + 1, readableSessions: descendants.length + 1, unreadableSessions },
|
|
145
|
+
}
|
|
146
|
+
}
|