Merge branch 'main' into fix/omp-session-discovery

This commit is contained in:
AgentSeal 2026-08-03 21:43:59 +02:00
commit 2c65764c0b
67 changed files with 6270 additions and 256 deletions

View file

@ -2,6 +2,17 @@
## Unreleased
### Added
- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar.
### Added (CLI)
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution".
### Fixed (CLI)
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)

View file

@ -1,5 +1,5 @@
<p align="center">
<a href="https://claude.com/open-source-max"><img src="https://img.shields.io/badge/Claude_for_Open_Source-Recipient-da7756?style=for-the-badge&labelColor=1a1a1a" alt="Claude for Open Source Recipient" /></a>
<a href="https://claude.com/open-source-max"><img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/open-source-recipient.png" alt="Codex and Claude for Open Source Recipient" width="720" /></a>
</p>
<p align="center">

View file

@ -89,6 +89,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
// Combined scope emits --scope combined; an explicit local scope is identical
// to the default (no flag). The CLI rejects --scope with --provider, so a
// provider passed alongside combined is dropped (the renderer forces 'all').
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'local'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
@ -212,6 +218,7 @@ describe('createBridgeHandlers (IPC input validation)', () => {
{ name: 'remove price override model that looks like a flag', channel: 'codeburn:removePriceOverride', args: ['--all'] },
{ name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] },
{ name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] },
{ name: 'unknown scope', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'everything'] },
]
it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => {

View file

@ -165,6 +165,11 @@ function vConfigSource(source: string | null | undefined): string | null {
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source')
return source
}
function vScope(scope: string | undefined): 'local' | 'combined' {
if (scope === 'combined') return 'combined'
if (scope === undefined || scope === 'local') return 'local'
throw new CliError('bad-args', 'invalid scope')
}
function vOutPath(outPath: string): string {
if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute')
return outPath
@ -269,19 +274,28 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// The desktop never renders the granular timeline, so it always passes
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
// omits the flag and keeps the timeline unchanged.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
//
// Combined scope aggregates paired-device usage: the CLI rejects --scope
// combined alongside --provider/--project/--exclude (paired devices report
// unfiltered usage), so the provider filter is dropped in that mode. The
// caller (renderer) forces provider='all' when combined, so nothing is lost.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null, scope?: string): string[] => {
const vScopeValue = vScope(scope)
return [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...(vScopeValue === 'combined' ? ['--scope', 'combined'] : providerArgs(vProvider(provider))),
...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
}
// `background` (renderer prefetch only) drops this fetch to background priority
// so it yields the CLI's run slots to any interactive poll or click. Optional
// and defaulting to interactive, so an older preload that omits it is unchanged.
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => {
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => {
coldStartBegan ??= Date.now()
const priority: SpawnPriority | undefined = background ? 'background' : undefined
try {
const args = buildOverviewArgs(period, provider, range, configSource)
const args = buildOverviewArgs(period, provider, range, configSource, scope)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
const value = await deps.spawnCli(args, {
timeoutMs: WARMUP_TIMEOUT_MS,

View file

@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
const bridge = {
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),

View file

@ -38,6 +38,153 @@ describe('Codex quota', () => {
expect(quota.details).toHaveLength(1)
})
// Shape captured from a live ChatGPT Enterprise workspace.
const enterpriseBody = {
plan_type: 'business',
rate_limit: null,
additional_rate_limits: null,
credits: { has_credits: false, unlimited: false, balance: null },
spend_control: {
reached: false,
individual_limit: {
source: 'workspace_spend_controls',
limit: '10000',
used: '3028.9909675121307',
remaining: '6971.009032487869',
used_percent: 30,
remaining_percent: 70,
reset_after_seconds: 441_896,
reset_at: 1_785_542_400,
},
},
rate_limit_reset_credits: { available_count: 0 },
}
it('surfaces the spend-control credit limit when there are no rate windows', () => {
const quota = decodeCodexUsage(enterpriseBody)
expect(quota.primary).toEqual({
label: 'Monthly usage limit · 3,029 / 10,000 credits',
percent: 0.3,
resetsAt: new Date(1_785_542_400 * 1000).toISOString(),
})
expect(quota.details).toEqual([quota.primary])
expect(quota.planLabel).toBe('Business')
expect(quota.footerLines).toEqual([])
})
it('keeps rate windows primary and appends the credit limit alongside them', () => {
const quota = decodeCodexUsage({
...enterpriseBody,
rate_limit: { primary_window: { used_percent: 20, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } },
})
expect(quota.primary?.label).toBe('5-hour')
expect(quota.details.map(row => row.label)).toEqual([
'5-hour',
'Monthly usage limit · 3,029 / 10,000 credits',
])
})
it.each([
['top level', (limit: unknown) => ({ individual_limit: limit })],
['camelCase key', (limit: unknown) => ({ spend_control: { individualLimit: limit } })],
['nested in rate_limit', (limit: unknown) => ({ rate_limit: { individual_limit: limit } })],
])('reads the credit limit positioned at %s', (_name, wrap) => {
const quota = decodeCodexUsage(wrap({ limit: 10_000, used: 2500, used_percent: 25 }))
expect(quota.primary?.percent).toBe(0.25)
expect(quota.primary?.label).toBe('Monthly usage limit · 2,500 / 10,000 credits')
})
it('derives the percent from remaining_percent, then from used/limit', () => {
const fromRemaining = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, remaining_percent: 70 } } })
expect(fromRemaining.primary?.percent).toBeCloseTo(0.3)
expect(fromRemaining.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits')
const fromRatio = decodeCodexUsage({ spend_control: { individual_limit: { limit: 400, used: 100 } } })
expect(fromRatio.primary?.percent).toBeCloseTo(0.25)
})
it('ignores a spend control with no usable limit', () => {
for (const individual_limit of [{ limit: 0, used: 5 }, { limit: null }, { used_percent: 40 }, null]) {
const quota = decodeCodexUsage({ spend_control: { individual_limit } })
expect(quota.primary).toBeNull()
expect(quota.details).toEqual([])
}
})
it('renders no row when the allowance is known but the draw on it is not', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, reset_at: 1_785_542_400 } } })
expect(quota.primary).toBeNull()
expect(quota.details).toEqual([])
})
it('treats a blank numeric string as absent, not as zero', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: '10000', used: ' ', used_percent: 30 } } })
expect(quota.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits')
expect(decodeCodexUsage({ spend_control: { individual_limit: { limit: '' } } }).primary).toBeNull()
})
it('marks a spent-out allowance as reached', () => {
const quota = decodeCodexUsage({
spend_control: { reached: true, individual_limit: { limit: 10_000, used: 10_000, used_percent: 100 } },
})
expect(quota.primary?.label).toBe('Monthly usage limit · 10,000 / 10,000 credits · limit reached')
expect(quota.primary?.percent).toBe(1)
})
it('keeps overage counts truthful while clamping the bar', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used: 12_000, used_percent: 120 } } })
expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits')
expect(quota.primary?.percent).toBe(1)
})
it('keeps the implied overage when only the percent is given', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used_percent: 120 } } })
expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits')
expect(quota.primary?.percent).toBe(1)
})
it('skips a garbage alias instead of letting it mask a valid one', () => {
const quota = decodeCodexUsage({
spend_control: { individual_limit: 'bad', individualLimit: { limit: 100, usedPercent: 25 } },
})
expect(quota.primary?.label).toBe('Monthly usage limit · 25 / 100 credits')
const perField = decodeCodexUsage({
spend_control: { individual_limit: { limit: 100, used_percent: 'bad', usedPercent: 25 } },
})
expect(perField.primary?.percent).toBe(0.25)
})
it('survives a reset timestamp beyond the Date range', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 100, used_percent: 10, reset_at: 9_000_000_000_000 } } })
expect(quota.primary?.resetsAt).toBeNull()
expect(quota.primary?.percent).toBe(0.1)
})
it('says so when the account is credit-metered but uncapped', () => {
const quota = decodeCodexUsage({ plan_type: 'business', credits: { has_credits: true, unlimited: true } })
expect(quota.footerLines).toEqual(['Credits · Unlimited'])
const capped = decodeCodexUsage({ credits: { unlimited: true }, spend_control: { individual_limit: { limit: 10_000, used_percent: 30 } } })
expect(capped.footerLines).toEqual([])
})
it('normalizes credit-based-pricing plan tiers', () => {
const label = (plan_type: string) => decodeCodexUsage({ plan_type }).planLabel
expect(label('enterprise_cbp_usage_based')).toBe('Enterprise')
expect(label('self_serve_business_usage_based')).toBe('Business')
expect(label('enterprise')).toBe('Enterprise')
expect(label('some_future_tier')).toBe('Some Future Tier')
})
it('labels a credit-settled balance in credits, not dollars', () => {
const inCredits = decodeCodexUsage({ credits: { has_credits: true, balance: 3410.4 } })
expect(inCredits.footerLines).toEqual(['Credits remaining · 3,410'])
const inDollars = decodeCodexUsage({ credits: { has_credits: false, balance: 3.5 } })
expect(inDollars.footerLines).toEqual(['Credits remaining · $3.50'])
// Thousands separators must match the menubar's en_US currency formatter.
const inDollarsLarge = decodeCodexUsage({ credits: { has_credits: false, balance: 12500 } })
expect(inDollarsLarge.footerLines).toEqual(['Credits remaining · $12,500.00'])
})
it('returns disconnected without credentials', async () => {
const fetchMock = vi.fn()
const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) })

View file

@ -133,10 +133,27 @@ function windowOf(value: unknown, override?: string): QuotaWindow | null {
return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset }
}
// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN,
// so blank is rejected or an absent `used` decodes as a confident zero.
function num(value: unknown): number | null {
if (typeof value === 'string' && !value.trim()) return null
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN
return Number.isFinite(parsed) ? parsed : null
}
// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
function normalizePlanType(value: string): string {
return value
.replace(/[_-]usage[_-]based$/, '')
.replace(/^self[_-]serve[_-]/, '')
.replace(/[_-]cbp$/, '')
.replace(/[_-]cbp[_-]/g, '_')
}
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const raw = value.trim()
const lower = raw.toLowerCase()
const lower = normalizePlanType(raw.toLowerCase())
const known: Record<string, string> = {
guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro',
prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite',
@ -146,6 +163,44 @@ function planLabel(value: unknown): string | null {
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
// The admin-set monthly allowance, the only limit a credit-metered workspace
// has. `spend_control` is the live position, the others forward-compat. `any`
// because this walks six optional-chained hops, all validated by `num()`.
function spendControlWindow(data: Record<string, any>): QuotaWindow | null {
// `find`/`num` per alias, not `??`: a non-null garbage value would stop `??`
// and mask a valid alias further down. Object-shaped garbage still wins the
// position, matching Swift, which likewise commits to the first that decodes.
const row = [
data.spend_control?.individual_limit,
data.spend_control?.individualLimit,
data.individual_limit,
data.individualLimit,
data.rate_limit?.individual_limit,
data.rate_limit?.individualLimit,
].find(candidate => candidate && typeof candidate === 'object')
if (!row) return null
const limit = num(row.limit)
if (limit === null || limit <= 0) return null
const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent)
const used = num(row.used)
const rawPercent = num(row.used_percent) ?? num(row.usedPercent)
?? (remainingPercent === null ? null : 100 - remainingPercent)
?? (used === null ? null : (used / limit) * 100)
if (rawPercent === null) return null
const percent = Math.min(1, Math.max(0, rawPercent / 100))
const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt)
// Past 8.64e15 ms `toISOString()` throws RangeError.
const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15
? new Date(resetRaw * 1000).toISOString()
: null
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
const spent = used ?? limit * Math.max(0, rawPercent) / 100
const round = (n: number) => Math.round(n).toLocaleString('en-US')
const reached = data.spend_control?.reached === true
const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits`
return { label: reached ? `${label} · limit reached` : label, percent, resetsAt }
}
export function decodeCodexUsage(body: unknown): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const primaryRaw = windowOf(data.rate_limit?.primary_window)
@ -165,12 +220,21 @@ export function decodeCodexUsage(body: unknown): QuotaProvider {
}
}
}
const rawBalance = data.credits?.balance
const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN
const credits = spendControlWindow(data)
if (credits) details.push(credits)
const balance = num(data.credits?.balance)
// Credit-settled accounts denominate in credits, so no currency symbol.
const hasCredits = data.credits?.has_credits === true
const footerLines: string[] = []
if (balance !== null && balance > 0) {
footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : balance.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`)
}
// Uncapped on purpose, so a bar-less card does not read as a failed fetch.
if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited')
return {
provider: 'codex', connection: 'connected', primary, details,
provider: 'codex', connection: 'connected', primary: primary ?? credits, details,
planLabel: planLabel(data.plan_type),
footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [],
footerLines,
}
}

View file

@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', {
})
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
getModels: vi.fn(),
@ -273,6 +273,25 @@ describe('App shortcuts', () => {
})
})
it('drives combined-scope overview fetches and persists the Scope setting', async () => {
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
fireEvent.keyDown(document, { key: ',', metaKey: true })
fireEvent.click(await screen.findByLabelText('Scope'))
fireEvent.click(await screen.findByRole('option', { name: 'Combined' }))
// Combined scope forces provider='all' and passes --scope combined (6th arg).
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
expect(localStorage.getItem('codeburn.scope')).toBe('combined')
})
it('boots in combined scope from the persisted Scope setting', async () => {
localStorage.setItem('codeburn.scope', 'combined')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
})
it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
// grok's display name is "Grok Build"; the picker must show the label but
// send the internal id `grok` as --provider (which assertProvider accepts).

View file

@ -27,7 +27,7 @@ import { Compare } from './sections/Compare'
import { Plans } from './sections/Plans'
import { Settings, type SettingsPane } from './sections/Settings'
import { SpendContent } from './sections/Spend'
import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types'
import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types'
// Bucket raw dollar amounts before they leave the machine: telemetry carries
// coarse ranges, never exact spend.
@ -130,8 +130,8 @@ const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', '
// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync. Exported so
// the prefetch-storm test can assert warmed keys survive between polls.
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}`
}
// Prefetch pacing: wait a short idle after the first paint, then warm one
@ -172,6 +172,15 @@ function persistConfigSource(id: string | null): void {
} catch { /* storage can be unavailable */ }
}
/** Boot scope = the persisted dashboard Scope setting, else local. */
function initialScope(): Scope {
try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' }
}
function persistScope(scope: Scope): void {
try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ }
}
function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
@ -218,20 +227,27 @@ function AppMain() {
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
const [scope, setScopeState] = useState<Scope>(initialScope)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [, setCurrencyTick] = useState(0)
// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
// stays flag-free; only add --claude-config-source once a config is picked.
// Combined scope aggregates paired-device usage; the CLI rejects it alongside
// a provider/config filter, so onScopeChange forces provider='all' and clears
// the config scope before this poll runs. Passing scope='local' produces the
// same flag-free argv as before, so local users are unaffected.
const overview = usePolled<MenubarPayload>(
() => claudeConfigSource
() => scope === 'combined'
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
: claudeConfigSource
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
: customRange
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
[period, provider, customRange?.from, customRange?.to, claudeConfigSource],
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) },
[period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope],
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) },
)
const refreshOverview = overview.refresh
@ -273,7 +289,7 @@ function AppMain() {
// fails we still emit the snapshot, just without the model x category cross.
const snapshotDayRef = useRef<string | null>(null)
useEffect(() => {
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return
const today = localDateKey(new Date())
if (snapshotDayRef.current === today) return
snapshotDayRef.current = today
@ -285,7 +301,7 @@ function AppMain() {
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
})()
}, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent])
}, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent])
useEffect(() => {
let saved: string | null = null
@ -360,7 +376,9 @@ function AppMain() {
overviewBusyRef.current = overview.loading
const warmedKeys = useRef<Set<string>>(new Set())
useEffect(() => {
if (!ready || overview.data == null || customRange || claudeConfigSource) return
// Combined scope has no provider picker to warm — it always shows unfiltered
// all-device usage — so the per-provider prefetch is local-scope only.
if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
if (targets.length === 0) return
let cancelled = false
@ -391,7 +409,7 @@ function AppMain() {
// `overview.data == null` (a boolean) gates on first-resolution without
// re-running every poll; the data content itself is intentionally not a dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
}, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
@ -451,17 +469,22 @@ function AppMain() {
// A Claude config scopes Claude usage only, so a non-Claude provider filter
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
// filter is already compatible and is left alone).
// filter is already compatible and is left alone). Picking a config also
// implies a device-specific view, so drop combined scope back to local.
const onConfigSelect = (id: string) => {
const next = id || null
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
if (next && scope === 'combined') { setScopeState('local'); persistScope('local') }
setClaudeConfigSource(next)
persistConfigSource(next)
}
// Symmetric direction: picking a non-Claude provider while a config is
// scoped would hit the same CLI rejection, so drop the config scope.
// scoped would hit the same CLI rejection, so drop the config scope. A
// specific provider filter is a device-specific view, so it also drops
// combined scope back to local (combined reports unfiltered usage).
const onProviderSelect = (value: string) => {
if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') }
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
setClaudeConfigSource(null)
persistConfigSource(null)
@ -469,6 +492,19 @@ function AppMain() {
setProvider(value)
}
// Combined scope reports unfiltered, all-provider usage across paired devices,
// so switching to it resets the provider filter and Claude-config scope (which
// the CLI would otherwise reject), mirroring the menubar's setMenubarScope.
const onScopeChange = (value: string) => {
const next: Scope = value === 'combined' ? 'combined' : 'local'
if (next === 'combined') {
if (provider !== 'all') setProvider('all')
if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) }
}
setScopeState(next)
persistScope(next)
}
const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
@ -478,7 +514,11 @@ function AppMain() {
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
// Combined scope reports unfiltered all-device usage, so the caption reads
// "Combined" in place of the (forced-'all') provider label.
const scopeCaption = scope === 'combined'
? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined`
: `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
return (
<Window>
@ -494,12 +534,12 @@ function AppMain() {
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
) : (
<>
<TopBar
title={SECTION_TITLES[section]}
scope={scope}
scope={scopeCaption}
period={period}
onPeriodChange={onPeriodChange}
customRange={customRange}
@ -514,7 +554,7 @@ function AppMain() {
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (

View file

@ -6,6 +6,10 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime'
// Dashboard usage scope: this device only ('local') or the aggregate across
// every paired device ('combined'). Mirrors the macOS menubar's Scope setting.
export type Scope = 'local' | 'combined'
export type DateRange = { from: string; to: string }
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
@ -637,7 +641,9 @@ export interface CodeburnBridge {
getQuota(force?: boolean): Promise<QuotaProvider[]>
// `background` (prefetch only) requests background CLI-spawn priority; optional
// so an older preload that ignores it degrades to interactive priority.
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise<MenubarPayload>
// `scope` selects local-device usage ('local', default) or paired-device
// aggregate ('combined'); optional so an older preload degrades to local.
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string

View file

@ -520,6 +520,49 @@ describe('Overview', () => {
expect(screen.queryByText('Saved to date')).not.toBeInTheDocument()
})
it('shows paired-device aggregate totals in the hero under combined scope', async () => {
const now = new Date()
const payload = makePayload(now)
// Local device: $312.40 / 4200 calls / 88 sessions (from makePayload).
// Combined swaps the hero to the cross-device aggregate and lists devices.
payload.combined = {
perDevice: [
{ id: 'local', name: 'laptop', local: true, cost: 312.4, calls: 4200, sessions: 88, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
{ id: 'fp-workstation', name: 'workstation', local: false, cost: 187.6, calls: 2100, sessions: 40, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
],
combined: { cost: 500, calls: 6300, sessions: 128, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
}
const { container } = render(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="combined" />)
const kpis = container.querySelector('.ov-hero-main') as HTMLElement
// Hero cost is the combined $500, not the local $312.40.
expect(within(kpis).getByText('$500.00')).toBeInTheDocument()
expect(within(kpis).getByText(/6,300 calls · 128 sessions/)).toBeInTheDocument()
expect(within(kpis).getByText('Combined · Last 30 days')).toBeInTheDocument()
expect(within(kpis).getByText('2 of 2 devices')).toBeInTheDocument()
expect(within(kpis).getByText('workstation')).toBeInTheDocument()
expect(within(kpis).getByText('laptop · this device')).toBeInTheDocument()
// Combined mode hides the local savings lines (they are device-specific).
expect(within(kpis).queryByText('Saved via local models')).not.toBeInTheDocument()
})
it('keeps local hero totals when scope is local even if a combined payload is present', async () => {
const now = new Date()
const payload = makePayload(now)
payload.combined = {
perDevice: [],
combined: { cost: 999, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
}
const { container } = render(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="local" />)
const kpis = container.querySelector('.ov-hero-main') as HTMLElement
expect(within(kpis).getByText('$312.40')).toBeInTheDocument()
expect(within(kpis).queryByText('$999.00')).not.toBeInTheDocument()
expect(within(kpis).queryByText(/devices/)).not.toBeInTheDocument()
})
it('shows a stale banner when last-good data is present but the latest poll failed', async () => {
const now = new Date()
const overview: Polled<MenubarPayload> = {

View file

@ -15,10 +15,12 @@ import { codeburn } from '../lib/ipc'
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
CombinedUsage,
DailyHistoryEntry,
DateRange,
MenubarPayload,
Period,
Scope,
YieldJsonReport,
} from '../lib/types'
@ -650,6 +652,23 @@ export function Overview({ period, provider }: { period: Period; provider: strin
return <OverviewContent period={period} provider={provider} overview={overview} />
}
/** Combined-scope hero footer: a per-device cost breakdown plus a reachable/
* total device count, mirroring the menubar's combined view. An unreachable
* device (powered off, off-network) shows its error in place of a cost. */
function CombinedDevices({ usage }: { usage: CombinedUsage }) {
return (
<div className="ov-combined-devices">
<div className="ov-combined-head">{usage.combined.reachableCount} of {usage.combined.deviceCount} devices</div>
{usage.perDevice.map(device => (
<div className={device.error ? 'ov-combined-row err' : 'ov-combined-row'} key={device.id}>
<span className="ov-combined-name">{device.local ? `${device.name} · this device` : device.name}</span>
<span className="ov-combined-val">{device.error ?? formatUsd(device.cost)}</span>
</div>
))}
</div>
)
}
export function OverviewContent({
period,
provider = 'all',
@ -657,6 +676,7 @@ export function OverviewContent({
overview,
onNavigate,
ready = true,
scope = 'local',
}: {
period: Period
provider?: string
@ -664,6 +684,7 @@ export function OverviewContent({
overview: Polled<MenubarPayload>
onNavigate?: (section: 'optimize' | 'sessions') => void
ready?: boolean
scope?: Scope
}) {
// Gate secondary spawns on the app-level readiness (first overview resolved),
// so the cold hydration runs once (via overview) rather than 3 parses at once
@ -680,7 +701,14 @@ export function OverviewContent({
const now = new Date()
const rangeActive = !!range
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
// Combined scope shows the paired-device aggregate in the hero KPIs, mirroring
// the menubar. Only the hero totals are aggregated; the detailed panels below
// (daily chart, models) stay local — the combined payload carries totals only.
const combined = scope === 'combined' ? data.combined : undefined
const heroCost = combined ? combined.combined.cost : data.current.cost
const heroCalls = combined ? combined.combined.calls : data.current.calls
const heroSessions = combined ? combined.combined.sessions : data.current.sessions
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}|${scope}`
const stats = deriveStats(data, now)
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
// Daily chart: contiguous zero-filled calendar window. A custom range spans
@ -715,15 +743,21 @@ export function OverviewContent({
{error && <StaleBanner error={error} />}
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
<div className="ov-hero-main">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} animateKey={animateKey} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
<div className="ov-hero-top"><span className="ov-label">{combined ? `Combined · ${data.current.label}` : data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={heroCost} animateKey={animateKey} />
<div className="ov-hero-sub">{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions</div>
{combined
? <CombinedDevices usage={combined} />
: (
<>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
</>
)}
</div>
<ActivityHeatmap daily={data.history.daily} bare />
<EfficiencyScorecard current={data.current} bare />

View file

@ -145,6 +145,17 @@ describe('Settings', () => {
expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy()
})
it('reflects the current scope and reports a change through onScopeChange', async () => {
const user = userEvent.setup()
const onScopeChange = vi.fn()
render(<Settings period="month" scope="local" onScopeChange={onScopeChange} />)
const scope = screen.getByLabelText('Scope')
expect(scope).toHaveTextContent('Local')
await user.click(scope)
await user.click(screen.getByRole('option', { name: 'Combined' }))
expect(onScopeChange).toHaveBeenCalledWith('combined')
})
it('lists providers from the real overview payload', async () => {
const user = userEvent.setup()
render(<Settings period="week" />)

View file

@ -18,7 +18,7 @@ import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import { rateLimitedNote } from './Plans'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
type Pane = SettingsPane
@ -97,7 +97,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
)
}
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) {
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
return (
@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
))}
</nav>
<main className="set-pane">
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
@ -128,7 +128,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
)
}
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) {
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [currencyNonce, setCurrencyNonce] = useState(0)
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState<Theme>(() => {
@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
</span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-scope">Scope<small>Combined aggregates usage across every paired device, like the menubar. Local shows this device only.</small></label><span className="r"><Dropdown id="settings-scope" ariaLabel="Scope" value={scope} options={[{ value: 'local', label: 'Local' }, { value: 'combined', label: 'Combined' }]} onChange={value => onScopeChange?.(value)} width={110} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on R.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
{budgetError && <p className="set-action-msg error">{budgetError}</p>}

View file

@ -512,6 +512,12 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-saved-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 7px; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10.5px; }
.ov-saved-line strong { color: var(--ok); font-family: var(--mono); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; }
.ov-saved-line small { color: var(--mut2); font-size: 10px; }
.ov-combined-devices { width: 100%; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); display: flex; flex-direction: column; gap: 3px; }
.ov-combined-head { color: var(--mut2); font-size: 10.5px; font-weight: 560; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 2px; }
.ov-combined-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 11.5px; color: var(--mut); }
.ov-combined-row .ov-combined-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ov-combined-row .ov-combined-val { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); }
.ov-combined-row.err .ov-combined-val { color: var(--warn); font-family: inherit; }
.ov-hero-split .ov-heatmap-bare { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; }
.ov-activity-head { display: flex; align-items: baseline; gap: 8px; }
.ov-hero-sub .neutral { color: var(--mut); font-weight: 560; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

View file

@ -48,6 +48,110 @@ A session that yielded zero parseable lines does **not** write to the cache (`co
- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes.
- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate).
## Live quota (ChatGPT subscription)
Separate from the log parser above: the desktop app and the macOS menubar read
live quota from `GET https://chatgpt.com/backend-api/wham/usage` using the Codex
OAuth token. Two independent implementations of the same decoder, which must be
kept in sync:
- `app/electron/quota/codex.ts`: `decodeCodexUsage()` is the pure, exported decoder.
- `mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift`: `decodeUsage()`.
### Seat-based plans (Plus, Pro, Team)
`rate_limit.primary_window` / `secondary_window` carry `used_percent`,
`reset_at` and `limit_window_seconds`. The window *label* is inferred from the
duration (5-hour, Weekly, …), never from the plan, because window size is dynamic per
account. `additional_rate_limits[]` holds per-model limits (Codex Spark, etc.)
and is only surfaced when utilization is non-zero.
### Credit-metered plans (Business, Edu, Enterprise on flexible pricing)
These workspaces have **no rate-limit windows**: `rate_limit` comes back
`null`. Usage scales with credits, and an admin sets a monthly per-user credit
allowance. That allowance is the account's only limit and lives in
`spend_control`:
```jsonc
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "10000", // string
"used": "3028.9909675121307", // string
"used_percent": 30, // number
"remaining_percent": 70,
"reset_after_seconds": 441896, // time *remaining*, not window length
"reset_at": 1785542400
}
}
```
Notes that have bitten us:
- **Number encodings are mixed within the same object**: `limit` and `used`
arrive as strings while `used_percent` arrives as a number. Every numeric
field is decoded flexibly (number | string) on both sides.
- **`reset_after_seconds` is not the window length.** Pace projection needs the
whole-window duration, so it is derived as the calendar month preceding
`reset_at`, resolved in **UTC**: `reset_at` is a UTC boundary, and a local
calendar would make the month length depend on the viewer's timezone (a
2026-03-01Z reset spans 28 days in UTC but 31 in Toronto).
- Two other positions for this object have been observed in other clients
(top-level `individual_limit`, and nested under `rate_limit`), in both
snake_case and camelCase. All are accepted; `spend_control` wins.
- `credits.has_credits` means the account settles in **credits, not dollars**, so
`credits.balance` must not be rendered with a currency symbol in that case.
`credits.unlimited` means credit-metered but deliberately uncapped.
- **`has_credits` is not "is credit-metered".** The live Enterprise workspace
above is credit-metered (it has a `spend_control` allowance) yet reports
`has_credits: false` with a `null` balance, so the flag tracks whether the
account holds a *credit balance*, which is orthogonal to the allowance. Do not
derive one from the other. The `has_credits: true` rendering path has not been
observed against a real account; if a seat-based account ever reports it
alongside a dollar balance, the footer would drop the `$` and round to whole
units.
### `plan_type` cannot distinguish Business from Enterprise
A live ChatGPT **Enterprise** workspace reports `plan_type: "business"` on this
endpoint, and the `id_token`'s `https://api.openai.com/auth → chatgpt_plan_type`
claim says `"business"` too, even though ChatGPT's own workspace switcher
displays "Enterprise". Neither source carries the distinction, so the label
CodeBurn shows is faithfully what OpenAI returns. Do not try to infer a tier
from the presence of a spend control.
The switcher renders from the accounts endpoints, and **those are not reachable
with a Codex token**, verified against a live Enterprise workspace:
| Endpoint | Result |
| --- | --- |
| `/backend-api/accounts/check/v4-2023-04-27` | 403 |
| `/backend-api/accounts/check` | 403 |
| `/backend-api/me` | 403 |
| `/backend-api/settings/account_user_setting` | 403 |
Not an expiry or a missing-header problem: the same token returns 200 on
`/wham/usage` (and on `/backend-api/gizmo_creator_profile`) in the same run. The
Codex OAuth access token carries scopes `openid profile email offline_access
api.connectors.read api.connectors.invoke` with audience
`https://api.openai.com/v1`, with no ChatGPT web-app account scope, so the accounts
surfaces reject it by design. Adding a `ChatGPT-Account-Id` header does not
change this. **Business is therefore the correct label to display**; closing
this gap would need a different credential, not a different endpoint.
Composite tiers (`enterprise_cbp_usage_based`, `self_serve_business_usage_based`)
*are* normalized down to their base tier before lookup.
### Reset credits
`rate_limit_reset_credits` is carried inline on the usage payload
(`available_count`). The dedicated `GET /wham/rate-limit-reset-credits`
endpoint is only called when the inline block is absent. It is the sole source
of per-credit `expires_at` values, so the "next expires" caption is omitted on
the inline path.
## When fixing a bug here
1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`.

View file

@ -48,6 +48,9 @@ codeburn sync push --since 30d
# Preview what would be sent
codeburn sync push --dry-run
# Also push git attribution (opt-in — see "Git attribution" below)
codeburn sync push --attribution
```
### `codeburn sync status`
@ -95,6 +98,36 @@ Each AI interaction becomes one OTLP span with these attributes:
A pseudonymous `device_id` distinguishes your machines without revealing hostnames.
### Git attribution (opt-in: `--attribution`)
`codeburn sync push --attribution` additionally sends the session→commit correlation that `codeburn yield` computes locally, so the backend can join AI usage to git activity without git hooks. Two extra span types are emitted:
**`codeburn.session.attribution`** — one per session with joinable evidence:
| Field | Example | Description |
|---|---|---|
| `ai.session_id` | `abc123…` | Session (shares the usage spans' traceId) |
| `ai.project` | `my-app` | Project name |
| `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) |
| `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session |
| `git.commit_count` | `2` | Number of attributed commits |
**`codeburn.commit`** — one per commit attributed to a session:
| Field | Example | Description |
|---|---|---|
| `git.sha` | `4f2a…` | Commit SHA |
| `git.in_main` | `true` | Whether the commit landed in the main branch |
| `git.was_reverted` | `false` | Whether a later commit reverted it |
Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current.
With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent:
- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from.
- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded.
- Without the flag, none of this is sent.
### What is NOT sent
- **Prompts** — your actual messages to AI are never included
@ -102,7 +135,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
- **Bash commands** — may contain secrets, never sent
- **Your name/email** — identity is derived server-side from your login token
There is no flag to override this. Privacy is structural, not configurable.
There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is `--attribution` (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above.
## Authentication

View file

@ -271,6 +271,38 @@ final class AppStore {
cache[menubarStatusKey]?.payload
}
private var menubarCombinedKey: PayloadCacheKey {
PayloadCacheKey(scope: .combined, period: menubarPeriod, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId)
}
/// Cross-device totals for the menubar badge's period, used so the badge
/// figure matches the popover hero under combined scope. `nil` under local
/// scope, or when no combined payload for the badge period is cached yet
/// (cold start, or the peer is unreachable) the badge then falls back to
/// the local figure, exactly like the popover.
var menubarBadgeCombined: CombinedUsageTotals? {
guard effectiveSelectedScope == .combined else { return nil }
return cache[menubarCombinedKey]?.payload.combined?.combined
}
/// Refresh the payloads the badge renders for `period`: always the local
/// figure, plus the combined cross-device total when combined scope is
/// active. Combined is best-effort a slow or unreachable peer degrades to
/// the local figure so the local fetch alone determines success.
@discardableResult
func refreshMenubarBadge(period: Period, force: Bool = false, qualityOfService: QualityOfService = .userInitiated) async -> Bool {
async let local = refreshQuietly(period: period, force: force, qualityOfService: qualityOfService)
guard effectiveSelectedScope == .combined else { return await local }
async let combined = refreshQuietly(
key: PayloadCacheKey(scope: .combined, period: period, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId),
includeOptimize: false,
force: force,
qualityOfService: qualityOfService
)
let (localSucceeded, _) = await (local, combined)
return localSucceeded
}
/// All-provider payload for the selected period. Used by the tab strip to show
/// per-provider costs that match the active period, not just today.
var periodAllPayload: MenubarPayload? {
@ -1378,19 +1410,38 @@ final class AppStore {
details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt))
}
}
// No rate windows here, so the allowance feeds the bar and badge.
if let credits = usage.creditLimit {
let row = QuotaSummary.Window(
label: credits.shortLabel,
percent: credits.usedPercent / 100,
resetsAt: credits.resetsAt
)
if primary == nil { primary = row }
details.append(row)
}
}
let plan = codexUsage?.plan.displayName
var footerLines: [String] = []
if let balance = codexUsage?.creditsBalance, balance > 0 {
// Format as plain dollars; ChatGPT settles in USD regardless of
// the user's display-currency preference.
// Credit-settled accounts denominate in credits, so no symbol.
let inCredits = codexUsage?.hasCredits == true
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "USD"
formatter.maximumFractionDigits = 2
let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)"
formatter.numberStyle = inCredits ? .decimal : .currency
formatter.maximumFractionDigits = inCredits ? 0 : 2
// Half-up matches the desktop decoder's Math.round; the default is
// half-even, which disagrees on exact-half balances.
formatter.roundingMode = .halfUp
// `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
formatter.locale = Locale(identifier: "en_US")
if !inCredits { formatter.currencyCode = "USD" }
let fallback = inCredits ? "\(Int(balance.rounded()))" : "$\(balance)"
let formatted = formatter.string(from: NSNumber(value: balance)) ?? fallback
footerLines.append("Credits remaining · \(formatted)")
}
if codexUsage?.creditLimit == nil, codexUsage?.creditsUnlimited == true {
footerLines.append("Credits · Unlimited")
}
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
}

