diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c3807..d846966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index f6d04b6..d5a318c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Claude for Open Source Recipient + Codex and Claude for Open Source Recipient

diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 30dfe17..208bd86 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -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 }) => { diff --git a/app/electron/main.ts b/app/electron/main.ts index 82a95c0..5eddbdc 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -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, diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 39426eb..1c4bc3e 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -20,7 +20,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise { // 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), diff --git a/app/electron/quota/codex.test.ts b/app/electron/quota/codex.test.ts index b8e2de4..4e429d6 100644 --- a/app/electron/quota/codex.test.ts +++ b/app/electron/quota/codex.test.ts @@ -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) }) diff --git a/app/electron/quota/codex.ts b/app/electron/quota/codex.ts index b339d50..42b32df 100644 --- a/app/electron/quota/codex.ts +++ b/app/electron/quota/codex.ts @@ -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 = { 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): 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 : {} 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, } } diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 81c7e09..8548917 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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>(), + getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getModels: vi.fn(), @@ -273,6 +273,25 @@ describe('App shortcuts', () => { }) }) + it('drives combined-scope overview fetches and persists the Scope setting', async () => { + render() + 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() + 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). diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 1540bd3..a3110a2 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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>([]) const [customRange, setCustomRange] = useState(null) const [claudeConfigSource, setClaudeConfigSource] = useState(initialConfigSource) + const [scope, setScopeState] = useState(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( - () => 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(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>(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 ( @@ -494,12 +534,12 @@ function AppMain() { {section === 'plans' ? ( ) : section === 'settings' ? ( - + ) : ( <>

{section === 'overview' ? ( - + ) : section === 'sessions' ? ( ) : section === 'pullRequests' ? ( diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 14e2d80..faf00a9 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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 // `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 + // `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 getPlans(period: Period): Promise getActReport(): Promise readonly platform: string diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 3138792..1093eeb 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -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() + + 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() + + 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 = { diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 06cb9c6..b2a9dd2 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -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 } +/** 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 ( +
+
{usage.combined.reachableCount} of {usage.combined.deviceCount} devices
+ {usage.perDevice.map(device => ( +
+ {device.local ? `${device.name} · this device` : device.name} + {device.error ?? formatUsd(device.cost)} +
+ ))} +
+ ) +} + 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 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 && }
-
{data.current.label}{streakDays(data.history.daily, now)}-day streak
- -
{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions
- {saved > 0 && ( -
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
- )} - {localSaved > 0 && ( -
Saved via local models{formatUsd(localSaved)}local-model routing
- )} +
{combined ? `Combined · ${data.current.label}` : data.current.label}{streakDays(data.history.daily, now)}-day streak
+ +
{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions
+ {combined + ? + : ( + <> + {saved > 0 && ( +
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ )} + {localSaved > 0 && ( +
Saved via local models{formatUsd(localSaved)}local-model routing
+ )} + + )}
diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index c2d1282..58aa987 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -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() + 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() diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 17666f2..f3af6fe 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -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(initialPane ?? 'general') return ( @@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ))}
- {pane === 'general' && } + {pane === 'general' && } {pane === 'providers' && } {pane === 'aliases' && } {pane === 'pricing' && } @@ -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(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce]) const [theme, setTheme] = useState(() => { @@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+
onScopeChange?.(value)} width={110} />
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
{budgetError &&

{budgetError}

} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 64b45c5..f82172b 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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; } diff --git a/assets/open-source-recipient.png b/assets/open-source-recipient.png new file mode 100644 index 0000000..9c54891 Binary files /dev/null and b/assets/open-source-recipient.png differ diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 505b308..b68847d 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -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`. diff --git a/docs/sync/README.md b/docs/sync/README.md index 5f1343e..64540b8 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -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 diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 32c9cdd..ca8f026 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -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) } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 1188eea..f8b2958 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -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] diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift index 0eea71f..d25637c 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -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(_ c: KeyedDecodingContainer, _ 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(_ c: KeyedDecodingContainer, _ key: K) -> Int? { + double(c, key).flatMap { Int(exactly: $0.rounded()) } + } + static func bool(_ c: KeyedDecodingContainer, _ 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: 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].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 diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index 3a5d814..2fbbdbf 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -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 + } } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 25b128a..42ae294 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -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 } diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index f220ba5..289ad58 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -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: { diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 74d0ec8..d4e8b27 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -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() diff --git a/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift new file mode 100644 index 0000000..bd55447 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift @@ -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))) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift new file mode 100644 index 0000000..07f0443 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift @@ -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") + } +} diff --git a/src/cache-refresh-lock.ts b/src/cache-refresh-lock.ts index a012009..58faf28 100644 --- a/src/cache-refresh-lock.ts +++ b/src/cache-refresh-lock.ts @@ -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, 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 { @@ -100,6 +124,7 @@ async function observe(path: string): Promise { // 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 { await delay(1) continue } - const parsed = JSON.parse(raw) as Partial - 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 | undefined + try { parsed = JSON.parse(raw) as Partial } 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 { } 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 = 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) diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 0eb59b4..6146e8e 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -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 } diff --git a/src/codex-throughput.ts b/src/codex-throughput.ts new file mode 100644 index 0000000..4796206 --- /dev/null +++ b/src/codex-throughput.ts @@ -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>((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 + 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 { + 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 { + return new CodexThroughputReader().update(filePath, limit, true) +} + +export async function newestCodexSession(sessions: Array<{ path: string }>): Promise { + 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') +} diff --git a/src/daily-cache.ts b/src/daily-cache.ts index c5439c3..7a7486c 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -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 = {} 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[]): DailyEntry[] { })) } -function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean }): DailyCache { +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; 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 { // 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((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 diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 1b68a22..c4c1dbb 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 { 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 ( - {''.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)} + {''.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) : ''} {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 ( @@ -477,6 +487,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} {oneShotLabel.padStart(MODEL_COL_ONESHOT)} + {showTps && {tpsLabel.padStart(MODEL_COL_TPS)}} ) })} @@ -488,6 +499,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {anyEstimated && ( ~ estimated cost (priced from estimated tokens) )} + {showTps && ( + ~ Tok/s: generated tokens / active time; tool wait excluded + )} ) } diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 5563de6..cdac6a2 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -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 diff --git a/src/main.ts b/src/main.ts index ae6867c..6ce62b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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[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 ', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10) + .option('--watch ', '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 => { + 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((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') diff --git a/src/model-breakdown.ts b/src/model-breakdown.ts index 5801be8..799338e 100644 --- a/src/model-breakdown.ts +++ b/src/model-breakdown.ts @@ -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 0) { + out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`)) + } + return out.join('\n') + '\n' } diff --git a/src/parser.ts b/src/parser.ts index ee165e3..4fd7e14 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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(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): 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() 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() 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 { const key = cacheKey(dateRange, providerFilter) const cached = sessionCache.get(key) @@ -3594,6 +3696,7 @@ async function runParse( options: RunParseOptions = {}, ): Promise { const { isCold = false, readOnly = false, refreshLock } = options + readOnlyServedStale = false const seenMsgIds = new Set() const seenKeys = new Set() 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 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 diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 5cdce82..faa8fcf 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -32,6 +32,9 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0 const toolNameMap: Record = { 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 + 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 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 { 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() const sessionsDir = join(codexDir, 'sessions') const years = await readdir(sessionsDir).catch(() => [] as string[]) @@ -335,8 +504,9 @@ async function discoverSessionsInDir(codexDir: string): Promise 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 // 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): 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() // 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): 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): 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>((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 @@ -490,6 +741,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // attributed. Rebuild the canonical `mcp____` 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)['invocation'] as Record | 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): 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): SessionPars ...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}), ...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}), }) + taskGeneratedTokens += estOutput pendingTools = [] pendingToolSequence = [] @@ -660,7 +917,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars 0, ) - results.push({ + pendingTaskCalls.push({ provider: 'codex', model, inputTokens: uncachedInputTokens, @@ -685,6 +942,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars ...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}), ...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}), }) + taskGeneratedTokens += outputTokens + reasoningTokens pendingTools = [] pendingToolSequence = [] @@ -701,6 +959,9 @@ function createParser(source: SessionSource, seenKeys: Set): 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) { diff --git a/src/providers/types.ts b/src/providers/types.ts index b5396a2..8f9e902 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -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. diff --git a/src/session-cache.ts b/src/session-cache.ts index 461dd46..2682756 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -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 = { // 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']) diff --git a/src/sync/cli.ts b/src/sync/cli.ts index 174b616..f6f28db 100644 --- a/src/sync/cli.ts +++ b/src/sync/cli.ts @@ -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 ', '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 ` 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>['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) { diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts index 8a12c7e..8df36c5 100644 --- a/src/sync/otlp.ts +++ b/src/sync/otlp.ts @@ -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 +} diff --git a/src/sync/push.ts b/src/sync/push.ts index 0444c71..7a22420 100644 --- a/src/sync/push.ts +++ b/src/sync/push.ts @@ -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 { + 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 = Omit & { + batches: T[][] + buildPayload: (batch: T[]) => OtlpPayload + toOutbound: (item: T) => OutboundItem +} + +async function sendBatchesCore(opts: SendBatchesCoreOptions): Promise { assertHttps(opts.endpoint, 'Traces endpoint') const log = opts.log ?? (() => {}) const sleep = opts.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))) @@ -107,7 +133,7 @@ export async function sendBatches(opts: SendBatchesOptions): Promise // 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 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 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() + 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 { + 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 { + return sendBatchesCore({ + ...opts, + buildPayload: buildAttributionOtlpPayload, + toOutbound: item => ({ key: item.dedupKey, ts: item.timestamp, costUSD: 0 }), + }) +} + export { batchCalls } diff --git a/src/types.ts b/src/types.ts index d5624cf..a51ae67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 + modelBreakdown: Record toolBreakdown: Record mcpBreakdown: Record bashBreakdown: Record diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index baf4c31..86d0e2e 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -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, + include: string[], + exclude: string[], +): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record; matched: number } { + const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record, 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 = {} + 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 | 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 } } /** diff --git a/src/yield.ts b/src/yield.ts index ce3cbd0..79239b9 100644 --- a/src/yield.ts +++ b/src/yield.ts @@ -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 { - 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 { const repoIdentityCache = new Map() - // 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() 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 { + 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() + 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() + const lostCandidacyBySession = new Map() + 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 +} diff --git a/tests/cache-refresh-lock-corrupt-body.test.ts b/tests/cache-refresh-lock-corrupt-body.test.ts new file mode 100644 index 0000000..c4a7702 --- /dev/null +++ b/tests/cache-refresh-lock-corrupt-body.test.ts @@ -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 { + 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 => 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) +}) diff --git a/tests/cache-refresh-lock-process.test.ts b/tests/cache-refresh-lock-process.test.ts index cf907c3..d837f81 100644 --- a/tests/cache-refresh-lock-process.test.ts +++ b/tests/cache-refresh-lock-process.test.ts @@ -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) diff --git a/tests/cache-refresh-lock.test.ts b/tests/cache-refresh-lock.test.ts index bb8e44b..4ba633d 100644 --- a/tests/cache-refresh-lock.test.ts +++ b/tests/cache-refresh-lock.test.ts @@ -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'] + }) +}) diff --git a/tests/cli-codex-tps.test.ts b/tests/cli-codex-tps.test.ts new file mode 100644 index 0000000..4b39027 --- /dev/null +++ b/tests/cli-codex-tps.test.ts @@ -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') + }) +}) diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts index 82f1e44..8a19b65 100644 --- a/tests/cli-durable-totals.test.ts +++ b/tests/cli-durable-totals.test.ts @@ -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 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 { + 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 { + 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) +}) diff --git a/tests/codex-throughput.test.ts b/tests/codex-throughput.test.ts new file mode 100644 index 0000000..1e0fd4d --- /dev/null +++ b/tests/codex-throughput.test.ts @@ -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) => 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 }) + }) +}) diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index 62197fa..a3056b8 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -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() diff --git a/tests/daily-cache-degraded-completeness.test.ts b/tests/daily-cache-degraded-completeness.test.ts new file mode 100644 index 0000000..d3df6cc --- /dev/null +++ b/tests/daily-cache-degraded-completeness.test.ts @@ -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 { + return { cost, calls, savingsUSD: 0, ...extra } +} + +function day(date: string, providers: Record, overrides: Partial = {}): 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 => [] + +/// 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 = {}): Promise { + 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) + }) +}) diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 6ae0707..d887d25 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -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) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 59ada8f..0abd36d 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -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) + }) +}) diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 24b9c16..7d70d14 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -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 { const byDate: Record = {} 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) }) }) diff --git a/tests/fixtures/cache-refresh-corrupt-owner.ts b/tests/fixtures/cache-refresh-corrupt-owner.ts new file mode 100644 index 0000000..dfacd26 --- /dev/null +++ b/tests/fixtures/cache-refresh-corrupt-owner.ts @@ -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'), '') diff --git a/tests/fixtures/cache-refresh-slow-owner.ts b/tests/fixtures/cache-refresh-slow-owner.ts new file mode 100644 index 0000000..70b5755 --- /dev/null +++ b/tests/fixtures/cache-refresh-slow-owner.ts @@ -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() +} diff --git a/tests/fixtures/mock-idp.ts b/tests/fixtures/mock-idp.ts index e873b6b..d70b9f5 100644 --- a/tests/fixtures/mock-idp.ts +++ b/tests/fixtures/mock-idp.ts @@ -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 { @@ -52,6 +54,7 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise issuedTokens: { access: [], refresh: [] }, revokedTokens: [], exchangedCodes: [], + tracesRequests: [], close: async () => {}, } @@ -59,6 +62,20 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise 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' }) diff --git a/tests/parser-cache-refresh-timeout.test.ts b/tests/parser-cache-refresh-timeout.test.ts index 9241577..8883c5a 100644 --- a/tests/parser-cache-refresh-timeout.test.ts +++ b/tests/parser-cache-refresh-timeout.test.ts @@ -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) + }) }) diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 5de2dad..211c41f 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -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 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>): Set => + 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 { + 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) + }) +}) diff --git a/tests/project-filter-durable-totals.test.ts b/tests/project-filter-durable-totals.test.ts new file mode 100644 index 0000000..ff030aa --- /dev/null +++ b/tests/project-filter-durable-totals.test.ts @@ -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 + +// 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 { + 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 { + 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) + }) +}) diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts index bee7585..e729c5d 100644 --- a/tests/provider-turn-grouping.test.ts +++ b/tests/provider-turn-grouping.test.ts @@ -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'] + } + }) +}) diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 9595e35..dba279d 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -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() + 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', diff --git a/tests/sync-attribution-cli.test.ts b/tests/sync-attribution-cli.test.ts new file mode 100644 index 0000000..295350f --- /dev/null +++ b/tests/sync-attribution-cli.test.ts @@ -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 { + 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 { + 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') + }) +}) diff --git a/tests/sync-attribution.test.ts b/tests/sync-attribution.test.ts new file mode 100644 index 0000000..d4b8f6c --- /dev/null +++ b/tests/sync-attribution.test.ts @@ -0,0 +1,753 @@ +/** + * Tests for sync git attribution (sync push --attribution). + * + * Covers: remote URL normalization, session→commit 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 { + 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 { + 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 { + 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 { + 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 } + +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() + } + }) +})