View file

@ -467,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// is refreshed by refreshPayloadForPopoverOpen the moment it opens,
// so a closed-popover tick never pays for it (#647).
if !(popover?.isShown ?? false) {
async let menubar = store.refreshQuietly(
async let menubar = store.refreshMenubarBadge(
period: menubarPeriod,
force: force,
qualityOfService: qualityOfService
@ -489,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
qualityOfService: qualityOfService
)
async let menubar = needsMenubarPayload
? store.refreshQuietly(period: menubarPeriod, force: force, qualityOfService: qualityOfService)
? store.refreshMenubarBadge(period: menubarPeriod, force: force, qualityOfService: qualityOfService)
: true
async let today = needsTodayPayload
? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService)
@ -870,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
_ = self.store.payload
_ = self.store.menubarPeriod
_ = self.store.menubarPayload
// Combined-scope badge total: re-render the badge when the cross-device
// aggregate for the menubar period lands (or a peer goes reachable).
_ = self.store.menubarBadgeCombined
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
@ -1023,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
if store.displayMetric != .iconOnly {
let suffix = menubarPeriod.menubarSuffix(compact: compact)
// Under combined scope the badge shows the cross-device aggregate, so
// it matches the popover hero instead of trailing it with the local
// figure. Falls back to local when no combined payload is available
// (local scope, cold cache, or an unreachable peer). Credits have no
// combined total, so that metric always reflects the local device.
let badgeCombined = store.menubarBadgeCombined
let cost: Double? = badgeCombined?.cost ?? menubarPayload?.current.cost
let outputTokens: Int? = badgeCombined?.outputTokens ?? menubarPayload?.current.outputTokens
let inputTokens: Int? = badgeCombined?.inputTokens ?? menubarPayload?.current.inputTokens
let valueText: String
if store.displayMetric == .tokens, let p = menubarPayload?.current {
let out = formatTokensMenubar(Double(p.outputTokens))
let inp = formatTokensMenubar(Double(p.inputTokens))
valueText = compact ? "\(out)\(inp)\(suffix)" : "\(out)\(inp)\(suffix)"
} else if store.displayMetric == .totalTokens, let p = menubarPayload?.current {
let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
if store.displayMetric == .tokens, let out = outputTokens, let inp = inputTokens {
let outText = formatTokensMenubar(Double(out))
let inpText = formatTokensMenubar(Double(inp))
valueText = compact ? "\(outText)\(inpText)\(suffix)" : "\(outText)\(inpText)\(suffix)"
} else if store.displayMetric == .totalTokens, let out = outputTokens, let inp = inputTokens {
let total = formatTokensMenubar(Double(inp + out))
valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)"
} else if store.displayMetric == .credits, let p = menubarPayload?.current {
let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded())
valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)"
} else {
let fallback = compact ? "$-" : "$—"
let formatted = menubarPayload?.current.cost
valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix
: " " + (formatted?.asCompactCurrency() ?? fallback) + suffix
? (cost?.asCompactCurrencyWhole() ?? fallback) + suffix
: " " + (cost?.asCompactCurrency() ?? fallback) + suffix
}
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]

View file

@ -111,10 +111,12 @@ enum CodexSubscriptionService {
switch http.statusCode {
case 200:
clearUsageBlock()
// Companion fetch, strictly best-effort: any failure yields nil and
// the Plan view simply omits the row. This endpoint must never be
// able to break the quota display.
let resetCredits = await fetchResetCredits(token: token)
// Skip the companion request only when the inline block says zero.
// Best-effort either way: nil just omits the row.
var resetCredits = inlineResetCreditsShortcut(data: data)
if resetCredits == nil {
resetCredits = await fetchResetCredits(token: token)
}
do {
return try decodeUsage(data: data, resetCredits: resetCredits)
} catch {
@ -144,15 +146,83 @@ enum CodexSubscriptionService {
}
}
/// chatgpt.com mixes encodings inside one payload: `"limit": "10000"` next
/// to `"used_percent": 30`. Every numeric field decodes through here.
private enum Flexible {
// `decode`, not `decodeIfPresent`: missing, null and wrong-typed all
// mean "not available", without the double-optional footgun.
// Int first keeps precision above 2^53. Infinity and NaN survive
// `Double(_ text:)`, so reject them here.
static func double<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Double? {
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
if let v = try? c.decode(Double.self, forKey: key) { return v.isFinite ? v : nil }
if let v = try? c.decode(String.self, forKey: key),
let d = Double(v.trimmingCharacters(in: .whitespacesAndNewlines)) {
return d.isFinite ? d : nil
}
return nil
}
// `Int(exactly:)`, never `Int(_:)`: the plain initializer traps on an
// out-of-range Double, and a trap is not a catchable DecodingError.
static func int<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Int? {
double(c, key).flatMap { Int(exactly: $0.rounded()) }
}
static func bool<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Bool {
(try? c.decode(Bool.self, forKey: key)) ?? false
}
}
/// Decoding `[T]` is atomic, so one bad entry would discard every sibling.
private struct Lossy<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws { value = try? T(from: decoder) }
}
private struct UsageDTO: Decodable {
let plan_type: String?
let rate_limit: RateLimit?
let additional_rate_limits: [AdditionalLimitDTO]?
let credits: Credits?
let spend_control: SpendControl?
/// Forward-compat: some variants hoist this to the top level.
let individual_limit: IndividualLimit?
enum CodingKeys: String, CodingKey {
case plan_type, rate_limit, additional_rate_limits, credits, spend_control
case individual_limit
case individualLimit
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
plan_type = try? c.decode(String.self, forKey: .plan_type)
rate_limit = try? c.decode(RateLimit.self, forKey: .rate_limit)
additional_rate_limits = (try? c.decode([Lossy<AdditionalLimitDTO>].self, forKey: .additional_rate_limits))?
.compactMap(\.value)
credits = try? c.decode(Credits.self, forKey: .credits)
spend_control = try? c.decode(SpendControl.self, forKey: .spend_control)
individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
}
struct RateLimit: Decodable {
let primary_window: WindowDTO?
let secondary_window: WindowDTO?
/// Forward-compat: another observed position for the spend control.
let individual_limit: IndividualLimit?
enum CodingKeys: String, CodingKey {
case primary_window, secondary_window, individual_limit
case individualLimit
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
primary_window = try? c.decode(WindowDTO.self, forKey: .primary_window)
secondary_window = try? c.decode(WindowDTO.self, forKey: .secondary_window)
individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
}
}
struct AdditionalLimitDTO: Decodable {
let limit_name: String?
@ -163,22 +233,72 @@ enum CodexSubscriptionService {
let reset_at: Int?
let limit_window_seconds: Int?
}
/// Credit-metered workspaces report `rate_limit: null` and carry their
/// real limit here: the monthly allowance an admin sets.
struct SpendControl: Decodable {
let reached: Bool
let individualLimit: IndividualLimit?
enum CodingKeys: String, CodingKey {
case reached
case individual_limit
case individualLimit
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
reached = Flexible.bool(c, .reached)
individualLimit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
}
}
struct IndividualLimit: Decodable {
let limit: Double?
let used: Double?
let usedPercent: Double?
let remainingPercent: Double?
let resetAt: Int?
enum CodingKeys: String, CodingKey {
case limit, used
case used_percent, usedPercent
case remaining_percent, remainingPercent
case reset_at, resets_at, resetsAt
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
limit = Flexible.double(c, .limit)
used = Flexible.double(c, .used)
usedPercent = Flexible.double(c, .used_percent) ?? Flexible.double(c, .usedPercent)
remainingPercent = Flexible.double(c, .remaining_percent)
?? Flexible.double(c, .remainingPercent)
resetAt = Flexible.int(c, .reset_at)
?? Flexible.int(c, .resets_at)
?? Flexible.int(c, .resetsAt)
}
}
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
// resilient decode so a schema drift on either shape doesn't blow up
// the whole quota fetch.
struct Credits: Decodable {
let balance: Double?
enum CodingKeys: String, CodingKey { case balance }
/// Settles in credits, not dollars, which relabels `balance`.
let hasCredits: Bool
let unlimited: Bool
enum CodingKeys: String, CodingKey {
case balance
case has_credits
case unlimited
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let n = try? c.decode(Double.self, forKey: .balance) {
balance = n
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
balance = n
} else {
balance = nil
}
balance = Flexible.double(c, .balance)
hasCredits = Flexible.bool(c, .has_credits)
unlimited = Flexible.bool(c, .unlimited)
}
}
}
@ -204,6 +324,28 @@ enum CodexSubscriptionService {
return parseResetCredits(data: data)
}
/// The inline block carries no per-credit expiry list, so it is only a safe
/// shortcut at zero, where there is no expiry to report. A non-zero count
/// still pays for the companion request rather than dropping the
/// "next expires" caption the popover would otherwise show.
static func inlineResetCreditsShortcut(data: Data) -> CodexUsage.ResetCredits? {
guard let inline = inlineResetCredits(data: data), inline.availableCount == 0 else { return nil }
return inline
}
/// Reset-credit inventory carried inline on the usage payload. Nil means
/// absent.
static func inlineResetCredits(data: Data) -> CodexUsage.ResetCredits? {
struct InlineDTO: Decodable {
struct Block: Decodable { let available_count: Int? }
let rate_limit_reset_credits: Block?
}
guard let count = (try? JSONDecoder().decode(InlineDTO.self, from: data))?
.rate_limit_reset_credits?.available_count, count >= 0
else { return nil }
return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nil)
}
/// Internal (not private) so tests can drive it with fixture payloads.
/// Returns nil on any unexpected shape the caller treats nil as
/// "feature unavailable", never as an error.
@ -239,7 +381,8 @@ enum CodexSubscriptionService {
return plain.date(from: raw)
}
private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
/// Internal (not private) so tests can drive it with fixture payloads.
static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
guard let name = dto.limit_name, !name.isEmpty else { return nil }
@ -249,17 +392,61 @@ enum CodexSubscriptionService {
secondary: makeWindow(dto.rate_limit?.secondary_window)
)
}
let limitDTO = root.spend_control?.individualLimit
?? root.individual_limit
?? root.rate_limit?.individual_limit
return CodexUsage(
plan: CodexUsage.planType(from: root.plan_type),
primary: makeWindow(root.rate_limit?.primary_window),
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
hasCredits: root.credits?.hasCredits ?? false,
creditsUnlimited: root.credits?.unlimited ?? false,
creditLimit: makeCreditLimit(limitDTO, reached: root.spend_control?.reached ?? false),
resetCredits: resetCredits,
fetchedAt: Date()
)
}
private static func makeCreditLimit(
_ dto: UsageDTO.IndividualLimit?,
reached: Bool
) -> CodexUsage.CreditLimit? {
guard let dto, let limit = dto.limit, limit > 0 else { return nil }
// Server percentage, then remaining_percent, then the raw ratio. No
// signal at all means the draw is unknown; a 0% bar would claim otherwise.
guard let raw = dto.usedPercent
?? dto.remainingPercent.map({ 100 - $0 })
?? dto.used.map({ $0 / limit * 100 })
else { return nil }
let percent = min(max(raw, 0), 100)
let resetsAt = dto.resetAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil }
return CodexUsage.CreditLimit(
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
used: dto.used ?? limit * max(raw, 0) / 100,
limit: limit,
usedPercent: percent,
resetsAt: resetsAt,
windowSeconds: monthlyWindowSeconds(endingAt: resetsAt),
reached: reached
)
}
/// Spend controls reset on a calendar-month boundary, so the window is the
/// month preceding the reset. Not `reset_after_seconds`, which is remaining.
/// UTC, not `Calendar.current`: a 2026-03-01Z reset spans 28 days in UTC
/// but 31 in Toronto, so a local calendar makes pace timezone-dependent.
private static func monthlyWindowSeconds(endingAt resetsAt: Date?) -> Int? {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
guard let resetsAt,
let start = calendar.date(byAdding: .month, value: -1, to: resetsAt)
else { return nil }
let seconds = Int(resetsAt.timeIntervalSince(start))
return seconds > 0 ? seconds : nil
}
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
return nil

View file

@ -31,7 +31,12 @@ struct CodexUsage: Sendable, Equatable {
case .k12: "K-12"
case .enterprise: "Enterprise"
case .edu: "Edu"
case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized
case let .unknown(raw):
raw.isEmpty
? "Subscription"
: raw.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
}
@ -76,16 +81,55 @@ struct CodexUsage: Sendable, Equatable {
let nextExpiresAt: Date?
}
/// The monthly allowance an admin sets. Credit-metered workspaces report
/// `rate_limit: null`, so this is their only limit.
struct CreditLimit: Sendable, Equatable {
let used: Double
let limit: Double
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
/// Calendar month the allowance resets on, for pace projection. Not the
/// payload's `reset_after_seconds`, which is the time remaining.
let windowSeconds: Int?
/// Allowance already spent: a hard stop, not a near-limit warning.
let reached: Bool
/// `.halfUp` matches the desktop decoder's `Math.round`.
var displayLabel: String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.roundingMode = .halfUp
// `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
formatter.locale = Locale(identifier: "en_US")
func text(_ value: Double) -> String {
formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))"
}
let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits"
return reached ? "\(base) · limit reached" : base
}
var shortLabel: String {
reached ? "Monthly usage limit · limit reached" : "Monthly usage limit"
}
}
let plan: PlanType
let primary: Window?
let secondary: Window?
let additionalLimits: [AdditionalLimit]
let creditsBalance: Double?
/// Account settles in credits, not dollars, which changes `creditsBalance`.
let hasCredits: Bool
/// Uncapped on purpose, as distinct from a limit we failed to read.
let creditsUnlimited: Bool
let creditLimit: CreditLimit?
let resetCredits: ResetCredits?
let fetchedAt: Date
static func planType(from raw: String?) -> PlanType {
guard let raw = raw?.lowercased() else { return .unknown("") }
guard let original = raw?.lowercased() else { return .unknown("") }
let raw = normalizePlanType(original)
switch raw {
case "guest": return .guest
case "free": return .free
@ -101,7 +145,28 @@ struct CodexUsage: Sendable, Equatable {
case "k12": return .k12
case "enterprise": return .enterprise
case "edu": return .edu
// Normalized, so an unknown composite reads "Some Future Tier".
default: return .unknown(raw)
}
}
/// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
private static func normalizePlanType(_ raw: String) -> String {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
for suffix in ["_usage_based", "-usage-based", "_usage-based", "-usage_based"]
where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
for prefix in ["self_serve_", "self-serve-", "self_serve-", "self-serve_"]
where value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}
for suffix in ["_cbp", "-cbp"] where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
for infix in ["_cbp_", "-cbp-", "_cbp-", "-cbp_"] {
value = value.replacingOccurrences(of: infix, with: "_")
}
return value
}
}

View file

@ -2067,7 +2067,7 @@ private struct CodexPlanInsight: View {
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt ?? usage.creditLimit?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
@ -2109,6 +2109,26 @@ private struct CodexPlanInsight: View {
)
}
}
// No rate windows here, so without this row the card is empty.
if let credits = usage.creditLimit {
UtilizationRow(
label: credits.displayLabel,
percent: credits.usedPercent,
resetsAt: credits.resetsAt,
projection: pace(for: credits)
)
} else if usage.creditsUnlimited {
// Uncapped on purpose, not a failed fetch.
HStack(alignment: .firstTextBaseline) {
Text("Credits")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text("Unlimited")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
// Limit-reset credits the account is holding. Hidden at zero so
// plans that never receive these grants see no extra row.
if let resets = usage.resetCredits, resets.availableCount > 0 {
@ -2147,6 +2167,25 @@ private struct CodexPlanInsight: View {
)
}
/// Rate-window pace math over the spend control's calendar month.
private func pace(for credits: CodexUsage.CreditLimit) -> WindowProjection? {
guard let windowSeconds = credits.windowSeconds,
let result = QuotaPace.evaluate(
usedPercent: credits.usedPercent,
resetsAt: credits.resetsAt,
windowSeconds: windowSeconds
)
else { return nil }
return WindowProjection(
percent: result.projectedPercent,
willOverflow: result.willOverflow,
hitsLimitAt: result.hitsLimitAt,
source: .linear,
deltaPercent: result.deltaPercent,
compact: TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds
)
}
private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String {
let count = "\(resets.availableCount) available"
guard let next = resets.nextExpiresAt else { return count }

View file

@ -487,7 +487,7 @@ private struct CodexSettingsTab: View {
CodexConnectionRow()
}
Section {
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {

View file

@ -202,6 +202,56 @@ struct AppStoreRefreshRecoveryTests {
#expect(store.payload.combined == nil)
}
@Test("menubar badge shows the combined total under combined scope")
func menubarBadgeShowsCombinedTotal() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
// Local badge figure and a higher cross-device combined total, both for
// the badge's period.
store.setCachedPayloadForTesting(
menubarPayload(cost: 30),
scope: .local,
period: period,
provider: .all,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 30, combined: combinedUsage(cost: 75)),
scope: .combined,
period: period,
provider: .all,
fetchedAt: Date()
)
// Local scope: no combined total, so the badge renders the local figure.
store.selectedScope = .local
#expect(store.menubarBadgeCombined == nil)
// Combined scope: the badge total is the cross-device aggregate ($75),
// not the local $30 this is the fix for the badge trailing the popover.
store.selectedScope = .combined
#expect(store.menubarBadgeCombined?.cost == 75)
}
@Test("menubar badge falls back to local when no combined payload is cached")
func menubarBadgeFallsBackWhenCombinedMissing() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
store.setCachedPayloadForTesting(
menubarPayload(cost: 30),
scope: .local,
period: period,
provider: .all,
fetchedAt: Date()
)
// Combined scope selected but no combined payload cached yet (cold cache
// or an unreachable peer): the badge must fall back to the local figure.
store.selectedScope = .combined
#expect(store.menubarBadgeCombined == nil)
}
@Test("switching to combined resets selected provider to all")
func switchingToCombinedResetsSelectedProviderToAll() {
let store = AppStore()

View file

@ -0,0 +1,248 @@
import Foundation
import XCTest
@testable import CodeBurnMenubar
/// `plan_type` parsing and the credit-metered branch of the wham/usage decoder.
final class CodexPlanParsingTests: XCTestCase {
private func decode(_ json: String) throws -> CodexUsage {
try CodexSubscriptionService.decodeUsage(data: Data(json.utf8))
}
func testKnownTiersMapToDisplayNames() {
let expected: [String: String] = [
"guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "pro": "Pro",
"prolite": "Pro Lite", "pro_lite": "Pro Lite", "pro-lite": "Pro Lite",
"free_workspace": "Free Workspace", "team": "Team", "business": "Business",
"education": "Education", "quorum": "Quorum", "k12": "K-12",
"enterprise": "Enterprise", "edu": "Edu",
]
for (raw, display) in expected {
XCTAssertEqual(CodexUsage.planType(from: raw).displayName, display, "plan_type: \(raw)")
}
}
func testTierMatchingIsCaseInsensitive() {
XCTAssertEqual(CodexUsage.planType(from: "pLuS"), .plus)
XCTAssertEqual(CodexUsage.planType(from: "ENTERPRISE"), .enterprise)
}
func testCreditBasedPricingCompositesNormalize() {
XCTAssertEqual(CodexUsage.planType(from: "enterprise_cbp_usage_based"), .enterprise)
XCTAssertEqual(CodexUsage.planType(from: "self_serve_business_usage_based"), .business)
XCTAssertEqual(CodexUsage.planType(from: "business_cbp"), .business)
}
func testHyphenSeparatedCompositesNormalizeToo() {
XCTAssertEqual(CodexUsage.planType(from: "enterprise-cbp-usage-based"), .enterprise)
XCTAssertEqual(CodexUsage.planType(from: "self-serve-business-usage-based"), .business)
XCTAssertEqual(CodexUsage.planType(from: "business-cbp"), .business)
}
func testUnknownTierNormalizesAndTitleCasesLikeTheDesktopDecoder() {
XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based"),
.unknown("some_future_tier"))
XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based").displayName,
"Some Future Tier")
XCTAssertEqual(CodexUsage.planType(from: nil), .unknown(""))
XCTAssertEqual(CodexUsage.planType(from: nil).displayName, "Subscription")
}
/// Captured from a live ChatGPT Enterprise workspace (identifiers replaced).
private let enterprisePayload = #"""
{
"plan_type": "business",
"rate_limit": null,
"code_review_rate_limit": null,
"additional_rate_limits": null,
"credits": {
"has_credits": false, "unlimited": false, "overage_limit_reached": false,
"balance": null, "approx_local_messages": null, "approx_cloud_messages": null
},
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "10000",
"used": "3028.9909675121307",
"remaining": "6971.009032487869",
"used_percent": 30,
"remaining_percent": 70,
"reset_after_seconds": 441896,
"reset_at": 1785542400
}
},
"rate_limit_reset_credits": {"available_count": 0, "applicable_available_count": 0}
}
"""#
func testEnterprisePayloadDecodesTheSpendControlLimit() throws {
let usage = try decode(enterprisePayload)
XCTAssertNil(usage.primary)
XCTAssertNil(usage.secondary)
XCTAssertTrue(usage.additionalLimits.isEmpty)
let credits = try XCTUnwrap(usage.creditLimit)
XCTAssertEqual(credits.limit, 10_000)
XCTAssertEqual(credits.used, 3028.9909675121307, accuracy: 0.0001)
XCTAssertEqual(credits.usedPercent, 30)
XCTAssertEqual(credits.resetsAt, Date(timeIntervalSince1970: 1_785_542_400))
XCTAssertFalse(credits.reached)
// July 2026, not the payload's `reset_after_seconds`.
XCTAssertEqual(try XCTUnwrap(credits.windowSeconds), 31 * 86_400)
XCTAssertEqual(usage.plan, .business)
XCTAssertNil(usage.creditsBalance)
XCTAssertFalse(usage.hasCredits)
XCTAssertFalse(usage.creditsUnlimited)
}
func testSpendControlIsReadAtEveryObservedPosition() throws {
let bodies = [
#"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
#"{"spend_control": {"individualLimit": {"limit": 10000, "usedPercent": 25}}}"#,
#"{"individual_limit": {"limit": 10000, "used_percent": 25}}"#,
#"{"rate_limit": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
]
for body in bodies {
let credits = try XCTUnwrap(decode(body).creditLimit, body)
XCTAssertEqual(credits.usedPercent, 25, body)
XCTAssertEqual(credits.used, 2500, body)
}
}
func testPercentFallsBackThroughRemainingPercentThenRawRatio() throws {
let fromRemaining = try XCTUnwrap(
decode(#"{"spend_control": {"individual_limit": {"limit": 10000, "remaining_percent": 70}}}"#).creditLimit)
XCTAssertEqual(fromRemaining.usedPercent, 30, accuracy: 0.0001)
let fromRatio = try XCTUnwrap(
decode(#"{"spend_control": {"individual_limit": {"limit": 400, "used": 100}}}"#).creditLimit)
XCTAssertEqual(fromRatio.usedPercent, 25, accuracy: 0.0001)
}
func testUnusableSpendControlYieldsNoRow() throws {
let bodies = [
#"{"spend_control": {"individual_limit": {"limit": 0, "used": 5}}}"#,
#"{"spend_control": {"individual_limit": {"limit": null}}}"#,
#"{"spend_control": {"individual_limit": {"used_percent": 40}}}"#,
#"{"spend_control": {"individual_limit": null}}"#,
#"{"spend_control": null}"#,
"{}",
]
for body in bodies {
XCTAssertNil(try decode(body).creditLimit, body)
}
}
func testAllowanceWithoutAnyUsageSignalYieldsNoRow() throws {
let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "reset_at": 1785542400}}}"#
XCTAssertNil(try decode(body).creditLimit)
}
func testBlankNumericStringIsAbsentNotZero() throws {
let credits = try XCTUnwrap(decode(#"""
{"spend_control": {"individual_limit": {"limit": "10000", "used": " ", "used_percent": 30}}}
"""#).creditLimit)
XCTAssertEqual(credits.used, 3000, accuracy: 0.001)
XCTAssertNil(try decode(#"{"spend_control": {"individual_limit": {"limit": ""}}}"#).creditLimit)
}
func testOverageKeepsCountsTruthfulWhileClampingPercent() throws {
let credits = try XCTUnwrap(decode(#"""
{"spend_control": {"individual_limit": {"limit": 10000, "used": 12000, "used_percent": 120}}}
"""#).creditLimit)
XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
XCTAssertEqual(credits.usedPercent, 100)
}
func testMonthWindowIsTimezoneIndependent() throws {
// 2026-03-01Z reads as 28 days in UTC but 31 through a local calendar.
let body = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1772323200}}}"#
XCTAssertEqual(try XCTUnwrap(decode(body).creditLimit?.windowSeconds), 28 * 86_400)
}
func testOutOfRangeNumbersDoNotTrap() throws {
// `Int(_:)` on 1e100 traps, and a trap is not a catchable error.
for body in [
#"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1e100}}}"#,
#"{"spend_control": {"individual_limit": {"limit": "Infinity", "used_percent": 10}}}"#,
#"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": "NaN"}}}"#,
] {
_ = try? decode(body)
}
let huge = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": "1e100"}}}"#
XCTAssertNil(try XCTUnwrap(decode(huge).creditLimit).resetsAt)
}
func testPercentOnlyOverageKeepsTheImpliedCount() throws {
let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 120}}}"#
let credits = try XCTUnwrap(decode(body).creditLimit)
XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
XCTAssertEqual(credits.usedPercent, 100)
XCTAssertEqual(credits.displayLabel, "Monthly usage limit · 12,000 / 10,000 credits")
}
func testOneMalformedAdditionalLimitDoesNotDiscardTheRest() throws {
let body = #"""
{"additional_rate_limits": [
null,
{"limit_name": "Spark", "rate_limit": {"primary_window": {"used_percent": 40, "limit_window_seconds": 18000}}}
]}
"""#
XCTAssertEqual(try decode(body).additionalLimits.map(\.name), ["Spark"])
}
func testReachedSpendControlIsCarriedThrough() throws {
let credits = try XCTUnwrap(decode(#"""
{"spend_control": {"reached": true, "individual_limit": {"limit": 10000, "used_percent": 100}}}
"""#).creditLimit)
XCTAssertTrue(credits.reached)
XCTAssertEqual(credits.usedPercent, 100)
}
func testCreditFlagsAndMixedNumberEncodings() throws {
let usage = try decode(#"""
{"credits": {"has_credits": true, "unlimited": true, "balance": "3410.40"}}
"""#)
XCTAssertTrue(usage.hasCredits)
XCTAssertTrue(usage.creditsUnlimited)
XCTAssertEqual(try XCTUnwrap(usage.creditsBalance), 3410.40, accuracy: 0.0001)
}
func testRateWindowsStillDecodeAlongsideASpendControl() throws {
let usage = try decode(#"""
{
"plan_type": "plus",
"rate_limit": {
"primary_window": {"used_percent": 20, "reset_at": 1800000000, "limit_window_seconds": 18000}
},
"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 30}}
}
"""#)
XCTAssertEqual(usage.primary?.usedPercent, 20)
XCTAssertEqual(usage.primary?.windowLabel, "5-hour")
XCTAssertEqual(usage.creditLimit?.usedPercent, 30)
}
func testOnlyAZeroCountSkipsTheCompanionRequest() {
let zero = #"{"rate_limit_reset_credits": {"available_count": 0}}"#
let some = #"{"rate_limit_reset_credits": {"available_count": 3}}"#
XCTAssertEqual(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(zero.utf8))?.availableCount, 0)
XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(some.utf8)))
XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data("{}".utf8)))
XCTAssertEqual(CodexSubscriptionService.inlineResetCredits(data: Data(some.utf8))?.availableCount, 3)
}
func testInlineResetCreditsParseFromTheUsagePayload() {
let inline = CodexSubscriptionService.inlineResetCredits(data: Data(enterprisePayload.utf8))
XCTAssertEqual(inline?.availableCount, 0)
XCTAssertNil(inline?.nextExpiresAt)
}
func testInlineResetCreditsAbsentSignalsFallback() {
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("{}".utf8)))
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("not json".utf8)))
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(
data: Data(#"{"rate_limit_reset_credits": {}}"#.utf8)))
}
}

View file

@ -0,0 +1,85 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private func usage(
balance: Double? = nil,
hasCredits: Bool = false,
unlimited: Bool = false,
creditLimit: CodexUsage.CreditLimit? = nil
) -> CodexUsage {
CodexUsage(
plan: .business,
primary: nil,
secondary: nil,
additionalLimits: [],
creditsBalance: balance,
hasCredits: hasCredits,
creditsUnlimited: unlimited,
creditLimit: creditLimit,
resetCredits: nil,
fetchedAt: Date()
)
}
private func limit(used: Double, of total: Double, reached: Bool = false) -> CodexUsage.CreditLimit {
CodexUsage.CreditLimit(
used: used,
limit: total,
usedPercent: used / total * 100,
resetsAt: Date(timeIntervalSince1970: 1_785_542_400),
windowSeconds: 31 * 86_400,
reached: reached
)
}
@MainActor
private func store(_ usage: CodexUsage) -> AppStore {
let store = AppStore()
store.codexUsage = usage
// Pinned: the default depends on whether this machine has Codex connected.
store.codexLoadState = .loaded
return store
}
@MainActor
struct CodexQuotaSummaryTests {
@Test("credit-settled balances group without a currency symbol")
func creditSettledBalanceIsGroupedAndUnprefixed() {
let store = store(usage(balance: 3410.4, hasCredits: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,410"])
}
@Test("dollar balances keep the currency formatting")
func dollarBalanceKeepsCurrencyFormatting() {
let store = store(usage(balance: 3410.4, hasCredits: false))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · $3,410.40"])
}
@Test("an exact-half credit balance rounds up, matching the desktop decoder")
func creditBalanceRoundsHalfUp() {
let store = store(usage(balance: 3410.5, hasCredits: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,411"])
}
@Test("an uncapped credit account says so instead of showing nothing")
func uncappedAccountSaysUnlimited() {
let store = store(usage(hasCredits: true, unlimited: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits · Unlimited"])
}
@Test("the allowance row drives the chip with the short label")
func allowanceRowUsesTheShortLabel() {
let store = store(usage(creditLimit: limit(used: 3033, of: 10_000)))
let summary = store.quotaSummary(for: .codex)
#expect(summary?.primary?.label == "Monthly usage limit")
#expect(summary?.primary?.percent == 0.3033)
#expect(summary?.footerLines.isEmpty == true)
}
@Test("a spent-out allowance is called out on the chip")
func reachedAllowanceIsCalledOut() {
let store = store(usage(creditLimit: limit(used: 10_000, of: 10_000, reached: true)))
#expect(store.quotaSummary(for: .codex)?.primary?.label == "Monthly usage limit · limit reached")
}
}

View file

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto'
import { createHash, randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
import { homedir } from 'os'
@ -81,6 +81,13 @@ async function retryWindowsMutation(operation: () => Promise<void>, sleep: (ms:
return false
}
// The directory entry becomes visible before the awaited body write, so the
// file is briefly observable at zero bytes. Deliberately left as is: a corrupt
// body is only ever recovered once its mtime is older than staleMs, and this
// window is milliseconds wide on a file whose mtime is by definition now, so
// no observer can reach the age gate through it. Closing it would mean
// link()ing a temp file into place, which is not portable to filesystems
// without hard links.
async function createExclusive(path: string, body: string): Promise<'created' | 'exists' | 'unavailable'> {
try {
const handle = await open(path, 'wx', 0o600)
@ -92,7 +99,24 @@ async function createExclusive(path: string, body: string): Promise<'created' |
}
}
type Observation = { record: LockRecord; mtimeMs: number }
// A null record is a body whose stat bracket agreed across the read and that
// still does not parse into a lock record: a corrupt leftover of 0 bytes, a
// truncation, or a wrong shape. The bracket is a heuristic, not proof that the
// read was whole — a same-size rewrite moves neither size nor (on a coarse
// filesystem) mtime — which is why nothing here treats a single read as
// authoritative. It owns nothing, but it is a real file with a
// real mtime, not an infrastructure failure — classifying it 'unavailable'
// routed every later refresh to the read-only path and froze ingestion. It
// carries no authority: it is only ever recovered through the unmodified
// staleness gate, exactly like an abandoned but well-formed lock.
//
// `digest` fingerprints the exact bytes. A corrupt body has no token, so
// token equality between two corrupt observations degenerates to
// `undefined === undefined`, and mtime granularity is coarse on some
// filesystems (measured on macOS: a 2s grid on FAT32, 10ms on exFAT, sub-ms on
// APFS — and on all three a same-size rewrite moves neither mtime nor size), so
// mtime is not a reliable change signal on its own.
type Observation = { record: LockRecord | null; mtimeMs: number; digest: string }
type ObservationResult = Observation | 'missing' | 'changing' | 'unavailable'
async function observe(path: string): Promise<ObservationResult> {
@ -100,6 +124,7 @@ async function observe(path: string): Promise<ObservationResult> {
// written, and heartbeat rewrites briefly truncate it. Treat that bounded
// transition as contention, not broken infrastructure.
let sawChange = false
let corrupt: Observation | null = null
for (let attempt = 0; attempt < 3; attempt++) {
try {
const before = await stat(path)
@ -110,10 +135,23 @@ async function observe(path: string): Promise<ObservationResult> {
await delay(1)
continue
}
const parsed = JSON.parse(raw) as Partial<LockRecord>
if (typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs }
const digest = createHash('sha1').update(raw).digest('hex')
// A body that is valid JSON of the wrong shape is corrupt like any other,
// including one written by a future version with a different record
// shape. That is safe precisely because staleness is never waived: a
// foreign version's LIVE lock keeps its mtime fresh through its own
// heartbeat, so it is never taken — both versions just degrade to the
// read-only path. Only an abandoned one is recovered, and a lock record
// is per-run state with nothing in it worth preserving.
let parsed: Partial<LockRecord> | undefined
try { parsed = JSON.parse(raw) as Partial<LockRecord> } catch { parsed = undefined }
if (parsed && typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs, digest }
}
// Keep the most recent corrupt read. It is not evidence of stability on
// its own: tryTakeover re-observes under the guard and compares with
// sameObservation before acting, so stability is proven there, not here.
corrupt = { record: null, mtimeMs: after.mtimeMs, digest }
} catch (err) {
if (isMissingError(err)) return 'missing'
const code = (err as NodeJS.ErrnoException | undefined)?.code
@ -121,11 +159,19 @@ async function observe(path: string): Promise<ObservationResult> {
}
await delay(1)
}
return sawChange ? 'changing' : 'unavailable'
// Contention outranks corruption: a body seen mid-rewrite is a live owner's,
// and the caller must poll rather than treat it as recoverable.
if (sawChange) return 'changing'
return corrupt ?? 'unavailable'
}
function sameObservation(a: Observation, b: Observation): boolean {
return a.record.token === b.record.token && a.mtimeMs === b.mtimeMs
// A corrupt body and an owned one are never "the same observation", even
// though `a.record?.token === b.record?.token` cannot tell them apart once
// both sides are corrupt. Compare that boundary explicitly, then require the
// bytes themselves to match, so "unchanged" survives a coarse mtime.
if ((a.record === null) !== (b.record === null)) return false
return a.record?.token === b.record?.token && a.mtimeMs === b.mtimeMs && a.digest === b.digest
}
let singleFlightTail: Promise<void> = Promise.resolve()
@ -209,7 +255,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (current === 'missing') return true
if (current === 'changing') return false
if (current === 'unavailable') return false
if (current.record.token !== token) return true
if (current.record?.token !== token) return true
return retryWindowsMutation(() => unlink(lockPath), sleep)
} finally {
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
@ -221,7 +267,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (guard !== 'created') return false
try {
const current = await observe(lockPath)
return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record.token === token
return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record?.token === token
} finally {
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
}
@ -238,7 +284,23 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (guard !== 'created') { heartbeatRunning = false; return }
try {
const current = await observe(lockPath)
if (current === 'missing' || current === 'changing' || current === 'unavailable' || current.record.token !== token) return
if (current === 'missing' || current === 'changing' || current === 'unavailable') return
// A corrupt body is NOT ours to rewrite, even though no parseable
// token contradicts us. Holding the takeover guard excludes the other
// guard-takers, but NOT createExclusive, which publishes a directory
// entry before its body — so an unparseable body may be a successor's
// lock a millisecond from being written, or a foreign version's whose
// record shape we cannot read. Stamping our token over it made this
// process an owner again after it had been legitimately replaced:
// verifyStillOwner then answered true for a displaced writer, and
// release()'s removeIfOwned deleted the live successor's lock.
//
// So a body we cannot prove is ours ends our ownership. The mtime
// stops advancing, the fence refuses to publish (the parse is
// discarded, which is the fail-safe direction), and a successor
// recovers the lock one staleMs later through the age gate. Losing a
// parse is the correct price for never having two owners.
if (current.record === null || current.record.token !== token) return
await writeFile(lockPath, body(), { encoding: 'utf-8' })
const now = new Date(clock.wallNow())
await utimes(lockPath, now, now)
@ -325,6 +387,12 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
continue
}
// A corrupt observation takes this path unchanged. Staleness is never
// waived for it: an abandoned corrupt lock is older than staleMs and is
// recovered here, while a corrupt body younger than that is waited out
// and left alone, because it may belong to a live owner whose heartbeat
// will repair it. Worst case we time out and serve the prior snapshot
// read-only for one staleMs window instead of freezing forever.
const age = Math.max(0, clock.wallNow() - observation.mtimeMs)
if (age > staleMs) {
const takeover = await tryTakeover(observation)

View file

@ -11,9 +11,10 @@ import type { ParsedProviderCall } from './providers/types.js'
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
// so sessions cached under v4 pick up the CLI-MCP attribution.
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
const CODEX_CACHE_VERSION = 7
// v8: persist native MCP timing and compact invocation attribution.
const CODEX_CACHE_VERSION = 8
const CACHE_FILE = 'codex-results.json'
type FileFingerprint = { mtimeMs: number; sizeBytes: number }

521
src/codex-throughput.ts Normal file
View file

@ -0,0 +1,521 @@
import { open, stat } from 'node:fs/promises'
import { StringDecoder } from 'node:string_decoder'
export type CodexThroughputPoint = {
timestamp: string
model?: string
outputTokens: number
reasoningTokens: number
generatedTokens: number
taskGeneratedTokens?: number
elapsedSeconds?: number
generatedTokensPerSecond?: number
activeDurationSeconds?: number
activeGeneratedTokensPerSecond?: number
toolWaitSeconds?: number
}
type TokenUsage = {
output_tokens?: number
reasoning_output_tokens?: number
total_tokens?: number
}
type RolloutLine = {
type?: string
timestamp?: string
payload?: {
type?: string
turn_id?: string
call_id?: string
started_at?: number
duration_ms?: number
duration?: { secs?: number; nanos?: number } | string
model?: string
forked_from_id?: string
info?: {
last_token_usage?: TokenUsage
total_token_usage?: TokenUsage
}
}
}
const CHUNK_BYTES = 64 * 1024
const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024
const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__'
function rawString(source: string, field: string): string | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source)
if (!match) return undefined
try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined }
}
function rawNumber(source: string, field: string): number | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source)
if (!match) return undefined
const value = Number(match[1])
return Number.isFinite(value) ? value : undefined
}
function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined {
const index = source.indexOf(`"${field}"`)
if (index < 0) return undefined
const body = source.slice(index, index + 4096)
return {
output_tokens: rawNumber(body, 'output_tokens'),
reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'),
total_tokens: rawNumber(body, 'total_tokens'),
}
}
function parseRawDurationValue(value: string): number | undefined {
const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value)
if (objectMatch) {
const seconds = Number(objectMatch[1])
const nanos = Number(objectMatch[2])
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
}
const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value)
if (stringMatch) {
const parsed = Number(stringMatch[1])
if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1)
}
const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
if (numberMatch) {
const parsed = Number(numberMatch[1])
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
function durationMs(payload: RolloutLine['payload']): number | undefined {
if (!payload) return undefined
if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms
if (typeof payload.duration === 'object' && payload.duration) {
const seconds = payload.duration.secs
const nanos = payload.duration.nanos
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
return seconds * 1000 + nanos / 1e6
}
}
if (typeof payload.duration === 'string') {
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim())
if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1)
}
return undefined
}
function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number {
const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined)
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
const clipped = intervals.map(([start, end]) => [
windowStart !== undefined ? Math.max(start, windowStart) : start,
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
] as [number, number]).filter(([start, end]) => end > start)
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((result, interval) => {
const previous = result.at(-1)
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
else result.push([...interval])
return result
}, [])
return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0))
}
function parseLine(line: string): RolloutLine | null {
const payloadStart = line.indexOf('"payload"')
const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line
if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) {
const payloadType = rawString(payloadHead, 'type')
const infoStart = payloadHead.indexOf('"info"')
const info = infoStart >= 0 ? payloadHead.slice(infoStart) : ''
return {
type: rawString(line, 'type'),
timestamp: rawString(line, 'timestamp'),
payload: {
type: payloadType,
turn_id: rawString(payloadHead, 'turn_id'),
call_id: rawString(payloadHead, 'call_id'),
started_at: rawNumber(payloadHead, 'started_at'),
duration_ms: rawNumber(payloadHead, 'duration_ms'),
duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined
? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') }
: undefined),
model: rawString(payloadHead, 'model'),
forked_from_id: rawString(payloadHead, 'forked_from_id'),
info: {
last_token_usage: compactUsage(info, 'last_token_usage'),
total_token_usage: compactUsage(info, 'total_token_usage'),
},
},
}
}
try {
return JSON.parse(line) as RolloutLine
} catch {
return null
}
}
/**
* Estimate generated tokens/sec from a Codex rollout's persisted checkpoints.
* Codex JSONL has no per-token timestamps, so this is deliberately a
* checkpoint-to-checkpoint estimate, not live decode speed.
*/
type ThroughputState = {
model?: string
previousTotal?: number
previousOutput: number
previousReasoning: number
previousTimestamp?: number
currentTaskGenerated: number
currentTaskToolIntervals: Array<[number, number]>
currentTaskStartedAt?: number
toolStarts: Map<string, number>
latestPoint?: CodexThroughputPoint
points: CodexThroughputPoint[]
forkCutoffMs?: number
}
function newThroughputState(): ThroughputState {
return {
previousOutput: 0,
previousReasoning: 0,
currentTaskGenerated: 0,
currentTaskToolIntervals: [],
toolStarts: new Map(),
points: [],
}
}
/**
* Incrementally parses a rollout. Watch mode feeds only newly appended bytes
* to this reader, so a growing JSONL file is not reparsed from byte zero.
*/
export class CodexThroughputReader {
private offset = 0
private pending = ''
private decoder = new StringDecoder('utf8')
private pendingDurationMs: number | undefined
private scanDepth = 0
private scanPayloadDepth: number | undefined
private scanInString = false
private scanEscape = false
private scanString = ''
private scanLastString = ''
private scanAwaitingColon = false
private scanCurrentKey: string | undefined
private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined
private state = newThroughputState()
reset(): void {
this.offset = 0
this.pending = ''
this.decoder = new StringDecoder('utf8')
this.pendingDurationMs = undefined
this.scanDepth = 0
this.scanPayloadDepth = undefined
this.scanInString = false
this.scanEscape = false
this.scanString = ''
this.scanLastString = ''
this.scanAwaitingColon = false
this.scanCurrentKey = undefined
this.scanCapture = undefined
this.state = newThroughputState()
}
private finishDurationCapture(): void {
if (!this.scanCapture) return
const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text
const parsed = parseRawDurationValue(value)
if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed
this.scanCapture = undefined
}
private scanDurationSegment(source: string): void {
for (let i = 0; i < source.length; i++) {
const char = source[i]!
if (this.scanInString) {
if (this.scanEscape) {
this.scanEscape = false
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char
else this.scanString += char
continue
}
if (char === '\\') {
this.scanEscape = true
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
continue
}
if (char === '"') {
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
this.scanInString = false
if (this.scanCapture?.mode === 'string') this.finishDurationCapture()
else if (this.scanCapture?.mode === 'object') {
this.scanAwaitingColon = false
this.scanCurrentKey = undefined
} else {
this.scanLastString = this.scanString
this.scanAwaitingColon = true
}
continue
}
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
else this.scanString += char
continue
}
if (this.scanCapture?.mode === 'primitive') {
if (char === ',' || char === '}' || char === ']') this.finishDurationCapture()
else { this.scanCapture.text += char; continue }
}
if (this.scanAwaitingColon) {
if (/\s/.test(char)) continue
if (char === ':') {
this.scanCurrentKey = this.scanLastString
this.scanAwaitingColon = false
continue
}
this.scanAwaitingColon = false
}
if (char === '"') {
this.scanString = ''
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth }
this.scanCurrentKey = undefined
}
this.scanInString = true
continue
}
if (char === '{' || char === '[') {
if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) {
this.scanPayloadDepth = this.scanDepth + 1
}
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 }
this.scanCurrentKey = undefined
} else if (this.scanCapture?.mode === 'object') {
this.scanCapture.text += char
}
this.scanDepth++
continue
}
if (char === '}' || char === ']') {
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
this.scanDepth = Math.max(0, this.scanDepth - 1)
if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture()
continue
}
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) {
this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth }
this.scanCurrentKey = undefined
continue
}
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
}
}
private processLine(line: string, durationOverride?: number): void {
const entry = parseLine(line)
if (!entry) return
if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) {
entry.payload = { ...entry.payload, duration_ms: durationOverride }
}
const state = this.state
if (entry.type === 'session_meta') {
if (entry.payload?.model) state.model = entry.payload.model
if (entry.payload?.forked_from_id && entry.timestamp) {
const timestamp = Date.parse(entry.timestamp)
if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000
}
return
}
if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model
const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN
const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs
if (isForkReplay && (
entry.payload?.type === 'task_started' ||
entry.payload?.type === 'task_complete' ||
entry.payload?.type === 'function_call' ||
entry.payload?.type === 'function_call_output' ||
entry.payload?.type === 'custom_tool_call' ||
entry.payload?.type === 'custom_tool_call_output' ||
entry.payload?.type === 'mcp_tool_call_end' ||
entry.payload?.type === 'patch_apply_end' ||
entry.payload?.type === 'token_count'
)) return
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
state.currentTaskGenerated = 0
state.currentTaskToolIntervals = []
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
state.toolStarts.clear()
}
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) {
const started = Date.parse(entry.timestamp)
if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started)
}
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) {
const ended = Date.parse(entry.timestamp)
const started = state.toolStarts.get(entry.payload.call_id)
if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended])
state.toolStarts.delete(entry.payload.call_id)
}
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) {
const ended = Date.parse(entry.timestamp)
const elapsed = durationMs(entry.payload)
if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended])
}
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
const taskDurationMs = durationMs(entry.payload)
if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) {
state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined
const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined)
const activeMs = taskDurationMs - toolWaitMs
if (activeMs > 0) {
state.latestPoint.activeDurationSeconds = activeMs / 1000
state.latestPoint.toolWaitSeconds = toolWaitMs / 1000
state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000)
}
}
}
if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return
const info = entry.payload.info
if (!info || !entry.timestamp) return
const last = info.last_token_usage
const total = info.total_token_usage
const cumulative = total?.total_tokens
if (cumulative !== undefined && cumulative === state.previousTotal) return
let outputTokens = last?.output_tokens ?? 0
let reasoningTokens = last?.reasoning_output_tokens ?? 0
if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) {
outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput)
reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning)
}
if (cumulative !== undefined) {
state.previousTotal = cumulative
state.previousOutput = total?.output_tokens ?? state.previousOutput
state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning
}
const generatedTokens = outputTokens + reasoningTokens
if (generatedTokens <= 0) return
const timestampMs = Date.parse(entry.timestamp)
if (!Number.isFinite(timestampMs)) return
const point: CodexThroughputPoint = {
timestamp: entry.timestamp,
model: state.model,
outputTokens,
reasoningTokens,
generatedTokens,
}
state.currentTaskGenerated += generatedTokens
state.latestPoint = point
if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) {
const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000
point.elapsedSeconds = elapsedSeconds
point.generatedTokensPerSecond = generatedTokens / elapsedSeconds
}
state.previousTimestamp = timestampMs
state.points.push(point)
if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000)
}
async update(filePath: string, limit = 10, finalize = false): Promise<CodexThroughputPoint[]> {
const info = await stat(filePath)
if (info.size < this.offset) this.reset()
const bytesToRead = info.size - this.offset
if (bytesToRead > 0) {
const file = await open(filePath, 'r')
try {
let position = this.offset
while (position < info.size) {
const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position))
const { bytesRead } = await file.read(buffer, 0, buffer.length, position)
if (bytesRead === 0) break
position += bytesRead
this.offset = position
let chunk = this.decoder.write(buffer.subarray(0, bytesRead))
while (chunk.length > 0) {
const newlineIndex = chunk.search(/\r?\n/)
const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk
this.pending += segment
this.scanDurationSegment(segment)
if (newlineIndex < 0) break
const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1
const line = this.pending
const durationOverride = this.pendingDurationMs
this.pending = ''
this.pendingDurationMs = undefined
this.scanDepth = 0
this.scanPayloadDepth = undefined
this.scanInString = false
this.scanEscape = false
this.scanString = ''
this.scanLastString = ''
this.scanAwaitingColon = false
this.scanCurrentKey = undefined
this.scanCapture = undefined
this.processLine(line, durationOverride)
chunk = chunk.slice(newlineIndex + newlineLength)
}
if (this.pending.length > MAX_PENDING_LINE_CHARS) {
const body = this.pending.startsWith(TRUNCATION_MARKER)
? this.pending.slice(TRUNCATION_MARKER.length)
: this.pending
this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024)
}
}
} finally {
await file.close()
}
}
if (finalize && this.pending) {
this.processLine(this.pending, this.pendingDurationMs)
this.pending = ''
this.pendingDurationMs = undefined
}
return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice()
}
}
export async function readCodexThroughput(filePath: string, limit = 10): Promise<CodexThroughputPoint[]> {
return new CodexThroughputReader().update(filePath, limit, true)
}
export async function newestCodexSession(sessions: Array<{ path: string }>): Promise<string | undefined> {
let newest: { path: string; mtimeMs: number } | undefined
for (const session of sessions) {
try {
const info = await stat(session.path)
if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs }
} catch {
// A session can disappear while Codex rotates or archives it.
}
}
return newest?.path
}
export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string {
const latest = points.at(-1)
if (!latest) return `No token_count checkpoints found in ${filePath}.`
const lines = [
'CodeBurn Codex throughput estimate',
`Session: ${filePath}`,
`Latest checkpoint: ${latest.timestamp}`,
`Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`,
]
if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`)
if (latest.activeGeneratedTokensPerSecond !== undefined) {
lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`)
lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`)
} else if (latest.generatedTokensPerSecond !== undefined) {
lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`)
} else {
lines.push('Throughput: unavailable (waiting for a completed turn)')
}
lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.')
return lines.join('\n')
}

View file

@ -150,6 +150,14 @@ export type DailyCache = {
/// as incomplete and is fully re-backfilled. Absent on caches written before
/// this field existed → treated as incomplete (one self-healing re-backfill).
complete?: boolean
/// True once a COMPLETE parse finalized this watermark. The pull-back below
/// only distrusts caches WITHOUT this stamp: a degraded parse can no longer
/// set `complete`, so a stamped cache whose watermark sits past its newest
/// populated day is a legitimately idle tail (recent days had no activity),
/// not a frozen hole, and re-deriving it every launch is pure waste. Absent
/// on caches written before this field: distrusted once (one healing
/// pull-back), then stamped.
watermarkTrusted?: boolean
}
function getCacheDir(): string {
@ -257,7 +265,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
if (!isRecord(raw)) return {}
const out: NonNullable<DailyEntry['projects']> = {}
for (const [name, p] of Object.entries(raw)) {
if (name in Object.prototype || !isRecord(p)) continue
// A project key is a directory basename, so it can legitimately be a
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
// as an own property via defineProperty, so keeping it is pollution-safe —
// and dropping it would silently subtract that project's cost from a
// --project/--exclude total (the day's split would no longer sum to its own
// cost, which the filtered headline relies on).
if (!isRecord(p)) continue
setOwn(out, name, {
cost: num(p.cost),
calls: num(p.calls),
@ -294,7 +308,7 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
}))
}
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
return {
version: DAILY_CACHE_VERSION,
savingsConfigHash: parsed.savingsConfigHash ?? '',
@ -306,6 +320,9 @@ function migratedFrom(parsed: { version: number; lastComputedDate: string | null
// Only a cache explicitly marked complete stays trusted; one written before
// the marker existed reads false and is re-backfilled once.
complete: parsed.complete === true,
// Absent on a pre-fix cache: the watermark is distrusted once (healing
// pull-back), then re-stamped by the finalize that follows.
watermarkTrusted: parsed.watermarkTrusted === true,
}
}
@ -409,6 +426,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
// accounting: leave complete unset so the next hydration re-derives every
// day whose sources survive (the merge keeps the rest).
complete: rest.length === candidates.length ? false : base.complete,
watermarkTrusted: rest.length === candidates.length ? false : base.watermarkTrusted,
}
await saveDailyCache(adopted).catch(() => {})
return adopted
@ -451,6 +469,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
lastComputedDate: nextLast,
days: applyRetention(merged, newestDate),
complete: cache.complete,
watermarkTrusted: cache.watermarkTrusted,
}
}
@ -671,6 +690,27 @@ export async function ensureCacheHydrated(
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
}
// A cache can claim `complete` while its watermark points PAST its newest
// populated day — what a run finalizing off a degraded (read-only) parse
// leaves behind: it advanced lastComputedDate over days the parse never
// covered. Since gapStart is lastComputedDate + 1, that hole is invisible
// to the gap logic forever. Trust the DATA over the marker: pull the
// watermark back to the newest day actually present so the ordinary gap
// parse re-derives the tail. Nothing is dropped — the cached days all stay.
//
// Only UNSTAMPED caches are distrusted here. A degraded parse can no longer
// set `complete` (that is this fix), so the corrupt state can only be
// written by pre-fix code: an unstamped cache. A stamped one whose watermark
// outruns its newest day is a legitimately idle tail (recent days had no
// activity), and re-deriving that empty tail on every launch is the
// regression this guard avoids. A cache with NO days is exempt: it has no
// newest day to trust, and a machine with no history at all must still be
// able to finalize (below) rather than re-backfill on every launch.
const newestCachedDate = c.days.reduce<string | null>((max, d) => (max === null || d.date > max ? d.date : max), null)
if (c.watermarkTrusted !== true && newestCachedDate !== null && c.lastComputedDate !== null && c.lastComputedDate > newestCachedDate) {
c = { ...c, lastComputedDate: newestCachedDate }
}
// Three reasons to re-derive the whole retention window:
// 1. Savings config changed — cached `savingsUSD` totals are stale.
// 2. The cache was never finalized against a COMPLETE session parse (an old
@ -691,6 +731,7 @@ export async function ensureCacheHydrated(
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
const baseline = c.days
const priorWatermark = c.lastComputedDate
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
let freshDays: DailyEntry[] = []
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
@ -708,9 +749,18 @@ export async function ensureCacheHydrated(
version: DAILY_CACHE_VERSION,
savingsConfigHash,
tzKey,
lastComputedDate: yesterdayStr,
// The watermark records how far history has actually been derived, so
// only a COMPLETE parse may advance it. A partial one produced no data
// for whatever it could not read; moving the watermark to yesterday
// anyway would place those days behind the next run's gapStart and
// freeze the hole in (retention still anchors on yesterdayStr — the
// real calendar edge — so holding the watermark can't evict anything).
lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark,
days: applyRetention(merged, yesterdayStr),
complete: parseWasComplete,
// Stamp the watermark as trusted only when a COMPLETE parse produced it,
// so a later idle tail under this watermark is not distrusted above.
watermarkTrusted: parseWasComplete,
}
await saveDailyCache(c)
return c
@ -733,18 +783,23 @@ export async function ensureCacheHydrated(
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = await parseSessions(gapRange)
const gapDays = aggregateDays(gapProjects)
const parseWasComplete = sessionComplete()
const priorWatermark = c.lastComputedDate
c = addNewDays(c, gapDays, yesterdayStr)
// Finalize as complete ONLY when the session parse that produced these days
// was itself complete. If it was partial, leave `complete: false` so the
// next launch (once the session cache is whole) re-backfills instead of
// freezing the partial history.
c = { ...c, complete: sessionComplete() }
// freezing the partial history — and hold the watermark where it was, for
// the same reason as the re-derive path above: a partial parse cannot
// vouch for the days it never read, and gapStart is the only thing that
// will ever bring them back.
c = { ...c, lastComputedDate: parseWasComplete ? c.lastComputedDate : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
await saveDailyCache(c)
} else if (c.complete !== true && sessionComplete()) {
// No gap to fill (already current through yesterday) but not yet marked —
// e.g. a brand-new machine whose only data is today. Finalize so future
// launches don't re-backfill the whole window every time.
c = { ...c, complete: true }
c = { ...c, complete: true, watermarkTrusted: true }
await saveDailyCache(c)
}
return c

View file

@ -46,6 +46,8 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean,
return historyProjectCount === 0 && !historyLoading
}
// The By Model panel drops the Tok/s column when the panel is too narrow, so
// the wider two-column layout can still activate at ordinary terminal widths.
const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
@ -196,9 +198,9 @@ function nextTick(): Promise<void> {
return new Promise(resolve => setImmediate(resolve))
}
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
function getLayout(columns?: number): Layout {
export function getLayout(columns?: number): Layout {
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
const dashWidth = Math.min(160, termWidth)
const wide = dashWidth >= MIN_WIDE
@ -440,6 +442,7 @@ const MODEL_COL_COST = 8
const MODEL_COL_CACHE = 7
const MODEL_COL_CALLS = 7
const MODEL_COL_ONESHOT = 7
const MODEL_COL_TPS = 7
const MODEL_NAME_WIDTH = 14
const MIN_EDIT_TURNS_FOR_RATE = 5
@ -449,6 +452,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const modelTotals = aggregateModelTotals(projects)
const modelEfficiency = aggregateModelEfficiency(projects)
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0)
// The Tok/s column needs 61 inner columns for the full row; hide it on narrower
// panels and when no model has timing data (non-Codex users get no dead column).
const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
@ -460,7 +467,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
return (
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}</Text>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}</Text>
{sorted.map(([model, data], i) => {
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
@ -469,6 +476,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
? `${efficiency.oneShotRate.toFixed(1)}%`
: '-'
const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0
? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1)
: '-'
return (
<Text key={`${model}-${i}`} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
@ -477,6 +487,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
{showTps && <Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>}
</Text>
)
})}
@ -488,6 +499,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{anyEstimated && (
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
)}
{showTps && (
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
)}
</Panel>
)
}

View file

@ -75,14 +75,25 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
// Turn-anchored bucketing: attribute the WHOLE turn — every one of its
// calls — to the day of the turn's user-message timestamp, matching the
// live headline/report rollup (main.ts daily). Falls back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions that begin mid-conversation). Previously the calls were
// bucketed per-call by each call's own timestamp, so a midnight-
// straddling turn split across two days and history.daily / the provider
// breakdown never reconciled to current.cost (a constant offset).
// Two bucketing rules, deliberately different per level:
// - Turn-level judgments (category, editTurns, oneShotTurns) stay
// anchored to the turn's day (its timestamp — the user-message time,
// or the re-anchored first surviving call when the parser sliced
// the turn to a range, and falling back to the first assistant call
// when the user line is missing). They describe the whole exchange,
// not a per-call sum, so a sliced straddling turn reports them on
// each side's anchor day — summed across days they inflate, which
// is the accepted, documented semantics (see review on #852).
// - Call-derived values (cost/savings/calls/tokens and the model,
// project, and provider-slice rollups built from them) bucket under
// EACH CALL's own local day (the per-call loop below). The parser
// slices straddling turns per range (issue #852), so every parse
// only holds in-range calls and per-call bucketing keeps day-N +
// day-N+1 equal to the whole range — and history.daily reconciled
// to the headline built from the same days. (Before the parser
// sliced per call, per-call bucketing here was what caused the
// constant offset against the whole-turn headline; the slice is
// what makes it exact now.)
const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp)
const turnDay = ensure(turnDate)
@ -140,21 +151,26 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0
// Call-derived values bucket under the call's OWN day (see the
// two-rule comment above). An unparseable call timestamp falls back
// to the turn's anchor day rather than producing a garbage date key.
const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKey(call.timestamp)
const callDay = ensure(callDate)
turnDay.cost += call.costUSD
turnDay.savingsUSD += callSavings
turnDay.calls += 1
turnDay.inputTokens += call.usage.inputTokens
turnDay.outputTokens += call.usage.outputTokens
turnDay.cacheReadTokens += call.usage.cacheReadInputTokens
turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
callDay.cost += call.costUSD
callDay.savingsUSD += callSavings
callDay.calls += 1
callDay.inputTokens += call.usage.inputTokens
callDay.outputTokens += call.usage.outputTokens
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
const dayProject = ensureProject(turnDay, session.project, project.projectPath)
const dayProject = ensureProject(callDay, session.project, project.projectPath)
dayProject.cost += call.costUSD
dayProject.calls += 1
dayProject.savingsUSD += callSavings
const model = turnDay.models[call.model] ?? {
const model = callDay.models[call.model] ?? {
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
@ -166,9 +182,9 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
turnDay.models[call.model] = model
callDay.models[call.model] = model
const slice = ensureSlice(turnDay, call.provider)
const slice = ensureSlice(callDay, call.provider)
slice.calls += 1
slice.cost += call.costUSD
slice.savingsUSD += callSavings

View file

@ -5,6 +5,7 @@ import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
import { allProviderNames, getAllProviders } from './providers/index.js'
import { getProvider } from './providers/index.js'
import { convertCost, formatCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { toDateString } from './daily-cache.js'
@ -46,6 +47,7 @@ import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js'
// A downstream reader that closes the pipe early (`| head`, quitting `less`, or
// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather
@ -68,6 +70,22 @@ function parseInteger(value: string): number {
return parseInt(value, 10)
}
function parseCodexTpsLimit(value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 10000) {
throw new Error('limit must be an integer from 1 to 10000')
}
return parsed
}
function parseCodexTpsWatch(value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0 || (parsed > 0 && parsed < 1) || parsed > 3600) {
throw new Error('watch must be 0 or at least 1 second (up to 3600 seconds)')
}
return parsed
}
type PriceOverrideConfig = NonNullable<CodeburnConfig['priceOverrides']>[string]
type PriceOverrideOptions = {
@ -497,9 +515,17 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].savings += call.savingsUSD ?? 0
dailyMap[day].calls += 1
// Cost/savings/calls bucket under each call's OWN day — the same
// per-call rule as the durable day set (day-aggregator.ts), so this
// fallback and durable.days never diverge on a midnight-straddling
// turn (issue #852). Turn counts/edit stats stay anchored on the
// turn's day above. An unparseable call timestamp falls back to the
// turn's day rather than producing a garbage date key.
const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp)
if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[callDay].cost += call.costUSD
dailyMap[callDay].savings += call.savingsUSD ?? 0
dailyMap[callDay].calls += 1
}
}
}
@ -966,6 +992,7 @@ program
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
unattributedCostUSD: durable.unattributedCostUSD,
},
}))
})
@ -1843,6 +1870,94 @@ program
await runContextCommand(session, opts)
})
program
.command('codex-tps [session]')
.description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)')
.option('--json', 'JSON output')
.option('--limit <n>', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10)
.option('--watch <seconds>', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0)
.action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => {
const intervalMs = Math.max(0, opts.watch) * 1000
if (opts.json && intervalMs > 0) {
process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n')
process.exitCode = 2
return
}
const provider = await getProvider('codex')
if (!provider) {
process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n')
process.exitCode = 1
return
}
let cachedPath: string | undefined = session
let throughputReader: CodexThroughputReader | undefined
let lastFileState: { size: number; mtimeMs: number } | undefined
let lastDiscoveryMs = 0
let refreshInFlight = false
const render = async (): Promise<void> => {
if (refreshInFlight) return
refreshInFlight = true
try {
let filePath = session ?? cachedPath
// Keep an idle watcher on its chosen rollout. A full active+archive
// discovery can be hundreds of milliseconds on large histories, so
// only re-scan slowly to notice rotation; disappearance still triggers
// an immediate discovery on the next tick.
if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) {
lastDiscoveryMs = Date.now()
filePath = await newestCodexSession(await provider.discoverSessions())
}
if (!filePath) {
process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n')
if (intervalMs === 0) process.exitCode = 1
return
}
const previousPath = cachedPath
cachedPath = filePath
if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader()
const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null)
if (!fileInfo) {
process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`)
if (intervalMs === 0) process.exitCode = 1
if (!session) cachedPath = undefined
return
}
if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return
lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs }
const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0)
if (opts.json) {
process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n')
} else {
if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H')
process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n'))
}
} finally {
refreshInFlight = false
}
}
try {
await render()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
if (intervalMs === 0) {
process.exitCode = 1
return
}
}
if (intervalMs > 0) {
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
void render().catch(error => {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
})
}, intervalMs)
process.once('SIGINT', () => { clearInterval(timer); resolve() })
})
}
})
program
.command('compare')
.description('Compare two AI models side-by-side')

View file

@ -8,6 +8,8 @@ export interface ModelTotals {
freshInput: number
cacheRead: number
cacheWrite: number
activeDurationMs: number
activeGeneratedTokens: number
}
/// Aggregate per-model usage across every session, keyed by the friendly display
@ -24,6 +26,7 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
const name = getShortModelName(model)
const totals = (modelTotals[name] ??= {
calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0,
activeDurationMs: 0, activeGeneratedTokens: 0,
})
totals.calls += data.calls
totals.costUSD += data.costUSD
@ -31,6 +34,8 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
totals.freshInput += data.tokens.inputTokens
totals.cacheRead += data.tokens.cacheReadInputTokens
totals.cacheWrite += data.tokens.cacheCreationInputTokens
totals.activeDurationMs += data.activeDurationMs ?? 0
totals.activeGeneratedTokens += data.activeGeneratedTokens ?? 0
}
}
}

View file

@ -97,6 +97,9 @@ export type OverviewDurable = {
cacheWriteTokens: number
days: DailyEntry[]
carriedCostUSD: number
/// Cost a --project/--exclude filter could not attribute (cached days with no
/// per-project split). Optional so callers that never filter can omit it.
unattributedCostUSD?: number
}
export function renderOverview(
@ -350,5 +353,12 @@ export function renderOverview(
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
}
// A project filter cannot claim days the cache holds without a project split
// (recorded before that split existed), so they sit outside this total. Say how
// much rather than let the filtered figure look inexplicably short.
if (durable && (durable.unattributedCostUSD ?? 0) > 0) {
out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
}
return out.join('\n') + '\n'
}

View file

@ -27,6 +27,7 @@ import {
saveCache,
} from './session-cache.js'
import { acquireCacheRefreshLock, type RefreshLockHandle } from './cache-refresh-lock.js'
import { dateKey } from './day-aggregator.js'
import type { ParsedProviderCall, SessionSource } from './providers/types.js'
import type {
ApiUsageIteration,
@ -1734,6 +1735,11 @@ function buildSessionSummary(
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens
modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens
if (call.activeDurationMs !== undefined) {
modelBreakdown[modelKey].activeDurationMs = (modelBreakdown[modelKey].activeDurationMs ?? 0) + call.activeDurationMs
modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? call.usage.outputTokens + call.usage.reasoningTokens)
modelBreakdown[modelKey].toolWaitMs = (modelBreakdown[modelKey].toolWaitMs ?? 0) + (call.toolWaitMs ?? 0)
}
for (const tool of extractCoreTools(call.tools)) {
toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 }
@ -1931,6 +1937,7 @@ async function scanProjectDirs(
const cached = section.files[filePath]
const action = reconcileFile(fp, cached)
if (cached && (readOnly || action.action === 'unchanged')) {
if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true
unchangedFiles.push({ filePath, dirName, source, cached: section.files[filePath]! })
} else if (!readOnly) {
if (action.action === 'appended') {
@ -1942,6 +1949,10 @@ async function scanProjectDirs(
continue
}
changedFiles.push({ filePath, info: { dirName, fp, source } })
} else {
// Read-only with no cache entry at all: this file is dropped from what
// we serve, so the snapshot under-reports whatever days it covers.
readOnlyServedStale = true
}
}
dirsDone++
@ -2200,12 +2211,12 @@ async function scanProjectDirs(
const spawnPrSets = cachedFile.prLinks?.length ? buildSpawnPrSets(cachedFile.turns) : {}
if (dateRange) {
classifiedTurns = classifiedTurns.filter(turn => {
if (turn.assistantCalls.length === 0) return false
const firstCallTs = turn.assistantCalls[0]!.timestamp
if (!firstCallTs) return false
const ts = new Date(firstCallTs)
return ts >= dateRange.start && ts <= dateRange.end
// Slice rather than drop: a turn spanning local midnight would otherwise
// lose every call that lands in the requested day (issue #852). Only
// `assistantCalls`/`timestamp` are touched — see classifiedTurnSlicedToRange.
classifiedTurns = classifiedTurns.flatMap(turn => {
const sliced = classifiedTurnSlicedToRange(turn, dateRange)
return sliced ? [sliced] : []
})
}
@ -2393,6 +2404,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
...(call.editFailed ? { editFailed: call.editFailed } : {}),
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
}
}
@ -2429,6 +2443,9 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
...(call.interrupted ? { interrupted: true } : {}),
...(call.userModified ? { userModified: true } : {}),
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
}
}
@ -2541,6 +2558,9 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
deduplicationKey: call.deduplicationKey,
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
toolSequence: call.toolSequence,
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
})
}
@ -2786,6 +2806,59 @@ export function createScanProgress(label: string, total: number) {
}
}
// Shared by the turn-range slicers below: which of a turn's calls actually
// fall inside dateRange. Returns null when none do (the turn should be dropped
// entirely, not kept with an empty call list).
function callsInRange<T extends { timestamp: string }>(calls: T[], dateRange: DateRange): T[] | null {
const inRange = calls.filter(c => {
const ts = new Date(c.timestamp)
return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end
})
return inRange.length > 0 ? inRange : null
}
// A turn can span local midnight (e.g. a long-running autonomous Codex
// session): dropping the whole turn because its FIRST call falls outside
// dateRange discards every later call that lands in the requested day (issue
// #852). Instead, keep only the calls actually inside the range. `timestamp`
// is re-anchored to the first surviving call so downstream turn-anchored
// bucketing (session day, report rollups) keys the slice under the day its
// retained calls actually fall in, not the pre-slice turn's original
// (possibly prior-day) start. Returns null when no call is in range.
function turnSlicedToRange(turn: CachedTurn, dateRange: DateRange): CachedTurn | null {
const inRangeCalls = callsInRange(turn.calls, dateRange)
if (!inRangeCalls) return null
if (inRangeCalls.length === turn.calls.length) return turn
return { ...turn, calls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
}
// Same slice, applied post-classification (scanProjectDirs classifies every
// turn from its FULL call list up front, before date filtering — see the
// carriedBranch/carriedPrRefs comments in scanProjectDirs — so this only
// trims `assistantCalls` and re-anchors `timestamp`; `category`/`subCategory`/
// `retries`/`hasEdits` stay exactly as classified from the complete turn.
// Those are turn-level judgments about the whole exchange, not a per-call
// sum, so they aren't recomputed from the partial call list.
function classifiedTurnSlicedToRange(turn: ClassifiedTurn, dateRange: DateRange): ClassifiedTurn | null {
const inRangeCalls = callsInRange(turn.assistantCalls, dateRange)
if (!inRangeCalls) return null
if (inRangeCalls.length === turn.assistantCalls.length) return turn
return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
}
// Day-set variant of classifiedTurnSlicedToRange for the menubar/history day
// selection: keep only the calls whose own local day is selected and
// re-anchor `timestamp` to the first survivor — the same split rule.
function classifiedTurnSlicedToDays(turn: ClassifiedTurn, days: Set<string>): ClassifiedTurn | null {
const inRangeCalls = turn.assistantCalls.filter(c => {
const ts = new Date(c.timestamp)
return !Number.isNaN(ts.getTime()) && days.has(dateKey(c.timestamp))
})
if (inRangeCalls.length === 0) return null
if (inRangeCalls.length === turn.assistantCalls.length) return turn
return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
}
async function parseProviderSources(
providerName: string,
sources: SessionSource[],
@ -2826,9 +2899,13 @@ async function parseProviderSources(
// re-read a file that already threw and hasn't changed. It re-parses only
// when the file changes (then `reconcileFile` reports non-'unchanged').
if (cached && (readOnly || (action.action === 'unchanged' && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))))) {
if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true
unchangedSources.push({ source, cached })
} else if (!readOnly) {
changedSources.push({ source, fp })
} else {
// Read-only with no cache entry at all — see scanProjectDirs.
readOnlyServedStale = true
}
}
@ -2993,24 +3070,32 @@ async function parseProviderSources(
for (const c of turn.calls) seenKeys.add(c.deduplicationKey)
let slicedTurn = turn
if (dateRange) {
const callTs = turn.calls[0]?.timestamp
if (!callTs) continue
const ts = new Date(callTs)
if (ts < dateRange.start || ts > dateRange.end) continue
const sliced = turnSlicedToRange(turn, dateRange)
if (!sliced) continue
slicedTurn = sliced
}
const classified = cachedTurnToClassified(turn)
const project = turn.calls[0]?.project ?? source.project
// Classify the FULL turn, then keep only the in-range calls: category /
// hasEdits / retries are whole-exchange judgments, not per-call sums, so a
// midnight-straddling turn is classified identically to the Claude path
// (scanProjectDirs) rather than being re-derived from a partial slice.
// Cost/calls come from the retained calls, unchanged.
const classifiedFull = cachedTurnToClassified(turn)
const classified = dateRange
? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull)
: classifiedFull
const project = slicedTurn.calls[0]?.project ?? source.project
const key = `${providerName}:${turn.sessionId}:${project}`
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
if (!existing.projectPath && turn.calls[0]?.projectPath) {
existing.projectPath = turn.calls[0]!.projectPath
if (!existing.projectPath && slicedTurn.calls[0]?.projectPath) {
existing.projectPath = slicedTurn.calls[0]!.projectPath
}
if (!existing.workingDirectory && turn.calls[0]?.workingDirectory) existing.workingDirectory = turn.calls[0].workingDirectory
if (!existing.workingDirectory && slicedTurn.calls[0]?.workingDirectory) existing.workingDirectory = slicedTurn.calls[0].workingDirectory
if (cachedFile.prLinks?.length) {
const links = (existing.prLinks ??= new Set())
for (const link of cachedFile.prLinks) links.add(link)
@ -3019,8 +3104,8 @@ async function parseProviderSources(
} else {
sessionMap.set(key, {
project,
projectPath: turn.calls[0]?.projectPath,
workingDirectory: turn.calls[0]?.workingDirectory,
projectPath: slicedTurn.calls[0]?.projectPath,
workingDirectory: slicedTurn.calls[0]?.workingDirectory,
turns: [classified],
...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}),
...(cachedFile.title ? { title: cachedFile.title } : {}),
@ -3042,25 +3127,31 @@ async function parseProviderSources(
for (const c of turn.calls) seenKeys.add(c.deduplicationKey)
let slicedTurn = turn
if (dateRange) {
const callTs = turn.calls[0]?.timestamp
if (!callTs) continue
const ts = new Date(callTs)
if (ts < dateRange.start || ts > dateRange.end) continue
const sliced = turnSlicedToRange(turn, dateRange)
if (!sliced) continue
slicedTurn = sliced
}
const classified = cachedTurnToClassified(turn)
const project = turn.calls[0]?.project ?? providerName
// Classify the FULL turn, then keep only the in-range calls (same rule
// as the loop above and the Claude path): whole-exchange judgments stay
// whole-turn; cost/calls come from the retained calls.
const classifiedFull = cachedTurnToClassified(turn)
const classified = dateRange
? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull)
: classifiedFull
const project = slicedTurn.calls[0]?.project ?? providerName
const key = `${providerName}:${turn.sessionId}:${project}`
const existingEntry = sessionMap.get(key)
if (existingEntry) {
existingEntry.turns.push(classified)
if (!existingEntry.projectPath && turn.calls[0]?.projectPath) {
existingEntry.projectPath = turn.calls[0]!.projectPath
if (!existingEntry.projectPath && slicedTurn.calls[0]?.projectPath) {
existingEntry.projectPath = slicedTurn.calls[0]!.projectPath
}
} else {
sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, workingDirectory: turn.calls[0]?.workingDirectory, turns: [classified] })
sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified] })
}
}
}
@ -3153,14 +3244,6 @@ export function filterProjectsByName(
return result
}
function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean {
if (turn.assistantCalls.length === 0) return false
const firstCallTs = turn.assistantCalls[0]!.timestamp
if (!firstCallTs) return false
const ts = new Date(firstCallTs)
return ts >= dateRange.start && ts <= dateRange.end
}
function turnDayString(turn: ClassifiedTurn): string | null {
if (turn.assistantCalls.length === 0) return null
const ts = turn.assistantCalls[0]!.timestamp
@ -3289,9 +3372,13 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
const survivingIdentities = new Set<string>()
for (const session of project.sessions) {
const turns = session.turns.filter(turn => {
const ds = turnDayString(turn)
return ds !== null && days.has(ds)
// Slice turns per call by the selected days (not whole-turn keep/drop):
// a midnight-straddling turn contributes the calls that actually
// happened on each selected day (issue #852, same split rule as the
// range slicers — see classifiedTurnSlicedToDays).
const turns = session.turns.flatMap(turn => {
const sliced = classifiedTurnSlicedToDays(turn, days)
return sliced ? [sliced] : []
})
if (turns.length === 0) {
if (isSpawnParent(session)) anchors.push(session)
@ -3498,7 +3585,13 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
const survivingIdentities = new Set<string>()
for (const session of project.sessions) {
const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange))
// Slice turns per call (not whole-turn keep/drop) so a midnight-
// straddling turn keeps the calls that landed inside the range — the
// same split rule as the parse-time slicers (issue #852).
const turns = session.turns.flatMap(turn => {
const sliced = classifiedTurnSlicedToRange(turn, dateRange)
return sliced ? [sliced] : []
})
if (turns.length === 0) {
if (isSpawnParent(session)) anchors.push(session)
continue
@ -3525,6 +3618,15 @@ export function isSessionHydrationComplete(): boolean {
return sessionHydrationComplete
}
// Set by the read-only serving paths when the snapshot they served did NOT
// match what is on disk: in read-only mode a changed file is served at its
// stale fingerprint and a file with no cache entry is skipped entirely. A
// read-only run under which nothing changed is equivalent to a full parse and
// stays trustworthy; one that skipped real data is a PARTIAL hydration, and
// finalizing daily history off it freezes the days it never saw out of the
// chart (gapStart = lastComputedDate + 1 never looks back at them).
let readOnlyServedStale = false
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)
@ -3594,6 +3696,7 @@ async function runParse(
options: RunParseOptions = {},
): Promise<ProjectSummary[]> {
const { isCold = false, readOnly = false, refreshLock } = options
readOnlyServedStale = false
const seenMsgIds = new Set<string>()
const seenKeys = new Set<string>()
const allSources = await discoverAllSessions(providerFilter)
@ -3633,15 +3736,26 @@ async function runParse(
? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind }
: undefined,
}))
// Claude is scanned through scanProjectDirs rather than parseProviderSources, so
// it needs the same provider-filter guard the durable-orphan loop below applies at
// its own level. Without it a --provider <other> run still enters scanProjectDirs
// with an empty dirs list, and the orphan pass there (which reads the whole cached
// claude section) treats every cached file as "no longer discovered" and re-injects
// it into the result. Note this is deliberately NOT a `claudeDirs.length > 0` check:
// when claude IS in scope but every transcript has been pruned from disk, that
// orphan pass is exactly what keeps PR-attributed spend from vanishing.
const claudeInScope = !providerFilter || providerFilter === 'all' || providerFilter === 'claude'
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' })
let claudeProjects: ProjectSummary[] = []
try {
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
} catch (err) {
if (!isPermissionError(err)) throw err
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
if (claudeInScope) {
try {
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
} catch (err) {
if (!isPermissionError(err)) throw err
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
}
}
const otherProjects: ProjectSummary[] = []
@ -3698,7 +3812,10 @@ async function runParse(
if (refreshLock) throw new RefreshPublicationUnavailableError()
}
}
sessionHydrationComplete = true
// Assigned, not forced true: a read-only run that had to skip or stale real
// files reached the end of the scan without hydrating everything, and the
// daily backfill must not finalize history off it.
sessionHydrationComplete = !readOnly || !readOnlyServedStale
// Merge across providers by normalised project path so the same repository
// is not double-counted when it was worked on with more than one tool

View file

@ -32,6 +32,9 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0
const toolNameMap: Record<string, string> = {
exec_command: 'Bash',
// Codex Desktop's custom-tool transport uses the shorter `exec` name for
// the same shell tool that CLI rollouts record as `exec_command`.
exec: 'Bash',
read_file: 'Read',
write_file: 'Edit',
apply_diff: 'Edit',
@ -96,6 +99,11 @@ type CodexEntry = {
timestamp?: string
payload?: {
type?: string
turn_id?: string
call_id?: string
started_at?: number
duration_ms?: number
duration?: { secs?: number; nanos?: number } | string
role?: string
cwd?: string
model_provider?: string
@ -104,6 +112,7 @@ type CodexEntry = {
forked_from_id?: string
model?: string
name?: string
invocation?: { server?: string; tool?: string }
content?: Array<{ type?: string; text?: string }>
info?: {
model?: string
@ -196,11 +205,128 @@ function getRawJsonStringField(head: string, field: string): string | undefined
}
}
function getRawJsonNumberField(head: string, field: string): number | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(head)
if (!match) return undefined
const value = Number(match[1])
return Number.isFinite(value) ? value : undefined
}
function getRawPayloadFieldWindow(source: Buffer, field: string, windowBytes = 4096): string | undefined {
const payloadKey = Buffer.from('"payload"')
const payloadIndex = source.indexOf(payloadKey)
if (payloadIndex < 0) return undefined
let payloadStart = source.indexOf(0x7b, payloadIndex + payloadKey.length) // {
if (payloadStart < 0) return undefined
let depth = 0
let inString = false
let escaped = false
for (let i = payloadStart; i < source.length; i++) {
const byte = source[i]!
if (inString) {
if (escaped) escaped = false
else if (byte === 0x5c) escaped = true // \\
else if (byte === 0x22) inString = false // "
continue
}
if (byte === 0x22) {
const keyStart = i + 1
let keyEnd = keyStart
let keyEscaped = false
for (; keyEnd < source.length; keyEnd++) {
const keyByte = source[keyEnd]!
if (keyEscaped) { keyEscaped = false; continue }
if (keyByte === 0x5c) { keyEscaped = true; continue }
if (keyByte === 0x22) break
}
if (depth === 1 && keyEnd < source.length) {
const key = source.subarray(keyStart, keyEnd).toString('utf-8')
let valueStart = keyEnd + 1
while (valueStart < source.length && (source[valueStart] === 0x20 || source[valueStart] === 0x09 || source[valueStart] === 0x0a || source[valueStart] === 0x0d)) valueStart++
if (source[valueStart] === 0x3a && key === field) {
return source.subarray(i, Math.min(source.length, i + windowBytes)).toString('utf-8')
}
}
i = keyEnd
inString = false
continue
}
if (byte === 0x22) inString = true
else if (byte === 0x7b || byte === 0x5b) depth++ // { or [
else if (byte === 0x7d || byte === 0x5d) depth-- // } or ]
if (depth < 0) break
}
return undefined
}
function getRawDurationMs(head: string): number | undefined {
const objectMatch = /"duration"\s*:\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(head)
if (objectMatch) {
const seconds = Number(objectMatch[1])
const nanos = Number(objectMatch[2])
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
}
const text = getRawJsonStringField(head, 'duration')
if (text) {
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(text.trim())
if (match) {
const value = Number(match[1])
if (Number.isFinite(value)) return value * (match[2] === 's' ? 1000 : 1)
}
}
return undefined
}
function durationValueMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'object' && value) {
const record = value as Record<string, unknown>
const seconds = record['secs']
const nanos = record['nanos']
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
return seconds * 1000 + nanos / 1e6
}
}
if (typeof value === 'string') {
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value.trim())
if (match) {
const parsed = Number(match[1])
if (Number.isFinite(parsed)) return parsed * (match[2] === 's' ? 1000 : 1)
}
}
return undefined
}
function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token_usage'): CodexTokenUsage | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*\\{([^}]*)\\}`).exec(head)
if (!match) return undefined
const body = match[1]!
return {
input_tokens: getRawJsonNumberField(body, 'input_tokens'),
cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'),
output_tokens: getRawJsonNumberField(body, 'output_tokens'),
reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'),
total_tokens: getRawJsonNumberField(body, 'total_tokens'),
}
}
function payloadHead(head: string): string {
const idx = head.indexOf('"payload"')
return idx === -1 ? head : head.slice(idx)
}
function getRawInvocation(head: string): { server?: string; tool?: string } | undefined {
const idx = head.indexOf('"invocation"')
if (idx === -1) return undefined
// Server/tool are shallow fields and precede the potentially huge arguments
// object in Codex MCP records. Limit this scan to keep compact parsing cheap.
const invocationHead = head.slice(idx, idx + 8192)
const server = getRawJsonStringField(invocationHead, 'server')
const tool = getRawJsonStringField(invocationHead, 'tool')
return server || tool ? { server, tool } : undefined
}
function countJsonStringBytes(source: Buffer, valueStart: number): number {
let count = 0
for (let i = valueStart; i < source.length; i++) {
@ -270,6 +396,31 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
const pHead = payloadHead(head)
const payloadType = getRawJsonStringField(pHead, 'type')
const role = getRawJsonStringField(pHead, 'role')
// task_complete appends the potentially huge final assistant message before
// its duration fields. Fall back to the full Buffer only for this event so
// timing metadata is not lost when the compact head stops early.
const needsTimingTail = type === 'event_msg' && (payloadType === 'task_complete' || payloadType === 'mcp_tool_call_end')
const timingTail = needsTimingTail && line.length > RAW_HEAD_BYTES
? line.subarray(Math.max(0, line.length - 16 * 1024)).toString('utf-8')
: pHead
const timingNumber = (field: string): number | undefined =>
getRawJsonNumberField(pHead, field) ?? getRawJsonNumberField(timingTail, field)
// MCP records can place a large invocation.arguments object before duration
// and a large result after it. Searching a small window around the field
// avoids materializing the middle of the Buffer while still preserving wait
// timing for those records.
const payloadDuration = payloadType === 'mcp_tool_call_end'
? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '')
: undefined
const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail)
const compactModel = getRawJsonStringField(pHead, 'model')
const compactModelName = getRawJsonStringField(pHead, 'model_name')
const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage')
const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage')
const compactInfo = compactModel || compactModelName || compactLastUsage || compactTotalUsage
? { model: compactModel, model_name: compactModelName, last_token_usage: compactLastUsage, total_token_usage: compactTotalUsage }
: undefined
const invocation = getRawInvocation(pHead) ?? getRawInvocation(timingTail)
const entry: CodexEntry = {
type,
@ -284,6 +435,16 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'),
model: getRawJsonStringField(pHead, 'model'),
name: getRawJsonStringField(pHead, 'name'),
invocation,
call_id: getRawJsonStringField(pHead, 'call_id'),
turn_id: getRawJsonStringField(pHead, 'turn_id'),
// On mcp_tool_call_end a coincidental `duration_ms` inside the large
// invocation.arguments object can shadow the payload-level duration, so the
// depth-aware value wins. The naive scan stays as the fallback for
// task_complete, which records duration_ms at the payload level directly.
duration_ms: timingDuration ?? timingNumber('duration_ms'),
started_at: timingNumber('started_at'),
info: compactInfo,
},
}
@ -300,6 +461,8 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
const s = await stat(filePath).catch(() => null)
if (!s?.isFile()) return null
// Fast path: cached results already know the project, so avoid opening the
// file. This keeps discovery cheap on large session directories.
const cachedProject = await getCachedCodexProject(filePath)
if (cachedProject) {
return { path: filePath, project: cachedProject, provider: 'codex' }
@ -314,6 +477,12 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
// Codex archives a session by moving it from sessions/YYYY/MM/DD/ to
// archived_sessions/, keeping the same basename. Deduplicate by basename so
// a session does not appear twice while it exists in both roots. This avoids
// reading every file to extract session_id and preserves the cheap cached
// fast path.
const seenBasenames = new Set<string>()
const sessionsDir = join(codexDir, 'sessions')
const years = await readdir(sessionsDir).catch(() => [] as string[])
@ -335,8 +504,9 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
for (const file of files) {
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
const filePath = join(dayDir, file)
const source = await discoverSessionFile(filePath)
if (seenBasenames.has(file)) continue
seenBasenames.add(file)
const source = await discoverSessionFile(join(dayDir, file))
if (source) sources.push(source)
}
}
@ -345,10 +515,14 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
// Codex moves archived sessions into a flat directory. Keep them in usage
// reports so archiving a conversation does not erase its historical usage.
// Call-level deduplication (seenKeys) already collapses any remaining
// archived copies, while basename dedup above prevents double discovery.
const archivedDir = join(codexDir, 'archived_sessions')
const archivedFiles = await readdir(archivedDir).catch(() => [] as string[])
for (const file of archivedFiles) {
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
if (seenBasenames.has(file)) continue
seenBasenames.add(file)
const source = await discoverSessionFile(join(archivedDir, file))
if (source) sources.push(source)
}
@ -410,6 +584,16 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
let currentTurnId = `${sessionId}:t0`
let sawAnyLine = false
const results: ParsedProviderCall[] = []
// Calls decoded since the last task_started, held back so task_complete can
// stamp active/toolWait timing before they are appended to results. Emitting
// a task only once its timing is known keeps single-pass and split/resume
// decodes in agreement instead of back-patching already-emitted calls.
// Bounded by one task's calls; flushed at the next task_started and at EOF.
let pendingTaskCalls: ParsedProviderCall[] = []
let taskGeneratedTokens = 0
let taskToolIntervals: Array<[number, number]> = []
let taskStartedAt: number | undefined
const openToolStarts = new Map<string, number>()
// Stream the session file line by line. Heavy Codex sessions can exceed
// 250 MB on disk; reading the entire file into a string would either hit
@ -437,7 +621,32 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
continue
}
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
const isForkReplay = Boolean(forkCutoff && entry.timestamp && entry.timestamp < forkCutoff)
if (isForkReplay && (
entry.payload?.type === 'task_started' ||
entry.payload?.type === 'task_complete' ||
entry.payload?.type === 'function_call' ||
entry.payload?.type === 'function_call_output' ||
entry.payload?.type === 'custom_tool_call' ||
entry.payload?.type === 'custom_tool_call_output' ||
entry.payload?.type === 'mcp_tool_call_end' ||
entry.payload?.type === 'patch_apply_end'
)) continue
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
// Emit the previous task. If it never reached task_complete its timing
// fields simply stay unset, matching the un-buffered behaviour.
results.push(...pendingTaskCalls)
pendingTaskCalls = []
taskGeneratedTokens = 0
taskToolIntervals = []
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
openToolStarts.clear()
continue
}
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call')) {
const rawName = entry.payload.name ?? ''
const mapped = toolNameMap[rawName] ?? rawName
pendingTools.push(mapped)
@ -459,10 +668,52 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
pendingToolSequence.push([{ tool: mcpTool }])
}
}
const callId = entry.payload.call_id
const started = entry.timestamp ? Date.parse(entry.timestamp) : NaN
if (callId && Number.isFinite(started)) openToolStarts.set(callId, started)
pendingToolSequence.push([call])
continue
}
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output')) {
const callId = entry.payload.call_id
const ended = entry.timestamp ? Date.parse(entry.timestamp) : NaN
const started = callId ? openToolStarts.get(callId) : undefined
if (started !== undefined && Number.isFinite(ended) && ended > started) taskToolIntervals.push([started, ended])
if (callId) openToolStarts.delete(callId)
continue
}
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
const durationMs = entry.payload.duration_ms
if (typeof durationMs === 'number' && durationMs > 0 && taskGeneratedTokens > 0 && pendingTaskCalls.length > 0) {
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
const windowStart = taskStartedAt ?? (Number.isFinite(completedAt) ? completedAt - durationMs : undefined)
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
const clipped = taskToolIntervals.map(([start, end]) => [
windowStart !== undefined ? Math.max(start, windowStart) : start,
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
] as [number, number]).filter(([start, end]) => end > start)
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((acc, interval) => {
const previous = acc.at(-1)
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
else acc.push([...interval])
return acc
}, [])
const toolWaitMs = Math.min(durationMs, merged.reduce((sum, interval) => sum + interval[1] - interval[0], 0))
const activeMs = durationMs - toolWaitMs
if (activeMs <= 0) continue
for (const call of pendingTaskCalls) {
const generated = call.outputTokens + call.reasoningTokens
if (generated <= 0) continue
call.activeGeneratedTokens = generated
call.activeDurationMs = activeMs * (generated / taskGeneratedTokens)
call.toolWaitMs = toolWaitMs * (generated / taskGeneratedTokens)
}
}
continue
}
if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') {
pendingTools.push('Edit')
const p = entry.payload as Record<string, unknown>
@ -490,6 +741,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
// attributed. Rebuild the canonical `mcp__<server>__<tool>` name the
// classifier recognizes.
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') {
const endedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
const durationMs = entry.payload.duration_ms ?? durationValueMs(entry.payload.duration)
if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(endedAt)) {
taskToolIntervals.push([endedAt - durationMs, endedAt])
}
const inv = (entry.payload as Record<string, unknown>)['invocation'] as Record<string, unknown> | undefined
const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : ''
const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : ''
@ -542,7 +798,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
results.push({
pendingTaskCalls.push({
provider: 'codex',
model,
inputTokens: estInput,
@ -568,6 +824,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
taskGeneratedTokens += estOutput
pendingTools = []
pendingToolSequence = []
@ -660,7 +917,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
0,
)
results.push({
pendingTaskCalls.push({
provider: 'codex',
model,
inputTokens: uncachedInputTokens,
@ -685,6 +942,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
taskGeneratedTokens += outputTokens + reasoningTokens
pendingTools = []
pendingToolSequence = []
@ -701,6 +959,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
// result set against a fingerprint that would otherwise be re-parsed.
if (!sawAnyLine) return
// Flush the final task, which has no following task_started to trigger it.
results.push(...pendingTaskCalls)
await writeCachedCodexResults(source.path, source.project, results, fp)
for (const call of results) {

View file

@ -53,6 +53,9 @@ export type ParsedProviderCall = {
// Exact provider-recorded cwd, kept separately because projectPath may later
// canonicalize a linked worktree to its main repository.
workingDirectory?: string
activeDurationMs?: number
activeGeneratedTokens?: number
toolWaitMs?: number
}
// A directory or database file that a provider's discoverSessions() scans.

View file

@ -51,6 +51,9 @@ export type CachedCall = {
toolErrors?: number
// Codex: count of this call's patch applications with success === false.
editFailed?: number
activeDurationMs?: number
activeGeneratedTokens?: number
toolWaitMs?: number
}
export type CachedTurn = {
@ -213,7 +216,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// rich-session-capture-v1: per-call LOC deltas + editFailed from
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
// lockstep so the pre-session-cache layer re-parses too.)
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1',
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'cli-shutdown-cost-v1-skills',
@ -337,6 +340,9 @@ function validateCall(c: unknown): c is CachedCall {
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
&& isOptionalNum(o['costUSD'])
&& isOptionalBool(o['isEstimated'])
&& isOptionalNum(o['activeDurationMs'])
&& isOptionalNum(o['activeGeneratedTokens'])
&& isOptionalNum(o['toolWaitMs'])
&& isStringArray(o['tools'])
&& isStringArray(o['bashCommands'])
&& isStringArray(o['skills'])

View file

@ -22,7 +22,8 @@ import {
} from './auth.js'
import { createCredentialStore } from './credentials.js'
import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js'
import { collectUnsentCalls, sendBatches, batchCalls, MAX_PER_PUSH } from './push.js'
import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js'
import { batchAttributionItems } from './otlp.js'
export function registerSyncCommands(program: Command): void {
const sync = program
@ -226,7 +227,8 @@ export function registerSyncCommands(program: Command): void {
.description('Push unsent telemetry data to the configured endpoint')
.option('--since <period>', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d')
.option('--dry-run', 'Show what would be sent without sending')
.action(async (opts: { since: string; dryRun?: boolean }) => {
.option('--attribution', 'Also push git attribution spans (session→commit correlation from `codeburn yield`, plus PR links). Sends normalized repo remotes and commit SHAs to the endpoint.')
.action(async (opts: { since: string; dryRun?: boolean; attribution?: boolean }) => {
const config = readSyncConfig()
if (!config) {
process.stderr.write('Sync not configured. Run `codeburn sync setup <url>` first.\n')
@ -277,6 +279,18 @@ export function registerSyncCommands(program: Command): void {
// Flatten + filter against sent-ledger
const { allCalls, unsent } = collectUnsentCalls(projects)
// Attribution records (opt-in): session→commit correlation computed
// locally from the same parsed projects. Reuses the yield engine.
let attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent'] = []
let attributionTotal = 0
if (opts.attribution) {
const { computeAttributionRecords } = await import('../yield.js')
const records = computeAttributionRecords(projects, range, process.cwd())
const collected = collectUnsentAttribution(records)
attributionUnsent = collected.unsent
attributionTotal = collected.allItems.length
}
if (opts.dryRun) {
const toPushCount = Math.min(unsent.length, MAX_PER_PUSH)
const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0)
@ -285,10 +299,19 @@ export function registerSyncCommands(program: Command): void {
if (unsent.length > MAX_PER_PUSH) {
process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`)
}
if (opts.attribution) {
const toPushAttr = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
const commits = toPushAttr.filter(i => i.kind === 'commit').length
const sessions = toPushAttr.filter(i => i.kind === 'session').length
process.stderr.write(`[dry-run] Attribution: ${attributionTotal} facts total, would push ${toPushAttr.length} (${sessions} sessions, ${commits} commits)\n`)
if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
process.stderr.write(`[dry-run] ${attributionUnsent.length - MAX_ATTRIBUTION_PER_PUSH} more attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit — a second push would be needed\n`)
}
}
return
}
if (unsent.length === 0) {
if (unsent.length === 0 && attributionUnsent.length === 0) {
process.stderr.write(`Nothing to push (${allCalls.length} calls already synced).\n`)
updateLastSync()
return
@ -302,15 +325,18 @@ export function registerSyncCommands(program: Command): void {
// Batch and send (loops until done; waits out 429 rate limits)
const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl)
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
const endpoint = `${config.baseUrl}${config.tracesPath}`
const result = await sendBatches({
endpoint,
accessToken: tokens.access_token,
batches,
log: msg => process.stderr.write(`${msg}\n`),
})
let result: PushResult = { outcome: 'complete', totalSent: 0, totalRejected: 0, totalCostSent: 0 }
if (toPush.length > 0) {
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
result = await sendBatches({
endpoint,
accessToken: tokens.access_token,
batches,
log: msg => process.stderr.write(`${msg}\n`),
})
}
if (result.outcome === 'auth-rejected') {
process.stderr.write('Auth rejected by server. Run `codeburn sync setup` to re-authenticate.\n')
@ -323,11 +349,50 @@ export function registerSyncCommands(program: Command): void {
process.stderr.write(`Server error (HTTP ${result.httpStatus}). Remaining calls will be sent on the next push.\n`)
}
// Attribution spans ride the same endpoint after the usage push
// completes. Skipped when the usage push hit rate limits or server
// errors — the endpoint is already unhappy; both retry on next push.
let attrResult: PushResult | null = null
if (opts.attribution && attributionUnsent.length > 0) {
if (result.outcome === 'complete') {
// Safety valve, mirroring the usage-call cap
const attrToPush = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
process.stderr.write(`${attributionUnsent.length} attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit. Pushing first ${MAX_ATTRIBUTION_PER_PUSH}; run again to continue.\n`)
}
const attrBatches = batchAttributionItems(attrToPush, discoveryDoc.max_batch_size)
attrResult = await sendAttributionBatches({
endpoint,
accessToken: tokens.access_token,
batches: attrBatches,
log: msg => process.stderr.write(`${msg}\n`),
})
if (attrResult.outcome === 'auth-rejected') {
process.stderr.write('Auth rejected by server during attribution push. Run `codeburn sync setup` to re-authenticate.\n')
process.exit(1)
}
if (attrResult.outcome === 'rate-limited') {
process.stderr.write(`Rate limited during attribution push — gave up after repeated retries. Remaining facts will be sent on the next push.\n`)
}
if (attrResult.outcome === 'server-error') {
process.stderr.write(`Server error (HTTP ${attrResult.httpStatus}) during attribution push. Remaining facts will be sent on the next push.\n`)
}
} else {
process.stderr.write(`Skipping attribution push (${attributionUnsent.length} facts) — will retry on next push.\n`)
}
}
// Update lastSync
updateLastSync()
// Summary
process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`)
if (attrResult) {
const attrSuffix = attrResult.outcome !== 'complete'
? ` (push incomplete — remainder retries next push)`
: attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''
process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrSuffix}\n`)
}
if (result.totalRejected > 0) {
process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`)
}
@ -337,7 +402,7 @@ export function registerSyncCommands(program: Command): void {
// Non-zero exit when the push did not complete, so cron/scripts can
// detect it. Ledgered progress is kept; next push resumes.
if (result.outcome !== 'complete') {
if (result.outcome !== 'complete' || (attrResult !== null && attrResult.outcome !== 'complete')) {
process.exitCode = 1
}
} catch (err) {

View file

@ -8,6 +8,7 @@
import { createHash } from 'crypto'
import { hostname, userInfo } from 'os'
import type { ParsedApiCall } from '../types.js'
import type { SessionAttributionRecord } from '../yield.js'
export interface OtlpSpan {
traceId: string
@ -141,3 +142,175 @@ export function batchCalls(calls: CallWithSession[], maxBatchSize: number): Call
}
return batches
}
// --- Attribution spans (sync push --attribution) ---
export const SESSION_ATTRIBUTION_SPAN_NAME = 'codeburn.session.attribution'
export const COMMIT_ATTRIBUTION_SPAN_NAME = 'codeburn.commit'
/**
* A single ledger-able attribution unit: either one session-level record
* (repo + PR links + commit count) or one attributed commit. The dedup key
* encodes the mutable state (inMain/wasReverted for commits; repo, PR links,
* and commit set for sessions), so a state TRANSITION mints a new key and the
* updated fact is re-sent on the next push the receiver upserts by
* (repo, sha) / (session). Identical states dedupe via the sent-ledger.
*/
export type AttributionItem = {
kind: 'session' | 'commit'
dedupKey: string
/** Span start: commit author time for commits, session start for sessions. ISO 8601. */
timestamp: string
/** Span end for session items (session lastTimestamp). Absent for commits. */
endTimestamp?: string
sessionId: string
project: string
repo: string | null
// session kind
prLinks?: string[]
commitCount?: number
// commit kind
sha?: string
inMain?: boolean
wasReverted?: boolean
}
function stateHash(parts: string[]): string {
return createHash('sha256').update(parts.join('\u001e')).digest('hex').slice(0, 16)
}
/** Deterministic dedup key for a commit attribution fact (state included). */
export function commitAttributionKey(sessionId: string, sha: string, inMain: boolean, wasReverted: boolean): string {
return `attr:c:${sessionId}:${sha}:${inMain ? 1 : 0}${wasReverted ? 1 : 0}`
}
/** Deterministic dedup key for a session attribution fact (state included). */
export function sessionAttributionKey(record: SessionAttributionRecord): string {
const commitStates = record.commits
.map(c => `${c.sha}:${c.inMain ? 1 : 0}${c.wasReverted ? 1 : 0}`)
.sort()
// Project and both window timestamps are part of the state: an ongoing
// session whose window grew (or whose project resolution changed) re-emits
// with the corrected span times instead of freezing at first send.
return `attr:s:${record.sessionId}:${stateHash([
record.repo ?? '',
record.project,
record.firstTimestamp,
record.lastTimestamp,
...record.prLinks,
...commitStates,
])}`
}
/** Ledger-key prefix for a session's attribution facts (any state). */
export function sessionAttributionKeyPrefix(sessionId: string): string {
return `attr:s:${sessionId}:`
}
/** Flatten attribution records into ledger-able items (one session item + one per commit). */
export function flattenAttributionRecords(records: SessionAttributionRecord[]): AttributionItem[] {
const items: AttributionItem[] = []
for (const record of records) {
items.push({
kind: 'session',
dedupKey: sessionAttributionKey(record),
timestamp: record.firstTimestamp,
endTimestamp: record.lastTimestamp,
sessionId: record.sessionId,
project: record.project,
repo: record.repo,
prLinks: record.prLinks,
commitCount: record.commits.length,
})
for (const commit of record.commits) {
items.push({
kind: 'commit',
dedupKey: commitAttributionKey(record.sessionId, commit.sha, commit.inMain, commit.wasReverted),
timestamp: commit.timestamp,
sessionId: record.sessionId,
project: record.project,
repo: record.repo,
sha: commit.sha,
inMain: commit.inMain,
wasReverted: commit.wasReverted,
})
}
}
return items
}
/**
* Build an OTLP payload from attribution items. Spans share the session's
* traceId with the usage spans (`deriveTraceId(sessionId)`), so a receiver
* can correlate cost and attribution without any extra key.
*/
export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPayload {
const deviceId = getDeviceId()
const spans: OtlpSpan[] = items.map(item => {
const startNano = toUnixNano(item.timestamp)
// Clamp like the usage builder: end is never 0 (malformed timestamp) and
// never earlier than start + 1ms (out-of-order session timestamps).
const minEndNano = BigInt(startNano) + 1_000_000n
const rawEndNano = item.endTimestamp ? BigInt(toUnixNano(item.endTimestamp)) : 0n
const endNano = (rawEndNano > minEndNano ? rawEndNano : minEndNano).toString()
const attributes: OtlpAttribute[] = [
{ key: 'ai.session_id', value: { stringValue: item.sessionId } },
{ key: 'ai.project', value: { stringValue: item.project } },
]
if (item.repo) {
attributes.push({ key: 'git.repo', value: { stringValue: item.repo } })
}
if (item.kind === 'commit') {
attributes.push(
{ key: 'git.sha', value: { stringValue: item.sha ?? '' } },
{ key: 'git.in_main', value: { boolValue: item.inMain ?? false } },
{ key: 'git.was_reverted', value: { boolValue: item.wasReverted ?? false } },
)
} else {
attributes.push({ key: 'git.commit_count', value: { intValue: String(item.commitCount ?? 0) } })
if (item.prLinks && item.prLinks.length > 0) {
attributes.push({
key: 'git.pr_links',
value: { arrayValue: { values: item.prLinks.map(u => ({ stringValue: u })) } },
})
}
}
return {
traceId: deriveTraceId(item.sessionId),
spanId: deriveSpanId(item.dedupKey),
name: item.kind === 'commit' ? COMMIT_ATTRIBUTION_SPAN_NAME : SESSION_ATTRIBUTION_SPAN_NAME,
startTimeUnixNano: startNano,
endTimeUnixNano: endNano,
attributes,
}
})
return {
resourceSpans: [{
resource: {
attributes: [
{ key: 'codeburn.device_id', value: { stringValue: deviceId } },
// Honesty marker: this attribution is inferred (timestamp-window
// correlation), not declared. Receivers should label it as such.
{ key: 'codeburn.attribution_methodology', value: { stringValue: 'timestamp-window' } },
],
},
scopeSpans: [{
spans,
}],
}],
}
}
/** Split attribution items into batches of maxBatchSize. */
export function batchAttributionItems(items: AttributionItem[], maxBatchSize: number): AttributionItem[][] {
const batches: AttributionItem[][] = []
for (let i = 0; i < items.length; i += maxBatchSize) {
batches.push(items.slice(i, i + maxBatchSize))
}
return batches
}

View file

@ -8,7 +8,16 @@
import type { ProjectSummary } from '../types.js'
import { assertHttps } from './discovery.js'
import { ledgerKeySet, appendToLedger, type LedgerEntry } from './ledger.js'
import { buildOtlpPayload, batchCalls, type CallWithSession } from './otlp.js'
import {
buildOtlpPayload,
batchCalls,
buildAttributionOtlpPayload,
flattenAttributionRecords,
type CallWithSession,
type AttributionItem,
type OtlpPayload,
} from './otlp.js'
import type { SessionAttributionRecord } from '../yield.js'
/**
* Safety valve, not a routine cap pushes now loop until all batches are
@ -91,6 +100,23 @@ export function parseRetryAfterMs(value: string | null): number | null {
* retry on the next push.
*/
export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult> {
return sendBatchesCore({
...opts,
buildPayload: buildOtlpPayload,
toOutbound: c => ({ key: c.call.deduplicationKey, ts: c.call.timestamp, costUSD: c.call.costUSD }),
})
}
/** How sendBatchesCore ledgers and prices a batch item. */
type OutboundItem = { key: string; ts: string; costUSD: number }
type SendBatchesCoreOptions<T> = Omit<SendBatchesOptions, 'batches'> & {
batches: T[][]
buildPayload: (batch: T[]) => OtlpPayload
toOutbound: (item: T) => OutboundItem
}
async function sendBatchesCore<T>(opts: SendBatchesCoreOptions<T>): Promise<PushResult> {
assertHttps(opts.endpoint, 'Traces endpoint')
const log = opts.log ?? (() => {})
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
@ -107,7 +133,7 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
// Retry loop for the current batch (429 only)
for (;;) {
const payload = buildOtlpPayload(batch)
const payload = opts.buildPayload(batch)
const response = await fetch(opts.endpoint, {
method: 'POST',
@ -158,13 +184,11 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
totalRejected += rejected
log(` Batch: ${rejected}/${batch.length} spans rejected — whole batch will retry on next push`)
} else {
const entries: LedgerEntry[] = batch.map(c => ({
key: c.call.deduplicationKey,
ts: c.call.timestamp,
}))
const outbound = batch.map(opts.toOutbound)
const entries: LedgerEntry[] = outbound.map(o => ({ key: o.key, ts: o.ts }))
appendToLedger(entries)
totalSent += batch.length
totalCostSent += batch.reduce((s, c) => s + c.call.costUSD, 0)
totalCostSent += outbound.reduce((s, o) => s + o.costUSD, 0)
}
break // batch done (success or partial) — move to next batch
}
@ -173,4 +197,56 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
return { outcome: 'complete', totalSent, totalRejected, totalCostSent, totalWaitMs }
}
/**
* Safety valve for attribution items, mirroring MAX_PER_PUSH: bounds a first
* `--since all --attribution` push over a long history. Remaining facts are
* sent on the next push (the ledger tracks progress).
*/
export const MAX_ATTRIBUTION_PER_PUSH = 10_000
/** Flatten attribution records into items and filter out already-sent ones. */
export function collectUnsentAttribution(records: SessionAttributionRecord[]): {
allItems: AttributionItem[]
unsent: AttributionItem[]
} {
const sent = ledgerKeySet()
// Empty records (no commits, no PR links) exist only to RETRACT a session
// span whose commits migrated to another session. Send one only when a
// PRIOR state for that session was already ledgered — a session that was
// never sent has nothing to retract.
const sessionsWithPriorState = new Set<string>()
for (const key of sent) {
if (key.startsWith('attr:s:')) {
const sessionId = key.slice('attr:s:'.length, key.lastIndexOf(':'))
sessionsWithPriorState.add(sessionId)
}
}
const sendable = records.filter(r =>
r.commits.length > 0 || r.prLinks.length > 0 || sessionsWithPriorState.has(r.sessionId),
)
const allItems = flattenAttributionRecords(sendable)
const unsent = allItems.filter(i => !sent.has(i.dedupKey))
return { allItems, unsent }
}
export interface SendAttributionBatchesOptions extends Omit<SendBatchesOptions, 'batches'> {
batches: AttributionItem[][]
}
/**
* Send attribution batches through the same retry/ledger pipeline as usage
* batches. Items are ledgered by their state-encoding dedup keys, so an
* identical attribution fact is sent once and a state transition (commit
* merged to main, commit reverted) re-sends the updated fact.
*/
export async function sendAttributionBatches(opts: SendAttributionBatchesOptions): Promise<PushResult> {
return sendBatchesCore({
...opts,
buildPayload: buildAttributionOtlpPayload,
toOutbound: item => ({ key: item.dedupKey, ts: item.timestamp, costUSD: 0 }),
})
}
export { batchCalls }

View file

@ -153,6 +153,9 @@ export type ParsedApiCall = {
/// Count of this call's tool results flagged `is_error` (Claude tool_result
/// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0.
toolErrors?: number
activeDurationMs?: number
activeGeneratedTokens?: number
toolWaitMs?: number
}
export type ToolCall = {
@ -268,7 +271,7 @@ export type SessionSummary = {
/// from a provider that never captures branches (→ contributes nothing).
/// Claude only; absent otherwise.
everHadBranch?: boolean
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number; activeDurationMs?: number; activeGeneratedTokens?: number; toolWaitMs?: number }>
toolBreakdown: Record<string, { calls: number }>
mcpBreakdown: Record<string, { calls: number }>
bashBreakdown: Record<string, { calls: number }>

View file

@ -12,7 +12,7 @@ import { aggregateModels } from './models-report.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
import { scanAndDetect } from './optimize.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
@ -226,25 +226,130 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
}
}
/// Does a cached day's project entry pass the active name filters? Mirrors
/// parser.filterProjectsByName exactly — case-insensitive substring match
/// against the project name OR its filesystem path, include first then exclude —
/// so a filter selects the same projects whether it is resolved against a fresh
/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is
/// absent on entries whose sessions were gone before it could be recorded; the
/// name is then all there is to match on, as it is for the display layers.
function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean {
const n = name.toLowerCase()
const p = (path ?? '').toLowerCase()
const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern))
if (include.length > 0 && !include.some(hit)) return false
if (exclude.length > 0 && exclude.some(hit)) return false
return true
}
/// Sum the per-project day stats that pass the filters. `defineProperty` so a
/// project directory named "__proto__" stays an own key instead of mutating the
/// prototype link (same reason day-aggregator does it when writing them).
function sumMatchingProjects(
projects: Record<string, ProjectDayStats>,
include: string[],
exclude: string[],
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, matched: 0 }
for (const [name, p] of Object.entries(projects)) {
if (!dayProjectMatches(name, p.path, include, exclude)) continue
out.cost += p.cost
out.calls += p.calls
out.savingsUSD += p.savingsUSD ?? 0
out.sessions += p.sessions ?? 0
out.matched += 1
Object.defineProperty(out.projects, name, { value: p, enumerable: true, writable: true, configurable: true })
}
return out
}
/// Collapse a day to the slice matching the active --project/--exclude filters,
/// the project-level counterpart of sliceDayToProvider. Without this a
/// project-filtered headline counted every historical day WHOLE — excluded
/// projects included — while every detail panel (By Project / By Activity / By
/// Model, all built from the name-filtered live parse) left them out, so the two
/// could not be reconciled.
///
/// `DailyEntry.projects` (cache v15+) carries cost/calls/savingsUSD/sessions per
/// project, so those four are recomputed EXACTLY. The day's tokens, models and
/// categories have no per-project split to slice, so they are dropped here
/// rather than reported as if they belonged to the surviving projects;
/// buildDurablePeriod refills them from the project-filtered live parse, which
/// is exact for every session that still exists.
///
/// A day recorded before v15 has no `projects` at all and nothing can
/// reconstruct one once the sources are gone. Such a day cannot be attributed to
/// any project, so it contributes nothing to a project-filtered total and its
/// cost is surfaced as `unattributedCostUSD` instead of being silently folded in
/// (understating with a stated figure beats overstating with excluded spend).
function sliceDayToProject(day: DailyEntry, include: string[], exclude: string[]): DailyEntry {
const zeroDay = (): DailyEntry => ({
date: day.date, cost: 0, savingsUSD: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {},
...(day.carried ? { carried: true as const } : {}),
})
if (!day.projects) return zeroDay()
const totals = sumMatchingProjects(day.projects, include, exclude)
if (totals.matched === 0) return zeroDay()
// Provider slices carry their own per-project split, so `--provider X` on top
// of a project filter stays consistent with the day-level slice. A slice
// adopted from a pre-v15 cache has no split and is dropped for the same
// reason the day-level one is.
const providers: Record<string, ProviderDaySlice> = {}
for (const [name, slice] of Object.entries(day.providers)) {
if (!slice.projects) continue
const sliced = sumMatchingProjects(slice.projects, include, exclude)
if (sliced.matched === 0) continue
Object.defineProperty(providers, name, {
value: { cost: sliced.cost, calls: sliced.calls, savingsUSD: sliced.savingsUSD, sessions: sliced.sessions, projects: sliced.projects },
enumerable: true, writable: true, configurable: true,
})
}
return {
date: day.date,
cost: totals.cost,
savingsUSD: totals.savingsUSD,
calls: totals.calls,
sessions: totals.sessions,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
providers,
projects: totals.projects,
...(day.carried ? { carried: true as const } : {}),
}
}
/// The durable day set behind a period's headline: historical days from the
/// carry-forward cache (up to yesterday, INCLUDING days whose session files have
/// expired) unioned with today parsed live, then narrowed to the requested range
/// and (when given) the heatmap day selection. Identical construction to the
/// menubar's all-provider headline — this IS that construction, extracted.
///
/// `sliceHistorical` narrows the cache-sourced days only. Today's days come from
/// a parse the caller already name-filtered, so re-slicing them would be a no-op
/// at best and could only lose data the filter meant to keep.
function unionDaysForPeriod(
cache: DailyCache,
todayAllDays: DailyEntry[],
periodInfo: PeriodInfo,
daysSelection: Set<string> | null,
sliceHistorical?: (day: DailyEntry) => DailyEntry,
): DailyEntry[] {
const now = new Date()
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
const historicalDays = rangeStartStr <= historicalRangeEndStr
const cacheDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
// Apply the day selection BEFORE slicing so a day the heatmap filtered out
// never reaches the slicer (which tallies what it could not attribute).
const selectedCacheDays = daysSelection ? cacheDays.filter(d => daysSelection.has(d.date)) : cacheDays
const historicalDays = sliceHistorical ? selectedCacheDays.map(d => sliceHistorical(d)) : selectedCacheDays
const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered
@ -266,6 +371,12 @@ export type DurablePeriod = {
days: DailyEntry[]
/// Sum of `cost` on `carried` days included in the period (footnote source).
carriedCostUSD: number
/// Cost the active --project/--exclude filter had to set aside: cached days
/// recorded before per-project day stats existed (v15) carry no project split,
/// so they cannot be attributed to the filtered projects. Always 0 when no
/// project filter is active. Reported so a filtered total that is short of the
/// unfiltered one says so instead of just looking wrong.
unattributedCostUSD: number
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
@ -325,7 +436,33 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
scanRange = isTodayOnly ? todayRange : periodInfo.range
}
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null)
// Name filters must reach the cache-sourced days too. Today's parse is already
// name-filtered above (`fp`), but the historical remainder comes straight out
// of the day cache, so without this slice a --project/--exclude headline
// counted every expired-source day whole while the detail panels did not.
const projectInclude = (opts.project ?? []).map(s => s.toLowerCase())
const projectExclude = (opts.exclude ?? []).map(s => s.toLowerCase())
const hasProjectFilter = projectInclude.length > 0 || projectExclude.length > 0
// What a filtered total cannot claim, and therefore has to leave out: a cached
// day with no project split at all, or — with a provider filter also active,
// since the headline then reads that provider's slice — a slice carried from a
// cache generation that predates per-project splits. Both are stated back to
// the caller (footnoted by the overview) instead of vanishing from the total.
const unattributableCost = (day: DailyEntry): number => {
if (pf === 'all') return day.projects ? 0 : day.cost
const slice = Object.hasOwn(day.providers, pf) ? day.providers[pf] : undefined
if (!slice) return 0
return !day.projects || !slice.projects ? slice.cost : 0
}
let unattributedCostUSD = 0
const sliceHistorical = hasProjectFilter
? (day: DailyEntry): DailyEntry => {
unattributedCostUSD += unattributableCost(day)
return sliceDayToProject(day, projectInclude, projectExclude)
}
: undefined
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
const data = buildPeriodDataFromDays(days, periodInfo.label)
@ -342,6 +479,21 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
// Cache buckets a session on its START day, the scan on any ACTIVE day; both
// are lower bounds of distinct sessions, so max is the tightest safe bound.
data.sessions = Math.max(data.sessions, scanData.sessions)
// Tokens/models/categories have no per-project split in the day cache, so
// sliceDayToProject drops them (see there). Under a project filter they come
// from the live parse instead: exact for the filtered projects, bounded by
// source retention like every other scan-derived field above, and consistent
// with the By Model / By Activity panels that read the same parse. Cost, calls,
// sessions and savings stay durable — sliced out of the cache, expired days
// included.
if (hasProjectFilter) {
data.inputTokens = scanData.inputTokens
data.outputTokens = scanData.outputTokens
data.cacheReadTokens = scanData.cacheReadTokens
data.cacheWriteTokens = scanData.cacheWriteTokens
data.models = scanData.models
data.categories = scanData.categories
}
const estimatedByModel = new Map(
scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]),
)
@ -352,7 +504,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
}
const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0)
return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange }
return { data, days, carriedCostUSD, unattributedCostUSD, liveProjects, cache, todayAllDays, scanRange }
}
/**

View file

@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'
import { realpathSync } from 'fs'
import { resolve } from 'path'
import { parseAllSessions } from './parser.js'
import type { DateRange, SessionSummary } from './types.js'
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous'
@ -133,7 +133,98 @@ function getMainBranch(cwd: string): string {
return 'main'
}
type CommitInfo = {
/**
* Normalize a git remote URL to a host-scoped repo identity (`host/org/repo`)
* usable as a server-side join key. Handles the three common transports:
*
* git@github.com:org/repo.git -> github.com/org/repo
* ssh://git@github.com:22/org/repo.git -> github.com/org/repo
* https://user:tok@github.com/org/repo.git -> github.com/org/repo
*
* Credentials and ports are stripped (a token embedded in an https remote must
* never leave the machine), the host is lowercased (path case is preserved),
* and a trailing `.git` / `/` is removed. Local paths and `file://` remotes
* return null a repo with no network remote has no server-side identity.
*/
/** Max length of an emitted repo identity (`host/org/repo`). */
const MAX_REPO_IDENTITY_LENGTH = 200
/**
* Positive validation (allow-list) of a composed repo identity the final
* gate EVERY branch passes through before anything is returned. The host must
* look like a hostname and every path segment like a repo path segment, so no
* upstream parsing quirk (transport-helper remotes like `ext::…` or
* `codecommit::…`, credentials that survived a malformed URL, oversized
* strings) can reach the wire. Rejecting is always safe: an unrecognizable
* remote simply has no server-side identity.
*/
function isValidRepoIdentity(host: string, segments: string[]): boolean {
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host)) return false
if (segments.length === 0) return false
return segments.every(s => /^[A-Za-z0-9._~-]+$/.test(s))
}
export function normalizeRemoteUrl(url: string): string | null {
const trimmed = url.trim()
if (!trimmed) return null
// Windows drive-letter paths (`C:\Users\...`, `C:/Users/...`) are local
// filesystem paths, not scp-like remotes — without this check the scp-like
// branch would parse `C:` as a host and emit the user's local path as a
// repo identity.
if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return null
let host: string
let path: string
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return null
}
if (parsed.protocol === 'file:') return null
if (!parsed.hostname) return null
host = parsed.hostname
path = parsed.pathname
} else {
// scp-like syntax: [user@]host:path. Credentials (userinfo) are split off
// at the FIRST `@` BEFORE any host matching — expressing userinfo as an
// optional regex group lets backtracking abandon the group and re-parse a
// credential prefix as `host:path`, dumping the token into the path
// (e.g. `x-access-token:ghp_…@github.com/org/repo`). Host must be at
// least 2 chars: a single-character "host" is a Windows drive-relative
// path (`C:repo`), never a real remote host.
const at = trimmed.indexOf('@')
const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed
const scpLike = /^([^:/\\]{2,}):(?!\/\/)(.+)$/.exec(rest)
if (!scpLike) return null
host = scpLike[1]
path = scpLike[2]
}
const cleanPath = path
.replace(/\/+/g, '/') // collapse doubled slashes → one join key, not two
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replace(/\.git$/i, '') // case-insensitive: Repo.GIT joins with repo.git
if (!cleanPath) return null
const identity = `${host.toLowerCase()}/${cleanPath}`
if (identity.length > MAX_REPO_IDENTITY_LENGTH) return null
if (!isValidRepoIdentity(host.toLowerCase(), cleanPath.split('/'))) return null
return identity
}
/** `git remote get-url origin`, normalized. Null when absent or local-only. */
function getRepoRemote(gitDir: string): string | null {
const url = runGit(['remote', 'get-url', 'origin'], gitDir)
return url ? normalizeRemoteUrl(url) : null
}
export type CommitInfo = {
sha: string
timestamp: Date
inMain: boolean
@ -296,34 +387,39 @@ function categorizeSession(
return { category: 'abandoned', commitCount: commits.length }
}
export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise<YieldSummary> {
const projects = await parseAllSessions(range, provider)
const summary: YieldSummary = {
productive: { cost: 0, sessions: 0 },
reverted: { cost: 0, sessions: 0 },
abandoned: { cost: 0, sessions: 0 },
ambiguous: { cost: 0, sessions: 0 },
total: { cost: 0, sessions: 0 },
details: [],
}
type RepoGroup = {
commits: CommitInfo[]
sessions: SessionSummary[]
projectNames: string[]
/** Parallel to `sessions`: true when the session's identity came from its
* OWN project path; false when it inherited the cwd-fallback identity.
* The attribution (sync) path must never egress fallback-derived repos. */
ownIdentity: boolean[]
/** A directory to run further git queries in (remote lookup); null when the group has no git identity. */
gitDir: string | null
}
/**
* Group sessions by canonical repository identity and load each group's
* commits for the range. Shared by `computeYield` (categorization) and
* `computeAttributionRecords` (sync). Grouping semantics are unchanged from
* the original computeYield implementation: each commit is awarded at most
* once across the whole repo; monorepo subdirectories and worktrees collapse
* to one group; a project whose path is missing or not a git repo falls back
* to the cwd repo (or an empty commit list when cwd is not a repo either).
*/
function buildRepoGroups(
projects: ProjectSummary[],
range: DateRange,
cwd: string,
): Map<string, RepoGroup> {
const repoIdentityCache = new Map<string, RepoIdentity | null>()
// Get all commits in the date range for correlation
const cwdIdentity = resolveRepoIdentity(cwd, repoIdentityCache)
const cwdCommits = cwdIdentity
? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd))
: []
// Group sessions by canonical repository identity before attributing so that
// each commit is awarded at most once across the whole repo. Two monorepo
// subdirectory sessions, or two worktrees of one repo, resolve to the same
// git-common-dir and share ONE group; keying on the raw path would double
// count. A project whose path is missing or not a git repo falls back to the
// cwd repo (or, when cwd is not a repo either, an empty commit list) exactly
// as before.
type RepoGroup = { commits: CommitInfo[]; sessions: SessionSummary[]; projectNames: string[] }
const repoGroups = new Map<string, RepoGroup>()
for (const project of projects) {
const projectIdentity = project.projectPath
@ -342,15 +438,35 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
: getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)),
sessions: [],
projectNames: [],
ownIdentity: [],
gitDir: identity?.gitDir ?? null,
}
repoGroups.set(groupKey, group)
}
for (const session of project.sessions) {
group.sessions.push(session)
group.projectNames.push(project.project)
group.ownIdentity.push(projectIdentity !== null)
}
}
return repoGroups
}
export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise<YieldSummary> {
const projects = await parseAllSessions(range, provider)
const summary: YieldSummary = {
productive: { cost: 0, sessions: 0 },
reverted: { cost: 0, sessions: 0 },
abandoned: { cost: 0, sessions: 0 },
ambiguous: { cost: 0, sessions: 0 },
total: { cost: 0, sessions: 0 },
details: [],
}
const repoGroups = buildRepoGroups(projects, range, cwd)
for (const group of repoGroups.values()) {
const attributions = attributeCommits(group.sessions, group.commits)
for (const [index, session] of group.sessions.entries()) {
@ -446,3 +562,160 @@ export function buildYieldJsonReport(
})),
}
}
// --- Sync attribution (codeburn sync push --attribution) ---
export type CommitAttribution = {
sha: string
/** Commit author time, ISO 8601. */
timestamp: string
inMain: boolean
wasReverted: boolean
}
/**
* Per-session git attribution record the sync-facing projection of the
* yield timestamp-window correlation. One record per session that produced
* server-joinable evidence: attributed commits (requires a normalized remote)
* and/or PR links.
*/
export type SessionAttributionRecord = {
sessionId: string
project: string
/** Normalized origin remote (`host/org/repo`), the server-side join key. Null when only prLinks are available. */
repo: string | null
/** GitHub PR URLs captured for the session (already normalized upstream). */
prLinks: string[]
/** Commits attributed to this session. Empty when repo is null (SHAs without a repo identity cannot be joined). */
commits: CommitAttribution[]
/** Session window, ISO 8601 — lets the receiver reason about attribution recency. */
firstTimestamp: string
lastTimestamp: string
}
/** Max PR links retained per session attribution record. */
export const MAX_PR_LINKS_PER_SESSION = 20
/**
* Shape-check PR links before they leave the machine. Upstream parsers only
* verify truthiness, so arbitrary strings can land in `session.prLinks`.
* Keep only https URLs shaped like a PR (`/org/repo/pull/N` GitHub and
* GitHub Enterprise), bounded in length, capped per session, sorted.
*
* Links are REBUILT from `origin + pathname`, never passed through verbatim:
* userinfo (`https://alice:token@…`), query strings (copy-pasted GitHub
* links routinely carry `?notification_referrer_id=…`), and fragments are
* all dropped. Rebuilt links that collapse to the same URL dedupe.
*/
export function sanitizePrLinks(links: string[]): string[] {
const valid = new Set<string>()
for (const link of links) {
if (typeof link !== 'string' || link.length === 0 || link.length > 512) continue
let url: URL
try {
url = new URL(link)
} catch {
continue
}
if (url.protocol !== 'https:') continue
if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue
const rebuilt = `${url.origin}${url.pathname}`
if (rebuilt.length > 256) continue
valid.add(rebuilt)
}
return [...valid].sort().slice(0, MAX_PR_LINKS_PER_SESSION)
}
/**
* Compute per-session attribution records for sync. Reuses the exact yield
* repo grouping + tightest-window commit attribution (`methodology:
* timestamp-window`), then joins in each repo group's normalized origin
* remote and the session's sanitized PR links.
*
* Inclusion rules:
* - Only sessions whose OWN project path resolved to a repo participate in
* commit attribution; cwd-fallback sessions never carry a repo or commits
* (privacy gate see below) but still emit a record when they have PR links.
* - Commits require a normalized remote; a SHA without a repo identity has
* no server-side meaning.
* - A session with no commits and no PR links is emitted ONLY when it lost a
* commit to a tighter-window session in THIS computation (`lostCandidacy`)
* a retraction candidate. A session that merely aged its commits out of
* the range lost them to nobody, and emitting an empty record for it would
* permanently retract a still-correct server-side count.
*
* Takes already-parsed projects (sync push has them in hand) instead of
* re-parsing like computeYield does.
*/
export function computeAttributionRecords(
projects: ProjectSummary[],
range: DateRange,
cwd: string,
): SessionAttributionRecord[] {
const repoGroups = buildRepoGroups(projects, range, cwd)
const records: SessionAttributionRecord[] = []
for (const group of repoGroups.values()) {
const remote = group.gitDir ? getRepoRemote(group.gitDir) : null
// Privacy gate: only sessions whose identity came from their OWN project
// path participate in commit attribution. A session whose project path no
// longer resolves (deleted/renamed dir, non-repo session) inherits the
// cwd-fallback identity in buildRepoGroups — attributing it here would
// egress whatever repo the user happens to be pushing from, with commits
// that session never touched. Fallback sessions get no repo and no
// commits; they still emit a record when they carry PR links (which are
// session-native and safe). Excluding them from the competition also
// prevents a fallback window from stealing a commit that belongs to a
// genuine session.
const ownSessions = group.sessions.filter((_, i) => group.ownIdentity[i])
const attributions = attributeCommits(ownSessions, group.commits)
// Keyed by object reference: session objects are unique per group entry,
// whereas sessionId strings could collide across projects.
const attributionBySession = new Map<SessionSummary, CommitInfo[]>()
const lostCandidacyBySession = new Map<SessionSummary, boolean>()
for (const [i, session] of ownSessions.entries()) {
attributionBySession.set(session, attributions[i]?.commits ?? [])
lostCandidacyBySession.set(session, attributions[i]?.lostCandidacy ?? false)
}
for (const [index, session] of group.sessions.entries()) {
if (!session.firstTimestamp) continue
const isOwn = group.ownIdentity[index] === true
const sessionRemote = isOwn ? remote : null
const attributedCommits = sessionRemote
? (attributionBySession.get(session) ?? [])
: []
const prLinks = sanitizePrLinks(session.prLinks ?? [])
// Empty sessions are retraction candidates ONLY when they lost a commit
// to a tighter-window session in THIS run: that commit's server-side
// attribution is migrating, so the loser must re-emit commit_count=0.
// An empty session whose commits merely aged out of the --since range
// (rolling window, or a narrower window than a previous push) lost them
// to NOBODY — emitting a retraction for it would permanently zero a
// still-correct server-side count, because the original state key stays
// ledgered and is never re-sent.
const lostToTighterSession = sessionRemote !== null &&
(lostCandidacyBySession.get(session) ?? false)
if (attributedCommits.length === 0 && prLinks.length === 0 && !lostToTighterSession) continue
records.push({
sessionId: session.sessionId,
project: group.projectNames[index] ?? session.project,
repo: sessionRemote,
prLinks,
commits: attributedCommits.map(c => ({
sha: c.sha,
timestamp: c.timestamp.toISOString(),
inMain: c.inMain,
wasReverted: c.wasReverted,
})),
firstTimestamp: session.firstTimestamp,
lastTimestamp: session.lastTimestamp ?? session.firstTimestamp,
})
}
}
return records
}

View file

@ -0,0 +1,121 @@
import { spawn, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js'
// Recovering a corrupt session-refresh.lock must never cost the two design
// commitments the lock exists for: it may not fail open into mutation, and it
// may not be stolen from a live heartbeating owner. An earlier fix waived the
// staleness gate for a corrupt body once the contender's wait expired, which
// handed two processes the lock at the same time in both directions below.
// Corruption is recovered only through the unmodified staleness gate.
const roots: string[] = []
afterEach(async () => {
while (roots.length) await rm(roots.pop()!, { recursive: true, force: true })
})
async function tempCase(prefix: string): Promise<{ cacheDir: string; barriers: string; lockPath: string }> {
const root = await mkdtemp(join(tmpdir(), prefix))
roots.push(root)
const cacheDir = join(root, 'cache')
const barriers = join(root, 'barriers')
await mkdir(cacheDir, { recursive: true })
await mkdir(barriers, { recursive: true })
return { cacheDir, barriers, lockPath: join(cacheDir, 'session-refresh.lock') }
}
function spawnFixture(fixture: string, args: string[], env: NodeJS.ProcessEnv = {}): ChildProcess {
return spawn(process.execPath, ['--import', 'tsx', join(process.cwd(), 'tests/fixtures', fixture), ...args], {
cwd: process.cwd(),
stdio: ['ignore', 'ignore', 'inherit'],
env: { ...process.env, ...env },
})
}
async function waitForFile(path: string, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!existsSync(path)) {
if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`)
await new Promise(resolve => { setTimeout(resolve, 5) })
}
}
const exited = (child: ChildProcess): Promise<unknown> => new Promise(resolve => child.once('exit', resolve))
describe('warm refresh lock: a corrupt body never displaces a live owner', () => {
it('leaves the zero-byte lock alone while its creator is still inside createExclusive', async () => {
const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-race-')
// UV_THREADPOOL_SIZE=1 plus the fixture's pbkdf2 churn stretches the gap
// between open(path,'wx') and the awaited body write, so the lock is
// genuinely observable at zero bytes with no external corruption at all.
const owner = spawnFixture('cache-refresh-slow-owner.ts', [cacheDir, barriers], { UV_THREADPOOL_SIZE: '1' })
try {
const deadline = Date.now() + 20_000
let sawZeroByteLock = false
while (Date.now() < deadline) {
const info = await stat(lockPath).catch(() => null)
if (info) { sawZeroByteLock = info.size === 0; break }
await new Promise(resolve => { setTimeout(resolve, 1) })
}
expect(sawZeroByteLock, 'never caught the owner mid-createExclusive').toBe(true)
const contender = await acquireCacheRefreshLock({ cacheDir, waitMs: 40, pollMs: 5, staleMs: 90_000 })
if (contender.outcome === 'acquired') await contender.handle.release()
expect(contender.outcome).toBe('timed-out')
} finally {
await exited(owner)
}
// The owner kept the lock end to end, so its publication fence held.
expect(existsSync(join(barriers, 'owner.acquired'))).toBe(true)
expect(existsSync(join(barriers, 'owner.verify.true')), 'owner lost its own lock').toBe(true)
}, 60_000)
it('leaves a live owner alone after its body is truncated, however long the wait', async () => {
const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-live-')
const owner = spawnFixture('cache-refresh-corrupt-owner.ts', [cacheDir, barriers, '4000', '100'])
try {
await waitForFile(join(barriers, 'owner.acquired'))
const ownerToken = await readFile(join(barriers, 'owner.acquired'), 'utf-8')
// The state a heartbeat leaves when writeFile() truncates and then fails
// (ENOSPC/EIO, swallowed by the heartbeat's own catch). No guard is held,
// and the body carries no token to compare against.
await writeFile(lockPath, '')
// This wait is shorter than one heartbeat period, so the body is still
// truncated throughout: the contender has nothing but the fresh mtime to
// go on, and that alone must keep it out.
const early = await acquireCacheRefreshLock({ cacheDir, waitMs: 80, pollMs: 5, staleMs: 90_000 })
if (early.outcome === 'acquired') await early.handle.release()
expect(early.outcome).toBe('timed-out')
// Past the age gate, the successor SHOULD win. Only the heartbeat
// advances mtime and it refuses to rewrite a body it cannot prove is
// ours, so a corrupt body freezes its own mtime and ages out. That is the
// intended end state, not a displacement to be prevented: an owner that
// cannot prove ownership must not publish, and the alternative — letting
// the heartbeat restamp its token over an unparseable body — resurrected
// legitimately-replaced writers and let release() delete a live
// successor's lock.
await writeFile(lockPath, '')
const late = await acquireCacheRefreshLock({ cacheDir, waitMs: 2_400, pollMs: 10, staleMs: 400 })
expect(late.outcome).toBe('acquired')
if (late.outcome === 'acquired') {
// The successor owns it outright: the body carries its token, not the
// original owner's.
expect(JSON.parse(await readFile(lockPath, 'utf-8')).token).not.toBe(ownerToken)
await late.handle.release()
}
} finally {
await exited(owner)
}
// The displaced owner's fence must refuse. Discarding its parse is the
// fail-safe direction; two writers believing they own the lock is not.
expect(existsSync(join(barriers, 'owner.verify.false')), 'displaced owner still passed its fence').toBe(true)
expect(existsSync(join(barriers, 'owner.verify.true'))).toBe(false)
}, 30_000)
})

View file

@ -85,6 +85,41 @@ describe('warm refresh child-process regression', () => {
await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it('gives exactly one contender ownership of a stale zero-byte lock', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-corrupt-'))
roots.push(root)
const cacheDir = join(root, 'cache')
const barriers = join(root, 'barriers')
await mkdir(cacheDir, { recursive: true })
await mkdir(barriers, { recursive: true })
process.env['CODEBURN_CACHE_DIR'] = cacheDir
const initial = emptyCache()
initial.complete = true
await saveCache(initial)
const corruptPath = join(cacheDir, 'session-refresh.lock')
await writeFile(corruptPath, '')
await utimes(corruptPath, new Date(1), new Date(1))
const source = join(root, 'changed.json')
await writeFile(source, JSON.stringify({ output: 404 }))
const a = worker(cacheDir, barriers, 'a', source)
const b = worker(cacheDir, barriers, 'b', source)
const winner = await Promise.race([
waitFor(join(barriers, 'a.parsed')).then(() => 'a'),
waitFor(join(barriers, 'b.parsed')).then(() => 'b'),
])
const loser = winner === 'a' ? 'b' : 'a'
expect(Number(existsSync(join(barriers, 'a.parsed'))) + Number(existsSync(join(barriers, 'b.parsed')))).toBe(1)
const loserOutcome = await waitForAny(barriers, [
`${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`,
])
expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`)
await writeFile(join(barriers, `${winner}.save`), '')
await Promise.all([waitForExit(a), waitForExit(b)])
await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(Object.keys((await loadCache()).providers['regression']?.files ?? {})).toEqual([source])
})
it('serializes disjoint parsed updates so the later publication cannot drop the first', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-process-'))
roots.push(root)

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises'
import { chmod, mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
@ -7,6 +7,7 @@ import {
acquireCacheRefreshLock,
type RefreshLockClock,
} from '../src/cache-refresh-lock.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { emptyCache, loadCache, saveCache, sessionCachePath } from '../src/session-cache.js'
const dirs: string[] = []
@ -223,3 +224,225 @@ describe('warm session-cache refresh lock', () => {
await rm(dir, { recursive: true, force: true })
})
})
// A lock body that never parses into a record is a corrupt leftover, not an
// unusable filesystem: classifying it as 'unavailable' routed every subsequent
// refresh to the read-only path and froze ingestion permanently.
describe('warm session-cache refresh lock: corrupt lock recovery', () => {
it('takes over a stale zero-byte lock', async () => {
const dir = await tempDir()
const clock = fakeClock(100_000)
const path = lockPath(dir)
await writeFile(path, '')
const old = new Date(1)
await utimes(path, old, old)
const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 })
expect(result.outcome).toBe('acquired')
if (result.outcome !== 'acquired') return
expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token)
await result.handle.release()
await expect(stat(join(dir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it('takes over a stale malformed-JSON lock', async () => {
const dir = await tempDir()
const clock = fakeClock(100_000)
const path = lockPath(dir)
await writeFile(path, '{"pid":1,"token":')
const old = new Date(1)
await utimes(path, old, old)
const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 })
expect(result.outcome).toBe('acquired')
if (result.outcome !== 'acquired') return
expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token)
await result.handle.release()
})
it('takes over a stale lock whose body parses but has the wrong shape', async () => {
const dir = await tempDir()
const clock = fakeClock(100_000)
const path = lockPath(dir)
await writeFile(path, JSON.stringify({ pid: 'one', token: 7 }))
const old = new Date(1)
await utimes(path, old, old)
const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 })
expect(result.outcome).toBe('acquired')
if (result.outcome === 'acquired') await result.handle.release()
})
// Staleness is never waived for a corrupt body, so a FRESH one is waited out
// and left alone: it may belong to a live owner whose heartbeat is about to
// repair it. The resulting freeze is bounded by staleMs rather than
// permanent, which is the whole of the reported defect.
it('waits out a fresh malformed lock, then recovers it once it ages past staleMs', async () => {
const dir = await tempDir()
const clock = fakeClock(100_000)
const path = lockPath(dir)
await writeFile(path, '')
const now = new Date(clock.wallNow())
await utimes(path, now, now)
let polls = 0
const fresh = await acquireCacheRefreshLock({
cacheDir: dir,
clock,
staleMs: 90_000,
waitMs: 50,
pollMs: 10,
sleep: async ms => { polls++; clock.advance(ms) },
})
expect(polls).toBeGreaterThan(0)
expect(fresh).toEqual({ outcome: 'timed-out' })
expect(await readFile(path, 'utf-8')).toBe('')
// Nothing rewrote the body, so its mtime is still frozen. One stale window
// later the very next run recovers it through the unmodified age gate.
clock.advance(90_001)
const later = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90_000, waitMs: 50, pollMs: 10 })
expect(later.outcome).toBe('acquired')
if (later.outcome !== 'acquired') return
expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(later.handle.token)
await later.handle.release()
})
it('never steals a fresh malformed lock from an owner that repairs it mid-wait', async () => {
const dir = await tempDir()
const clock = fakeClock(100_000)
const path = lockPath(dir)
await writeFile(path, '')
const now = new Date(clock.wallNow())
await utimes(path, now, now)
let polls = 0
const result = await acquireCacheRefreshLock({
cacheDir: dir,
clock,
staleMs: 90_000,
waitMs: 50,
pollMs: 10,
sleep: async ms => {
if (++polls === 1) {
await writeFile(path, JSON.stringify({ pid: 1, token: 'holder', at: clock.wallNow() }))
const t = new Date(clock.wallNow())
await utimes(path, t, t)
}
clock.advance(ms)
},
})
expect(result).toEqual({ outcome: 'timed-out' })
expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder')
})
it('treats a body truncated mid-heartbeat as contention, not corruption', async () => {
const dir = await tempDir()
const path = lockPath(dir)
const record = (): string => JSON.stringify({ pid: 1, token: 'holder', at: Date.now() })
await writeFile(path, record())
// A real heartbeat rewrite exposes a zero-length body for an instant. The
// waiter must keep polling and leave the live owner alone.
const heartbeat = setInterval(() => {
void (async () => {
try {
await writeFile(path, '')
await writeFile(path, record())
} catch { /* the winner may have replaced the file */ }
})()
}, 1)
let result
try {
result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 30, waitMs: 120, pollMs: 5 })
} finally {
clearInterval(heartbeat)
}
await new Promise(resolve => { setTimeout(resolve, 20) })
expect(result).toEqual({ outcome: 'timed-out' })
expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder')
})
// The sidecar is created by the same createExclusive as the lock, so it can be
// left 0-byte by exactly the same crash. Before observe() separated corrupt
// from unreadable this returned 'unavailable' from acquireTakeoverGuard and
// froze recovery even when the primary lock was perfectly fine.
it('reclaims a stale zero-byte takeover sidecar', async () => {
const dir = await tempDir()
const path = lockPath(dir)
const sidecar = join(dir, 'session-refresh.lock.takeover')
await writeFile(path, '')
await writeFile(sidecar, '')
const old = new Date(1)
await utimes(path, old, old)
await utimes(sidecar, old, old)
const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 90, waitMs: 200, pollMs: 5 })
expect(result.outcome).toBe('acquired')
if (result.outcome !== 'acquired') return
expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token)
await result.handle.release()
await expect(stat(sidecar)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('still reports unavailable when the lock body cannot be read', async () => {
if (process.getuid?.() === 0) return
const dir = await tempDir()
const path = lockPath(dir)
await writeFile(path, '')
await chmod(path, 0o000)
const old = new Date(1)
await utimes(path, old, old)
try {
const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 1, waitMs: 50, pollMs: 5 })
expect(result).toEqual({ outcome: 'unavailable' })
} finally {
await chmod(path, 0o600)
}
})
it('resumes ingesting changed sources after a corrupt lock froze the refresh', async () => {
const root = await tempDir()
const cacheDir = join(root, 'cache')
const config = join(root, 'claude')
const projectDir = join(config, 'projects', 'frozen-proj')
await mkdir(cacheDir, { recursive: true })
await mkdir(projectDir, { recursive: true })
process.env['CODEBURN_CACHE_DIR'] = cacheDir
process.env['CLAUDE_CONFIG_DIR'] = config
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'desktop-sessions')
const session = (id: string, ts: string): string => [
JSON.stringify({ type: 'user', sessionId: id, timestamp: ts, cwd: '/tmp/frozen-proj', message: { role: 'user', content: 'hi' } }),
JSON.stringify({
type: 'assistant', sessionId: id, timestamp: ts, cwd: '/tmp/frozen-proj',
message: { id: `msg-${id}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: 20 } },
}),
].join('\n') + '\n'
await writeFile(join(projectDir, 'sess-1.jsonl'), session('sess-1', '2026-05-01T10:00:00.000Z'))
clearSessionCache()
const warm = await parseAllSessions()
expect(warm[0]?.sessions.map(s => s.sessionId)).toEqual(['sess-1'])
// The field state: a 0-byte lock AND the takeover sidecar of the dead owner
// that was mid-recovery when it died, both older than the stale window.
// Nothing else on the machine ever repairs either file, so on every later
// run the lock read as 'unavailable', the parser fell back to a read-only
// re-parse, and sess-2 was never ingested.
const old = new Date(1)
for (const name of ['session-refresh.lock', 'session-refresh.lock.takeover']) {
await writeFile(join(cacheDir, name), name.endsWith('.takeover') ? JSON.stringify({ pid: 999_999, token: 'dead-owner', at: 1 }) : '')
await utimes(join(cacheDir, name), old, old)
}
await writeFile(join(projectDir, 'sess-2.jsonl'), session('sess-2', '2026-05-01T11:00:00.000Z'))
clearSessionCache()
const after = await parseAllSessions()
expect(after[0]?.sessions.map(s => s.sessionId).sort()).toEqual(['sess-1', 'sess-2'])
expect(Object.keys((await loadCache()).providers['claude']?.files ?? {}).length).toBe(2)
clearSessionCache()
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
})
})

View file

@ -0,0 +1,46 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const homes: string[] = []
afterEach(async () => {
while (homes.length) await rm(homes.pop()!, { recursive: true, force: true })
})
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})
}
describe('codex-tps CLI validation', () => {
it('rejects sub-second watch intervals', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
homes.push(home)
const result = runCli(['codex-tps', '--watch', '0.1'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('watch must be 0 or at least 1 second')
})
it('rejects JSON watch output instead of concatenating invalid JSON documents', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
homes.push(home)
const result = runCli(['codex-tps', '--json', '--watch', '1'], home)
expect(result.status).toBe(2)
expect(result.stderr).toContain('--json cannot be combined with --watch')
})
it('returns a nonzero status for a missing explicit rollout', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
homes.push(home)
const result = runCli(['codex-tps', join(home, 'missing.jsonl')], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('session file not found')
})
})

View file

@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
@ -13,7 +13,7 @@ import {
buildPeriodData,
getDailyCacheConfigHash,
} from '../src/usage-aggregator.js'
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from '../src/parser.js'
import { renderOverview } from '../src/overview.js'
import type { DateRange } from '../src/types.js'
@ -28,7 +28,7 @@ import type { DateRange } from '../src/types.js'
// queries, and in the plain live regime with no carried days at all.
const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME', 'USERPROFILE', 'KIMI_CODE_HOME', 'CODEBURN_DESKTOP_SESSIONS_DIR'] as const
let savedEnv: Record<string, string | undefined>
const CARRIED_COST = 100
@ -120,11 +120,22 @@ beforeEach(async () => {
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
await mkdir(join(ROOT, 'cache'), { recursive: true })
await mkdir(join(ROOT, 'no-desktop-sessions'), { recursive: true })
await mkdir(join(ROOT, 'no-kimi-home'), { recursive: true })
process.env['HOME'] = join(ROOT, 'home')
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEX_HOME']
// Keep real provider data on the machine out of every parse: absolute-count
// assertions are meaningless when the host's own sessions leak in.
// USERPROFILE matters on Windows, where os.homedir() ignores HOME;
// KIMI_CODE_HOME / the desktop-sessions override redirect the two env-aware
// discovery roots. (The codex provider captures its home at import time, so
// it is redirected separately in vi.hoisted below.)
process.env['USERPROFILE'] = join(ROOT, 'home')
process.env['KIMI_CODE_HOME'] = join(ROOT, 'no-kimi-home')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(ROOT, 'no-desktop-sessions')
clearSessionCache()
})
@ -259,3 +270,156 @@ describe('terminal overview carried-day footnote', () => {
expect(noCarried).not.toContain('preserved from expired session logs')
})
})
// Issue #852 review: per-call slicing is only conservation-correct when day
// bucketing attributes call-derived values to each call's own day. These
// tests pin the straddling-turn case the review reproduced end-to-end through
// buildDurablePeriod: a turn starting the previous day at 23:57 with one call
// before and one after local midnight must keep BOTH calls across a multi-day
// period (cache ≤ yesterday + live today union), each on its own day.
//
// The codex provider captures CODEX_HOME when its module is first imported,
// so the redirect must happen before module evaluation (vi.hoisted) rather
// than in beforeEach. The captured dir is per-test-process and empty except
// for the fixture written below, which also shields the suite from any real
// ~/.codex on the machine running it.
const CODEX_ROOT = vi.hoisted(() => {
const root = `${process.env['TMPDIR'] || '/tmp'}/codeburn-straddle-codex-${process.pid}-${Date.now()}`
process.env['CODEX_HOME'] = `${root}/codex`
return root
})
describe('midnight-straddling turn conservation (issue #852)', () => {
// Day N = 2026-07-27, day N+1 ("today") = 2026-07-28 — LOCAL dates built
// from constructor args so the case is machine-TZ independent (dateKey /
// toDateString use the same local getters).
const NOW = new Date(2026, 6, 28, 12, 0, 0)
const DAY_N = '2026-07-27'
const DAY_N1 = '2026-07-28'
function fakeNow(): void {
// Date only: the daily-cache lock and retry helpers must keep real timers.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
}
beforeEach(async () => {
await rm(CODEX_ROOT, { recursive: true, force: true })
})
afterEach(async () => {
await rm(CODEX_ROOT, { recursive: true, force: true })
})
// One codex turn (the issue's provider) with a token_count call on each
// side of local midnight: 23:58 (input 1000/output 200), 00:10 (2000/400).
async function seedStraddlingCodexTurn(): Promise<void> {
const sessionDir = join(CODEX_ROOT, 'codex', 'sessions', '2026', '07', '27')
await mkdir(sessionDir, { recursive: true })
const line = (obj: unknown): string => JSON.stringify(obj)
await writeFile(join(sessionDir, 'rollout-straddle.jsonl'), [
line({ type: 'session_meta', timestamp: new Date(2026, 6, 27, 23, 55, 0).toISOString(), payload: { session_id: 'sess-straddle', model: 'gpt-5.5', cwd: '/tmp/straddle-proj', originator: 'codex_cli_rs' } }),
line({ type: 'response_item', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'work through midnight' }] } }),
line({ type: 'event_msg', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 1000, output_tokens: 200 }, total_token_usage: { total_tokens: 1200 } } } }),
line({ type: 'event_msg', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 2000, output_tokens: 400 }, total_token_usage: { total_tokens: 3600 } } } }),
].join('\n') + '\n', 'utf-8')
}
// The same straddle through the Claude Code path (scanProjectDirs).
async function seedStraddlingClaudeTurn(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'straddle-proj')
await mkdir(projectDir, { recursive: true })
const line = (obj: unknown): string => JSON.stringify(obj)
await writeFile(join(projectDir, 's-straddle.jsonl'), [
line({ type: 'user', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { role: 'user', content: 'work through midnight' } }),
line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm2', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 2000, output_tokens: 400, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
].join('\n') + '\n', 'utf-8')
}
// The two calls' exact costs from an unfiltered parse (pricing-agnostic truth).
async function truthCosts(provider: string): Promise<[number, number]> {
clearSessionCache()
const projects = await parseAllSessions(undefined, provider)
const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
expect(calls).toHaveLength(2)
return [calls[0]!.costUSD, calls[1]!.costUSD]
}
it('keeps day-N + day-N+1 equal to the whole-range totals through buildDurablePeriod', async () => {
fakeNow()
try {
await seedStraddlingCodexTurn()
const [costN, costN1] = await truthCosts('codex')
expect(costN + costN1).toBeGreaterThan(0)
const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() }
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' })
const dayN = durable.days.find(d => d.date === DAY_N)
const dayN1 = durable.days.find(d => d.date === DAY_N1)
// Each side of the turn lands on its own day...
expect(dayN?.calls).toBe(1)
expect(dayN1?.calls).toBe(1)
expect(dayN!.cost).toBeCloseTo(costN, 8)
expect(dayN1!.cost).toBeCloseTo(costN1, 8)
// ...and the two sides conserve the whole-range totals (the review's
// week/month leak returned 1 call and only the pre-midnight cost).
expect(durable.data.calls).toBe(2)
expect(dayN!.calls + dayN1!.calls).toBe(durable.data.calls)
expect(durable.data.cost).toBeCloseTo(costN + costN1, 8)
expect(dayN!.cost + dayN1!.cost).toBeCloseTo(durable.data.cost, 8)
expect(durable.data.inputTokens).toBe(3000)
expect(durable.data.outputTokens).toBe(600)
expect(dayN!.inputTokens + dayN1!.inputTokens).toBe(durable.data.inputTokens)
expect(dayN!.outputTokens + dayN1!.outputTokens).toBe(durable.data.outputTokens)
} finally {
vi.useRealTimers()
}
}, 60_000)
it('shows the post-midnight call in the today-only view on the Claude Code path', async () => {
fakeNow()
try {
await seedStraddlingClaudeTurn()
const [, costN1] = await truthCosts('claude')
clearSessionCache()
const durable = await buildDurablePeriod({ range: getDateRange('today').range, label: 'today' }, { provider: 'all' })
expect(durable.data.calls).toBe(1)
expect(durable.data.cost).toBeCloseTo(costN1, 8)
expect(durable.data.inputTokens).toBe(2000)
expect(durable.data.outputTokens).toBe(400)
} finally {
vi.useRealTimers()
}
}, 60_000)
it('slices the straddling turn per call in the dashboard/menubar surface filters', async () => {
fakeNow()
try {
await seedStraddlingClaudeTurn()
clearSessionCache()
const all = await parseAllSessions(undefined, 'claude')
const callsOf = (ps: typeof all) => ps.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
const inputOf = (ps: typeof all) => ps.flatMap(p => p.sessions).reduce((s, sess) => s + sess.totalInputTokens, 0)
// Dashboard Today/7-Days narrowing over an unfiltered parse.
const dashToday = filterProjectsByDateRange(all, getDateRange('today').range)
expect(callsOf(dashToday)).toHaveLength(1)
expect(inputOf(dashToday)).toBe(2000)
// Menubar/history day selection, on each side of midnight.
const menubarToday = filterProjectsByDays(all, new Set([DAY_N1]))
expect(callsOf(menubarToday)).toHaveLength(1)
expect(inputOf(menubarToday)).toBe(2000)
const menubarYesterday = filterProjectsByDays(all, new Set([DAY_N]))
expect(callsOf(menubarYesterday)).toHaveLength(1)
expect(inputOf(menubarYesterday)).toBe(1000)
} finally {
vi.useRealTimers()
}
}, 60_000)
})

View file

@ -0,0 +1,124 @@
import { appendFile, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { CodexThroughputReader, readCodexThroughput, renderCodexThroughput } from '../src/codex-throughput.js'
describe('Codex throughput prototype', () => {
it('estimates generated tokens/sec between token_count checkpoints', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-'))
const path = join(dir, 'rollout.jsonl')
await writeFile(path, [
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:02.000Z', payload: { type: 'function_call', call_id: 'tool-1' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'function_call_output', call_id: 'tool-1' } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'mcp_tool_call_end', duration: { secs: 3, nanos: 0 } } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 80, reasoning_output_tokens: 20 }, total_token_usage: { total_tokens: 100, output_tokens: 80, reasoning_output_tokens: 20 } } } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:15.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 40, reasoning_output_tokens: 10 }, total_token_usage: { total_tokens: 150, output_tokens: 120, reasoning_output_tokens: 30 } } } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:16.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
].join('\n'))
const points = await readCodexThroughput(path)
expect(points).toHaveLength(2)
expect(points[1]).toMatchObject({ generatedTokens: 50, elapsedSeconds: 5, generatedTokensPerSecond: 10, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 21.428571428571427, toolWaitSeconds: 3, model: 'gpt-5.6-sol' })
expect(renderCodexThroughput(points, path)).toContain('21.4 generated tokens/sec')
})
it('parses only appended complete lines while watching a growing rollout', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-watch-'))
const path = join(dir, 'rollout.jsonl')
const first = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 8, reasoning_output_tokens: 2 }, total_token_usage: { total_tokens: 10, output_tokens: 8, reasoning_output_tokens: 2 } } } })
const second = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:01.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 4, reasoning_output_tokens: 1 }, total_token_usage: { total_tokens: 15, output_tokens: 12, reasoning_output_tokens: 3 } } } })
await writeFile(path, first.slice(0, 40))
const reader = new CodexThroughputReader()
expect(await reader.update(path)).toEqual([])
await appendFile(path, first.slice(40) + '\n' + second + '\n')
const points = await reader.update(path)
expect(points).toHaveLength(2)
expect(points[1]).toMatchObject({ generatedTokens: 5, generatedTokensPerSecond: 5 })
})
it('ignores replayed pre-fork checkpoints before estimating new work', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-fork-'))
const path = join(dir, 'rollout.jsonl')
const line = (timestamp: string, payload: Record<string, unknown>) => JSON.stringify({ type: 'event_msg', timestamp, payload })
await writeFile(path, [
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol', forked_from_id: 'parent' } }),
line('2026-07-25T00:00:01.000Z', { type: 'task_started' }),
line('2026-07-25T00:00:02.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } }),
line('2026-07-25T00:00:03.000Z', { type: 'task_complete', duration_ms: 1000 }),
line('2026-07-25T00:00:06.000Z', { type: 'task_started' }),
line('2026-07-25T00:00:08.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 20 }, total_token_usage: { total_tokens: 20, output_tokens: 20 } } }),
line('2026-07-25T00:00:10.000Z', { type: 'task_complete', duration_ms: 4000 }),
].join('\n'))
const points = await readCodexThroughput(path)
expect(points).toHaveLength(1)
expect(points[0]).toMatchObject({ generatedTokens: 20, activeGeneratedTokensPerSecond: 5 })
})
it('keeps oversized rollout lines bounded while extracting token usage', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-large-'))
const path = join(dir, 'rollout.jsonl')
const largeResult = JSON.stringify({
type: 'event_msg',
timestamp: '2026-07-25T00:00:01.000Z',
payload: {
type: 'token_count',
info: { last_token_usage: { output_tokens: 12 }, total_token_usage: { total_tokens: 12, output_tokens: 12 } },
result: 'x'.repeat(5 * 1024 * 1024),
},
})
await writeFile(path, largeResult)
const points = await readCodexThroughput(path)
expect(points).toHaveLength(1)
expect(points[0]?.generatedTokens).toBe(12)
})
it('keeps MCP duration when arguments and result surround the middle field', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-large-'))
const path = join(dir, 'rollout.jsonl')
const mcp = JSON.stringify({
type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
payload: {
type: 'mcp_tool_call_end',
invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
duration: { secs: 3, nanos: 0 },
result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
},
})
await writeFile(path, [
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
mcp,
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
].join('\n'))
const points = await readCodexThroughput(path)
expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
})
it('keeps a streamed string MCP duration when arguments and result surround the middle field', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-string-large-'))
const path = join(dir, 'rollout.jsonl')
const mcp = JSON.stringify({
type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
payload: {
type: 'mcp_tool_call_end',
invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
duration: '3s',
result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
},
})
await writeFile(path, [
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
mcp,
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
].join('\n'))
const points = await readCodexThroughput(path)
expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
})
})

View file

@ -609,6 +609,7 @@ describe('adoption union across older cache files', () => {
lastComputedDate: daysAgoStr(1),
days: [rich],
complete: true,
watermarkTrusted: true,
}
await saveDailyCache(cache)
const loaded = await loadDailyCache()

View file

@ -0,0 +1,187 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, rm } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from '../src/types.js'
import {
DAILY_CACHE_VERSION,
type DailyCache,
type DailyEntry,
type ProviderDaySlice,
currentTzKey,
ensureCacheHydrated,
saveDailyCache,
} from '../src/daily-cache.js'
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-degraded-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
beforeEach(async () => {
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
await mkdir(TMP_CACHE_ROOT, { recursive: true })
})
afterEach(async () => {
if (existsSync(TMP_CACHE_ROOT)) {
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
}
})
function slice(cost: number, calls: number, extra: Partial<ProviderDaySlice> = {}): ProviderDaySlice {
return { cost, calls, savingsUSD: 0, ...extra }
}
function day(date: string, providers: Record<string, ProviderDaySlice>, overrides: Partial<DailyEntry> = {}): DailyEntry {
const cost = Object.values(providers).reduce((s, p) => s + p.cost, 0)
const calls = Object.values(providers).reduce((s, p) => s + p.calls, 0)
return {
date,
cost,
savingsUSD: 0,
calls,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {},
categories: {},
providers,
...overrides,
}
}
function daysAgoStr(n: number): string {
const d = new Date()
d.setDate(d.getDate() - n)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const noSessions = async (): Promise<ProjectSummary[]> => []
/// The day whose session files are long gone: it exists in the daily cache and
/// nowhere else, so every path below must still hand it back untouched.
const VANISHED = day(daysAgoStr(40), { claude: slice(399.70, 1572) }, { carried: true })
async function seed(overrides: Partial<DailyCache> = {}): Promise<void> {
await saveDailyCache({
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(4),
days: [VANISHED, day(daysAgoStr(4), { claude: slice(120, 900) })],
complete: true,
...overrides,
})
}
/** The vanished-sources day is still there, with its original accounting. */
function expectPreserved(cache: DailyCache): void {
const kept = cache.days.find(d => d.date === VANISHED.date)
expect(kept).toMatchObject({ cost: 399.70, calls: 1572 })
expect(kept!.providers['claude']!.cost).toBe(399.70)
}
describe('daily cache: a degraded session parse never finalizes history', () => {
it('does not publish complete, and does not advance the watermark past what it covered', async () => {
await seed()
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
// The parse covered nothing it can vouch for, so the watermark stays put:
// advancing it to yesterday would put the missed days behind gapStart
// (lastComputedDate + 1) forever.
expect(out.lastComputedDate).toBe(daysAgoStr(4))
expect(out.complete).toBe(false)
expectPreserved(out)
})
it('does not advance the watermark on the full re-derive path either', async () => {
await seed({ complete: false })
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
expect(out.lastComputedDate).toBe(daysAgoStr(4))
expect(out.complete).toBe(false)
expectPreserved(out)
})
it('a later healthy run rebuilds the days the degraded run missed', async () => {
await seed()
await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) }))
const healed = await ensureCacheHydrated(noSessions, () => missed, 'cfg-A', () => true)
expect(healed.days.map(d => d.date)).toEqual([
daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1),
])
expect(healed.lastComputedDate).toBe(daysAgoStr(1))
expect(healed.complete).toBe(true)
expectPreserved(healed)
})
})
describe('daily cache: a complete cache that outruns its own data is not trusted', () => {
it('re-derives the days between the newest entry and the watermark', async () => {
// The field artifact: complete: true, lastComputedDate yesterday, entries
// stopping four days earlier — written by a run that finalized off a parse
// which never covered those days.
await seed({ lastComputedDate: daysAgoStr(1) })
const ranges: DateRange[] = []
const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) }))
const out = await ensureCacheHydrated(
async (range) => { ranges.push(range); return [] },
() => missed,
'cfg-A',
() => true,
)
expect(ranges).toHaveLength(1)
expect(out.days.map(d => d.date)).toEqual([
daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1),
])
expect(out.days.find(d => d.date === daysAgoStr(2))!.cost).toBe(20)
expect(out.complete).toBe(true)
expectPreserved(out)
})
it('trusts a stamped watermark over an idle tail — no re-derive treadmill', async () => {
// Same shape as the corrupt case above (watermark past the newest populated
// day), but stamped by a COMPLETE parse: the recent days are genuinely
// empty, not a frozen hole. A degraded parse can no longer produce this
// state, so the stamp means the watermark is trustworthy and re-deriving the
// empty tail on every launch (the perf regression) must not happen.
await seed({ lastComputedDate: daysAgoStr(1), watermarkTrusted: true })
let parses = 0
const out = await ensureCacheHydrated(
async () => { parses += 1; return [] },
() => [],
'cfg-A',
() => true,
)
expect(parses).toBe(0)
expect(out.lastComputedDate).toBe(daysAgoStr(1))
expect(out.complete).toBe(true)
expectPreserved(out)
})
it('a degraded re-derivation of those days still keeps every carried day', async () => {
await seed({ lastComputedDate: daysAgoStr(1) })
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
expect(out.complete).toBe(false)
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(4)])
expectPreserved(out)
})
it('an empty cache still finalizes — no re-parse treadmill on a machine with no history', async () => {
await seed({ days: [], lastComputedDate: daysAgoStr(1) })
let parses = 0
const out = await ensureCacheHydrated(
async () => { parses += 1; return [] },
() => [],
'cfg-A',
() => true,
)
expect(parses).toBe(0)
expect(out.lastComputedDate).toBe(daysAgoStr(1))
expect(out.complete).toBe(true)
})
})

View file

@ -183,6 +183,7 @@ describe('loadDailyCache', () => {
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
complete: true,
watermarkTrusted: true,
}
await saveDailyCache(saved)
const loaded = await loadDailyCache()
@ -318,6 +319,7 @@ describe('ensureCacheHydrated', () => {
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 5, 10)],
complete: true,
watermarkTrusted: true,
}
await saveDailyCache(saved)

View file

@ -2,7 +2,7 @@ import { homedir } from 'os'
import { describe, it, expect } from 'vitest'
import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDateRange } from '../src/cli-date.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
@ -248,3 +248,25 @@ describe('showEmptyState', () => {
expect(showEmptyState(2, false, 0, false)).toBe(false)
})
})
describe('getLayout - dashboard width breakpoints', () => {
it('uses a single column at 89 columns or below', () => {
expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 })
})
it('switches to two columns at 90 columns', () => {
expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 })
})
it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => {
// Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60,
// inner=56, below the 61-col threshold where Tok/s renders.
expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 })
expect(getLayout(120).halfWidth - 4).toBeLessThan(61)
})
it('keeps two columns and has enough room for Tok/s at 130 columns', () => {
expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 })
expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61)
})
})

View file

@ -74,10 +74,12 @@ function makeSingleTurnProject(
}
describe('aggregateProjectsIntoDays', () => {
it('buckets a whole turn (all its calls) on the turn user-message date', () => {
// Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly
// on the day of its user-message timestamp — matching the live headline/
// report rollup — instead of splitting per-call across two days.
it("buckets call-derived values under each call's own date when a turn straddles midnight", () => {
// Per-call bucketing (issue #852): a turn whose calls straddle midnight
// puts each call's cost/calls/tokens on the day the call happened, so
// day-N + day-N+1 reconcile with a range parse that sliced the turn at
// the same boundary. Turn-level judgments (editTurns, category turns)
// stay anchored on the turn's day.
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
@ -116,9 +118,17 @@ describe('aggregateProjectsIntoDays', () => {
]
const days = aggregateProjectsIntoDays(projects)
expect(days.map(d => d.date)).toEqual(['2026-04-09'])
expect(days[0]!.cost).toBe(10)
expect(days[0]!.calls).toBe(2)
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
expect(days[0]!.cost).toBe(4)
expect(days[0]!.calls).toBe(1)
expect(days[1]!.cost).toBe(6)
expect(days[1]!.calls).toBe(1)
// Turn-level stats anchor on the turn's day only — they describe the
// whole exchange, not a per-call sum.
expect(days[0]!.editTurns).toBe(1)
expect(days[1]!.editTurns).toBe(0)
expect(days[0]!.categories['coding']?.turns).toBe(1)
expect(days[1]!.categories['coding']).toBeUndefined()
})
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
@ -406,17 +416,17 @@ describe('buildPeriodDataFromDays', () => {
expect(pd.models).toEqual([])
})
it('attributes a midnight-straddling turn to the user-message date, matching the live report', () => {
// A turn whose user message sits on one side of midnight and whose assistant
// response lands on the other must bucket by the USER-MESSAGE timestamp, so
// the daily cache (history.daily + provider breakdown) reconciles exactly to
// the live headline/report rollup (main.ts daily), which anchors on the same
// turn timestamp. The prior per-call bucketing split such turns and left a
// constant offset between the trend bars and current.cost.
it("attributes a midnight-straddling turn's cost to the call's own date", () => {
// A turn whose user message sits on one side of midnight and whose
// assistant response lands on the other buckets its cost under the CALL's
// day (issue #852's per-call rule), so the daily cache (history.daily +
// provider breakdown) reconciles exactly to a range parse that slices the
// same turn at the same boundary — and day-N + day-N+1 sum to the period
// total with nothing lost on either side.
const userTs = '2026-04-20T23:58:00Z'
const assistantTs = '2026-04-21T00:30:00Z'
const userLocal = new Date(userTs)
const expectedDate = `${userLocal.getFullYear()}-${String(userLocal.getMonth() + 1).padStart(2, '0')}-${String(userLocal.getDate()).padStart(2, '0')}`
const assistantLocal = new Date(assistantTs)
const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}`
const projects: ProjectSummary[] = [
makeProject({
@ -457,21 +467,22 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
// headline (main.ts daily rollup) must bucket days by the SAME rule, or their
// per-day totals drift and their period sums diverge from current.cost at
// window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both
// are now TURN-anchored: this asserts per-day equality against a reference
// that mirrors main.ts:486-499 (turn.timestamp anchor), plus the invariant
// history.daily Σ == report.daily Σ == total call cost.
// are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats
// still turn-anchored: this asserts per-day equality against a reference
// that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its
// own date), plus the invariant history.daily Σ == report.daily Σ == total
// call cost.
// Mirrors the live report/headline daily rollup in src/main.ts (bucket the
// whole turn — all its calls — on the turn's user-message date).
// Mirrors the live report/headline daily rollup fallback in src/main.ts
// (cost/savings/calls bucket under each call's own date).
function reportDailyByDate(projects: ProjectSummary[]): Record<string, number> {
const byDate: Record<string, number> = {}
for (const p of projects) {
for (const sess of p.sessions) {
for (const turn of sess.turns) {
if (turn.assistantCalls.length === 0) continue
const ts = turn.timestamp || turn.assistantCalls[0]!.timestamp
const day = dateKey(ts)
for (const call of turn.assistantCalls) byDate[day] = (byDate[day] ?? 0) + call.costUSD
for (const call of turn.assistantCalls) {
byDate[dateKey(call.timestamp)] = (byDate[dateKey(call.timestamp)] ?? 0) + call.costUSD
}
}
}
}
@ -492,8 +503,8 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight
// A midnight-straddling turn (calls on both days) plus a same-day turn, so
// per-CALL bucketing would produce DIFFERENT per-day totals than the turn-
// anchored report — the case the old code got wrong.
// whole-TURN anchoring would produce DIFFERENT per-day totals than the
// per-call rule — the case the old code got wrong.
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
@ -541,8 +552,10 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
const totalCallCost = 2 + 3 + 7
expect(historySum).toBeCloseTo(totalCallCost, 10)
expect(reportSum).toBeCloseTo(totalCallCost, 10)
// Day A owns the WHOLE straddling turn (2+3=5), not just its first call (2).
expect(historyByDate[dayA]).toBe(5)
expect(historyByDate[dayB]).toBe(7)
// Day A owns only the straddling turn's pre-midnight call (2); day B owns
// the post-midnight call plus the same-day turn (3+7=10). Both paths agree
// per day and the period total is conserved.
expect(historyByDate[dayA]).toBe(2)
expect(historyByDate[dayB]).toBe(10)
})
})

View file

@ -0,0 +1,22 @@
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js'
// A plain owner in its own process. It records its outcome, its token, and the
// result of the publication fence into the barrier directory so the parent can
// assert what the owner itself believed while a contender was racing it.
const [cacheDir, barrierDir, holdMs, heartbeatMs = '200'] = process.argv.slice(2)
if (!cacheDir || !barrierDir || !holdMs) throw new Error('missing owner argument')
const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: Number(heartbeatMs) })
if (refresh.outcome !== 'acquired') {
await writeFile(join(barrierDir, `owner.${refresh.outcome}`), '')
process.exit(0)
}
await writeFile(join(barrierDir, 'owner.acquired'), refresh.handle.token)
await new Promise(resolve => { setTimeout(resolve, Number(holdMs)) })
// The publication fence, exactly as parser.ts uses it before saving.
await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '')
await refresh.handle.release()
await writeFile(join(barrierDir, 'owner.done'), '')

View file

@ -0,0 +1,25 @@
import { pbkdf2 } from 'crypto'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js'
// Saturate the (size-1) libuv threadpool so every fs operation inside
// createExclusive queues behind a pbkdf2 round. No test hook and no patched
// module: this is what an ordinary process looks like mid cold parse, and it
// widens the window in which the lock exists at zero bytes -- between
// open(path,'wx') and the awaited body write -- to something observable.
const [cacheDir, barrierDir] = process.argv.slice(2)
if (!cacheDir || !barrierDir) throw new Error('missing owner argument')
let stop = false
const churn = (): void => { if (stop) return; pbkdf2('p', 's', 400_000, 32, 'sha512', () => churn()) }
churn()
const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: 10_000 })
stop = true
await writeFile(join(barrierDir, `owner.${refresh.outcome}`), refresh.outcome === 'acquired' ? refresh.handle.token : '')
if (refresh.outcome === 'acquired') {
await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '')
await refresh.handle.release()
}

View file

@ -34,6 +34,8 @@ export interface MockIdp {
revokedTokens: string[]
/** Authorization codes that have been exchanged */
exchangedCodes: string[]
/** OTLP trace batches received at POST /v1/traces */
tracesRequests: Array<{ auth: string | undefined; body: unknown }>
}
export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp> {
@ -52,6 +54,7 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp>
issuedTokens: { access: [], refresh: [] },
revokedTokens: [],
exchangedCodes: [],
tracesRequests: [],
close: async () => {},
}
@ -59,6 +62,20 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp>
const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`)
const path = url.pathname
// --- OTLP traces collector (records batches for push tests) ---
if (path === '/v1/traces' && req.method === 'POST') {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
let parsed: unknown = null
try { parsed = JSON.parse(body || '{}') } catch { /* keep null */ }
state.tracesRequests.push({ auth: req.headers.authorization, body: parsed })
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end('{}')
})
return
}
// --- Discovery doc ---
if (path === '/.well-known/codeburn-export.json') {
res.writeHead(200, { 'Content-Type': 'application/json' })

View file

@ -7,7 +7,7 @@ vi.mock('../src/cache-refresh-lock.js', () => ({
acquireCacheRefreshLock: async () => ({ outcome: 'timed-out' as const }),
}))
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js'
import { sessionCachePath } from '../src/session-cache.js'
let root: string
@ -59,4 +59,46 @@ describe('parseAllSessions warm refresh timeout', () => {
expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50)
expect(await readFile(sessionCachePath(), 'utf-8')).toBe(before)
})
// The snapshot a timed-out refresh serves is only as good as what has changed
// under it. Anything the daily backfill finalizes off a snapshot that skipped
// real files freezes those days out of history for good, so the completeness
// signal has to distinguish the two cases.
it('does not report a complete hydration when the served snapshot is stale', async () => {
await writeSession(50)
await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete()).toBe(true)
await writeSession(5000)
clearSessionCache()
await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete()).toBe(false)
})
it('does not report a complete hydration when a session file is missing from the snapshot', async () => {
await writeSession(50)
await parseAllSessions(undefined, 'claude')
await writeFile(join(sessionPath, '..', 'other.jsonl'), JSON.stringify({
type: 'assistant',
sessionId: 'sess-2',
timestamp: '2026-05-16T10:00:00Z',
cwd: '/tmp/proj',
message: {
id: 'msg-other', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], usage: { input_tokens: 100, output_tokens: 7 },
},
}) + '\n')
clearSessionCache()
await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete()).toBe(false)
})
it('still reports a complete hydration when nothing changed under the snapshot', async () => {
await writeSession(50)
await parseAllSessions(undefined, 'claude')
clearSessionCache()
await parseAllSessions(undefined, 'claude')
expect(isSessionHydrationComplete()).toBe(true)
})
})

View file

@ -488,3 +488,114 @@ describe('(g) skill attribution is independent of turn category', () => {
expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (h) Provider filter isolates claude: a --provider <other> run must not
// re-surface cached claude sessions through the orphan pass, while a run
// that DOES include claude still preserves PR-bearing orphans.
// ═══════════════════════════════════════════════════════════════════════════
describe('(h) provider filter excludes claude from the orphan pass', () => {
const SYNTH_SOURCE = (path: string): SessionSource[] =>
[{ path, project: 'synth-proj', provider: 'test-synthetic' }]
// The provider lives on each parsed call, not on SessionSummary.
const providersOf = (projects: Awaited<ReturnType<typeof parseAllSessions>>): Set<string> =>
new Set(projects
.flatMap(p => p.sessions)
.flatMap(s => s.turns)
.flatMap(t => t.assistantCalls)
.map(c => c.provider))
const SYNTH_CALL: ParsedProviderCall = {
provider: 'test-synthetic', model: 'gpt-4o',
inputTokens: 10, outputTokens: 5,
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0.25, tools: [], bashCommands: [],
skills: [],
timestamp: '2026-07-18T12:00:00.000Z',
speed: 'standard',
deduplicationKey: 'synth-isolation-call',
userMessage: '', sessionId: 'synth-isolation-session',
}
// A claude transcript carrying a pr-link: `prLinks` is exactly what lets a
// cached entry survive the write-mode orphan gate, so it is the shape that
// leaks. Cost is deliberately far larger than the synthetic call's, so a leak
// is unmistakable rather than a rounding difference.
async function writeClaudeSessionWithPrLink(): Promise<string> {
const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app')
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'session.jsonl')
await writeFile(filePath, [
JSON.stringify({
type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z',
message: { role: 'user', content: 'ship it' },
}),
JSON.stringify({
type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z',
message: {
id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'done' }],
usage: { input_tokens: 900_000, output_tokens: 90_000 },
},
}),
JSON.stringify({
type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z',
prUrl: 'https://github.com/getagentseal/codeburn/pull/1',
}),
].join('\n') + '\n')
return filePath
}
it('does not surface cached claude sessions when filtering to another provider', async () => {
const synthFile = join(tmpHome, 'synth-isolation.txt')
await writeFile(synthFile, 'placeholder')
await writeClaudeSessionWithPrLink()
_synthSources = SYNTH_SOURCE(synthFile)
_synthYields = [SYNTH_CALL]
// Baseline: what the synthetic provider costs on its own, before anything
// claude-shaped has ever entered the session cache. Self-calibrating, since
// cost is re-derived from tokens by the pricing engine.
const baseline = await parseAllSessions(undefined, 'test-synthetic')
const synthOnlyCost = totalCost(baseline)
expect([...providersOf(baseline)]).toEqual(['test-synthetic'])
clearSessionCache()
// Warm the session cache so the claude file is persisted WITH its prLinks.
const all = await parseAllSessions(undefined, 'all')
expect(providersOf(all)).toContain('claude')
expect(totalCost(all)).toBeGreaterThan(synthOnlyCost)
clearSessionCache()
// Filtering to the synthetic provider must yield ONLY its own spend. Before
// the fix, claudeDirs was empty yet scanProjectDirs still ran, so every
// cached PR-bearing claude file was treated as a pruned orphan and re-added.
const filtered = await parseAllSessions(undefined, 'test-synthetic')
expect([...providersOf(filtered)]).toEqual(['test-synthetic'])
expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10)
})
it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => {
const filePath = await writeClaudeSessionWithPrLink()
_synthSources = []
_synthYields = []
const before = await parseAllSessions(undefined, 'all')
const costBefore = totalCost(before)
expect(costBefore).toBeGreaterThan(0)
// Every claude transcript disappears from disk. Claude is still in scope, so
// the orphan pass must keep the PR-attributed spend alive — this is the case
// a naive `claudeDirs.length > 0` guard would silently break.
await unlink(filePath)
clearSessionCache()
const after = await parseAllSessions(undefined, 'all')
expect(totalCost(after)).toBeCloseTo(costBefore, 10)
})
})

View file

@ -0,0 +1,374 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
import { loadPricing } from '../src/models.js'
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
import { renderOverview } from '../src/overview.js'
import type { DateRange } from '../src/types.js'
// The durable headline (overview / report Overview panel / menubar current) is
// built from the carry-forward daily cache unioned with today's live parse, so
// days whose session files expired still count. Historical days were sliced to
// the requested PROVIDER but never to the requested PROJECT, so
// `--project` / `--exclude` were silently ignored for every day except today:
// the Overview total counted excluded projects while every detail panel (By
// Project / By Activity / By Model, all built from the name-filtered live
// parse) left them out, and the two panels could not be reconciled.
//
// Per-project day stats exist in the cache since v15 (DailyEntry.projects), so
// the historical days CAN be sliced — that is what these tests pin down.
const ROOT = join(tmpdir(), `codeburn-project-filter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
let savedEnv: Record<string, string | undefined>
// One carried day, split across two projects. The day totals are the sum, so a
// correct project slice returns strictly less than the whole day.
const KEEP = { cost: 30, calls: 10, sessions: 1 }
const DROP = { cost: 70, calls: 30, sessions: 2 }
const DAY_COST = KEEP.cost + DROP.cost
const DAY_CALLS = KEEP.calls + DROP.calls
const DAY_SESSIONS = KEEP.sessions + DROP.sessions
function daysAgoStr(n: number): string {
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** A day only the cache holds (sources aged out), split across two projects. */
function carriedDayWithProjects(date: string): DailyEntry {
const projects = {
'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' },
'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' },
}
return {
date,
cost: DAY_COST,
savingsUSD: 0,
calls: DAY_CALLS,
sessions: DAY_SESSIONS,
inputTokens: 5000,
outputTokens: 2000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 4,
oneShotTurns: 2,
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
providers: {
claude: {
calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, sessions: DAY_SESSIONS,
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 4, oneShotTurns: 2,
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
projects,
},
},
projects,
carried: true,
}
}
/** A carried day recorded before v15: totals, but no per-project split at all. */
function carriedDayWithoutProjects(date: string): DailyEntry {
const day = carriedDayWithProjects(date)
delete day.projects
delete day.providers['claude']!.projects
return day
}
async function seedCache(...days: DailyEntry[]): Promise<void> {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: getDailyCacheConfigHash(),
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days,
complete: true,
}
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
}
/** A real, priced Claude session dated TODAY, under project dir `live-proj`. */
async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'live-proj')
await mkdir(projectDir, { recursive: true })
const now = new Date()
// Timestamps a few minutes OLD, clamped into today: a wall-clock hour (12:00)
// is in the future whenever the suite runs before noon, and the periods here
// end at `new Date()`, so a fixed hour made the live half of the union vanish
// for a morning run. Relative-and-clamped keeps the session inside today and
// in the past at every hour.
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const minutesAgo = (m: number): string => new Date(Math.max(midnight, now.getTime() - m * 60_000)).toISOString()
const line = (id: string, t: string): string => JSON.stringify({
type: 'assistant',
timestamp: t,
sessionId: 's-today',
message: {
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
content: [],
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
},
})
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', minutesAgo(10)), line('m2', minutesAgo(5))].join('\n') + '\n', 'utf-8')
}
/** What the name-filtered live parse alone reports — the By Project panel's source. */
async function liveOnly(range: DateRange, include: string[], exclude: string[]): Promise<{ cost: number; calls: number }> {
clearSessionCache()
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), include, exclude)
const data = buildPeriodData('live', projects)
return { cost: data.cost, calls: data.calls }
}
beforeAll(async () => {
await loadPricing()
})
beforeEach(async () => {
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
await mkdir(join(ROOT, 'cache'), { recursive: true })
process.env['HOME'] = join(ROOT, 'home')
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEX_HOME']
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k]
else process.env[k] = savedEnv[k]
}
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
})
// A fixed 20-day window back from now: it always spans the carried day (10 days
// ago) and today, so the period never depends on where "now" falls in the
// calendar. A real `month` range drops the 10-days-ago day whenever today is
// within 10 days of the 1st, which made these tests flake early in each month.
const coveringRange = (): DateRange => ({
start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
end: new Date(),
})
describe('durable headline honours --project / --exclude on carried days', () => {
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
// The headline counts the surviving sessions plus ONLY the kept project's
// carried cost — not the whole carried day.
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
// And it reconciles with the detail panels, which see the same live parse.
const byProject = durable.liveProjects.reduce((s, p) => s + p.totalCostUSD, 0)
expect(durable.data.cost).toBeCloseTo(byProject + KEEP.cost, 6)
})
it('keeps only the named project when --project is given', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, ['keep-me'], [])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', project: ['keep-me'] })
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
})
it('matches a filter against the project path as well as its name', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/Users/gone/drop-me'] })
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
})
it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
// A project directory can legitimately be named "constructor"/"valueOf"/etc.
// The day cache must carry it as an own key: if the load path drops it, the
// day's per-project split no longer sums to the day cost, and the sliced
// headline silently loses that project's spend.
const day = carriedDayWithProjects(daysAgoStr(10))
const protoName = 'constructor'
Object.defineProperty(day.projects!, protoName, {
value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
enumerable: true, writable: true, configurable: true,
})
day.cost += 25
day.calls += 5
await seedCache(day)
await seedLiveTodaySession()
const range = coveringRange()
// Keep everything (exclude a non-matching term): the constructor project's
// $25 must be present alongside keep-me and drop-me.
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
const live = await liveOnly(range, [], ['zzz-nomatch'])
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
expect(durable.unattributedCostUSD).toBe(0)
})
it('contributes nothing from a carried day whose every project is excluded', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['keep-me', 'drop-me'] })
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
expect(durable.carriedCostUSD).toBe(0)
})
it('applies the project filter underneath a provider filter', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
// Stated as the delta the filter must produce, so the assertion holds
// whatever the provider-scoped live parse contributes.
clearSessionCache()
const unfiltered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude' })
clearSessionCache()
const filtered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
expect(unfiltered.data.cost - filtered.data.cost).toBeCloseTo(DROP.cost, 6)
expect(unfiltered.data.calls - filtered.data.calls).toBe(DROP.calls)
})
it('keeps the menubar payload in step with the report under a project filter', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
clearSessionCache()
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
// Both surfaces route the headline through the one shared builder, so a
// project filter must land on them identically.
expect(menubar.current.cost).toBe(durable.data.cost)
expect(menubar.current.calls).toBe(durable.data.calls)
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
})
it('leaves the unfiltered headline exactly as it was', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], [])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
expect(durable.data.calls).toBe(live.calls + DAY_CALLS)
expect(durable.carriedCostUSD).toBeCloseTo(DAY_COST, 6)
})
})
describe('carried days with no per-project split (pre-v15)', () => {
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], ['drop-me'])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
// The day cannot be attributed to any project, so a project-filtered total
// cannot claim it. It is reported separately rather than silently folded in.
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
})
it('reports a provider slice with no project split as unattributed too', async () => {
// A v14-era provider slice carried into a v15 day: the day knows its project
// split, that provider's slice does not. Under --provider the headline reads
// the slice, so the day drops out — say how much rather than lose it quietly.
const day = carriedDayWithProjects(daysAgoStr(10))
delete day.providers['claude']!.projects
await seedCache(day)
await seedLiveTodaySession()
const range = coveringRange()
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
expect(durable.carriedCostUSD).toBe(0)
})
it('says so in the overview instead of just showing a short total', async () => {
// Two cached days: one that cannot be attributed (the footnote's subject) and
// one that can. The attributable day keeps the headline non-zero from the
// CACHE alone, so the assertion tests the footnote rather than depending on
// the live parse to keep renderOverview off its "No usage found" path.
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)), carriedDayWithProjects(daysAgoStr(5)))
await seedLiveTodaySession()
const range = coveringRange()
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
expect(durable.unattributedCostUSD).toBeGreaterThan(0)
expect(durable.data.cost).toBeGreaterThan(0)
const rendered = renderOverview(durable.liveProjects, {
label: 'This month',
color: false,
durable: {
cost: durable.data.cost,
savingsUSD: durable.data.savingsUSD,
calls: durable.data.calls,
sessions: durable.data.sessions,
inputTokens: durable.data.inputTokens,
outputTokens: durable.data.outputTokens,
cacheReadTokens: durable.data.cacheReadTokens,
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
unattributedCostUSD: durable.unattributedCostUSD,
},
})
expect(rendered).toContain('no per-project history')
})
it('still counts the day in full when no project filter is active', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession()
const range = coveringRange()
const live = await liveOnly(range, [], [])
clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
expect(durable.unattributedCostUSD).toBe(0)
})
})

View file

@ -98,6 +98,47 @@ describe('provider turn grouping', () => {
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
it('classifies a range-sliced turn from the whole turn, not the surviving calls (#852)', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-b', 'chats')
await mkdir(chatsDir, { recursive: true })
await writeFile(join(chatsDir, 'session-slice.json'), JSON.stringify({
sessionId: 'gemini-slice-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'read then edit src/parser.ts' },
{
id: 'g1', timestamp: '2026-05-16T10:00:00.000Z', type: 'gemini', content: 'reading',
model: 'gemini-3.1-pro-preview', tokens: { input: 100, output: 30 },
toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/parser.ts' } }],
},
{
id: 'g2', timestamp: '2026-05-16T11:00:00.000Z', type: 'gemini', content: 'editing',
model: 'gemini-3.1-pro-preview', tokens: { input: 90, output: 25 },
toolCalls: [{ id: 't2', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
],
}))
const parseAllSessions = await loadParser()
// A range that keeps the 10:00 Read call but excludes the 11:00 Edit call,
// so the turn is sliced. `turnSlicedToRange`/`callsInRange` compare absolute
// times, so this is timezone-independent.
const sliceRange: DateRange = {
start: new Date('2026-05-16T10:00:00.000Z'),
end: new Date('2026-05-16T10:30:00.000Z'),
}
const projects = await parseAllSessions(sliceRange, 'gemini')
const turn = projects[0]!.sessions[0]!.turns[0]!
// Cost/calls are sliced to the range: only the Read call survives.
expect(turn.assistantCalls.map(c => c.deduplicationKey)).toEqual(['gemini:gemini-slice-1:g1'])
// But category/hasEdits are whole-turn judgments — the Edit is part of the
// exchange — so they stay classified from the FULL turn, matching the Claude
// path rather than being re-derived from the partial slice (which alone reads
// as a no-edit exploration turn).
expect(turn.hasEdits).toBe(true)
})
it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => {
const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe')
await mkdir(sessionDir, { recursive: true })
@ -197,3 +238,46 @@ describe('provider turn grouping', () => {
}
})
})
describe('provider turn range filtering', () => {
it('keeps the in-range calls of a codex turn that spans midnight instead of dropping the whole turn', async () => {
// Regression test for #852: the range filter keyed on the turn's FIRST
// call timestamp, so a long autonomous turn starting 23:59 the previous
// day was excluded from the next day's view entirely, losing every
// post-midnight call. One turn (t1) here has two token_count events
// straddling midnight; only the post-midnight call may survive.
const codexHome = join(home, 'codex')
const sessionDir = join(codexHome, 'sessions', '2026', '05', '15')
await mkdir(sessionDir, { recursive: true })
const lines = [
JSON.stringify({ type: 'session_meta', timestamp: '2026-05-15T23:55:00Z', payload: { session_id: 'sess-span', model: 'gpt-5.5', cwd: '/Users/test/project-a', originator: 'codex_cli_rs' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:57:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the long task' }] } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:58:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm test' }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-05-15T23:59:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 30 }, total_token_usage: { total_tokens: 130 } } } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-05-16T00:10:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm run build' }) } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:15:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 80, output_tokens: 20 }, total_token_usage: { total_tokens: 230 } } } }),
]
await writeFile(join(sessionDir, 'rollout-span.jsonl'), lines.join('\n') + '\n')
process.env['CODEX_HOME'] = codexHome
try {
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'codex')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => new Date(call.timestamp).toISOString())).toEqual([
'2026-05-16T00:15:00.000Z',
])
// The slice re-anchors the turn's timestamp from the user-message time
// (2026-05-15T23:57Z) to the first surviving call, so turn-anchored
// bucketing lands the slice on the day its calls actually fall in.
expect(new Date(turn.timestamp).toISOString()).toBe('2026-05-16T00:15:00.000Z')
expect(session.totalInputTokens).toBe(80)
expect(session.totalOutputTokens).toBe(20)
} finally {
delete process.env['CODEX_HOME']
}
})
})

View file

@ -160,6 +160,56 @@ describe('codex provider - session discovery', () => {
}])
})
it('deduplicates the same session_id across active and archived roots', async () => {
const sharedLines = [
sessionMeta({ cwd: '/Users/test/shared', session_id: 'sess-shared' }),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
]
const activePath = await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
const archivedCopyPath = await writeArchivedSession(tmpDir, 'rollout-shared.jsonl', sharedLines)
const distinctPath = await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
sessionMeta({ cwd: '/Users/test/distinct', session_id: 'sess-distinct' }),
tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
const paths = sessions.map(session => session.path)
expect(sessions).toHaveLength(2)
expect(paths).toEqual(expect.arrayContaining([activePath, distinctPath]))
expect(paths).not.toContain(archivedCopyPath)
})
it('does not double-count usage for an archived copy while counting distinct sessions', async () => {
const sharedLines = [
sessionMeta({ session_id: 'sess-shared' }),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
]
await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
await writeArchivedSession(tmpDir, 'rollout-shared-copy.jsonl', sharedLines)
await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
sessionMeta({ session_id: 'sess-distinct' }),
tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
const seenKeys = new Set<string>()
const calls: ParsedProviderCall[] = []
for (const session of sessions) {
for await (const call of provider.createSessionParser(session, seenKeys).parse()) {
calls.push(call)
}
}
expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-distinct', 'sess-shared'])
expect(calls.reduce(
(total, call) => total + call.inputTokens + call.cachedInputTokens + call.outputTokens + call.reasoningTokens,
0,
)).toBe(400)
})
it('returns empty for non-existent directory', async () => {
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
const sessions = await provider.discoverSessions()
@ -332,6 +382,89 @@ describe('codex provider - JSONL parsing', () => {
expect(call.deduplicationKey).toContain('codex:')
})
it('parses large rollout lines and computes active timing for custom tool calls', async () => {
const largeTokenLine = JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:01:10Z',
payload: {
type: 'token_count',
info: {
last_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
},
rate_limits: { filler: 'x'.repeat(40_000) },
},
})
const largeCompleteLine = JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:01:11Z',
payload: { type: 'task_complete', last_agent_message: 'x'.repeat(40_000), duration_ms: 10_000 },
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-timing.jsonl', [
sessionMeta({ session_id: 'sess-timing', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }),
userMessage('run the tool'),
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
largeTokenLine,
largeCompleteLine,
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
outputTokens: 100,
reasoningTokens: 20,
tools: ['Bash'],
activeDurationMs: 7000,
activeGeneratedTokens: 120,
toolWaitMs: 3000,
})
})
it('keeps estimated output parsing for large token lines without usage info', async () => {
// Some rollout variants put token_count metadata beyond the compact head
// or omit `info` entirely. The line must still reach the character-based
// estimate path rather than being interpreted as an empty usage object.
const largeTokenLine = JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:01:10Z',
payload: { type: 'token_count' },
filler: 'x'.repeat(40_000),
})
const assistantLine = JSON.stringify({
type: 'response_item',
timestamp: '2026-04-14T10:01:05Z',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'generated response '.repeat(100) }],
},
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-estimated-large.jsonl', [
sessionMeta({ session_id: 'sess-estimated-large', model: 'gpt-5.5' }),
userMessage('summarize the result'),
assistantLine,
largeTokenLine,
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
model: 'gpt-5.5',
costIsEstimated: true,
})
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
})
it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [
sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }),
@ -356,6 +489,154 @@ describe('codex provider - JSONL parsing', () => {
expect(calls[0]!.tools).toEqual(['mcp__github__get_issue'])
})
it('subtracts native MCP wait time from active timing', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-timing.jsonl', [
sessionMeta({ session_id: 'sess-mcp-timing', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
userMessage('look up the issue'),
JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:00:05Z',
payload: {
type: 'mcp_tool_call_end',
call_id: 'mcp-1',
invocation: { server: 'github', tool: 'get_issue', arguments: {} },
duration: { secs: 3, nanos: 0 },
},
}),
tokenCount({
timestamp: '2026-04-14T10:00:08Z',
last: { input: 300, output: 100 },
total: { total: 400 },
}),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ activeDurationMs: 7000, toolWaitMs: 3000 })
})
it('keeps MCP attribution on large result lines', async () => {
const largeMcpLine = JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:00:05Z',
payload: {
type: 'mcp_tool_call_end',
call_id: 'mcp-large',
invocation: { server: 'github', tool: 'get_issue', arguments: { duration: '1s', body: 'x'.repeat(100_000) } },
duration: { secs: 3, nanos: 0 },
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
},
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-large.jsonl', [
sessionMeta({ session_id: 'sess-mcp-large', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
userMessage('look up the issue'),
largeMcpLine,
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
})
it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => {
// Regression guard: a naive first-match regex would pick up the
// `duration_ms: 9999` inside invocation.arguments instead of the payload-level
// `duration: { secs: 3 }`. The depth-aware payload scan must win.
const largeMcpLine = JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:00:05Z',
payload: {
type: 'mcp_tool_call_end',
call_id: 'mcp-duration-collision',
invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } },
duration: { secs: 3, nanos: 0 },
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
},
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [
sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
userMessage('look up the issue'),
largeMcpLine,
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
})
it('attributes a task_complete over everything since the last task_started, even across a suppressed one', async () => {
// A mid-file session_meta carrying forked_from_id re-arms the fork-replay
// cutoff, which swallows the task_started right behind it while its
// task_complete lands past the cutoff. Attribution then has to span both
// turns, exactly as it did before calls were buffered per task.
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-suppressed-task-start.jsonl', [
sessionMeta({ session_id: 'sess-suppressed-start', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
userMessage('first ask'),
tokenCount({ timestamp: '2026-04-14T10:00:05Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
sessionMeta({ timestamp: '2026-04-14T10:00:11Z', session_id: 'sess-suppressed-start', model: 'gpt-5.5', forked_from_id: 'sess-parent' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:12Z', payload: { type: 'task_started' } }),
userMessage('second ask', '2026-04-14T10:00:18Z'),
tokenCount({ timestamp: '2026-04-14T10:00:20Z', last: { input: 300, output: 300 }, total: { total: 1000 } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:25Z', payload: { type: 'task_complete', duration_ms: 5_000 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(2)
// The second task_complete re-attributes the first turn too, so the 5s
// window is split across both by generated tokens rather than leaving the
// first turn pinned to its own 10s window.
expect(calls[0]!.activeDurationMs).toBeCloseTo(1250, 6)
expect(calls[1]!.activeDurationMs).toBeCloseTo(3750, 6)
expect(calls[0]!.activeDurationMs! + calls[1]!.activeDurationMs!).toBeCloseTo(5000, 6)
})
it('omits active timing when recorded tool wait consumes the task duration', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [
sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
userMessage('wait for the tool'),
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:13Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.activeDurationMs).toBeUndefined()
expect(calls[0]!.toolWaitMs).toBeUndefined()
})
it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => {
const execStr = (command: string) => JSON.stringify({
type: 'response_item',

View file

@ -0,0 +1,157 @@
/**
* CLI-level tests for `codeburn sync push --attribution`.
*
* Drives the real commander action against a mock IdP + collector:
* - --dry-run --attribution: NO telemetry reaches the traces endpoint
* - push WITHOUT the flag: no attribution span names on the wire
* - push WITH the flag: attribution spans arrive alongside usage spans
*/
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
import { Command } from 'commander'
import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js'
import type { ProjectSummary, SessionSummary, ParsedApiCall, TokenUsage } from '../src/types.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({ parseAllSessionsMock: vi.fn() }))
vi.mock('../src/parser.js', () => ({ parseAllSessions: parseAllSessionsMock }))
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, ...env } }).trim()
}
function makeUsage(): TokenUsage {
return { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }
}
function makeCall(key: string, ts: string): ParsedApiCall {
return {
provider: 'test', model: 'test-model', usage: makeUsage(), costUSD: 0.01,
tools: [], mcpTools: [], skills: [], subagentTypes: [], hasAgentSpawn: false,
hasPlanMode: false, speed: 'standard', timestamp: ts, bashCommands: [],
deduplicationKey: key,
}
}
function makeSession(id: string, first: string, last: string, calls: ParsedApiCall[]): SessionSummary {
return {
sessionId: id, project: 'app', firstTimestamp: first, lastTimestamp: last,
totalCostUSD: 0.01, totalSavingsUSD: 0, totalInputTokens: 10, totalOutputTokens: 5,
totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: calls.length,
turns: [{ userMessage: 'x', assistantCalls: calls, timestamp: first, sessionId: id, category: 'coding', retries: 0, hasEdits: false }],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'], skillBreakdown: {}, subagentBreakdown: {},
}
}
let idp: MockIdp
let tmpHome: string
let repoDir: string
const originalHome = process.env.HOME
const originalXdg = process.env.XDG_CACHE_HOME
const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE
async function runPush(args: string[]): Promise<void> {
const { registerSyncCommands } = await import('../src/sync/cli.js')
const program = new Command()
program.exitOverride() // throw instead of process.exit on commander errors
registerSyncCommands(program)
await program.parseAsync(['node', 'codeburn', 'sync', 'push', ...args])
}
beforeAll(async () => {
idp = await startMockIdp({ rotateTokens: false })
process.env.CODEBURN_SYNC_TOKEN_STORE = 'file'
// Real git repo with a remote — a recent commit inside the session window
repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-repo-'))
git(repoDir, ['init', '-b', 'main'])
git(repoDir, ['config', 'user.email', 't@e.com'])
git(repoDir, ['config', 'user.name', 'T'])
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/cli-widget.git'])
await writeFile(join(repoDir, 'f.txt'), 'x\n')
const commitIso = new Date(Date.now() - 60 * 60 * 1000).toISOString()
git(repoDir, ['add', '.'])
git(repoDir, ['commit', '-m', 'feat: recent'], { GIT_AUTHOR_DATE: commitIso, GIT_COMMITTER_DATE: commitIso })
})
afterAll(async () => {
await idp.close()
await rm(repoDir, { recursive: true, force: true })
if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE
else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore
})
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-'))
process.env.HOME = tmpHome
process.env.XDG_CACHE_HOME = join(tmpHome, '.cache')
idp.tracesRequests.length = 0
// Configure sync against the mock IdP + store the refresh token
const { writeSyncConfig } = await import('../src/sync/config.js')
const { createCredentialStore } = await import('../src/sync/credentials.js')
writeSyncConfig({ baseUrl: idp.baseUrl, clientId: 'mock-client-id', tracesPath: '/v1/traces', issuer: idp.baseUrl })
createCredentialStore().store('mock-refresh-token-v1')
// One session in the last hour, working in the real repo
const now = Date.now()
const first = new Date(now - 90 * 60 * 1000).toISOString()
const last = new Date(now - 30 * 60 * 1000).toISOString()
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
])
// The push action reads process.cwd() for the attribution cwd — run from
// a neutral non-repo dir so nothing can come from a cwd fallback.
vi.spyOn(process, 'cwd').mockReturnValue(tmpHome)
})
afterEach(async () => {
vi.restoreAllMocks()
process.exitCode = 0
process.env.HOME = originalHome
if (originalXdg === undefined) delete process.env.XDG_CACHE_HOME
else process.env.XDG_CACHE_HOME = originalXdg
await rm(tmpHome, { recursive: true, force: true })
})
const spanNames = (): string[] =>
idp.tracesRequests.flatMap(r => {
const body = r.body as { resourceSpans?: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
return (body.resourceSpans ?? []).flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.map(s => s.name)))
})
describe('sync push --attribution (CLI level)', () => {
it('--dry-run --attribution sends nothing to the traces endpoint', async () => {
await runPush(['--dry-run', '--attribution'])
expect(idp.tracesRequests).toHaveLength(0)
})
it('push WITHOUT --attribution never emits attribution span names', async () => {
await runPush([])
expect(idp.tracesRequests.length).toBeGreaterThan(0)
const names = spanNames()
expect(names.length).toBeGreaterThan(0)
expect(names).not.toContain('codeburn.session.attribution')
expect(names).not.toContain('codeburn.commit')
expect(JSON.stringify(idp.tracesRequests)).not.toContain('git.sha')
})
it('push WITH --attribution emits usage + attribution spans', async () => {
await runPush(['--attribution'])
const names = spanNames()
expect(names).toContain('test/test-model') // usage span
expect(names).toContain('codeburn.session.attribution') // session span
expect(names).toContain('codeburn.commit') // commit span
const wire = JSON.stringify(idp.tracesRequests)
expect(wire).toContain('github.com/acme/cli-widget')
})
})

View file

@ -0,0 +1,753 @@
/**
* Tests for sync git attribution (sync push --attribution).
*
* Covers: remote URL normalization, sessioncommit attribution record
* computation (reusing the yield engine), state-encoding dedup keys,
* attribution OTLP span construction, and the send/ledger pipeline.
*/
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer, type Server } from 'node:http'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
import {
normalizeRemoteUrl,
computeAttributionRecords,
sanitizePrLinks,
MAX_PR_LINKS_PER_SESSION,
type SessionAttributionRecord,
} from '../src/yield.js'
import {
flattenAttributionRecords,
commitAttributionKey,
sessionAttributionKey,
buildAttributionOtlpPayload,
batchAttributionItems,
deriveTraceId,
SESSION_ATTRIBUTION_SPAN_NAME,
COMMIT_ATTRIBUTION_SPAN_NAME,
type OtlpAttribute,
} from '../src/sync/otlp.js'
// ── Git fixtures (mirrors yield-repo-grouping.test.ts) ────────────────
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...env },
}).trim()
}
function initRepo(dir: string): void {
git(dir, ['init', '-b', 'main'])
git(dir, ['config', 'user.email', 'test@example.com'])
git(dir, ['config', 'user.name', 'Test'])
}
function commitAt(dir: string, message: string, iso: string): void {
git(dir, ['add', '.'])
git(dir, ['commit', '-m', message], {
GIT_AUTHOR_DATE: iso,
GIT_COMMITTER_DATE: iso,
})
}
function makeSession(overrides: Partial<SessionSummary>): SessionSummary {
return {
sessionId: 'session',
project: 'app',
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
totalCostUSD: 1,
totalSavingsUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
subagentBreakdown: {},
...overrides,
}
}
const range = {
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2026-01-02T00:00:00.000Z'),
}
// ── normalizeRemoteUrl ────────────────────────────────────────────────
describe('normalizeRemoteUrl', () => {
it('normalizes scp-like ssh remotes', () => {
expect(normalizeRemoteUrl('git@github.com:acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('git@GitHub.com:Acme/Widget')).toBe('github.com/Acme/Widget')
})
it('normalizes ssh:// remotes, dropping user and port', () => {
expect(normalizeRemoteUrl('ssh://git@github.com/acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('ssh://git@gitlab.example.com:2222/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
})
it('normalizes https remotes and strips embedded credentials', () => {
expect(normalizeRemoteUrl('https://github.com/acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('https://user:s3cret-token@github.com/acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('https://github.com/acme/widget/')).toBe('github.com/acme/widget')
})
it('returns null for local paths and file:// remotes', () => {
expect(normalizeRemoteUrl('/home/dev/repos/widget')).toBeNull()
expect(normalizeRemoteUrl('file:///home/dev/repos/widget')).toBeNull()
expect(normalizeRemoteUrl('../relative/repo')).toBeNull()
expect(normalizeRemoteUrl('')).toBeNull()
})
it('rejects Windows drive-letter paths (never a remote identity)', () => {
expect(normalizeRemoteUrl('C:/Users/alice/private/repo')).toBeNull()
expect(normalizeRemoteUrl('C:\\Users\\alice\\private\\repo')).toBeNull()
expect(normalizeRemoteUrl('c:/repo')).toBeNull()
expect(normalizeRemoteUrl('Z:\\work\\nda-client-repo')).toBeNull()
// Drive-relative (no slash after colon) — single-char host rejection
expect(normalizeRemoteUrl('C:repo')).toBeNull()
expect(normalizeRemoteUrl('c:relative\\path')).toBeNull()
})
it('rejects other adversarial forms without over-rejecting real remotes', () => {
// Single-character "host" is never a real remote host
expect(normalizeRemoteUrl('a:path/to/repo')).toBeNull()
expect(normalizeRemoteUrl('git@C:/foo')).toBeNull()
// Dotless intranet hosts remain valid (2+ chars)
expect(normalizeRemoteUrl('gitserver:team/repo.git')).toBe('gitserver/team/repo')
expect(normalizeRemoteUrl('git@gitbox:org/repo.git')).toBe('gitbox/org/repo')
})
it('never leaks credentials via scp-branch backtracking on malformed remotes', () => {
// Credential-prefixed remotes: the userinfo split happens BEFORE host
// matching, so a token can never be re-parsed as host:path.
expect(normalizeRemoteUrl('x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git')).toBeNull()
expect(normalizeRemoteUrl('oauth2:glpat-TOKEN@gitlab.com/org/repo.git')).toBeNull()
// One dropped slash: not a URL, must not fall through as host "https"
expect(normalizeRemoteUrl('https:/user:ghp_TOKEN@github.com/org/repo.git')).toBeNull()
// Multiple @: split at the first, residual @ fails the allow-list
expect(normalizeRemoteUrl('a@b@github.com:org/repo.git')).toBeNull()
expect(normalizeRemoteUrl('user@host:path@with-at')).toBeNull()
})
it('rejects transport-helper remotes and enforces shape + length on the identity', () => {
// git-remote-ext: embeds a local SSH key path
expect(normalizeRemoteUrl('ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git')).toBeNull()
expect(normalizeRemoteUrl('ext::sh -c whatever')).toBeNull()
// git-remote-codecommit: embeds an AWS profile name
expect(normalizeRemoteUrl('codecommit::us-east-1://MyAwsProfile@MyRepo')).toBeNull()
expect(normalizeRemoteUrl('codecommit::us-east-1://MyRepo')).toBeNull()
// Length bound
expect(normalizeRemoteUrl(`git@github.com:org/${'a'.repeat(300)}.git`)).toBeNull()
// Path segments must be repo-shaped (no spaces, colons, @)
expect(normalizeRemoteUrl('gitserver:has space/repo.git')).toBeNull()
// Legit multi-segment (GitLab subgroup) paths survive the allow-list
expect(normalizeRemoteUrl('https://gitlab.example.com/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
})
it('normalizes .GIT case-insensitively and collapses doubled slashes to one join key', () => {
expect(normalizeRemoteUrl('git@github.com:acme/Repo.GIT')).toBe('github.com/acme/Repo')
expect(normalizeRemoteUrl('https://github.com/acme/Repo.Git')).toBe('github.com/acme/Repo')
expect(normalizeRemoteUrl('https://github.com/acme//repo.git')).toBe('github.com/acme/repo')
expect(normalizeRemoteUrl('git@github.com:acme//repo.git')).toBe('github.com/acme/repo')
})
})
// ── computeAttributionRecords ─────────────────────────────────────────
describe('computeAttributionRecords', () => {
it('attributes commits with normalized remote, inMain, and timestamps', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-repo-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: shipped', '2026-01-01T10:30:00Z')
const sha = git(repoDir, ['rev-parse', 'HEAD'])
const session = makeSession({
sessionId: 'sess-a',
prLinks: ['https://github.com/acme/widget/pull/12'],
})
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
expect(records).toHaveLength(1)
const record = records[0]!
expect(record.sessionId).toBe('sess-a')
expect(record.repo).toBe('github.com/acme/widget')
expect(record.prLinks).toEqual(['https://github.com/acme/widget/pull/12'])
expect(record.commits).toHaveLength(1)
expect(record.commits[0]).toMatchObject({ sha, inMain: true, wasReverted: false })
expect(new Date(record.commits[0]!.timestamp).toISOString()).toBe('2026-01-01T10:30:00.000Z')
expect(record.firstTimestamp).toBe('2026-01-01T10:00:00.000Z')
expect(record.lastTimestamp).toBe('2026-01-01T11:00:00.000Z')
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('omits empty sessions that lost nothing — commits aged out of range are NOT retracted', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-empty-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
// Commit outside every session window: no session competes for it, so
// nobody "lost" it. Even if this session previously synced a commit
// (now outside the --since range), emitting an empty record here would
// permanently zero a still-correct server-side count.
commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z')
const session = makeSession({ sessionId: 'sess-idle' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
]
expect(computeAttributionRecords(projects, range, repoDir)).toEqual([])
// …and therefore nothing can be sent, even with prior ledger state for
// this session (simulating an earlier wider---since push).
const { writeLedger } = await import('../src/sync/ledger.js')
writeLedger([{ key: 'attr:s:sess-idle:0123456789abcdef', ts: '2026-01-01T10:00:00.000Z' }])
const { collectUnsentAttribution } = await import('../src/sync/push.js')
expect(collectUnsentAttribution(computeAttributionRecords(projects, range, repoDir)).unsent).toEqual([])
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('emits a retraction candidate for a session that lost its commit to a tighter window', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-lost-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: contested', '2026-01-01T10:30:00Z')
const tight = makeSession({
sessionId: 'sess-tight',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const broadLoser = makeSession({ sessionId: 'sess-broad-loser' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
const loser = records.find(r => r.sessionId === 'sess-broad-loser')!
// The loser IS emitted (retraction candidate: lostCandidacy) with zero
// commits — the sync layer decides whether a prior state warrants
// actually sending it.
expect(loser).toBeDefined()
expect(loser.commits).toEqual([])
expect(loser.repo).toBe('github.com/acme/widget')
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('drops commits when the repo has no remote, but keeps PR-linked sessions', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-noremote-'))
try {
initRepo(repoDir) // no origin remote
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: local only', '2026-01-01T10:30:00Z')
const withPr = makeSession({
sessionId: 'sess-pr',
prLinks: ['https://github.com/acme/widget/pull/7'],
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const withoutPr = makeSession({ sessionId: 'sess-nopr' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [withPr, withoutPr] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
// sess-pr wins the commit window but has no repo identity, so commits
// are dropped; the PR link alone justifies the record. sess-nopr has
// nothing joinable and is omitted.
expect(records).toHaveLength(1)
expect(records[0]!.sessionId).toBe('sess-pr')
expect(records[0]!.repo).toBeNull()
expect(records[0]!.commits).toEqual([])
expect(records[0]!.prLinks).toEqual(['https://github.com/acme/widget/pull/7'])
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('never egresses the cwd repo for sessions whose project path did not resolve (fallback)', async () => {
// The reviewer's repro: push from inside a private repo while a session's
// project path no longer resolves. The fallback identity must NOT leak
// the cwd repo's remote or commits into that session's attribution.
const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-privatecwd-'))
try {
initRepo(cwdRepo)
git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:secret-org/nda-client-repo.git'])
await writeFile(join(cwdRepo, 'file.txt'), 'confidential\n')
commitAt(cwdRepo, 'feat: private work', '2026-01-01T10:30:00Z')
// Session A: project path is gone (deleted dir) — falls back to cwd
const orphanNoPr = makeSession({ sessionId: 'orphan-nopr', ...{ firstTimestamp: '2026-01-01T10:15:00.000Z', lastTimestamp: '2026-01-01T10:45:00.000Z' } })
// Session B: also fallback, but carries a PR link (session-native, safe)
const orphanWithPr = makeSession({
sessionId: 'orphan-pr',
prLinks: ['https://github.com/acme/widget/pull/9'],
firstTimestamp: '2026-01-01T12:00:00.000Z',
lastTimestamp: '2026-01-01T12:30:00.000Z',
})
// Session C: genuinely belongs to the cwd repo (own path resolves)
const genuine = makeSession({ sessionId: 'genuine-cwd', firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' })
const projects = [
{ project: 'ghost', projectPath: join(cwdRepo, 'no-such-dir-anymore-xyz'), sessions: [orphanNoPr] },
{ project: 'ghost2', projectPath: '', sessions: [orphanWithPr] },
{ project: 'real', projectPath: cwdRepo, sessions: [genuine] },
] as ProjectSummary[]
const records = computeAttributionRecords(projects, range, cwdRepo)
// orphan-nopr: nothing joinable -> no record at all
expect(records.find(r => r.sessionId === 'orphan-nopr')).toBeUndefined()
// orphan-pr: PR link only — no repo, no commits
const pr = records.find(r => r.sessionId === 'orphan-pr')!
expect(pr.repo).toBeNull()
expect(pr.commits).toEqual([])
// genuine cwd session keeps full attribution
const own = records.find(r => r.sessionId === 'genuine-cwd')!
expect(own.repo).toBe('github.com/secret-org/nda-client-repo')
expect(own.commits).toHaveLength(1)
// The private repo identity appears ONLY on the genuine record
const leaked = records.filter(r => r.sessionId !== 'genuine-cwd' && JSON.stringify(r).includes('secret-org'))
expect(leaked).toEqual([])
} finally {
await rm(cwdRepo, { recursive: true, force: true })
}
})
it('fallback sessions cannot steal a commit from a genuine session', async () => {
const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-steal-'))
try {
initRepo(cwdRepo)
git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(cwdRepo, 'file.txt'), 'x\n')
commitAt(cwdRepo, 'feat: mine', '2026-01-01T10:30:00Z')
// Fallback session has the TIGHTER window (would win under old logic);
// genuine session has the broader window.
const fallbackTight = makeSession({
sessionId: 'fallback-tight',
prLinks: ['https://github.com/acme/widget/pull/2'],
firstTimestamp: '2026-01-01T10:25:00.000Z',
lastTimestamp: '2026-01-01T10:35:00.000Z',
})
const genuineBroad = makeSession({ sessionId: 'genuine-broad' })
const projects = [
{ project: 'ghost', projectPath: '', sessions: [fallbackTight] },
{ project: 'real', projectPath: cwdRepo, sessions: [genuineBroad] },
] as ProjectSummary[]
const records = computeAttributionRecords(projects, range, cwdRepo)
expect(records.find(r => r.sessionId === 'fallback-tight')!.commits).toEqual([])
expect(records.find(r => r.sessionId === 'genuine-broad')!.commits).toHaveLength(1)
} finally {
await rm(cwdRepo, { recursive: true, force: true })
}
})
it('awards each commit to a single session (tightest window)', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-overlap-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'https://github.com/acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: shared window', '2026-01-01T10:30:00Z')
const tight = makeSession({
sessionId: 'sess-tight',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const broad = makeSession({ sessionId: 'sess-broad' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [tight, broad] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
// Both sessions get records (the loser is a retraction candidate),
// but the commit is awarded exactly once — to the tighter window.
expect(records).toHaveLength(2)
const tightRecord = records.find(r => r.sessionId === 'sess-tight')!
const broadRecord = records.find(r => r.sessionId === 'sess-broad')!
expect(tightRecord.commits).toHaveLength(1)
expect(broadRecord.commits).toEqual([])
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
})
// ── Dedup keys and flattening ─────────────────────────────────────────
function makeRecord(overrides: Partial<SessionAttributionRecord> = {}): SessionAttributionRecord {
return {
sessionId: 'sess-1',
project: 'app',
repo: 'github.com/acme/widget',
prLinks: ['https://github.com/acme/widget/pull/3'],
commits: [
{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false },
],
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
...overrides,
}
}
describe('attribution dedup keys', () => {
it('encodes commit state so a state transition mints a new key', () => {
const before = commitAttributionKey('sess-1', 'abc123', false, false)
const merged = commitAttributionKey('sess-1', 'abc123', true, false)
const reverted = commitAttributionKey('sess-1', 'abc123', true, true)
expect(new Set([before, merged, reverted]).size).toBe(3)
// Same state = same key (ledger dedupes repeats)
expect(commitAttributionKey('sess-1', 'abc123', true, false)).toBe(merged)
})
it('session key is stable for identical state and changes with commit state', () => {
const record = makeRecord()
expect(sessionAttributionKey(record)).toBe(sessionAttributionKey(makeRecord()))
const mutated = makeRecord({
commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
})
expect(sessionAttributionKey(mutated)).not.toBe(sessionAttributionKey(record))
})
it('session key changes when the window or project changes (ongoing sessions re-emit)', () => {
const base = makeRecord()
const grown = makeRecord({ lastTimestamp: '2026-01-01T12:00:00.000Z' })
const renamed = makeRecord({ project: 'app-renamed' })
expect(sessionAttributionKey(grown)).not.toBe(sessionAttributionKey(base))
expect(sessionAttributionKey(renamed)).not.toBe(sessionAttributionKey(base))
})
it('flattens one session item plus one item per commit', () => {
const items = flattenAttributionRecords([makeRecord()])
expect(items).toHaveLength(2)
expect(items[0]).toMatchObject({ kind: 'session', commitCount: 1, endTimestamp: '2026-01-01T11:00:00.000Z' })
expect(items[1]).toMatchObject({ kind: 'commit', sha: 'a'.repeat(40), inMain: true, wasReverted: false })
expect(items.map(i => i.dedupKey)).toEqual([
sessionAttributionKey(makeRecord()),
commitAttributionKey('sess-1', 'a'.repeat(40), true, false),
])
})
})
// ── PR link sanitization ──────────────────────────────────────────────
describe('sanitizePrLinks', () => {
it('keeps only https URLs shaped like org/repo/pull/N', () => {
expect(sanitizePrLinks([
'https://github.com/acme/widget/pull/12',
'https://ghe.corp.example.com/team/svc/pull/3', // GHE hosts allowed
'http://github.com/acme/widget/pull/12', // not https
'javascript:alert(1)', // not a URL shape we accept
'https://github.com/acme/widget/issues/12', // not a PR path
'https://github.com/acme/widget/pull/12/files', // extra path segment
'not a url at all',
'',
'https://github.com/acme/widget/pull/notanumber',
])).toEqual([
'https://ghe.corp.example.com/team/svc/pull/3',
'https://github.com/acme/widget/pull/12',
])
})
it('rebuilds links from origin + pathname: userinfo, query, and fragment never survive', () => {
expect(sanitizePrLinks([
'https://alice:ghp_TOKEN@github.com/acme/widget/pull/5',
'https://github.com/acme/widget/pull/7?notification_referrer_id=xyz',
'https://github.com/acme/widget/pull/6#pullrequestreview-123',
])).toEqual([
'https://github.com/acme/widget/pull/5',
'https://github.com/acme/widget/pull/6',
'https://github.com/acme/widget/pull/7',
])
})
it('dedupes links that collapse to the same rebuilt URL', () => {
expect(sanitizePrLinks([
'https://github.com/acme/widget/pull/9',
'https://github.com/acme/widget/pull/9?ref=a',
'https://github.com/acme/widget/pull/9#comment',
])).toEqual(['https://github.com/acme/widget/pull/9'])
})
it('drops oversized inputs and caps the count per session', () => {
// A long referrer query is stripped, so the link survives...
const longQuery = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}`
expect(sanitizePrLinks([longQuery])).toEqual(['https://github.com/acme/widget/pull/1'])
// ...but pathological inputs beyond the input bound are dropped outright
const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(600)}`
expect(sanitizePrLinks([huge])).toEqual([])
// And a rebuilt link that is itself oversized is dropped
const longPath = `https://github.com/${'o'.repeat(150)}/${'r'.repeat(80)}/pull/1`
expect(sanitizePrLinks([longPath])).toEqual([])
const many = Array.from({ length: 30 }, (_, i) => `https://github.com/acme/widget/pull/${i + 1}`)
expect(sanitizePrLinks(many)).toHaveLength(MAX_PR_LINKS_PER_SESSION)
})
})
// ── OTLP payload ──────────────────────────────────────────────────────
function attrMap(attributes: OtlpAttribute[]): Record<string, unknown> {
return Object.fromEntries(attributes.map(a => [a.key, a.value]))
}
describe('buildAttributionOtlpPayload', () => {
it('builds session and commit spans sharing the session traceId', () => {
const items = flattenAttributionRecords([makeRecord()])
const payload = buildAttributionOtlpPayload(items)
const resource = payload.resourceSpans[0]!
const resourceAttrs = attrMap(resource.resource.attributes)
expect(resourceAttrs['codeburn.attribution_methodology']).toEqual({ stringValue: 'timestamp-window' })
expect(resourceAttrs['codeburn.device_id']).toBeDefined()
const spans = resource.scopeSpans[0]!.spans
expect(spans).toHaveLength(2)
const sessionSpan = spans.find(s => s.name === SESSION_ATTRIBUTION_SPAN_NAME)!
const commitSpan = spans.find(s => s.name === COMMIT_ATTRIBUTION_SPAN_NAME)!
expect(sessionSpan.traceId).toBe(deriveTraceId('sess-1'))
expect(commitSpan.traceId).toBe(deriveTraceId('sess-1'))
expect(sessionSpan.spanId).not.toBe(commitSpan.spanId)
const sessionAttrs = attrMap(sessionSpan.attributes)
expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' })
expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' })
expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' })
expect(sessionAttrs['git.pr_links']).toEqual({
arrayValue: { values: [{ stringValue: 'https://github.com/acme/widget/pull/3' }] },
})
// Session span carries the real window as its duration
expect(sessionSpan.startTimeUnixNano).toBe((BigInt(new Date('2026-01-01T10:00:00.000Z').getTime()) * 1_000_000n).toString())
expect(sessionSpan.endTimeUnixNano).toBe((BigInt(new Date('2026-01-01T11:00:00.000Z').getTime()) * 1_000_000n).toString())
const commitAttrs = attrMap(commitSpan.attributes)
expect(commitAttrs['git.sha']).toEqual({ stringValue: 'a'.repeat(40) })
expect(commitAttrs['git.in_main']).toEqual({ boolValue: true })
expect(commitAttrs['git.was_reverted']).toEqual({ boolValue: false })
expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
})
it('omits git.repo when null and pr_links when empty', () => {
const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })])
const payload = buildAttributionOtlpPayload(items)
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
expect(spans).toHaveLength(1)
const attrs = attrMap(spans[0]!.attributes)
expect(attrs['git.repo']).toBeUndefined()
expect(attrs['git.pr_links']).toBeUndefined()
expect(attrs['git.commit_count']).toEqual({ intValue: '0' })
})
it('batches items by maxBatchSize', () => {
const items = flattenAttributionRecords([makeRecord(), makeRecord({ sessionId: 'sess-2' })])
expect(batchAttributionItems(items, 3).map(b => b.length)).toEqual([3, 1])
})
it('clamps span end time: never 0, never earlier than start + 1ms', () => {
// Session window ends BEFORE it starts (out-of-order provider timestamps)
const outOfOrder = flattenAttributionRecords([makeRecord({
firstTimestamp: '2026-01-01T11:00:00.000Z',
lastTimestamp: '2026-01-01T10:00:00.000Z',
})])
const span1 = buildAttributionOtlpPayload(outOfOrder).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
expect(BigInt(span1.endTimeUnixNano)).toBe(BigInt(span1.startTimeUnixNano) + 1_000_000n)
// Malformed end timestamp (toUnixNano -> 0)
const malformed = flattenAttributionRecords([makeRecord({ lastTimestamp: 'not-a-date' })])
const span2 = buildAttributionOtlpPayload(malformed).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
expect(span2.endTimeUnixNano).not.toBe('0')
expect(BigInt(span2.endTimeUnixNano)).toBe(BigInt(span2.startTimeUnixNano) + 1_000_000n)
})
})
// ── Send + ledger pipeline ────────────────────────────────────────────
type MockResponse = { status: number; body?: unknown; headers?: Record<string, string> }
function startMockOtlp(responses: MockResponse[]): Promise<{
url: string
server: Server
requests: Array<{ auth: string | undefined; body: unknown }>
}> {
const requests: Array<{ auth: string | undefined; body: unknown }> = []
let idx = 0
return new Promise(resolve => {
const server = createServer((req, res) => {
let raw = ''
req.on('data', c => { raw += c })
req.on('end', () => {
requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') })
const r = responses[Math.min(idx, responses.length - 1)]!
idx++
res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers })
res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}')
})
})
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as { port: number }
resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests })
})
})
}
let tmpDir: string
const originalHome = process.env.HOME
const originalXdgCache = process.env.XDG_CACHE_HOME
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-push-'))
process.env.HOME = tmpDir
process.env.XDG_CACHE_HOME = join(tmpDir, '.cache')
})
afterEach(async () => {
process.env.HOME = originalHome
if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME
else process.env.XDG_CACHE_HOME = originalXdgCache
await rm(tmpDir, { recursive: true, force: true })
})
describe('sendAttributionBatches + collectUnsentAttribution', () => {
it('sends attribution spans, ledgers dedup keys, and filters them on the next collect', async () => {
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const record = makeRecord()
const first = collectUnsentAttribution([record])
expect(first.unsent).toHaveLength(2)
const mock = await startMockOtlp([{ status: 200 }])
try {
const result = await sendAttributionBatches({
endpoint: mock.url,
accessToken: 'token-1',
batches: [first.unsent],
})
expect(result.outcome).toBe('complete')
expect(result.totalSent).toBe(2)
expect(result.totalCostSent).toBe(0)
expect(mock.requests).toHaveLength(1)
expect(mock.requests[0]!.auth).toBe('Bearer token-1')
const body = mock.requests[0]!.body as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
const names = body.resourceSpans[0]!.scopeSpans[0]!.spans.map(s => s.name).sort()
expect(names).toEqual([COMMIT_ATTRIBUTION_SPAN_NAME, SESSION_ATTRIBUTION_SPAN_NAME])
const ledgered = readLedger().map(e => e.key).sort()
expect(ledgered).toEqual(first.unsent.map(i => i.dedupKey).sort())
// Identical state on the next push: nothing unsent
expect(collectUnsentAttribution([record]).unsent).toEqual([])
// State transition (commit reverted): the changed facts re-send
const mutated = makeRecord({
commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
})
const after = collectUnsentAttribution([mutated])
expect(after.unsent.map(i => i.kind).sort()).toEqual(['commit', 'session'])
} finally {
mock.server.close()
}
})
it('retracts a session span when its commit migrates to a tighter-window session', async () => {
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
const sha = 'b'.repeat(40)
const commit = { sha, timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false }
// Push 1: session A (broad window) owns the commit
const push1 = collectUnsentAttribution([makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [commit] })])
expect(push1.unsent).toHaveLength(2)
const mock = await startMockOtlp([{ status: 200 }])
try {
await sendAttributionBatches({ endpoint: mock.url, accessToken: 't', batches: [push1.unsent] })
// Push 2: a later-parsed tighter session B now wins the commit; A is empty
const push2 = collectUnsentAttribution([
makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [] }), // loser: retraction candidate
makeRecord({ sessionId: 'sess-B', prLinks: [], commits: [commit] }), // winner
])
// A re-emits with commit_count 0 (retraction), B emits session + commit
const kinds = push2.unsent.map(i => `${i.sessionId}:${i.kind}`).sort()
expect(kinds).toEqual(['sess-A:session', 'sess-B:commit', 'sess-B:session'])
const retraction = push2.unsent.find(i => i.sessionId === 'sess-A')!
expect(retraction.commitCount).toBe(0)
expect(retraction.dedupKey).not.toBe(push1.unsent.find(i => i.kind === 'session')!.dedupKey)
// A session that was NEVER sent stays excluded when empty
const neverSent = collectUnsentAttribution([
makeRecord({ sessionId: 'sess-never', prLinks: [], commits: [] }),
])
expect(neverSent.unsent).toEqual([])
} finally {
mock.server.close()
}
})
it('does not ledger on server error', async () => {
const { sendAttributionBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const items = flattenAttributionRecords([makeRecord()])
const mock = await startMockOtlp([{ status: 500 }])
try {
const result = await sendAttributionBatches({
endpoint: mock.url,
accessToken: 'token-1',
batches: [items],
})
expect(result.outcome).toBe('server-error')
expect(readLedger()).toEqual([])
} finally {
mock.server.close()
}
})
})