mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 13:51:50 +00:00
Merge branch 'main' into fix/opencode-session-model
This commit is contained in:
commit
80f5ed6cf7
101 changed files with 8547 additions and 471 deletions
45
.github/workflows/build-windows-store.yml
vendored
Normal file
45
.github/workflows/build-windows-store.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Build Windows Store package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-windows-store.yml
|
||||
- app/**
|
||||
- src/**
|
||||
- package.json
|
||||
- package-lock.json
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
appx:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
app/package-lock.json
|
||||
|
||||
- name: Install CLI dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci --prefix app
|
||||
|
||||
- name: Build Microsoft Store package
|
||||
run: npm --prefix app run package:store
|
||||
|
||||
- name: Upload Store package
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: CodeBurn-Microsoft-Store
|
||||
path: app/release/CodeBurn-Store-*.appx
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,5 +1,27 @@
|
|||
# Changelog
|
||||
|
||||
## 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. (#833)
|
||||
- **Combined-device scope in the desktop Dashboard**, mirroring the menu bar. A Local / Combined toggle aggregates paired-device usage in the Overview hero and the menu bar badge, degrading gracefully to the local figure when a peer is unreachable; the badge then shows a dimmed `reachable/total` marker so a momentary drop to the local number reads as "a peer is unreachable" rather than a glitch. (#866, #867, thanks @marcreynolds)
|
||||
|
||||
### 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)
|
||||
|
||||
- **Midnight-straddling turns keep both halves.** A turn whose calls span local midnight was attributed whole to its start day, so `codeburn today` under-reported until the turn ended and multi-day totals mis-split it. Calls are now range-filtered inside the turn so each day gets the calls that belong to it, and By Activity and the daily turn counts reconcile with the headline. (#853, thanks @KENSHI601)
|
||||
- **`--provider <x>` no longer leaks Claude spend into the detail panels.** A provider-filtered run still ran the Claude scan, whose orphan pass re-injected every cached Claude session, so By Project / By Model / By Activity showed Claude usage under, e.g., `--provider cursor` while the headline was correct. (#872, thanks @ozymandiashh)
|
||||
- **A degraded session parse no longer freezes daily history.** A read-only parse that served a stale or missing session file was treated as complete and finalized days it never covered, freezing warm-cache ingestion; a corrupt refresh lock is now recovered rather than ending ingestion, and a legitimately idle tail is no longer re-derived on every launch. (#856, thanks @avs-io)
|
||||
- **Pi / Oh My Pi transcripts with a leading title record are discovered.** OMP writes a `type: "title"` line before the session header; discovery now scans a bounded number of leading lines for the first session record instead of requiring it on the first physical line. (#846, #859, thanks @jbspeakr, @avs-io)
|
||||
|
||||
### Fixed
|
||||
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
|
||||
|
||||
## 0.9.19 - 2026-07-20
|
||||
|
||||
One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<p align="center">
|
||||
<a href="https://claude.com/open-source-max"><img src="https://img.shields.io/badge/Claude_for_Open_Source-Recipient-da7756?style=for-the-badge&labelColor=1a1a1a" alt="Claude for Open Source Recipient" /></a>
|
||||
<a href="https://claude.com/open-source-max"><img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/open-source-recipient.png" alt="Codex and Claude for Open Source Recipient" width="720" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ npm --prefix app run package # macOS, both arm64 and x64
|
|||
npm --prefix app run package:arm64 # macOS arm64 only (faster on Apple Silicon)
|
||||
npm --prefix app run package:x64 # macOS x64 only
|
||||
npm --prefix app run package:win # Windows NSIS installer, x64
|
||||
npm --prefix app run package:store # Microsoft Store AppX, x64 (Windows host only)
|
||||
npm --prefix app run package:linux # Linux AppImage, x64
|
||||
```
|
||||
|
||||
|
|
@ -184,6 +185,27 @@ shows **"Windows protected your PC"**. Users click **"More info" → "Run
|
|||
anyway"** to launch it. This is expected for an unsigned build; the only fix is
|
||||
a purchased code-signing (Authenticode/EV) certificate.
|
||||
|
||||
### Microsoft Store (`package:store`)
|
||||
|
||||
The Store build is a separate AppX target so the GitHub NSIS installer remains
|
||||
unchanged. AppX packaging requires Windows 10 or newer and is built by the
|
||||
manual `Build Windows Store package` GitHub Actions workflow on
|
||||
`windows-latest`. Download its `CodeBurn-Microsoft-Store` workflow artifact and
|
||||
upload the contained `CodeBurn-Store-<version>-x64.appx` file in Partner Center.
|
||||
|
||||
The manifest identity must exactly match the reserved Partner Center product:
|
||||
|
||||
- Identity name: `Codeburn.CodeBurn`
|
||||
- Publisher: `CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89`
|
||||
- Publisher display name: `Codeburn`
|
||||
- Store ID: `9P0R4ZL5XMB8`
|
||||
|
||||
The Store package is intentionally unsigned: Microsoft signs it during Store
|
||||
submission. Direct sideloading requires a separate trusted or development
|
||||
certificate. The AppX declares `runFullTrust` (electron-builder's required
|
||||
default for Electron apps), so CodeBurn retains access to the user's local
|
||||
provider session files rather than running in a UWP application sandbox.
|
||||
|
||||
### Linux (`package:linux`)
|
||||
|
||||
`electron-builder --linux` produces a single artifact in `app/release/`:
|
||||
|
|
|
|||
BIN
app/build/appx/Square150x150Logo.png
Normal file
BIN
app/build/appx/Square150x150Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
app/build/appx/Square44x44Logo.png
Normal file
BIN
app/build/appx/Square44x44Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/build/appx/StoreLogo.png
Normal file
BIN
app/build/appx/StoreLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/build/appx/Wide310x150Logo.png
Normal file
BIN
app/build/appx/Wide310x150Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
|||
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
|
||||
const bridge = {
|
||||
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
|
||||
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
|
||||
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope),
|
||||
getPlans: (period: string) => invoke('codeburn:getPlans', period),
|
||||
getActReport: () => invoke('codeburn:getActReport'),
|
||||
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -133,10 +133,27 @@ function windowOf(value: unknown, override?: string): QuotaWindow | null {
|
|||
return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset }
|
||||
}
|
||||
|
||||
// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN,
|
||||
// so blank is rejected or an absent `used` decodes as a confident zero.
|
||||
function num(value: unknown): number | null {
|
||||
if (typeof value === 'string' && !value.trim()) return null
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
|
||||
function normalizePlanType(value: string): string {
|
||||
return value
|
||||
.replace(/[_-]usage[_-]based$/, '')
|
||||
.replace(/^self[_-]serve[_-]/, '')
|
||||
.replace(/[_-]cbp$/, '')
|
||||
.replace(/[_-]cbp[_-]/g, '_')
|
||||
}
|
||||
|
||||
function planLabel(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
const raw = value.trim()
|
||||
const lower = raw.toLowerCase()
|
||||
const lower = normalizePlanType(raw.toLowerCase())
|
||||
const known: Record<string, string> = {
|
||||
guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro',
|
||||
prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite',
|
||||
|
|
@ -146,6 +163,44 @@ function planLabel(value: unknown): string | null {
|
|||
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
|
||||
}
|
||||
|
||||
// The admin-set monthly allowance, the only limit a credit-metered workspace
|
||||
// has. `spend_control` is the live position, the others forward-compat. `any`
|
||||
// because this walks six optional-chained hops, all validated by `num()`.
|
||||
function spendControlWindow(data: Record<string, any>): QuotaWindow | null {
|
||||
// `find`/`num` per alias, not `??`: a non-null garbage value would stop `??`
|
||||
// and mask a valid alias further down. Object-shaped garbage still wins the
|
||||
// position, matching Swift, which likewise commits to the first that decodes.
|
||||
const row = [
|
||||
data.spend_control?.individual_limit,
|
||||
data.spend_control?.individualLimit,
|
||||
data.individual_limit,
|
||||
data.individualLimit,
|
||||
data.rate_limit?.individual_limit,
|
||||
data.rate_limit?.individualLimit,
|
||||
].find(candidate => candidate && typeof candidate === 'object')
|
||||
if (!row) return null
|
||||
const limit = num(row.limit)
|
||||
if (limit === null || limit <= 0) return null
|
||||
const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent)
|
||||
const used = num(row.used)
|
||||
const rawPercent = num(row.used_percent) ?? num(row.usedPercent)
|
||||
?? (remainingPercent === null ? null : 100 - remainingPercent)
|
||||
?? (used === null ? null : (used / limit) * 100)
|
||||
if (rawPercent === null) return null
|
||||
const percent = Math.min(1, Math.max(0, rawPercent / 100))
|
||||
const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt)
|
||||
// Past 8.64e15 ms `toISOString()` throws RangeError.
|
||||
const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15
|
||||
? new Date(resetRaw * 1000).toISOString()
|
||||
: null
|
||||
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
|
||||
const spent = used ?? limit * Math.max(0, rawPercent) / 100
|
||||
const round = (n: number) => Math.round(n).toLocaleString('en-US')
|
||||
const reached = data.spend_control?.reached === true
|
||||
const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits`
|
||||
return { label: reached ? `${label} · limit reached` : label, percent, resetsAt }
|
||||
}
|
||||
|
||||
export function decodeCodexUsage(body: unknown): QuotaProvider {
|
||||
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
|
||||
const primaryRaw = windowOf(data.rate_limit?.primary_window)
|
||||
|
|
@ -165,12 +220,21 @@ export function decodeCodexUsage(body: unknown): QuotaProvider {
|
|||
}
|
||||
}
|
||||
}
|
||||
const rawBalance = data.credits?.balance
|
||||
const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN
|
||||
const credits = spendControlWindow(data)
|
||||
if (credits) details.push(credits)
|
||||
const balance = num(data.credits?.balance)
|
||||
// Credit-settled accounts denominate in credits, so no currency symbol.
|
||||
const hasCredits = data.credits?.has_credits === true
|
||||
const footerLines: string[] = []
|
||||
if (balance !== null && balance > 0) {
|
||||
footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : balance.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`)
|
||||
}
|
||||
// Uncapped on purpose, so a bar-less card does not read as a failed fetch.
|
||||
if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited')
|
||||
return {
|
||||
provider: 'codex', connection: 'connected', primary, details,
|
||||
provider: 'codex', connection: 'connected', primary: primary ?? credits, details,
|
||||
planLabel: planLabel(data.plan_type),
|
||||
footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [],
|
||||
footerLines,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"package:arm64": "npm run stage-cli && npm run build && electron-builder --mac --arm64",
|
||||
"package:x64": "npm run stage-cli && npm run build && electron-builder --mac --x64",
|
||||
"package:win": "npm run stage-cli && npm run build && electron-builder --win",
|
||||
"package:store": "npm run stage-cli && npm run build && electron-builder --win appx --x64",
|
||||
"package:linux": "npm run stage-cli && npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -100,6 +101,20 @@
|
|||
"perMachine": false,
|
||||
"artifactName": "CodeBurn-Setup-${version}.${ext}"
|
||||
},
|
||||
"appx": {
|
||||
"applicationId": "CodeBurn",
|
||||
"identityName": "Codeburn.CodeBurn",
|
||||
"publisher": "CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89",
|
||||
"publisherDisplayName": "Codeburn",
|
||||
"displayName": "CodeBurn",
|
||||
"artifactName": "CodeBurn-Store-${version}-${arch}.${ext}",
|
||||
"backgroundColor": "#15100D",
|
||||
"languages": [
|
||||
"en-US"
|
||||
],
|
||||
"minVersion": "10.0.17763.0",
|
||||
"maxVersionTested": "10.0.26100.0"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', {
|
|||
})
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
|
||||
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
|
||||
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
|
||||
getModels: vi.fn(),
|
||||
|
|
@ -273,6 +273,25 @@ describe('App shortcuts', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('drives combined-scope overview fetches and persists the Scope setting', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
|
||||
|
||||
fireEvent.keyDown(document, { key: ',', metaKey: true })
|
||||
fireEvent.click(await screen.findByLabelText('Scope'))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Combined' }))
|
||||
|
||||
// Combined scope forces provider='all' and passes --scope combined (6th arg).
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
|
||||
expect(localStorage.getItem('codeburn.scope')).toBe('combined')
|
||||
})
|
||||
|
||||
it('boots in combined scope from the persisted Scope setting', async () => {
|
||||
localStorage.setItem('codeburn.scope', 'combined')
|
||||
render(<App />)
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
|
||||
})
|
||||
|
||||
it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
|
||||
// grok's display name is "Grok Build"; the picker must show the label but
|
||||
// send the internal id `grok` as --provider (which assertProvider accepts).
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { Compare } from './sections/Compare'
|
|||
import { Plans } from './sections/Plans'
|
||||
import { Settings, type SettingsPane } from './sections/Settings'
|
||||
import { SpendContent } from './sections/Spend'
|
||||
import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types'
|
||||
import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types'
|
||||
|
||||
// Bucket raw dollar amounts before they leave the machine: telemetry carries
|
||||
// coarse ranges, never exact spend.
|
||||
|
|
@ -130,8 +130,8 @@ const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', '
|
|||
// Instant-switch memo key for an overview result. Shared by the overview poll
|
||||
// and the provider prefetcher so the two never drift out of sync. Exported so
|
||||
// the prefetch-storm test can assert warmed keys survive between polls.
|
||||
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
|
||||
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
|
||||
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string {
|
||||
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}`
|
||||
}
|
||||
|
||||
// Prefetch pacing: wait a short idle after the first paint, then warm one
|
||||
|
|
@ -172,6 +172,15 @@ function persistConfigSource(id: string | null): void {
|
|||
} catch { /* storage can be unavailable */ }
|
||||
}
|
||||
|
||||
/** Boot scope = the persisted dashboard Scope setting, else local. */
|
||||
function initialScope(): Scope {
|
||||
try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' }
|
||||
}
|
||||
|
||||
function persistScope(scope: Scope): void {
|
||||
try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ }
|
||||
}
|
||||
|
||||
function providerName(provider: string): string {
|
||||
if (provider === 'all') return 'All providers'
|
||||
return provider
|
||||
|
|
@ -218,20 +227,27 @@ function AppMain() {
|
|||
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
|
||||
const [customRange, setCustomRange] = useState<DateRange | null>(null)
|
||||
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
|
||||
const [scope, setScopeState] = useState<Scope>(initialScope)
|
||||
const [refreshToken, setRefreshToken] = useState(0)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [, setCurrencyTick] = useState(0)
|
||||
|
||||
// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
|
||||
// stays flag-free; only add --claude-config-source once a config is picked.
|
||||
// Combined scope aggregates paired-device usage; the CLI rejects it alongside
|
||||
// a provider/config filter, so onScopeChange forces provider='all' and clears
|
||||
// the config scope before this poll runs. Passing scope='local' produces the
|
||||
// same flag-free argv as before, so local users are unaffected.
|
||||
const overview = usePolled<MenubarPayload>(
|
||||
() => claudeConfigSource
|
||||
() => scope === 'combined'
|
||||
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
|
||||
: claudeConfigSource
|
||||
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
|
||||
: customRange
|
||||
? codeburn.getOverview(period, provider, customRange)
|
||||
: codeburn.getOverview(period, provider),
|
||||
[period, provider, customRange?.from, customRange?.to, claudeConfigSource],
|
||||
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) },
|
||||
[period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope],
|
||||
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) },
|
||||
)
|
||||
const refreshOverview = overview.refresh
|
||||
|
||||
|
|
@ -273,7 +289,7 @@ function AppMain() {
|
|||
// fails we still emit the snapshot, just without the model x category cross.
|
||||
const snapshotDayRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return
|
||||
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return
|
||||
const today = localDateKey(new Date())
|
||||
if (snapshotDayRef.current === today) return
|
||||
snapshotDayRef.current = today
|
||||
|
|
@ -285,7 +301,7 @@ function AppMain() {
|
|||
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
|
||||
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
|
||||
})()
|
||||
}, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent])
|
||||
}, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent])
|
||||
|
||||
useEffect(() => {
|
||||
let saved: string | null = null
|
||||
|
|
@ -360,7 +376,9 @@ function AppMain() {
|
|||
overviewBusyRef.current = overview.loading
|
||||
const warmedKeys = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource) return
|
||||
// Combined scope has no provider picker to warm — it always shows unfiltered
|
||||
// all-device usage — so the per-provider prefetch is local-scope only.
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return
|
||||
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
|
||||
if (targets.length === 0) return
|
||||
let cancelled = false
|
||||
|
|
@ -391,7 +409,7 @@ function AppMain() {
|
|||
// `overview.data == null` (a boolean) gates on first-resolution without
|
||||
// re-running every poll; the data content itself is intentionally not a dep.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
|
@ -451,17 +469,22 @@ function AppMain() {
|
|||
|
||||
// A Claude config scopes Claude usage only, so a non-Claude provider filter
|
||||
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
|
||||
// filter is already compatible and is left alone).
|
||||
// filter is already compatible and is left alone). Picking a config also
|
||||
// implies a device-specific view, so drop combined scope back to local.
|
||||
const onConfigSelect = (id: string) => {
|
||||
const next = id || null
|
||||
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
|
||||
if (next && scope === 'combined') { setScopeState('local'); persistScope('local') }
|
||||
setClaudeConfigSource(next)
|
||||
persistConfigSource(next)
|
||||
}
|
||||
|
||||
// Symmetric direction: picking a non-Claude provider while a config is
|
||||
// scoped would hit the same CLI rejection, so drop the config scope.
|
||||
// scoped would hit the same CLI rejection, so drop the config scope. A
|
||||
// specific provider filter is a device-specific view, so it also drops
|
||||
// combined scope back to local (combined reports unfiltered usage).
|
||||
const onProviderSelect = (value: string) => {
|
||||
if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') }
|
||||
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
|
||||
setClaudeConfigSource(null)
|
||||
persistConfigSource(null)
|
||||
|
|
@ -469,6 +492,19 @@ function AppMain() {
|
|||
setProvider(value)
|
||||
}
|
||||
|
||||
// Combined scope reports unfiltered, all-provider usage across paired devices,
|
||||
// so switching to it resets the provider filter and Claude-config scope (which
|
||||
// the CLI would otherwise reject), mirroring the menubar's setMenubarScope.
|
||||
const onScopeChange = (value: string) => {
|
||||
const next: Scope = value === 'combined' ? 'combined' : 'local'
|
||||
if (next === 'combined') {
|
||||
if (provider !== 'all') setProvider('all')
|
||||
if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) }
|
||||
}
|
||||
setScopeState(next)
|
||||
persistScope(next)
|
||||
}
|
||||
|
||||
const claudeConfigs = overview.data?.claudeConfigs
|
||||
const providerOptions = [
|
||||
{ value: 'all', label: 'All providers' },
|
||||
|
|
@ -478,7 +514,11 @@ function AppMain() {
|
|||
const activeConfigLabel = claudeConfigSource
|
||||
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
|
||||
: null
|
||||
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
|
||||
// Combined scope reports unfiltered all-device usage, so the caption reads
|
||||
// "Combined" in place of the (forced-'all') provider label.
|
||||
const scopeCaption = scope === 'combined'
|
||||
? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined`
|
||||
: `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
|
||||
|
||||
return (
|
||||
<Window>
|
||||
|
|
@ -494,12 +534,12 @@ function AppMain() {
|
|||
{section === 'plans' ? (
|
||||
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
|
||||
) : section === 'settings' ? (
|
||||
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
|
||||
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
|
||||
) : (
|
||||
<>
|
||||
<TopBar
|
||||
title={SECTION_TITLES[section]}
|
||||
scope={scope}
|
||||
scope={scopeCaption}
|
||||
period={period}
|
||||
onPeriodChange={onPeriodChange}
|
||||
customRange={customRange}
|
||||
|
|
@ -514,7 +554,7 @@ function AppMain() {
|
|||
/>
|
||||
<div className={motionClass('body', 'section-fade')}>
|
||||
{section === 'overview' ? (
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} />
|
||||
) : section === 'sessions' ? (
|
||||
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
|
||||
) : section === 'pullRequests' ? (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime'
|
||||
|
||||
// Dashboard usage scope: this device only ('local') or the aggregate across
|
||||
// every paired device ('combined'). Mirrors the macOS menubar's Scope setting.
|
||||
export type Scope = 'local' | 'combined'
|
||||
|
||||
export type DateRange = { from: string; to: string }
|
||||
|
||||
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
|
||||
|
|
@ -637,7 +641,9 @@ export interface CodeburnBridge {
|
|||
getQuota(force?: boolean): Promise<QuotaProvider[]>
|
||||
// `background` (prefetch only) requests background CLI-spawn priority; optional
|
||||
// so an older preload that ignores it degrades to interactive priority.
|
||||
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise<MenubarPayload>
|
||||
// `scope` selects local-device usage ('local', default) or paired-device
|
||||
// aggregate ('combined'); optional so an older preload degrades to local.
|
||||
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise<MenubarPayload>
|
||||
getPlans(period: Period): Promise<StatusJson>
|
||||
getActReport(): Promise<ActReportJson>
|
||||
readonly platform: string
|
||||
|
|
|
|||
|
|
@ -520,6 +520,49 @@ describe('Overview', () => {
|
|||
expect(screen.queryByText('Saved to date')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows paired-device aggregate totals in the hero under combined scope', async () => {
|
||||
const now = new Date()
|
||||
const payload = makePayload(now)
|
||||
// Local device: $312.40 / 4200 calls / 88 sessions (from makePayload).
|
||||
// Combined swaps the hero to the cross-device aggregate and lists devices.
|
||||
payload.combined = {
|
||||
perDevice: [
|
||||
{ id: 'local', name: 'laptop', local: true, cost: 312.4, calls: 4200, sessions: 88, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
|
||||
{ id: 'fp-workstation', name: 'workstation', local: false, cost: 187.6, calls: 2100, sessions: 40, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
|
||||
],
|
||||
combined: { cost: 500, calls: 6300, sessions: 128, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
|
||||
}
|
||||
|
||||
const { container } = render(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="combined" />)
|
||||
|
||||
const kpis = container.querySelector('.ov-hero-main') as HTMLElement
|
||||
// Hero cost is the combined $500, not the local $312.40.
|
||||
expect(within(kpis).getByText('$500.00')).toBeInTheDocument()
|
||||
expect(within(kpis).getByText(/6,300 calls · 128 sessions/)).toBeInTheDocument()
|
||||
expect(within(kpis).getByText('Combined · Last 30 days')).toBeInTheDocument()
|
||||
expect(within(kpis).getByText('2 of 2 devices')).toBeInTheDocument()
|
||||
expect(within(kpis).getByText('workstation')).toBeInTheDocument()
|
||||
expect(within(kpis).getByText('laptop · this device')).toBeInTheDocument()
|
||||
// Combined mode hides the local savings lines (they are device-specific).
|
||||
expect(within(kpis).queryByText('Saved via local models')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps local hero totals when scope is local even if a combined payload is present', async () => {
|
||||
const now = new Date()
|
||||
const payload = makePayload(now)
|
||||
payload.combined = {
|
||||
perDevice: [],
|
||||
combined: { cost: 999, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
|
||||
}
|
||||
|
||||
const { container } = render(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="local" />)
|
||||
|
||||
const kpis = container.querySelector('.ov-hero-main') as HTMLElement
|
||||
expect(within(kpis).getByText('$312.40')).toBeInTheDocument()
|
||||
expect(within(kpis).queryByText('$999.00')).not.toBeInTheDocument()
|
||||
expect(within(kpis).queryByText(/devices/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a stale banner when last-good data is present but the latest poll failed', async () => {
|
||||
const now = new Date()
|
||||
const overview: Polled<MenubarPayload> = {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ import { codeburn } from '../lib/ipc'
|
|||
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
|
||||
import type {
|
||||
ActReportJson,
|
||||
CombinedUsage,
|
||||
DailyHistoryEntry,
|
||||
DateRange,
|
||||
MenubarPayload,
|
||||
Period,
|
||||
Scope,
|
||||
YieldJsonReport,
|
||||
} from '../lib/types'
|
||||
|
||||
|
|
@ -650,6 +652,23 @@ export function Overview({ period, provider }: { period: Period; provider: strin
|
|||
return <OverviewContent period={period} provider={provider} overview={overview} />
|
||||
}
|
||||
|
||||
/** Combined-scope hero footer: a per-device cost breakdown plus a reachable/
|
||||
* total device count, mirroring the menubar's combined view. An unreachable
|
||||
* device (powered off, off-network) shows its error in place of a cost. */
|
||||
function CombinedDevices({ usage }: { usage: CombinedUsage }) {
|
||||
return (
|
||||
<div className="ov-combined-devices">
|
||||
<div className="ov-combined-head">{usage.combined.reachableCount} of {usage.combined.deviceCount} devices</div>
|
||||
{usage.perDevice.map(device => (
|
||||
<div className={device.error ? 'ov-combined-row err' : 'ov-combined-row'} key={device.id}>
|
||||
<span className="ov-combined-name">{device.local ? `${device.name} · this device` : device.name}</span>
|
||||
<span className="ov-combined-val">{device.error ?? formatUsd(device.cost)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverviewContent({
|
||||
period,
|
||||
provider = 'all',
|
||||
|
|
@ -657,6 +676,7 @@ export function OverviewContent({
|
|||
overview,
|
||||
onNavigate,
|
||||
ready = true,
|
||||
scope = 'local',
|
||||
}: {
|
||||
period: Period
|
||||
provider?: string
|
||||
|
|
@ -664,6 +684,7 @@ export function OverviewContent({
|
|||
overview: Polled<MenubarPayload>
|
||||
onNavigate?: (section: 'optimize' | 'sessions') => void
|
||||
ready?: boolean
|
||||
scope?: Scope
|
||||
}) {
|
||||
// Gate secondary spawns on the app-level readiness (first overview resolved),
|
||||
// so the cold hydration runs once (via overview) rather than 3 parses at once
|
||||
|
|
@ -680,7 +701,14 @@ export function OverviewContent({
|
|||
|
||||
const now = new Date()
|
||||
const rangeActive = !!range
|
||||
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
|
||||
// Combined scope shows the paired-device aggregate in the hero KPIs, mirroring
|
||||
// the menubar. Only the hero totals are aggregated; the detailed panels below
|
||||
// (daily chart, models) stay local — the combined payload carries totals only.
|
||||
const combined = scope === 'combined' ? data.combined : undefined
|
||||
const heroCost = combined ? combined.combined.cost : data.current.cost
|
||||
const heroCalls = combined ? combined.combined.calls : data.current.calls
|
||||
const heroSessions = combined ? combined.combined.sessions : data.current.sessions
|
||||
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}|${scope}`
|
||||
const stats = deriveStats(data, now)
|
||||
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
|
||||
// Daily chart: contiguous zero-filled calendar window. A custom range spans
|
||||
|
|
@ -715,15 +743,21 @@ export function OverviewContent({
|
|||
{error && <StaleBanner error={error} />}
|
||||
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
|
||||
<div className="ov-hero-main">
|
||||
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
|
||||
<CountUp value={data.current.cost} animateKey={animateKey} />
|
||||
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
|
||||
{saved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
|
||||
)}
|
||||
{localSaved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
|
||||
)}
|
||||
<div className="ov-hero-top"><span className="ov-label">{combined ? `Combined · ${data.current.label}` : data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
|
||||
<CountUp value={heroCost} animateKey={animateKey} />
|
||||
<div className="ov-hero-sub">{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions</div>
|
||||
{combined
|
||||
? <CombinedDevices usage={combined} />
|
||||
: (
|
||||
<>
|
||||
{saved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
|
||||
)}
|
||||
{localSaved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ActivityHeatmap daily={data.history.daily} bare />
|
||||
<EfficiencyScorecard current={data.current} bare />
|
||||
|
|
|
|||
|
|
@ -145,6 +145,17 @@ describe('Settings', () => {
|
|||
expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('reflects the current scope and reports a change through onScopeChange', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onScopeChange = vi.fn()
|
||||
render(<Settings period="month" scope="local" onScopeChange={onScopeChange} />)
|
||||
const scope = screen.getByLabelText('Scope')
|
||||
expect(scope).toHaveTextContent('Local')
|
||||
await user.click(scope)
|
||||
await user.click(screen.getByRole('option', { name: 'Combined' }))
|
||||
expect(onScopeChange).toHaveBeenCalledWith('combined')
|
||||
})
|
||||
|
||||
it('lists providers from the real overview payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="week" />)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
|
|||
import { showToast } from '../lib/toast'
|
||||
import { ToastHost } from '../components/ToastHost'
|
||||
import { rateLimitedNote } from './Plans'
|
||||
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
|
||||
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
|
||||
|
||||
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
|
||||
type Pane = SettingsPane
|
||||
|
|
@ -97,7 +97,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
|
|||
)
|
||||
}
|
||||
|
||||
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) {
|
||||
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
|
||||
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
|
||||
|
||||
return (
|
||||
|
|
@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
|
|||
))}
|
||||
</nav>
|
||||
<main className="set-pane">
|
||||
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
|
||||
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />}
|
||||
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
|
||||
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
|
||||
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
|
||||
|
|
@ -128,7 +128,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
|
|||
)
|
||||
}
|
||||
|
||||
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) {
|
||||
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
|
||||
const [currencyNonce, setCurrencyNonce] = useState(0)
|
||||
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
|
|
@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
|
|||
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
|
||||
</span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-scope">Scope<small>Combined aggregates usage across every paired device, like the menubar. Local shows this device only.</small></label><span className="r"><Dropdown id="settings-scope" ariaLabel="Scope" value={scope} options={[{ value: 'local', label: 'Local' }, { value: 'combined', label: 'Combined' }]} onChange={value => onScopeChange?.(value)} width={110} /></span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on ⌘R.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
|
||||
{budgetError && <p className="set-action-msg error">{budgetError}</p>}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -68,7 +68,11 @@ writeFileSync(
|
|||
// extraneous warnings, so capture stdout regardless of exit code.
|
||||
let listed = ''
|
||||
try {
|
||||
listed = execFileSync('npm', ['ls', '--omit=dev', '--all', '--parseable'], {
|
||||
// Execute npm's JavaScript entry point with the current Node binary. Windows
|
||||
// exposes npm as a .cmd shim, which execFile cannot launch without a shell.
|
||||
const npmCli = process.env.npm_execpath
|
||||
if (!npmCli) throw new Error('npm_execpath is unavailable')
|
||||
listed = execFileSync(process.execPath, [npmCli, 'ls', '--omit=dev', '--all', '--parseable'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
|
|
@ -81,11 +85,17 @@ try {
|
|||
// Map each back to its top-level node_modules entry (`name` or `@scope/name`),
|
||||
// then copy those dirs whole — a package's own nested node_modules comes with
|
||||
// it, which is exactly the closure it needs at runtime.
|
||||
const prefix = rootModules + '/'
|
||||
// `npm ls --parseable` uses native separators on Windows. Normalize both sides
|
||||
// before extracting the package name so Store builds do not treat a populated
|
||||
// node_modules tree as empty merely because it uses `\\` instead of `/`.
|
||||
const prefix = rootModules.replaceAll('\\', '/') + '/'
|
||||
const comparisonPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix
|
||||
const topLevel = new Set()
|
||||
for (const line of listed.split('\n')) {
|
||||
if (!line.startsWith(prefix)) continue
|
||||
const rest = line.slice(prefix.length)
|
||||
const normalizedLine = line.trim().replaceAll('\\', '/')
|
||||
const comparisonLine = process.platform === 'win32' ? normalizedLine.toLowerCase() : normalizedLine
|
||||
if (!comparisonLine.startsWith(comparisonPrefix)) continue
|
||||
const rest = normalizedLine.slice(prefix.length)
|
||||
const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/)
|
||||
if (match) topLevel.add(match[1])
|
||||
}
|
||||
|
|
|
|||
BIN
assets/open-source-recipient.png
Normal file
BIN
assets/open-source-recipient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
|
|
@ -12,11 +12,26 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode.
|
|||
|---|---|
|
||||
| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` |
|
||||
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages/<Claude package>/LocalCache/Roaming/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` |
|
||||
|
||||
For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`.
|
||||
|
||||
Desktop session roots are resolved in this order:
|
||||
|
||||
1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the
|
||||
only returned root.
|
||||
2. macOS uses the single path shown above.
|
||||
3. Windows always includes the classic path first. It then scans
|
||||
`%LOCALAPPDATA%/Packages` for package directories whose names start with
|
||||
`Claude_` or contain `.Claude_`, sorted by package name, and includes only
|
||||
packages whose full MSIX sessions path exists as a directory.
|
||||
4. Other platforms use the single Linux path shown above.
|
||||
|
||||
All returned roots are absolute, resolved, and deduplicated. Missing or
|
||||
unreadable Windows package directories are ignored.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, one event per line, per session file. Sessions live under `<project>/<sessionId>.jsonl`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity.
|
|||
|
||||
## Where it reads from
|
||||
|
||||
The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans:
|
||||
By default the provider scans every known Kimi Code runtime store:
|
||||
|
||||
```text
|
||||
$KIMI_CODE_HOME/sessions/wd_*/session_*/
|
||||
~/.kimi-code
|
||||
~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime)
|
||||
```
|
||||
|
||||
Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans:
|
||||
|
||||
```text
|
||||
$HOME/sessions/wd_*/<session-dir>/
|
||||
├── state.json
|
||||
└── agents/<agent-id>/wire.jsonl
|
||||
```
|
||||
|
||||
Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions.
|
||||
|
||||
Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions.
|
||||
|
||||
## Storage format
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -138,11 +138,19 @@ final class AppStore {
|
|||
var codexError: String?
|
||||
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
|
||||
|
||||
var kimiUsage: KimiUsage?
|
||||
var kimiError: String?
|
||||
// No keychain dance for Kimi — "connected" just means the CLI's
|
||||
// credential file exists, so we start dormant and auto-activate on the
|
||||
// first refresh tick.
|
||||
var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
|
||||
|
||||
/// Generation tokens for the in-flight refresh tasks. Incremented on every
|
||||
/// disconnect / reset so a fetch that started before the disconnect cannot
|
||||
/// resume after the await and re-populate the freshly-cleared state.
|
||||
private var claudeRefreshGen: Int = 0
|
||||
private var codexRefreshGen: Int = 0
|
||||
private var kimiRefreshGen: Int = 0
|
||||
|
||||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
private var cacheDate: String = ""
|
||||
|
|
@ -263,6 +271,49 @@ 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
|
||||
}
|
||||
|
||||
/// `(reachable, total)` only when combined scope is active and fewer paired
|
||||
/// devices reported than are paired — i.e. the badge total is degraded to
|
||||
/// the reachable subset (a peer is asleep/off-network this cycle). The badge
|
||||
/// shows this so a momentary drop to the local figure reads as "peer
|
||||
/// unreachable", not a glitch. `nil` when every paired device reported (or
|
||||
/// there is only one), and under local scope.
|
||||
var menubarBadgeDeviceShortfall: (reachable: Int, total: Int)? {
|
||||
guard let totals = menubarBadgeCombined, totals.reachableCount < totals.deviceCount else { return nil }
|
||||
return (totals.reachableCount, totals.deviceCount)
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
|
|
@ -1104,6 +1155,90 @@ final class AppStore {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Kimi Code
|
||||
|
||||
/// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's
|
||||
/// credential file is prompt-free, so the first refresh tick activates
|
||||
/// the dormant state automatically.
|
||||
func bootstrapKimi() async {
|
||||
// Capture the generation before the await so a disconnect that lands
|
||||
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
|
||||
let gen = kimiRefreshGen
|
||||
kimiLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await KimiSubscriptionService.refresh()
|
||||
guard gen == kimiRefreshGen else { return }
|
||||
kimiUsage = usage
|
||||
kimiError = nil
|
||||
kimiLoadState = .loaded
|
||||
} catch let err as KimiSubscriptionService.FetchError {
|
||||
guard gen == kimiRefreshGen else { return }
|
||||
applyKimiFetchError(err)
|
||||
} catch {
|
||||
guard gen == kimiRefreshGen else { return }
|
||||
kimiError = sanitizeForUI(String(describing: error))
|
||||
kimiLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func refreshKimi() async {
|
||||
_ = await refreshKimiReportingSuccess()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshKimiReportingSuccess() async -> Bool {
|
||||
if case .dormant = kimiLoadState {
|
||||
await bootstrapKimi()
|
||||
return kimiLoadState == .loaded
|
||||
}
|
||||
guard KimiSubscriptionService.hasCredential else {
|
||||
if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped }
|
||||
return false
|
||||
}
|
||||
let gen = kimiRefreshGen
|
||||
if kimiUsage == nil { kimiLoadState = .loading }
|
||||
do {
|
||||
let usage = try await KimiSubscriptionService.refresh()
|
||||
guard gen == kimiRefreshGen else { return false }
|
||||
kimiUsage = usage
|
||||
kimiError = nil
|
||||
kimiLoadState = .loaded
|
||||
return true
|
||||
} catch let err as KimiSubscriptionService.FetchError {
|
||||
guard gen == kimiRefreshGen else { return false }
|
||||
applyKimiFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == kimiRefreshGen else { return false }
|
||||
kimiError = sanitizeForUI(String(describing: error))
|
||||
kimiLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectKimi() {
|
||||
KimiSubscriptionService.disconnect()
|
||||
kimiRefreshGen &+= 1
|
||||
kimiUsage = nil
|
||||
kimiError = nil
|
||||
kimiLoadState = .notBootstrapped
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
kimiError = sanitized
|
||||
if case .noCredentials = err {
|
||||
kimiLoadState = .noCredentials
|
||||
} else if err.isTerminal {
|
||||
kimiLoadState = .terminalFailure(reason: sanitized)
|
||||
} else if let retryAt = err.rateLimitRetryAt {
|
||||
kimiLoadState = .transientFailure(retryAt: retryAt)
|
||||
} else {
|
||||
kimiLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
subscriptionError = sanitized
|
||||
|
|
@ -1172,6 +1307,10 @@ final class AppStore {
|
|||
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
|
||||
if worst > 0 { providers.append(("Codex", worst)) }
|
||||
}
|
||||
if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) {
|
||||
let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0)
|
||||
if worst > 0 { providers.append(("Kimi Code", worst)) }
|
||||
}
|
||||
let worst = providers.map(\.percent).max() ?? 0
|
||||
let severity = QuotaSummary.severity(for: worst / 100)
|
||||
let sorted = providers.sorted { $0.percent > $1.percent }
|
||||
|
|
@ -1192,6 +1331,7 @@ final class AppStore {
|
|||
switch filter {
|
||||
case .claude: return claudeQuotaSummary(filter: filter)
|
||||
case .codex: return codexQuotaSummary(filter: filter)
|
||||
case .kimiCode: return kimiQuotaSummary(filter: filter)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1281,22 +1421,78 @@ 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)
|
||||
}
|
||||
|
||||
private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = kimiLoadState { return nil }
|
||||
if case .bootstrapping = kimiLoadState { return nil }
|
||||
if case .noCredentials = kimiLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch kimiLoadState {
|
||||
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return kimiUsage == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return kimiUsage == nil ? .loading : .stale
|
||||
// Kimi tokens expire ~every 15 min and only the CLI renews them, so
|
||||
// terminal is the steady state between CLI uses. Keep the last-known
|
||||
// bars (marked stale) instead of flapping the chip to a reconnect
|
||||
// card; the reconnect card is reserved for the genuinely-no-data case.
|
||||
case let .terminalFailure(reason): return kimiUsage == nil ? .terminalFailure(reason: reason) : .stale
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = kimiUsage {
|
||||
if let w = usage.primary {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
primary = row
|
||||
details.append(row)
|
||||
}
|
||||
for w in usage.details {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
if primary == nil { primary = row }
|
||||
details.append(row)
|
||||
}
|
||||
}
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: [])
|
||||
}
|
||||
|
||||
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
|
||||
/// when the current window has just reset and projection from current data isn't meaningful.
|
||||
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
|
||||
|
|
@ -1400,6 +1596,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case ibmBob = "IBM Bob"
|
||||
case kiro = "Kiro"
|
||||
case kimi = "Kimi"
|
||||
case kimiCode = "Kimi Code"
|
||||
case lingtaiTui = "LingTai TUI"
|
||||
case kiloCode = "KiloCode"
|
||||
case openclaw = "OpenClaw"
|
||||
|
|
@ -1432,6 +1629,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .grok: ["grok", "grok build"]
|
||||
case .hermes: ["hermes", "hermes agent"]
|
||||
case .lingtaiTui: ["lingtai-tui", "lingtai tui"]
|
||||
case .kimiCode: ["kimicode", "kimi code"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
}
|
||||
|
|
@ -1453,6 +1651,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
case .kimi: "kimi"
|
||||
case .kimiCode: "kimicode"
|
||||
case .lingtaiTui: "lingtai-tui"
|
||||
case .openclaw: "openclaw"
|
||||
case .opencode: "opencode"
|
||||
|
|
|
|||
|
|
@ -49,11 +49,14 @@ struct CodeBurnApp: App {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var popover: NSPopover!
|
||||
private var rightClickMonitor: Any?
|
||||
private var lastContextMenuPresentedAt: Date = .distantPast
|
||||
/// Held only while the right-click menu is open. Cleared in menuDidClose so
|
||||
/// left-click goes back to the popover action instead of re-showing the menu.
|
||||
private var contextMenu: NSMenu?
|
||||
fileprivate let store = AppStore()
|
||||
let updateChecker = UpdateChecker()
|
||||
/// True while the displays are asleep. Refresh ticks skip spawning
|
||||
|
|
@ -464,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
// 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
|
||||
|
|
@ -486,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
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)
|
||||
|
|
@ -519,6 +522,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
|
||||
fileprivate var lastSubscriptionRefreshAt: Date?
|
||||
fileprivate var lastCodexRefreshAt: Date?
|
||||
fileprivate var lastKimiRefreshAt: Date?
|
||||
private var claudeQuotaFailureCount = 0
|
||||
private var nextClaudeQuotaRefreshAt: Date?
|
||||
|
||||
|
|
@ -614,8 +618,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
if let task = codexQuotaRefreshTask {
|
||||
return await task.value
|
||||
}
|
||||
let task = Task { [store] in
|
||||
await store.refreshCodexReportingSuccess()
|
||||
// Kimi Code rides the same tick, but with its own cadence anchor:
|
||||
// when Codex is not connected its refresh returns false immediately,
|
||||
// so lastCodexRefreshAt never advances — anchoring Kimi on it would
|
||||
// poll api.kimi.com on every payload tick instead of the configured
|
||||
// quota cadence. Anchor on attempt (not success) so a failing Kimi
|
||||
// endpoint also respects the cadence.
|
||||
let kimiDue: Bool = {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
guard cadence != .manual else { return false }
|
||||
return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
|
||||
}()
|
||||
if kimiDue { lastKimiRefreshAt = Date() }
|
||||
let task = Task { [store, kimiDue] in
|
||||
async let codex = store.refreshCodexReportingSuccess()
|
||||
if kimiDue {
|
||||
_ = await store.refreshKimiReportingSuccess()
|
||||
}
|
||||
return await codex
|
||||
}
|
||||
codexQuotaRefreshTask = task
|
||||
let result = await task.value
|
||||
|
|
@ -833,6 +853,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
func resetSubscriptionCadenceAnchor() {
|
||||
lastSubscriptionRefreshAt = nil
|
||||
lastCodexRefreshAt = nil
|
||||
lastKimiRefreshAt = nil
|
||||
claudeQuotaFailureCount = 0
|
||||
nextClaudeQuotaRefreshAt = nil
|
||||
}
|
||||
|
|
@ -849,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
_ = 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
|
||||
|
|
@ -909,15 +933,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
|
||||
// macOS 27 no longer routes any right-mouse event to the status-item
|
||||
// button's target/action. A global monitor still observes right-mouse-down;
|
||||
// button's target/action. A global monitor still observes right-mouse-up;
|
||||
// we hit-test it against our own status-item window and present the menu
|
||||
// ourselves. Harmless and stable on 15/26 too (the debounce in
|
||||
// showContextMenu prevents a double-present if the legacy path also fires).
|
||||
rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in
|
||||
// ourselves.
|
||||
//
|
||||
// Must be mouse-*up*, not mouse-down: presenting on down starts menu
|
||||
// tracking while the button is still held, so the matching rightMouseUp
|
||||
// is treated as an outside click and the menu flashes then dismisses.
|
||||
// Presenting on up (after the click completes) keeps it open. Harmless
|
||||
// on 15/26 too (the debounce in showContextMenu prevents a double-present
|
||||
// if the legacy path also fires).
|
||||
rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: StatusItemContextMenuPolicy.presentEventMask) { [weak self] _ in
|
||||
guard let self,
|
||||
let button = self.statusItem.button,
|
||||
let window = button.window,
|
||||
window.frame.contains(NSEvent.mouseLocation) else { return }
|
||||
// Defer one turn so menu presentation is not nested inside the
|
||||
// monitor callback. Safe on mouse-*up* (the click is already
|
||||
// complete); on mouse-down a deferred present was killed by the
|
||||
// matching up event and the menu only flashed.
|
||||
DispatchQueue.main.async { self.showContextMenu(from: button) }
|
||||
}
|
||||
|
||||
|
|
@ -992,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
|
||||
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]
|
||||
|
|
@ -1016,10 +1058,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
textAttrs[.foregroundColor] = NSColor.secondaryLabelColor
|
||||
}
|
||||
composed.append(NSAttributedString(string: valueText, attributes: textAttrs))
|
||||
|
||||
// Combined scope, but a paired device didn't report this cycle: append
|
||||
// a dimmed "reachable/total" so the reduced total reads as "peer
|
||||
// unreachable" rather than a glitch (mirrors the popover's device list).
|
||||
if let shortfall = store.menubarBadgeDeviceShortfall {
|
||||
let marker = " · \(shortfall.reachable)/\(shortfall.total)"
|
||||
let markerAttrs: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.baselineOffset: -1.0,
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
]
|
||||
composed.append(NSAttributedString(string: marker, attributes: markerAttrs))
|
||||
}
|
||||
}
|
||||
|
||||
button.attributedTitle = composed
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
|
||||
if let shortfall = store.menubarBadgeDeviceShortfall {
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting"
|
||||
} else {
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
|
||||
}
|
||||
|
||||
persistBadgeStatusFile()
|
||||
}
|
||||
|
|
@ -1126,9 +1185,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
private func showContextMenu(from button: NSStatusBarButton) {
|
||||
// Debounce: on macOS <= 26 both the legacy action path and the global
|
||||
// monitor can fire for a single right-click. Present at most once per click.
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return }
|
||||
lastContextMenuPresentedAt = now
|
||||
// Policy lives in StatusItemContextMenuPolicy so the gate is unit-tested (#802).
|
||||
guard StatusItemContextMenuPolicy.acceptPresent(
|
||||
now: Date(),
|
||||
lastPresentedAt: &lastContextMenuPresentedAt
|
||||
) else { return }
|
||||
|
||||
// Don't let an open popover steal the click / sit under the menu.
|
||||
if popover?.isShown == true {
|
||||
popover.performClose(nil)
|
||||
}
|
||||
|
||||
let menu = NSMenu()
|
||||
|
||||
|
|
@ -1160,12 +1226,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
quitItem.target = self
|
||||
menu.addItem(quitItem)
|
||||
|
||||
// Present directly. The previous `statusItem.menu = menu; button.performClick`
|
||||
// trick relies on the click -> action path that macOS 27 changed; popUp is
|
||||
// version-stable. Open a few px below the status item so the menu clears the
|
||||
// menu bar: anchoring flush clips the top edge and makes macOS engage menu
|
||||
// scrolling (a scroll chevron appears and the first row slides up on hover).
|
||||
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button)
|
||||
// Present via the status item's own menu slot. AppKit positions and tracks
|
||||
// that menu correctly under the status item (no scroll chevron / first-row
|
||||
// jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was
|
||||
// tracked against a point while the cursor still sat on the status item
|
||||
// above it, so the first mouse move engaged scroll mode.
|
||||
//
|
||||
// #472 dropped this pattern because assigning `statusItem.menu` from the
|
||||
// right-mouse *action* never ran on macOS 27 (right-clicks no longer reach
|
||||
// the button action). We still open from our global rightMouseUp monitor
|
||||
// (or the legacy action on ≤26); once we set `statusItem.menu` ourselves,
|
||||
// performClick is just "open the attached menu" and works on 27 too.
|
||||
//
|
||||
// menuDidClose clears `statusItem.menu` so the next left-click hits our
|
||||
// action (popover) instead of re-opening this menu.
|
||||
menu.delegate = self
|
||||
contextMenu = menu
|
||||
statusItem.menu = menu
|
||||
button.performClick(nil)
|
||||
}
|
||||
|
||||
// MARK: - NSMenuDelegate
|
||||
|
||||
// AppKit invokes menu callbacks on the main thread. Clear the status-item
|
||||
// menu slot so the next left-click hits our action (popover) again.
|
||||
nonisolated func menuDidClose(_ menu: NSMenu) {
|
||||
// Hop explicitly — don't assumeIsolated across the NSMenuDelegate boundary
|
||||
// under Swift 6 strict concurrency (NSMenu isn't Sendable).
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// Always clear: we only ever attach our own context menu to the item.
|
||||
self.statusItem.menu = nil
|
||||
self.contextMenu = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line "today" summary for the context menu's usage row.
|
||||
|
|
|
|||
|
|
@ -111,10 +111,12 @@ enum CodexSubscriptionService {
|
|||
switch http.statusCode {
|
||||
case 200:
|
||||
clearUsageBlock()
|
||||
// Companion fetch, strictly best-effort: any failure yields nil and
|
||||
// the Plan view simply omits the row. This endpoint must never be
|
||||
// able to break the quota display.
|
||||
let resetCredits = await fetchResetCredits(token: token)
|
||||
// Skip the companion request only when the inline block says zero.
|
||||
// Best-effort either way: nil just omits the row.
|
||||
var resetCredits = inlineResetCreditsShortcut(data: data)
|
||||
if resetCredits == nil {
|
||||
resetCredits = await fetchResetCredits(token: token)
|
||||
}
|
||||
do {
|
||||
return try decodeUsage(data: data, resetCredits: resetCredits)
|
||||
} catch {
|
||||
|
|
@ -144,15 +146,83 @@ enum CodexSubscriptionService {
|
|||
}
|
||||
}
|
||||
|
||||
/// chatgpt.com mixes encodings inside one payload: `"limit": "10000"` next
|
||||
/// to `"used_percent": 30`. Every numeric field decodes through here.
|
||||
private enum Flexible {
|
||||
// `decode`, not `decodeIfPresent`: missing, null and wrong-typed all
|
||||
// mean "not available", without the double-optional footgun.
|
||||
// Int first keeps precision above 2^53. Infinity and NaN survive
|
||||
// `Double(_ text:)`, so reject them here.
|
||||
static func double<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Double? {
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return v.isFinite ? v : nil }
|
||||
if let v = try? c.decode(String.self, forKey: key),
|
||||
let d = Double(v.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
return d.isFinite ? d : nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// `Int(exactly:)`, never `Int(_:)`: the plain initializer traps on an
|
||||
// out-of-range Double, and a trap is not a catchable DecodingError.
|
||||
static func int<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Int? {
|
||||
double(c, key).flatMap { Int(exactly: $0.rounded()) }
|
||||
}
|
||||
static func bool<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Bool {
|
||||
(try? c.decode(Bool.self, forKey: key)) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoding `[T]` is atomic, so one bad entry would discard every sibling.
|
||||
private struct Lossy<T: Decodable>: Decodable {
|
||||
let value: T?
|
||||
init(from decoder: Decoder) throws { value = try? T(from: decoder) }
|
||||
}
|
||||
|
||||
private struct UsageDTO: Decodable {
|
||||
let plan_type: String?
|
||||
let rate_limit: RateLimit?
|
||||
let additional_rate_limits: [AdditionalLimitDTO]?
|
||||
let credits: Credits?
|
||||
let spend_control: SpendControl?
|
||||
/// Forward-compat: some variants hoist this to the top level.
|
||||
let individual_limit: IndividualLimit?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case plan_type, rate_limit, additional_rate_limits, credits, spend_control
|
||||
case individual_limit
|
||||
case individualLimit
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
plan_type = try? c.decode(String.self, forKey: .plan_type)
|
||||
rate_limit = try? c.decode(RateLimit.self, forKey: .rate_limit)
|
||||
additional_rate_limits = (try? c.decode([Lossy<AdditionalLimitDTO>].self, forKey: .additional_rate_limits))?
|
||||
.compactMap(\.value)
|
||||
credits = try? c.decode(Credits.self, forKey: .credits)
|
||||
spend_control = try? c.decode(SpendControl.self, forKey: .spend_control)
|
||||
individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
|
||||
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
|
||||
}
|
||||
|
||||
struct RateLimit: Decodable {
|
||||
let primary_window: WindowDTO?
|
||||
let secondary_window: WindowDTO?
|
||||
/// Forward-compat: another observed position for the spend control.
|
||||
let individual_limit: IndividualLimit?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case primary_window, secondary_window, individual_limit
|
||||
case individualLimit
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
primary_window = try? c.decode(WindowDTO.self, forKey: .primary_window)
|
||||
secondary_window = try? c.decode(WindowDTO.self, forKey: .secondary_window)
|
||||
individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
|
||||
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
|
||||
}
|
||||
}
|
||||
struct AdditionalLimitDTO: Decodable {
|
||||
let limit_name: String?
|
||||
|
|
@ -163,22 +233,72 @@ enum CodexSubscriptionService {
|
|||
let reset_at: Int?
|
||||
let limit_window_seconds: Int?
|
||||
}
|
||||
/// Credit-metered workspaces report `rate_limit: null` and carry their
|
||||
/// real limit here: the monthly allowance an admin sets.
|
||||
struct SpendControl: Decodable {
|
||||
let reached: Bool
|
||||
let individualLimit: IndividualLimit?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case reached
|
||||
case individual_limit
|
||||
case individualLimit
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
reached = Flexible.bool(c, .reached)
|
||||
individualLimit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
|
||||
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
|
||||
}
|
||||
}
|
||||
struct IndividualLimit: Decodable {
|
||||
let limit: Double?
|
||||
let used: Double?
|
||||
let usedPercent: Double?
|
||||
let remainingPercent: Double?
|
||||
let resetAt: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case limit, used
|
||||
case used_percent, usedPercent
|
||||
case remaining_percent, remainingPercent
|
||||
case reset_at, resets_at, resetsAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
limit = Flexible.double(c, .limit)
|
||||
used = Flexible.double(c, .used)
|
||||
usedPercent = Flexible.double(c, .used_percent) ?? Flexible.double(c, .usedPercent)
|
||||
remainingPercent = Flexible.double(c, .remaining_percent)
|
||||
?? Flexible.double(c, .remainingPercent)
|
||||
resetAt = Flexible.int(c, .reset_at)
|
||||
?? Flexible.int(c, .resets_at)
|
||||
?? Flexible.int(c, .resetsAt)
|
||||
}
|
||||
}
|
||||
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
|
||||
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
|
||||
// resilient decode so a schema drift on either shape doesn't blow up
|
||||
// the whole quota fetch.
|
||||
struct Credits: Decodable {
|
||||
let balance: Double?
|
||||
enum CodingKeys: String, CodingKey { case balance }
|
||||
/// Settles in credits, not dollars, which relabels `balance`.
|
||||
let hasCredits: Bool
|
||||
let unlimited: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case balance
|
||||
case has_credits
|
||||
case unlimited
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let n = try? c.decode(Double.self, forKey: .balance) {
|
||||
balance = n
|
||||
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
|
||||
balance = n
|
||||
} else {
|
||||
balance = nil
|
||||
}
|
||||
balance = Flexible.double(c, .balance)
|
||||
hasCredits = Flexible.bool(c, .has_credits)
|
||||
unlimited = Flexible.bool(c, .unlimited)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -204,6 +324,28 @@ enum CodexSubscriptionService {
|
|||
return parseResetCredits(data: data)
|
||||
}
|
||||
|
||||
/// The inline block carries no per-credit expiry list, so it is only a safe
|
||||
/// shortcut at zero, where there is no expiry to report. A non-zero count
|
||||
/// still pays for the companion request rather than dropping the
|
||||
/// "next expires" caption the popover would otherwise show.
|
||||
static func inlineResetCreditsShortcut(data: Data) -> CodexUsage.ResetCredits? {
|
||||
guard let inline = inlineResetCredits(data: data), inline.availableCount == 0 else { return nil }
|
||||
return inline
|
||||
}
|
||||
|
||||
/// Reset-credit inventory carried inline on the usage payload. Nil means
|
||||
/// absent.
|
||||
static func inlineResetCredits(data: Data) -> CodexUsage.ResetCredits? {
|
||||
struct InlineDTO: Decodable {
|
||||
struct Block: Decodable { let available_count: Int? }
|
||||
let rate_limit_reset_credits: Block?
|
||||
}
|
||||
guard let count = (try? JSONDecoder().decode(InlineDTO.self, from: data))?
|
||||
.rate_limit_reset_credits?.available_count, count >= 0
|
||||
else { return nil }
|
||||
return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nil)
|
||||
}
|
||||
|
||||
/// Internal (not private) so tests can drive it with fixture payloads.
|
||||
/// Returns nil on any unexpected shape — the caller treats nil as
|
||||
/// "feature unavailable", never as an error.
|
||||
|
|
@ -239,7 +381,8 @@ enum CodexSubscriptionService {
|
|||
return plain.date(from: raw)
|
||||
}
|
||||
|
||||
private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
|
||||
/// Internal (not private) so tests can drive it with fixture payloads.
|
||||
static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
|
||||
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
|
||||
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
|
||||
guard let name = dto.limit_name, !name.isEmpty else { return nil }
|
||||
|
|
@ -249,17 +392,61 @@ enum CodexSubscriptionService {
|
|||
secondary: makeWindow(dto.rate_limit?.secondary_window)
|
||||
)
|
||||
}
|
||||
let limitDTO = root.spend_control?.individualLimit
|
||||
?? root.individual_limit
|
||||
?? root.rate_limit?.individual_limit
|
||||
return CodexUsage(
|
||||
plan: CodexUsage.planType(from: root.plan_type),
|
||||
primary: makeWindow(root.rate_limit?.primary_window),
|
||||
secondary: makeWindow(root.rate_limit?.secondary_window),
|
||||
additionalLimits: additional,
|
||||
creditsBalance: root.credits?.balance,
|
||||
hasCredits: root.credits?.hasCredits ?? false,
|
||||
creditsUnlimited: root.credits?.unlimited ?? false,
|
||||
creditLimit: makeCreditLimit(limitDTO, reached: root.spend_control?.reached ?? false),
|
||||
resetCredits: resetCredits,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeCreditLimit(
|
||||
_ dto: UsageDTO.IndividualLimit?,
|
||||
reached: Bool
|
||||
) -> CodexUsage.CreditLimit? {
|
||||
guard let dto, let limit = dto.limit, limit > 0 else { return nil }
|
||||
// Server percentage, then remaining_percent, then the raw ratio. No
|
||||
// signal at all means the draw is unknown; a 0% bar would claim otherwise.
|
||||
guard let raw = dto.usedPercent
|
||||
?? dto.remainingPercent.map({ 100 - $0 })
|
||||
?? dto.used.map({ $0 / limit * 100 })
|
||||
else { return nil }
|
||||
let percent = min(max(raw, 0), 100)
|
||||
let resetsAt = dto.resetAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil }
|
||||
return CodexUsage.CreditLimit(
|
||||
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
|
||||
used: dto.used ?? limit * max(raw, 0) / 100,
|
||||
limit: limit,
|
||||
usedPercent: percent,
|
||||
resetsAt: resetsAt,
|
||||
windowSeconds: monthlyWindowSeconds(endingAt: resetsAt),
|
||||
reached: reached
|
||||
)
|
||||
}
|
||||
|
||||
/// Spend controls reset on a calendar-month boundary, so the window is the
|
||||
/// month preceding the reset. Not `reset_after_seconds`, which is remaining.
|
||||
/// UTC, not `Calendar.current`: a 2026-03-01Z reset spans 28 days in UTC
|
||||
/// but 31 in Toronto, so a local calendar makes pace timezone-dependent.
|
||||
private static func monthlyWindowSeconds(endingAt resetsAt: Date?) -> Int? {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
|
||||
guard let resetsAt,
|
||||
let start = calendar.date(byAdding: .month, value: -1, to: resetsAt)
|
||||
else { return nil }
|
||||
let seconds = Int(resetsAt.timeIntervalSince(start))
|
||||
return seconds > 0 ? seconds : nil
|
||||
}
|
||||
|
||||
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
|
||||
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift
Normal file
51
mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
/// Pure display-decision helpers for the Kimi Code quota surfaces.
|
||||
///
|
||||
/// Kimi Code tokens live ~15 min and only the Kimi CLI renews them, so the
|
||||
/// load state sits in `.terminalFailure` as its dominant steady state between
|
||||
/// CLI uses. The always-visible surfaces (Plan tab, tab-strip chip) must keep
|
||||
/// showing the last good snapshot with a quiet caption instead of flapping to
|
||||
/// a reconnect screen every cycle. The reconnect screen is reserved for the
|
||||
/// no-data case, where there is genuinely nothing to show.
|
||||
enum KimiQuotaPresentation {
|
||||
/// Which Plan-tab subview to render, given the load state and whether a
|
||||
/// last-known snapshot exists.
|
||||
enum PlanContent: Equatable {
|
||||
case noCredentials
|
||||
case loading
|
||||
case failed
|
||||
case transientFailed
|
||||
case reconnect(reason: String?)
|
||||
/// Render the loaded usage bars. `idle` is true when the login has
|
||||
/// gone terminal but a snapshot is still on hand — the caller stamps a
|
||||
/// quiet "run the CLI" caption instead of hiding the data.
|
||||
case usage(idle: Bool)
|
||||
}
|
||||
|
||||
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return .noCredentials
|
||||
case .dormant, .bootstrapping:
|
||||
return .loading
|
||||
case .loading, .loaded:
|
||||
return hasUsage ? .usage(idle: false) : .loading
|
||||
case .failed:
|
||||
return .failed
|
||||
case .transientFailure:
|
||||
return hasUsage ? .usage(idle: false) : .transientFailed
|
||||
case .terminalFailure(let reason):
|
||||
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
|
||||
/// so a bar can never silently masquerade as current. Kimi's ~15-min token
|
||||
/// life makes 10 minutes the point where a snapshot is likely a cycle old.
|
||||
static let stalenessThreshold: TimeInterval = 10 * 60
|
||||
|
||||
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) > stalenessThreshold
|
||||
}
|
||||
}
|
||||
372
mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift
Normal file
372
mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import Foundation
|
||||
|
||||
/// Live quota snapshot for a Kimi Code subscription, returned by
|
||||
/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what
|
||||
/// steipete/CodexBar consumes: a top-level `usage` envelope plus a
|
||||
/// `limits` array of additional rate-limit windows. Numeric fields are
|
||||
/// decoded leniently (String or Int/Double) because the API has shipped
|
||||
/// both.
|
||||
struct KimiUsage: Sendable, Equatable {
|
||||
struct Window: Sendable, Equatable {
|
||||
let label: String
|
||||
let limit: Double
|
||||
let used: Double
|
||||
let remaining: Double?
|
||||
let resetsAt: Date?
|
||||
|
||||
var usedPercent: Double { // 0.0 ... 100.0
|
||||
guard limit > 0 else { return 0 }
|
||||
return min(100, max(0, used / limit * 100))
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level `usage` envelope — treated as the primary window.
|
||||
let primary: Window?
|
||||
/// Additional windows from the `limits` array (e.g. 5-hour rate limit).
|
||||
let details: [Window]
|
||||
/// Membership tier from user.membership.level (e.g. "Intermediate").
|
||||
let plan: String?
|
||||
/// Max parallel sessions from parallel.limit, when reported.
|
||||
let parallelLimit: Int?
|
||||
let fetchedAt: Date
|
||||
}
|
||||
|
||||
/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's
|
||||
/// credential file directly (~/.kimi-code/credentials/kimi-code.json) —
|
||||
/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived
|
||||
/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is
|
||||
/// a terminal state: the UI tells the user to run the CLI once.
|
||||
enum KimiSubscriptionService {
|
||||
private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")!
|
||||
private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil"
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
case noCredentials
|
||||
case tokenExpired
|
||||
case rateLimited(retryAt: Date)
|
||||
case usageHTTPError(Int, String?)
|
||||
case usageDecodeFailed
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return "No Kimi Code credentials found. Sign in with the Kimi CLI first."
|
||||
case .tokenExpired:
|
||||
return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again."
|
||||
case let .rateLimited(retryAt):
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
||||
case let .usageHTTPError(code, body):
|
||||
return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case .usageDecodeFailed: return "Kimi quota response was malformed."
|
||||
case let .network(err): return "Network error: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var isTerminal: Bool {
|
||||
if case .tokenExpired = self { return true }
|
||||
if case .noCredentials = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var rateLimitRetryAt: Date? {
|
||||
if case let .rateLimited(retryAt) = self { return retryAt }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credential file
|
||||
|
||||
private struct CredentialFile: Decodable {
|
||||
let accessToken: String
|
||||
let expiresAt: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case expiresAt = "expires_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accessToken = try c.decode(String.self, forKey: .accessToken)
|
||||
// Tolerate Int / Double / String epoch-seconds.
|
||||
if let n = try? c.decode(Double.self, forKey: .expiresAt) {
|
||||
expiresAt = n
|
||||
} else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) {
|
||||
expiresAt = n
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var credentialsURL: URL {
|
||||
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
|
||||
?? NSHomeDirectory() + "/.kimi-code"
|
||||
return URL(fileURLWithPath: home + "/credentials/kimi-code.json")
|
||||
}
|
||||
|
||||
static var hasCredential: Bool {
|
||||
FileManager.default.fileExists(atPath: credentialsURL.path)
|
||||
}
|
||||
|
||||
/// Returns the access token only when it is still fresh (60s skew).
|
||||
/// Throws noCredentials / tokenExpired otherwise.
|
||||
private static func freshToken() throws -> String {
|
||||
guard let data = FileManager.default.contents(atPath: credentialsURL.path),
|
||||
let cred = try? JSONDecoder().decode(CredentialFile.self, from: data),
|
||||
!cred.accessToken.isEmpty else {
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else {
|
||||
throw FetchError.tokenExpired
|
||||
}
|
||||
return cred.accessToken
|
||||
}
|
||||
|
||||
private static func deviceId() -> String? {
|
||||
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
|
||||
?? NSHomeDirectory() + "/.kimi-code"
|
||||
guard let data = FileManager.default.contents(atPath: home + "/device_id"),
|
||||
let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!id.isEmpty else { return nil }
|
||||
return id
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
static func refresh() async throws -> KimiUsage {
|
||||
if let until = usageBlockedUntil(), until > Date() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
let token = try freshToken()
|
||||
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
|
||||
// Kimi server expects these platform headers (same as CodexBar sends).
|
||||
request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform")
|
||||
if let deviceId = deviceId() {
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FetchError.usageHTTPError(-1, nil)
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
clearUsageBlock()
|
||||
do {
|
||||
return try parseUsage(data: data)
|
||||
} catch {
|
||||
// Never log the body — account data readable via `log stream`.
|
||||
NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error))
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
case 401, 403:
|
||||
// We don't self-refresh; surface as terminal so the UI prompts
|
||||
// the user to run the CLI.
|
||||
throw FetchError.tokenExpired
|
||||
case 429:
|
||||
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
|
||||
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
default:
|
||||
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Decode (internal so tests can drive fixtures)
|
||||
|
||||
private struct LenientDouble: Decodable {
|
||||
let value: Double?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let n = try? c.decode(Double.self) { value = n }
|
||||
else if let s = try? c.decode(String.self) { value = Double(s) }
|
||||
else { value = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageEnvelopeDTO: Decodable {
|
||||
let limit: LenientDouble?
|
||||
let used: LenientDouble?
|
||||
let remaining: LenientDouble?
|
||||
let resetTime: LenientString?
|
||||
let resetAt: LenientString?
|
||||
let reset_time: LenientString?
|
||||
let reset_at: LenientString?
|
||||
|
||||
var resetsAtRaw: String? {
|
||||
resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset timestamps are normally ISO-8601 strings, but decode numbers
|
||||
/// tolerantly too (epoch seconds) so a schema drift can't fail the whole
|
||||
/// response. A plain String? would throw on a JSON number.
|
||||
private struct LenientString: Decodable {
|
||||
let value: String?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let s = try? c.decode(String.self) { value = s }
|
||||
else if let n = try? c.decode(Double.self) { value = String(n) }
|
||||
else { value = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private struct LimitDTO: Decodable {
|
||||
let window: WindowDTO?
|
||||
let detail: UsageEnvelopeDTO?
|
||||
|
||||
struct WindowDTO: Decodable {
|
||||
let duration: LenientDouble?
|
||||
let timeUnit: String?
|
||||
}
|
||||
}
|
||||
|
||||
private struct ResponseDTO: Decodable {
|
||||
let usage: UsageEnvelopeDTO?
|
||||
let limits: [LimitDTO]?
|
||||
let user: UserDTO?
|
||||
let parallel: ParallelDTO?
|
||||
|
||||
struct UserDTO: Decodable {
|
||||
let membership: MembershipDTO?
|
||||
struct MembershipDTO: Decodable {
|
||||
let level: String?
|
||||
}
|
||||
}
|
||||
struct ParallelDTO: Decodable {
|
||||
let limit: LenientDouble?
|
||||
}
|
||||
}
|
||||
|
||||
static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage {
|
||||
let root = try JSONDecoder().decode(ResponseDTO.self, from: data)
|
||||
// The top-level usage envelope is the account's weekly quota (its
|
||||
// reset lands ~7 days out), so label it like Claude's weekly window.
|
||||
let primary = makeWindow(label: "Weekly", dto: root.usage)
|
||||
let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in
|
||||
guard let dto = limit.detail else { return nil }
|
||||
let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit)
|
||||
return makeWindow(label: label, dto: dto)
|
||||
}
|
||||
guard primary != nil || !details.isEmpty else {
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
return KimiUsage(
|
||||
primary: primary,
|
||||
details: details,
|
||||
plan: planName(from: root.user?.membership?.level),
|
||||
parallelLimit: root.parallel?.limit?.value.map { Int($0) },
|
||||
fetchedAt: now
|
||||
)
|
||||
}
|
||||
|
||||
/// "LEVEL_INTERMEDIATE" → "Intermediate". Unknown / missing → nil so the
|
||||
/// UI falls back to the plain "Kimi Code" label.
|
||||
private static func planName(from level: String?) -> String? {
|
||||
guard var raw = level, !raw.isEmpty else { return nil }
|
||||
if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) }
|
||||
return raw.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? {
|
||||
guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil }
|
||||
// Rate-limit windows report only limit + remaining — derive used.
|
||||
let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit))
|
||||
return KimiUsage.Window(
|
||||
label: label,
|
||||
limit: limit,
|
||||
used: used,
|
||||
remaining: dto.remaining?.value,
|
||||
resetsAt: dto.resetsAtRaw.flatMap(parseResetTime)
|
||||
)
|
||||
}
|
||||
|
||||
/// Window size → human label. The API sends enum-style units
|
||||
/// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"),
|
||||
/// so normalize first; sub-hour durations roll up to hours when exact.
|
||||
private static func windowLabel(duration: Double?, timeUnit: String?) -> String {
|
||||
guard let duration, let rawUnit = timeUnit else { return "Rate Limit" }
|
||||
var unit = rawUnit.lowercased()
|
||||
if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) }
|
||||
var d = duration
|
||||
// Roll up exact sub-day durations: 300 minutes → 5 hours.
|
||||
if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 {
|
||||
d /= 60; unit = "hour"
|
||||
}
|
||||
let i = Int(d)
|
||||
switch unit {
|
||||
case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min"
|
||||
case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour"
|
||||
case "day", "days":
|
||||
if i == 1 { return "Daily" }
|
||||
if i == 7 { return "Weekly" }
|
||||
return "\(i)-day"
|
||||
case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week"
|
||||
case "month", "months": return i == 1 ? "Monthly" : "\(i)-month"
|
||||
default: return "\(i) \(unit)"
|
||||
}
|
||||
}
|
||||
|
||||
/// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds.
|
||||
private static func parseResetTime(_ raw: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: raw) { return date }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
if let date = plain.date(from: raw) { return date }
|
||||
if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 429 backoff
|
||||
|
||||
private static func usageBlockedUntil() -> Date? {
|
||||
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
||||
}
|
||||
|
||||
private static func clearUsageBlock() {
|
||||
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
||||
}
|
||||
|
||||
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
|
||||
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
|
||||
if let seconds = Int(value), seconds >= 0 { return seconds }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
if let date = f.date(from: value) {
|
||||
return max(0, Int(date.timeIntervalSinceNow))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
||||
let seconds = max(retryAfterSeconds ?? 300, 60)
|
||||
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
||||
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
||||
return until
|
||||
}
|
||||
|
||||
static func disconnect() {
|
||||
clearUsageBlock()
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,35 @@ private let maxUpdateStderrBytes = 64 * 1024
|
|||
// `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first.
|
||||
private let minCliVersionForUpdate = "0.9.9"
|
||||
|
||||
enum UpdateFailureStage: Equatable {
|
||||
case check
|
||||
case cliUpdate
|
||||
case menubarUpdate
|
||||
|
||||
var badgeLabel: String {
|
||||
switch self {
|
||||
case .check: "Update Check Failed"
|
||||
case .cliUpdate: "CLI Update Failed"
|
||||
case .menubarUpdate: "Menubar Update Failed"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .check: "CodeBurn could not check GitHub for updates."
|
||||
case .cliUpdate: "CodeBurn could not update the CLI."
|
||||
case .menubarUpdate: "CodeBurn could not update the menubar app."
|
||||
}
|
||||
}
|
||||
|
||||
var retryHelp: String {
|
||||
switch self {
|
||||
case .check: "Click to retry the update check."
|
||||
case .cliUpdate, .menubarUpdate: "Click to retry the update."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var data = Data()
|
||||
|
|
@ -38,6 +67,19 @@ final class UpdateChecker {
|
|||
var installedCliVersion: String?
|
||||
var isUpdating = false
|
||||
var updateError: String?
|
||||
var updateFailureStage: UpdateFailureStage?
|
||||
|
||||
var updateBadgeLabel: String {
|
||||
if isUpdating { return "Updating..." }
|
||||
return updateFailureStage?.badgeLabel ?? "Update"
|
||||
}
|
||||
|
||||
var updateHelpText: String {
|
||||
guard let error = updateError, let stage = updateFailureStage else {
|
||||
return "Update the CLI and menubar to the latest release"
|
||||
}
|
||||
return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)"
|
||||
}
|
||||
|
||||
var updateAvailable: Bool {
|
||||
guard let latest = latestVersion else { return false }
|
||||
|
|
@ -88,6 +130,7 @@ final class UpdateChecker {
|
|||
|
||||
func check() async {
|
||||
updateError = nil
|
||||
updateFailureStage = nil
|
||||
installedCliVersion = Self.queryInstalledCliVersion()
|
||||
guard let url = URL(string: releasesAPI) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
|
|
@ -118,7 +161,8 @@ final class UpdateChecker {
|
|||
UserDefaults.standard.set(version, forKey: cachedVersionKey)
|
||||
if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) }
|
||||
} catch {
|
||||
updateError = "Update check failed: \(error.localizedDescription)"
|
||||
updateFailureStage = .check
|
||||
updateError = error.localizedDescription
|
||||
NSLog("CodeBurn: update check failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
|
@ -195,9 +239,11 @@ final class UpdateChecker {
|
|||
if cliUpdateAvailable || cliTooOldForUpdate {
|
||||
isUpdating = true
|
||||
updateError = nil
|
||||
updateFailureStage = nil
|
||||
let cliPath = CodeburnCLI.baseArgv().first ?? ""
|
||||
guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else {
|
||||
isUpdating = false
|
||||
updateFailureStage = .cliUpdate
|
||||
updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again."
|
||||
return
|
||||
}
|
||||
|
|
@ -209,6 +255,7 @@ final class UpdateChecker {
|
|||
guard let self else { return }
|
||||
if status != 0 {
|
||||
self.isUpdating = false
|
||||
self.updateFailureStage = .cliUpdate
|
||||
self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr
|
||||
NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)")
|
||||
return
|
||||
|
|
@ -263,11 +310,13 @@ final class UpdateChecker {
|
|||
func performUpdate() {
|
||||
installedCliVersion = Self.queryInstalledCliVersion()
|
||||
if cliTooOldForUpdate {
|
||||
updateFailureStage = .menubarUpdate
|
||||
updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again."
|
||||
return
|
||||
}
|
||||
isUpdating = true
|
||||
updateError = nil
|
||||
updateFailureStage = nil
|
||||
|
||||
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
|
||||
let errPipe = Pipe()
|
||||
|
|
@ -297,6 +346,7 @@ final class UpdateChecker {
|
|||
guard let self else { return }
|
||||
self.isUpdating = false
|
||||
if proc.terminationStatus != 0 {
|
||||
self.updateFailureStage = .menubarUpdate
|
||||
self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr
|
||||
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
|
||||
} else {
|
||||
|
|
@ -309,6 +359,7 @@ final class UpdateChecker {
|
|||
try process.run()
|
||||
} catch {
|
||||
isUpdating = false
|
||||
updateFailureStage = .menubarUpdate
|
||||
updateError = error.localizedDescription
|
||||
NSLog("CodeBurn: update spawn failed: \(error)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Pure policy for the status-item right-click menu (#802).
|
||||
///
|
||||
/// Keeps the event-mask, debounce, and presentation choices out of AppDelegate
|
||||
/// so they can be unit-tested without spinning up an NSStatusItem. The bugs this
|
||||
/// encodes:
|
||||
///
|
||||
/// 1. **Flash** — presenting on `rightMouseDown` lets the matching `rightMouseUp`
|
||||
/// dismiss the menu immediately. Monitor (and legacy action) use mouse-*up*.
|
||||
/// 2. **Jump/scroll** — manual `NSMenu.popUp(at:in:)` tracks poorly while the
|
||||
/// cursor still sits on the status item above the menu. Attach via
|
||||
/// `statusItem.menu` + `performClick` instead.
|
||||
/// 3. **Double-present** — on macOS ≤26 both the button action and the global
|
||||
/// monitor can fire for one click; debounce collapses them.
|
||||
enum StatusItemContextMenuPolicy {
|
||||
/// Global-monitor / action event. Must be mouse-up (see flash note above).
|
||||
static let presentEventMask: NSEvent.EventTypeMask = .rightMouseUp
|
||||
|
||||
/// Minimum gap between presents. Covers the dual-path race on macOS ≤26.
|
||||
static let presentDebounceSeconds: TimeInterval = 0.3
|
||||
|
||||
/// How the menu is shown once a present is accepted.
|
||||
enum Presentation: Equatable {
|
||||
/// Assign `statusItem.menu` then `button.performClick`. AppKit positions
|
||||
/// and tracks the menu under the status item. Clear menu in menuDidClose.
|
||||
case statusItemMenu
|
||||
/// Manual `NSMenu.popUp(at:in:)`. Causes scroll-chevron jump on mouse move
|
||||
/// when the cursor starts above the menu. Kept only as a named anti-pattern
|
||||
/// so tests can lock that we do *not* use it.
|
||||
case manualPopUp
|
||||
}
|
||||
|
||||
static let presentation: Presentation = .statusItemMenu
|
||||
|
||||
/// Returns true and advances `lastPresentedAt` when a new present is allowed.
|
||||
static func acceptPresent(
|
||||
now: Date,
|
||||
lastPresentedAt: inout Date,
|
||||
debounce: TimeInterval = presentDebounceSeconds
|
||||
) -> Bool {
|
||||
guard now.timeIntervalSince(lastPresentedAt) > debounce else { return false }
|
||||
lastPresentedAt = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -494,6 +494,7 @@ extension ProviderFilter {
|
|||
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
||||
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
||||
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
|
||||
case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0)
|
||||
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
|
||||
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
|
||||
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ struct HeatmapSection: View {
|
|||
// their own quota data sources.
|
||||
InsightMode.allCases.filter { mode in
|
||||
if mode == .plan {
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -80,6 +80,8 @@ struct HeatmapSection: View {
|
|||
case .plan:
|
||||
if store.selectedProvider == .codex {
|
||||
CodexPlanInsight()
|
||||
} else if store.selectedProvider == .kimiCode {
|
||||
KimiPlanInsight()
|
||||
} else {
|
||||
PlanInsight(usage: store.subscription)
|
||||
}
|
||||
|
|
@ -2065,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)
|
||||
|
|
@ -2107,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 {
|
||||
|
|
@ -2145,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 }
|
||||
|
|
@ -2158,6 +2199,117 @@ private struct CodexPlanInsight: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no
|
||||
/// OAuth refresh — tokens are short-lived and only the CLI renews them), so
|
||||
/// terminal failure means "run the CLI once to refresh your login".
|
||||
private struct KimiPlanInsight: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch KimiQuotaPresentation.planContent(loadState: store.kimiLoadState, hasUsage: store.kimiUsage != nil) {
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView(
|
||||
title: "No Kimi Code credentials found",
|
||||
message: "Sign in with the Kimi CLI first. Then click Try Again."
|
||||
) { Task { await store.bootstrapKimi() } }
|
||||
case .loading:
|
||||
PlanLoadingView(message: "Reading Kimi Code credentials...")
|
||||
case .failed:
|
||||
PlanFailedView(
|
||||
error: store.kimiError
|
||||
) { Task { await store.refreshKimi() } }
|
||||
case .transientFailed:
|
||||
PlanFailedView(
|
||||
error: store.kimiError ?? "Kimi temporarily unreachable — retrying."
|
||||
) { Task { await store.refreshKimi() } }
|
||||
case let .reconnect(reason):
|
||||
PlanReconnectView(
|
||||
title: "Refresh Kimi Code login",
|
||||
reason: reason,
|
||||
fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect."
|
||||
) { Task { await store.bootstrapKimi() } }
|
||||
case let .usage(idle):
|
||||
if let usage = store.kimiUsage {
|
||||
loadedBody(usage: usage, idle: idle)
|
||||
} else {
|
||||
PlanLoadingView(message: "Reading Kimi Code credentials...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadedBody(usage: KimiUsage, idle: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(usage.plan ?? "Kimi Code")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if let resetsAt = usage.primary?.resetsAt {
|
||||
Text("Resets \(relativeReset(resetsAt))")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let primary = usage.primary {
|
||||
UtilizationRow(
|
||||
label: "\(primary.label) window",
|
||||
percent: primary.usedPercent,
|
||||
resetsAt: primary.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
|
||||
UtilizationRow(
|
||||
label: "\(window.label) window",
|
||||
percent: window.usedPercent,
|
||||
resetsAt: window.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if let parallel = usage.parallelLimit, parallel > 0 {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Parallel sessions")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(parallel)")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if idle {
|
||||
Text("Login idle. Run the Kimi CLI to refresh.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if KimiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
|
||||
Text("as of \(shortTime(usage.fetchedAt))")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func relativeReset(_ date: Date) -> String {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return f.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private func shortTime(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.timeStyle = .short
|
||||
f.dateStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowProjection {
|
||||
enum Source { case linear, historicalBaseline }
|
||||
let percent: Double
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ struct MenuBarContent: View {
|
|||
|
||||
private var isFilteredEmpty: Bool {
|
||||
guard store.selectedProvider != .all else { return false }
|
||||
// Plan-capable providers keep their sections visible so the Plan tab
|
||||
// (live subscription quota) stays reachable even on days with no
|
||||
// local usage — the quota endpoint doesn't depend on local sessions.
|
||||
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false }
|
||||
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
|
||||
if providerHasCostInAllPayload { return false }
|
||||
return true
|
||||
|
|
@ -520,7 +524,9 @@ private struct UpdateBadge: View {
|
|||
|
||||
var body: some View {
|
||||
Button {
|
||||
if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable {
|
||||
if updateChecker.updateFailureStage == .check {
|
||||
Task { await updateChecker.check() }
|
||||
} else if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable {
|
||||
updateChecker.performFullUpdate()
|
||||
} else {
|
||||
Task { await updateChecker.check() }
|
||||
|
|
@ -538,7 +544,7 @@ private struct UpdateBadge: View {
|
|||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
|
||||
Text(updateChecker.updateBadgeLabel)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
|
@ -548,7 +554,9 @@ private struct UpdateBadge: View {
|
|||
.tint(Theme.brandAccent)
|
||||
.controlSize(.mini)
|
||||
.disabled(updateChecker.isUpdating)
|
||||
.help(updateChecker.updateError ?? "Update the CLI and menubar to the latest release")
|
||||
.help(updateChecker.updateHelpText)
|
||||
.accessibilityLabel(updateChecker.updateBadgeLabel)
|
||||
.accessibilityHint(updateChecker.updateHelpText)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ struct SettingsView: View {
|
|||
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
|
||||
.tag("codex")
|
||||
|
||||
KimiSettingsTab()
|
||||
.tabItem { Label("Kimi", systemImage: "moon.stars") }
|
||||
.tag("kimi")
|
||||
|
||||
DevinSettingsTab()
|
||||
.tabItem { Label("Devin", systemImage: "flame.fill") }
|
||||
.tag("devin")
|
||||
|
|
@ -29,7 +33,9 @@ struct SettingsView: View {
|
|||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
.tag("about")
|
||||
}
|
||||
.frame(width: 520, height: 430)
|
||||
// 6 tabs need ~600pt to render as a visible tab bar; narrower widths
|
||||
// make SwiftUI collapse the tab bar into a ">>" overflow menu.
|
||||
.frame(width: 600, height: 430)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -481,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: {
|
||||
|
|
@ -606,6 +612,131 @@ private struct CodexConnectionRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Kimi Code
|
||||
|
||||
private struct KimiSettingsTab: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
KimiConnectionRow()
|
||||
}
|
||||
Section {
|
||||
Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("How it works")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct KimiConnectionRow: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var showDisconnectConfirm = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: stateIcon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(stateTint)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stateTitle)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(stateDetail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
actionButton
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var stateIcon: String {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded: return "checkmark.circle.fill"
|
||||
case .terminalFailure: return "exclamationmark.triangle.fill"
|
||||
case .transientFailure: return "clock.arrow.circlepath"
|
||||
case .bootstrapping, .loading: return "ellipsis.circle"
|
||||
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
|
||||
case .failed: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTint: Color {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded: return .green
|
||||
case .terminalFailure, .failed: return .red
|
||||
case .transientFailure: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTitle: String {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded: return "Connected"
|
||||
case let .terminalFailure(reason): return reason ?? "Login refresh required"
|
||||
case .transientFailure: return "Backing off"
|
||||
case .bootstrapping: return "Connecting…"
|
||||
case .loading: return "Refreshing…"
|
||||
case .dormant: return "Ready"
|
||||
case .notBootstrapped, .noCredentials: return "Not connected"
|
||||
case .failed: return "Couldn't load Kimi quota"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateDetail: String {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded:
|
||||
return "Live quota tracked from api.kimi.com."
|
||||
case .terminalFailure:
|
||||
return "Run the Kimi CLI once to refresh your login, then click Reconnect."
|
||||
case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying."
|
||||
case .bootstrapping: return "Reading ~/.kimi-code credentials."
|
||||
case .loading: return "Background refresh in progress."
|
||||
case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com."
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return "Sign in with the Kimi CLI first, then click Connect."
|
||||
case .failed: return store.kimiError ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButton: some View {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded, .transientFailure, .loading:
|
||||
Button("Disconnect") { showDisconnectConfirm = true }
|
||||
.confirmationDialog(
|
||||
"Disconnect Kimi Code?",
|
||||
isPresented: $showDisconnectConfirm
|
||||
) {
|
||||
Button("Disconnect", role: .destructive) {
|
||||
store.disconnectKimi()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.")
|
||||
}
|
||||
case .terminalFailure, .noCredentials, .failed:
|
||||
Button("Reconnect") { Task { await store.bootstrapKimi() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .dormant:
|
||||
Button("Load Quota") { Task { await store.bootstrapKimi() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .notBootstrapped:
|
||||
Button("Connect") { Task { await store.bootstrapKimi() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .bootstrapping:
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Devin
|
||||
|
||||
private struct DevinSettingsTab: View {
|
||||
|
|
|
|||
|
|
@ -202,6 +202,113 @@ 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("badge reports a device shortfall when a paired peer is unreachable")
|
||||
func menubarBadgeReportsDeviceShortfall() {
|
||||
let store = AppStore()
|
||||
store.suppressRefreshesForTesting()
|
||||
let period = store.menubarPeriod
|
||||
// Combined payload where only 1 of 2 paired devices reported this cycle
|
||||
// (the peer is asleep/off-network), so the aggregate is degraded to local.
|
||||
let degraded = CombinedUsage(
|
||||
perDevice: [],
|
||||
combined: CombinedUsageTotals(
|
||||
cost: 30,
|
||||
calls: 3,
|
||||
sessions: 2,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheCreateTokens: 10,
|
||||
cacheReadTokens: 20,
|
||||
totalTokens: 180,
|
||||
deviceCount: 2,
|
||||
reachableCount: 1
|
||||
)
|
||||
)
|
||||
store.setCachedPayloadForTesting(
|
||||
menubarPayload(cost: 30, combined: degraded),
|
||||
scope: .combined,
|
||||
period: period,
|
||||
provider: .all,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
store.selectedScope = .combined
|
||||
|
||||
let shortfall = store.menubarBadgeDeviceShortfall
|
||||
#expect(shortfall?.reachable == 1)
|
||||
#expect(shortfall?.total == 2)
|
||||
}
|
||||
|
||||
@Test("badge reports no shortfall when every paired device reports")
|
||||
func menubarBadgeNoShortfallWhenAllReachable() {
|
||||
let store = AppStore()
|
||||
store.suppressRefreshesForTesting()
|
||||
let period = store.menubarPeriod
|
||||
// combinedUsage() carries deviceCount == reachableCount == 1.
|
||||
store.setCachedPayloadForTesting(
|
||||
menubarPayload(cost: 30, combined: combinedUsage(cost: 30)),
|
||||
scope: .combined,
|
||||
period: period,
|
||||
provider: .all,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
store.selectedScope = .combined
|
||||
#expect(store.menubarBadgeDeviceShortfall == nil)
|
||||
|
||||
// Local scope never reports a shortfall.
|
||||
store.selectedScope = .local
|
||||
#expect(store.menubarBadgeDeviceShortfall == nil)
|
||||
}
|
||||
|
||||
@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()
|
||||
|
|
|
|||
248
mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift
Normal file
248
mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import Foundation
|
||||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// `plan_type` parsing and the credit-metered branch of the wham/usage decoder.
|
||||
final class CodexPlanParsingTests: XCTestCase {
|
||||
private func decode(_ json: String) throws -> CodexUsage {
|
||||
try CodexSubscriptionService.decodeUsage(data: Data(json.utf8))
|
||||
}
|
||||
|
||||
func testKnownTiersMapToDisplayNames() {
|
||||
let expected: [String: String] = [
|
||||
"guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "pro": "Pro",
|
||||
"prolite": "Pro Lite", "pro_lite": "Pro Lite", "pro-lite": "Pro Lite",
|
||||
"free_workspace": "Free Workspace", "team": "Team", "business": "Business",
|
||||
"education": "Education", "quorum": "Quorum", "k12": "K-12",
|
||||
"enterprise": "Enterprise", "edu": "Edu",
|
||||
]
|
||||
for (raw, display) in expected {
|
||||
XCTAssertEqual(CodexUsage.planType(from: raw).displayName, display, "plan_type: \(raw)")
|
||||
}
|
||||
}
|
||||
|
||||
func testTierMatchingIsCaseInsensitive() {
|
||||
XCTAssertEqual(CodexUsage.planType(from: "pLuS"), .plus)
|
||||
XCTAssertEqual(CodexUsage.planType(from: "ENTERPRISE"), .enterprise)
|
||||
}
|
||||
|
||||
func testCreditBasedPricingCompositesNormalize() {
|
||||
XCTAssertEqual(CodexUsage.planType(from: "enterprise_cbp_usage_based"), .enterprise)
|
||||
XCTAssertEqual(CodexUsage.planType(from: "self_serve_business_usage_based"), .business)
|
||||
XCTAssertEqual(CodexUsage.planType(from: "business_cbp"), .business)
|
||||
}
|
||||
|
||||
func testHyphenSeparatedCompositesNormalizeToo() {
|
||||
XCTAssertEqual(CodexUsage.planType(from: "enterprise-cbp-usage-based"), .enterprise)
|
||||
XCTAssertEqual(CodexUsage.planType(from: "self-serve-business-usage-based"), .business)
|
||||
XCTAssertEqual(CodexUsage.planType(from: "business-cbp"), .business)
|
||||
}
|
||||
|
||||
func testUnknownTierNormalizesAndTitleCasesLikeTheDesktopDecoder() {
|
||||
XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based"),
|
||||
.unknown("some_future_tier"))
|
||||
XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based").displayName,
|
||||
"Some Future Tier")
|
||||
XCTAssertEqual(CodexUsage.planType(from: nil), .unknown(""))
|
||||
XCTAssertEqual(CodexUsage.planType(from: nil).displayName, "Subscription")
|
||||
}
|
||||
|
||||
/// Captured from a live ChatGPT Enterprise workspace (identifiers replaced).
|
||||
private let enterprisePayload = #"""
|
||||
{
|
||||
"plan_type": "business",
|
||||
"rate_limit": null,
|
||||
"code_review_rate_limit": null,
|
||||
"additional_rate_limits": null,
|
||||
"credits": {
|
||||
"has_credits": false, "unlimited": false, "overage_limit_reached": false,
|
||||
"balance": null, "approx_local_messages": null, "approx_cloud_messages": null
|
||||
},
|
||||
"spend_control": {
|
||||
"reached": false,
|
||||
"individual_limit": {
|
||||
"source": "workspace_spend_controls",
|
||||
"limit": "10000",
|
||||
"used": "3028.9909675121307",
|
||||
"remaining": "6971.009032487869",
|
||||
"used_percent": 30,
|
||||
"remaining_percent": 70,
|
||||
"reset_after_seconds": 441896,
|
||||
"reset_at": 1785542400
|
||||
}
|
||||
},
|
||||
"rate_limit_reset_credits": {"available_count": 0, "applicable_available_count": 0}
|
||||
}
|
||||
"""#
|
||||
|
||||
func testEnterprisePayloadDecodesTheSpendControlLimit() throws {
|
||||
let usage = try decode(enterprisePayload)
|
||||
XCTAssertNil(usage.primary)
|
||||
XCTAssertNil(usage.secondary)
|
||||
XCTAssertTrue(usage.additionalLimits.isEmpty)
|
||||
|
||||
let credits = try XCTUnwrap(usage.creditLimit)
|
||||
XCTAssertEqual(credits.limit, 10_000)
|
||||
XCTAssertEqual(credits.used, 3028.9909675121307, accuracy: 0.0001)
|
||||
XCTAssertEqual(credits.usedPercent, 30)
|
||||
XCTAssertEqual(credits.resetsAt, Date(timeIntervalSince1970: 1_785_542_400))
|
||||
XCTAssertFalse(credits.reached)
|
||||
// July 2026, not the payload's `reset_after_seconds`.
|
||||
XCTAssertEqual(try XCTUnwrap(credits.windowSeconds), 31 * 86_400)
|
||||
|
||||
XCTAssertEqual(usage.plan, .business)
|
||||
XCTAssertNil(usage.creditsBalance)
|
||||
XCTAssertFalse(usage.hasCredits)
|
||||
XCTAssertFalse(usage.creditsUnlimited)
|
||||
}
|
||||
|
||||
func testSpendControlIsReadAtEveryObservedPosition() throws {
|
||||
let bodies = [
|
||||
#"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
|
||||
#"{"spend_control": {"individualLimit": {"limit": 10000, "usedPercent": 25}}}"#,
|
||||
#"{"individual_limit": {"limit": 10000, "used_percent": 25}}"#,
|
||||
#"{"rate_limit": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
|
||||
]
|
||||
for body in bodies {
|
||||
let credits = try XCTUnwrap(decode(body).creditLimit, body)
|
||||
XCTAssertEqual(credits.usedPercent, 25, body)
|
||||
XCTAssertEqual(credits.used, 2500, body)
|
||||
}
|
||||
}
|
||||
|
||||
func testPercentFallsBackThroughRemainingPercentThenRawRatio() throws {
|
||||
let fromRemaining = try XCTUnwrap(
|
||||
decode(#"{"spend_control": {"individual_limit": {"limit": 10000, "remaining_percent": 70}}}"#).creditLimit)
|
||||
XCTAssertEqual(fromRemaining.usedPercent, 30, accuracy: 0.0001)
|
||||
|
||||
let fromRatio = try XCTUnwrap(
|
||||
decode(#"{"spend_control": {"individual_limit": {"limit": 400, "used": 100}}}"#).creditLimit)
|
||||
XCTAssertEqual(fromRatio.usedPercent, 25, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testUnusableSpendControlYieldsNoRow() throws {
|
||||
let bodies = [
|
||||
#"{"spend_control": {"individual_limit": {"limit": 0, "used": 5}}}"#,
|
||||
#"{"spend_control": {"individual_limit": {"limit": null}}}"#,
|
||||
#"{"spend_control": {"individual_limit": {"used_percent": 40}}}"#,
|
||||
#"{"spend_control": {"individual_limit": null}}"#,
|
||||
#"{"spend_control": null}"#,
|
||||
"{}",
|
||||
]
|
||||
for body in bodies {
|
||||
XCTAssertNil(try decode(body).creditLimit, body)
|
||||
}
|
||||
}
|
||||
|
||||
func testAllowanceWithoutAnyUsageSignalYieldsNoRow() throws {
|
||||
let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "reset_at": 1785542400}}}"#
|
||||
XCTAssertNil(try decode(body).creditLimit)
|
||||
}
|
||||
|
||||
func testBlankNumericStringIsAbsentNotZero() throws {
|
||||
let credits = try XCTUnwrap(decode(#"""
|
||||
{"spend_control": {"individual_limit": {"limit": "10000", "used": " ", "used_percent": 30}}}
|
||||
"""#).creditLimit)
|
||||
XCTAssertEqual(credits.used, 3000, accuracy: 0.001)
|
||||
XCTAssertNil(try decode(#"{"spend_control": {"individual_limit": {"limit": ""}}}"#).creditLimit)
|
||||
}
|
||||
|
||||
func testOverageKeepsCountsTruthfulWhileClampingPercent() throws {
|
||||
let credits = try XCTUnwrap(decode(#"""
|
||||
{"spend_control": {"individual_limit": {"limit": 10000, "used": 12000, "used_percent": 120}}}
|
||||
"""#).creditLimit)
|
||||
XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
|
||||
XCTAssertEqual(credits.usedPercent, 100)
|
||||
}
|
||||
|
||||
func testMonthWindowIsTimezoneIndependent() throws {
|
||||
// 2026-03-01Z reads as 28 days in UTC but 31 through a local calendar.
|
||||
let body = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1772323200}}}"#
|
||||
XCTAssertEqual(try XCTUnwrap(decode(body).creditLimit?.windowSeconds), 28 * 86_400)
|
||||
}
|
||||
|
||||
func testOutOfRangeNumbersDoNotTrap() throws {
|
||||
// `Int(_:)` on 1e100 traps, and a trap is not a catchable error.
|
||||
for body in [
|
||||
#"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1e100}}}"#,
|
||||
#"{"spend_control": {"individual_limit": {"limit": "Infinity", "used_percent": 10}}}"#,
|
||||
#"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": "NaN"}}}"#,
|
||||
] {
|
||||
_ = try? decode(body)
|
||||
}
|
||||
let huge = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": "1e100"}}}"#
|
||||
XCTAssertNil(try XCTUnwrap(decode(huge).creditLimit).resetsAt)
|
||||
}
|
||||
|
||||
func testPercentOnlyOverageKeepsTheImpliedCount() throws {
|
||||
let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 120}}}"#
|
||||
let credits = try XCTUnwrap(decode(body).creditLimit)
|
||||
XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
|
||||
XCTAssertEqual(credits.usedPercent, 100)
|
||||
XCTAssertEqual(credits.displayLabel, "Monthly usage limit · 12,000 / 10,000 credits")
|
||||
}
|
||||
|
||||
func testOneMalformedAdditionalLimitDoesNotDiscardTheRest() throws {
|
||||
let body = #"""
|
||||
{"additional_rate_limits": [
|
||||
null,
|
||||
{"limit_name": "Spark", "rate_limit": {"primary_window": {"used_percent": 40, "limit_window_seconds": 18000}}}
|
||||
]}
|
||||
"""#
|
||||
XCTAssertEqual(try decode(body).additionalLimits.map(\.name), ["Spark"])
|
||||
}
|
||||
|
||||
func testReachedSpendControlIsCarriedThrough() throws {
|
||||
let credits = try XCTUnwrap(decode(#"""
|
||||
{"spend_control": {"reached": true, "individual_limit": {"limit": 10000, "used_percent": 100}}}
|
||||
"""#).creditLimit)
|
||||
XCTAssertTrue(credits.reached)
|
||||
XCTAssertEqual(credits.usedPercent, 100)
|
||||
}
|
||||
|
||||
func testCreditFlagsAndMixedNumberEncodings() throws {
|
||||
let usage = try decode(#"""
|
||||
{"credits": {"has_credits": true, "unlimited": true, "balance": "3410.40"}}
|
||||
"""#)
|
||||
XCTAssertTrue(usage.hasCredits)
|
||||
XCTAssertTrue(usage.creditsUnlimited)
|
||||
XCTAssertEqual(try XCTUnwrap(usage.creditsBalance), 3410.40, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testRateWindowsStillDecodeAlongsideASpendControl() throws {
|
||||
let usage = try decode(#"""
|
||||
{
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"primary_window": {"used_percent": 20, "reset_at": 1800000000, "limit_window_seconds": 18000}
|
||||
},
|
||||
"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 30}}
|
||||
}
|
||||
"""#)
|
||||
XCTAssertEqual(usage.primary?.usedPercent, 20)
|
||||
XCTAssertEqual(usage.primary?.windowLabel, "5-hour")
|
||||
XCTAssertEqual(usage.creditLimit?.usedPercent, 30)
|
||||
}
|
||||
|
||||
func testOnlyAZeroCountSkipsTheCompanionRequest() {
|
||||
let zero = #"{"rate_limit_reset_credits": {"available_count": 0}}"#
|
||||
let some = #"{"rate_limit_reset_credits": {"available_count": 3}}"#
|
||||
XCTAssertEqual(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(zero.utf8))?.availableCount, 0)
|
||||
XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(some.utf8)))
|
||||
XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data("{}".utf8)))
|
||||
XCTAssertEqual(CodexSubscriptionService.inlineResetCredits(data: Data(some.utf8))?.availableCount, 3)
|
||||
}
|
||||
|
||||
func testInlineResetCreditsParseFromTheUsagePayload() {
|
||||
let inline = CodexSubscriptionService.inlineResetCredits(data: Data(enterprisePayload.utf8))
|
||||
XCTAssertEqual(inline?.availableCount, 0)
|
||||
XCTAssertNil(inline?.nextExpiresAt)
|
||||
}
|
||||
|
||||
func testInlineResetCreditsAbsentSignalsFallback() {
|
||||
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("{}".utf8)))
|
||||
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("not json".utf8)))
|
||||
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(
|
||||
data: Data(#"{"rate_limit_reset_credits": {}}"#.utf8)))
|
||||
}
|
||||
}
|
||||
85
mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift
Normal file
85
mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private func usage(
|
||||
balance: Double? = nil,
|
||||
hasCredits: Bool = false,
|
||||
unlimited: Bool = false,
|
||||
creditLimit: CodexUsage.CreditLimit? = nil
|
||||
) -> CodexUsage {
|
||||
CodexUsage(
|
||||
plan: .business,
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
additionalLimits: [],
|
||||
creditsBalance: balance,
|
||||
hasCredits: hasCredits,
|
||||
creditsUnlimited: unlimited,
|
||||
creditLimit: creditLimit,
|
||||
resetCredits: nil,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private func limit(used: Double, of total: Double, reached: Bool = false) -> CodexUsage.CreditLimit {
|
||||
CodexUsage.CreditLimit(
|
||||
used: used,
|
||||
limit: total,
|
||||
usedPercent: used / total * 100,
|
||||
resetsAt: Date(timeIntervalSince1970: 1_785_542_400),
|
||||
windowSeconds: 31 * 86_400,
|
||||
reached: reached
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func store(_ usage: CodexUsage) -> AppStore {
|
||||
let store = AppStore()
|
||||
store.codexUsage = usage
|
||||
// Pinned: the default depends on whether this machine has Codex connected.
|
||||
store.codexLoadState = .loaded
|
||||
return store
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct CodexQuotaSummaryTests {
|
||||
@Test("credit-settled balances group without a currency symbol")
|
||||
func creditSettledBalanceIsGroupedAndUnprefixed() {
|
||||
let store = store(usage(balance: 3410.4, hasCredits: true))
|
||||
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,410"])
|
||||
}
|
||||
|
||||
@Test("dollar balances keep the currency formatting")
|
||||
func dollarBalanceKeepsCurrencyFormatting() {
|
||||
let store = store(usage(balance: 3410.4, hasCredits: false))
|
||||
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · $3,410.40"])
|
||||
}
|
||||
|
||||
@Test("an exact-half credit balance rounds up, matching the desktop decoder")
|
||||
func creditBalanceRoundsHalfUp() {
|
||||
let store = store(usage(balance: 3410.5, hasCredits: true))
|
||||
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,411"])
|
||||
}
|
||||
|
||||
@Test("an uncapped credit account says so instead of showing nothing")
|
||||
func uncappedAccountSaysUnlimited() {
|
||||
let store = store(usage(hasCredits: true, unlimited: true))
|
||||
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits · Unlimited"])
|
||||
}
|
||||
|
||||
@Test("the allowance row drives the chip with the short label")
|
||||
func allowanceRowUsesTheShortLabel() {
|
||||
let store = store(usage(creditLimit: limit(used: 3033, of: 10_000)))
|
||||
let summary = store.quotaSummary(for: .codex)
|
||||
#expect(summary?.primary?.label == "Monthly usage limit")
|
||||
#expect(summary?.primary?.percent == 0.3033)
|
||||
#expect(summary?.footerLines.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("a spent-out allowance is called out on the chip")
|
||||
func reachedAllowanceIsCalledOut() {
|
||||
let store = store(usage(creditLimit: limit(used: 10_000, of: 10_000, reached: true)))
|
||||
#expect(store.quotaSummary(for: .codex)?.primary?.label == "Monthly usage limit · limit reached")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Kimi Code tokens live ~15 min and only the CLI renews them, so
|
||||
/// `.terminalFailure` is the dominant steady state between CLI uses. These
|
||||
/// tests pin the display decision: a terminal login with a snapshot on hand
|
||||
/// must keep showing the bars (with a quiet idle caption), and only the
|
||||
/// no-data case falls through to the reconnect screen.
|
||||
@Suite("Kimi quota presentation")
|
||||
struct KimiQuotaPresentationTests {
|
||||
typealias Presentation = KimiQuotaPresentation
|
||||
|
||||
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
|
||||
func terminalWithUsageShowsIdleUsage() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: true)
|
||||
#expect(content == .usage(idle: true))
|
||||
}
|
||||
|
||||
@Test("terminal failure with no snapshot falls through to reconnect")
|
||||
func terminalWithoutUsageShowsReconnect() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: false)
|
||||
#expect(content == .reconnect(reason: "expired"))
|
||||
}
|
||||
|
||||
@Test("loaded with a snapshot shows usage without the idle caption")
|
||||
func loadedShowsPlainUsage() {
|
||||
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
|
||||
}
|
||||
|
||||
@Test("transient failure keeps the last snapshot, else shows the retry screen")
|
||||
func transientFailureFallsBackToUsage() {
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
|
||||
}
|
||||
|
||||
@Test("credential-absent states route to the connect prompt")
|
||||
func credentialStatesRouteToNoCredentials() {
|
||||
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
|
||||
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
|
||||
}
|
||||
|
||||
@Test("dormant and bootstrapping render the loading state")
|
||||
func dormantAndBootstrappingLoad() {
|
||||
#expect(Presentation.planContent(loadState: .dormant, hasUsage: false) == .loading)
|
||||
#expect(Presentation.planContent(loadState: .bootstrapping, hasUsage: false) == .loading)
|
||||
}
|
||||
|
||||
@Test("loading with a snapshot shows usage; without one, the loading state")
|
||||
func loadingPrefersExistingSnapshot() {
|
||||
#expect(Presentation.planContent(loadState: .loading, hasUsage: true) == .usage(idle: false))
|
||||
#expect(Presentation.planContent(loadState: .loading, hasUsage: false) == .loading)
|
||||
}
|
||||
|
||||
@Test("a fresh snapshot is not stamped stale")
|
||||
func freshSnapshotIsNotStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
|
||||
}
|
||||
|
||||
@Test("a snapshot older than the threshold is stamped stale")
|
||||
func oldSnapshotIsStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
|
||||
}
|
||||
}
|
||||
114
mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift
Normal file
114
mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response.
|
||||
/// The API has shipped numbers as both JSON numbers and strings, and the
|
||||
/// reset timestamp under several key spellings (resetTime / reset_at / ...),
|
||||
/// so the parser must tolerate all of them.
|
||||
final class KimiUsageParsingTests: XCTestCase {
|
||||
|
||||
func testParsesNumericShapeWithResetTime() throws {
|
||||
let json = """
|
||||
{
|
||||
"usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"},
|
||||
"limits": [
|
||||
{"window": {"duration": 5, "timeUnit": "hour"},
|
||||
"detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.limit, 100)
|
||||
XCTAssertEqual(usage.primary?.used, 40)
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001)
|
||||
XCTAssertEqual(usage.primary?.remaining, 60)
|
||||
XCTAssertNotNil(usage.primary?.resetsAt)
|
||||
XCTAssertEqual(usage.details.count, 1)
|
||||
XCTAssertEqual(usage.details.first?.label, "5-hour")
|
||||
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testParsesStringNumbersAndSnakeCaseReset() throws {
|
||||
let json = """
|
||||
{
|
||||
"usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"},
|
||||
"limits": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.limit, 500)
|
||||
XCTAssertEqual(usage.primary?.used, 123)
|
||||
XCTAssertNotNil(usage.primary?.resetsAt)
|
||||
}
|
||||
|
||||
func testWeeklyWindowLabel() throws {
|
||||
let json = """
|
||||
{
|
||||
"limits": [
|
||||
{"window": {"duration": 7, "timeUnit": "day"},
|
||||
"detail": {"limit": 1000, "used": 250}}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertNil(usage.primary)
|
||||
XCTAssertEqual(usage.details.first?.label, "Weekly")
|
||||
}
|
||||
|
||||
func testEpochResetTime() throws {
|
||||
let json = """
|
||||
{"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
|
||||
}
|
||||
|
||||
func testNumericEpochResetTime() throws {
|
||||
// A JSON number (not string) must not fail the whole decode.
|
||||
let json = """
|
||||
{"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
|
||||
}
|
||||
|
||||
func testLiveResponseShape() {
|
||||
// Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23).
|
||||
let json = """
|
||||
{
|
||||
"user": {"userId": "x", "region": "REGION_OVERSEA",
|
||||
"membership": {"level": "LEVEL_INTERMEDIATE"}},
|
||||
"usage": {"limit": "100", "used": "5", "remaining": "95",
|
||||
"resetTime": "2026-07-30T13:27:17.211180Z"},
|
||||
"limits": [
|
||||
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
|
||||
"detail": {"limit": "100", "remaining": "100",
|
||||
"resetTime": "2026-07-23T23:27:17.211180Z"}}
|
||||
],
|
||||
"parallel": {"limit": "20"}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try! KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.plan, "Intermediate")
|
||||
XCTAssertEqual(usage.parallelLimit, 20)
|
||||
XCTAssertEqual(usage.primary?.label, "Weekly")
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001)
|
||||
// 300 minutes rolls up to a 5-hour label; used derives from remaining.
|
||||
XCTAssertEqual(usage.details.count, 1)
|
||||
XCTAssertEqual(usage.details.first?.label, "5-hour")
|
||||
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001)
|
||||
XCTAssertNotNil(usage.details.first?.resetsAt)
|
||||
}
|
||||
|
||||
func testEmptyEnvelopeThrows() {
|
||||
let json = "{}".data(using: .utf8)!
|
||||
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
|
||||
}
|
||||
|
||||
func testZeroLimitWindowDropped() throws {
|
||||
let json = """
|
||||
{"usage": {"limit": 0, "used": 0}, "limits": []}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import AppKit
|
||||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Locks the right-click menu policy that fixed flash + scroll-jump (#802).
|
||||
/// Does not drive a real NSStatusItem — that needs manual/AppKit integration.
|
||||
final class StatusItemContextMenuPolicyTests: XCTestCase {
|
||||
func testPresentEventMaskIsRightMouseUpNotDown() {
|
||||
// Presenting on mouse-down lets the matching up dismiss the menu (flash).
|
||||
XCTAssertEqual(
|
||||
StatusItemContextMenuPolicy.presentEventMask,
|
||||
NSEvent.EventTypeMask.rightMouseUp
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
StatusItemContextMenuPolicy.presentEventMask,
|
||||
NSEvent.EventTypeMask.rightMouseDown
|
||||
)
|
||||
// Mask must include up and must not include down (single-bit masks here).
|
||||
XCTAssertTrue(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseUp))
|
||||
XCTAssertFalse(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseDown))
|
||||
}
|
||||
|
||||
func testPresentationUsesStatusItemMenuNotManualPopUp() {
|
||||
// Manual popUp tracks against a point while the cursor sits on the status
|
||||
// item above the menu → scroll chevron / Today-row jump on mouse move.
|
||||
XCTAssertEqual(
|
||||
StatusItemContextMenuPolicy.presentation,
|
||||
.statusItemMenu
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
StatusItemContextMenuPolicy.presentation,
|
||||
.manualPopUp
|
||||
)
|
||||
}
|
||||
|
||||
func testDebounceAcceptsFirstPresent() {
|
||||
var last = Date.distantPast
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
XCTAssertTrue(
|
||||
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last)
|
||||
)
|
||||
XCTAssertEqual(last, now)
|
||||
}
|
||||
|
||||
func testDebounceRejectsWithinWindow() {
|
||||
let t0 = Date(timeIntervalSince1970: 1_000)
|
||||
var last = t0
|
||||
// Just inside the 0.3s window
|
||||
let t1 = t0.addingTimeInterval(0.299)
|
||||
XCTAssertFalse(
|
||||
StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last)
|
||||
)
|
||||
XCTAssertEqual(last, t0, "reject must not advance lastPresentedAt")
|
||||
}
|
||||
|
||||
func testDebounceAcceptsAfterWindow() {
|
||||
let t0 = Date(timeIntervalSince1970: 1_000)
|
||||
var last = t0
|
||||
let t1 = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds + 0.001)
|
||||
XCTAssertTrue(
|
||||
StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last)
|
||||
)
|
||||
XCTAssertEqual(last, t1)
|
||||
}
|
||||
|
||||
func testDebounceBoundaryIsStrictlyGreaterThan() {
|
||||
// Gate uses `>` not `>=`: exactly debounce seconds later is still rejected.
|
||||
let t0 = Date(timeIntervalSince1970: 1_000)
|
||||
var last = t0
|
||||
let exact = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds)
|
||||
XCTAssertFalse(
|
||||
StatusItemContextMenuPolicy.acceptPresent(now: exact, lastPresentedAt: &last)
|
||||
)
|
||||
XCTAssertEqual(last, t0)
|
||||
}
|
||||
|
||||
func testCustomDebounceOverride() {
|
||||
var last = Date(timeIntervalSince1970: 0)
|
||||
let now = Date(timeIntervalSince1970: 0.5)
|
||||
XCTAssertFalse(
|
||||
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 1.0)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 0.4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,44 @@ struct UpdateCheckerTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Suite("Update failure presentation")
|
||||
@MainActor
|
||||
struct UpdateFailurePresentationTests {
|
||||
@Test("identifies an automatic update check failure")
|
||||
func updateCheckFailure() {
|
||||
let checker = UpdateChecker()
|
||||
checker.updateFailureStage = .check
|
||||
checker.updateError = "GitHub returned HTTP 403."
|
||||
|
||||
#expect(checker.updateBadgeLabel == "Update Check Failed")
|
||||
#expect(checker.updateHelpText.contains("could not check GitHub for updates"))
|
||||
#expect(checker.updateHelpText.contains("GitHub returned HTTP 403."))
|
||||
#expect(checker.updateHelpText.contains("retry the update check"))
|
||||
}
|
||||
|
||||
@Test("distinguishes CLI update failures")
|
||||
func cliUpdateFailure() {
|
||||
let checker = UpdateChecker()
|
||||
checker.updateFailureStage = .cliUpdate
|
||||
checker.updateError = "npm exited with status 1."
|
||||
|
||||
#expect(checker.updateBadgeLabel == "CLI Update Failed")
|
||||
#expect(checker.updateHelpText.contains("could not update the CLI"))
|
||||
#expect(checker.updateHelpText.contains("npm exited with status 1."))
|
||||
}
|
||||
|
||||
@Test("distinguishes menubar update failures")
|
||||
func menubarUpdateFailure() {
|
||||
let checker = UpdateChecker()
|
||||
checker.updateFailureStage = .menubarUpdate
|
||||
checker.updateError = "Checksum mismatch."
|
||||
|
||||
#expect(checker.updateBadgeLabel == "Menubar Update Failed")
|
||||
#expect(checker.updateHelpText.contains("could not update the menubar app"))
|
||||
#expect(checker.updateHelpText.contains("Checksum mismatch."))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - one-click full update: package-manager resolution
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { randomBytes } from 'crypto'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
|
|
@ -81,6 +81,13 @@ async function retryWindowsMutation(operation: () => Promise<void>, sleep: (ms:
|
|||
return false
|
||||
}
|
||||
|
||||
// The directory entry becomes visible before the awaited body write, so the
|
||||
// file is briefly observable at zero bytes. Deliberately left as is: a corrupt
|
||||
// body is only ever recovered once its mtime is older than staleMs, and this
|
||||
// window is milliseconds wide on a file whose mtime is by definition now, so
|
||||
// no observer can reach the age gate through it. Closing it would mean
|
||||
// link()ing a temp file into place, which is not portable to filesystems
|
||||
// without hard links.
|
||||
async function createExclusive(path: string, body: string): Promise<'created' | 'exists' | 'unavailable'> {
|
||||
try {
|
||||
const handle = await open(path, 'wx', 0o600)
|
||||
|
|
@ -92,7 +99,24 @@ async function createExclusive(path: string, body: string): Promise<'created' |
|
|||
}
|
||||
}
|
||||
|
||||
type Observation = { record: LockRecord; mtimeMs: number }
|
||||
// A null record is a body whose stat bracket agreed across the read and that
|
||||
// still does not parse into a lock record: a corrupt leftover of 0 bytes, a
|
||||
// truncation, or a wrong shape. The bracket is a heuristic, not proof that the
|
||||
// read was whole — a same-size rewrite moves neither size nor (on a coarse
|
||||
// filesystem) mtime — which is why nothing here treats a single read as
|
||||
// authoritative. It owns nothing, but it is a real file with a
|
||||
// real mtime, not an infrastructure failure — classifying it 'unavailable'
|
||||
// routed every later refresh to the read-only path and froze ingestion. It
|
||||
// carries no authority: it is only ever recovered through the unmodified
|
||||
// staleness gate, exactly like an abandoned but well-formed lock.
|
||||
//
|
||||
// `digest` fingerprints the exact bytes. A corrupt body has no token, so
|
||||
// token equality between two corrupt observations degenerates to
|
||||
// `undefined === undefined`, and mtime granularity is coarse on some
|
||||
// filesystems (measured on macOS: a 2s grid on FAT32, 10ms on exFAT, sub-ms on
|
||||
// APFS — and on all three a same-size rewrite moves neither mtime nor size), so
|
||||
// mtime is not a reliable change signal on its own.
|
||||
type Observation = { record: LockRecord | null; mtimeMs: number; digest: string }
|
||||
type ObservationResult = Observation | 'missing' | 'changing' | 'unavailable'
|
||||
|
||||
async function observe(path: string): Promise<ObservationResult> {
|
||||
|
|
@ -100,6 +124,7 @@ async function observe(path: string): Promise<ObservationResult> {
|
|||
// written, and heartbeat rewrites briefly truncate it. Treat that bounded
|
||||
// transition as contention, not broken infrastructure.
|
||||
let sawChange = false
|
||||
let corrupt: Observation | null = null
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const before = await stat(path)
|
||||
|
|
@ -110,10 +135,23 @@ async function observe(path: string): Promise<ObservationResult> {
|
|||
await delay(1)
|
||||
continue
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LockRecord>
|
||||
if (typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
|
||||
return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs }
|
||||
const digest = createHash('sha1').update(raw).digest('hex')
|
||||
// A body that is valid JSON of the wrong shape is corrupt like any other,
|
||||
// including one written by a future version with a different record
|
||||
// shape. That is safe precisely because staleness is never waived: a
|
||||
// foreign version's LIVE lock keeps its mtime fresh through its own
|
||||
// heartbeat, so it is never taken — both versions just degrade to the
|
||||
// read-only path. Only an abandoned one is recovered, and a lock record
|
||||
// is per-run state with nothing in it worth preserving.
|
||||
let parsed: Partial<LockRecord> | undefined
|
||||
try { parsed = JSON.parse(raw) as Partial<LockRecord> } catch { parsed = undefined }
|
||||
if (parsed && typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
|
||||
return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs, digest }
|
||||
}
|
||||
// Keep the most recent corrupt read. It is not evidence of stability on
|
||||
// its own: tryTakeover re-observes under the guard and compares with
|
||||
// sameObservation before acting, so stability is proven there, not here.
|
||||
corrupt = { record: null, mtimeMs: after.mtimeMs, digest }
|
||||
} catch (err) {
|
||||
if (isMissingError(err)) return 'missing'
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code
|
||||
|
|
@ -121,11 +159,19 @@ async function observe(path: string): Promise<ObservationResult> {
|
|||
}
|
||||
await delay(1)
|
||||
}
|
||||
return sawChange ? 'changing' : 'unavailable'
|
||||
// Contention outranks corruption: a body seen mid-rewrite is a live owner's,
|
||||
// and the caller must poll rather than treat it as recoverable.
|
||||
if (sawChange) return 'changing'
|
||||
return corrupt ?? 'unavailable'
|
||||
}
|
||||
|
||||
function sameObservation(a: Observation, b: Observation): boolean {
|
||||
return a.record.token === b.record.token && a.mtimeMs === b.mtimeMs
|
||||
// A corrupt body and an owned one are never "the same observation", even
|
||||
// though `a.record?.token === b.record?.token` cannot tell them apart once
|
||||
// both sides are corrupt. Compare that boundary explicitly, then require the
|
||||
// bytes themselves to match, so "unchanged" survives a coarse mtime.
|
||||
if ((a.record === null) !== (b.record === null)) return false
|
||||
return a.record?.token === b.record?.token && a.mtimeMs === b.mtimeMs && a.digest === b.digest
|
||||
}
|
||||
|
||||
let singleFlightTail: Promise<void> = Promise.resolve()
|
||||
|
|
@ -209,7 +255,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
|
|||
if (current === 'missing') return true
|
||||
if (current === 'changing') return false
|
||||
if (current === 'unavailable') return false
|
||||
if (current.record.token !== token) return true
|
||||
if (current.record?.token !== token) return true
|
||||
return retryWindowsMutation(() => unlink(lockPath), sleep)
|
||||
} finally {
|
||||
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
|
||||
|
|
@ -221,7 +267,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
|
|||
if (guard !== 'created') return false
|
||||
try {
|
||||
const current = await observe(lockPath)
|
||||
return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record.token === token
|
||||
return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record?.token === token
|
||||
} finally {
|
||||
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
|
||||
}
|
||||
|
|
@ -238,7 +284,23 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
|
|||
if (guard !== 'created') { heartbeatRunning = false; return }
|
||||
try {
|
||||
const current = await observe(lockPath)
|
||||
if (current === 'missing' || current === 'changing' || current === 'unavailable' || current.record.token !== token) return
|
||||
if (current === 'missing' || current === 'changing' || current === 'unavailable') return
|
||||
// A corrupt body is NOT ours to rewrite, even though no parseable
|
||||
// token contradicts us. Holding the takeover guard excludes the other
|
||||
// guard-takers, but NOT createExclusive, which publishes a directory
|
||||
// entry before its body — so an unparseable body may be a successor's
|
||||
// lock a millisecond from being written, or a foreign version's whose
|
||||
// record shape we cannot read. Stamping our token over it made this
|
||||
// process an owner again after it had been legitimately replaced:
|
||||
// verifyStillOwner then answered true for a displaced writer, and
|
||||
// release()'s removeIfOwned deleted the live successor's lock.
|
||||
//
|
||||
// So a body we cannot prove is ours ends our ownership. The mtime
|
||||
// stops advancing, the fence refuses to publish (the parse is
|
||||
// discarded, which is the fail-safe direction), and a successor
|
||||
// recovers the lock one staleMs later through the age gate. Losing a
|
||||
// parse is the correct price for never having two owners.
|
||||
if (current.record === null || current.record.token !== token) return
|
||||
await writeFile(lockPath, body(), { encoding: 'utf-8' })
|
||||
const now = new Date(clock.wallNow())
|
||||
await utimes(lockPath, now, now)
|
||||
|
|
@ -325,6 +387,12 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
|
|||
continue
|
||||
}
|
||||
|
||||
// A corrupt observation takes this path unchanged. Staleness is never
|
||||
// waived for it: an abandoned corrupt lock is older than staleMs and is
|
||||
// recovered here, while a corrupt body younger than that is waited out
|
||||
// and left alone, because it may belong to a live owner whose heartbeat
|
||||
// will repair it. Worst case we time out and serve the prior snapshot
|
||||
// read-only for one staleMs window instead of freezing forever.
|
||||
const age = Math.max(0, clock.wallNow() - observation.mtimeMs)
|
||||
if (age > staleMs) {
|
||||
const takeover = await tryTakeover(observation)
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import type { ParsedProviderCall } from './providers/types.js'
|
|||
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
|
||||
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
|
||||
// so sessions cached under v4 pick up the CLI-MCP attribution.
|
||||
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
|
||||
// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from
|
||||
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
|
||||
const CODEX_CACHE_VERSION = 7
|
||||
// v8: persist native MCP timing and compact invocation attribution.
|
||||
const CODEX_CACHE_VERSION = 8
|
||||
const CACHE_FILE = 'codex-results.json'
|
||||
|
||||
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
|
||||
|
|
|
|||
521
src/codex-throughput.ts
Normal file
521
src/codex-throughput.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { open, stat } from 'node:fs/promises'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
|
||||
export type CodexThroughputPoint = {
|
||||
timestamp: string
|
||||
model?: string
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
generatedTokens: number
|
||||
taskGeneratedTokens?: number
|
||||
elapsedSeconds?: number
|
||||
generatedTokensPerSecond?: number
|
||||
activeDurationSeconds?: number
|
||||
activeGeneratedTokensPerSecond?: number
|
||||
toolWaitSeconds?: number
|
||||
}
|
||||
|
||||
type TokenUsage = {
|
||||
output_tokens?: number
|
||||
reasoning_output_tokens?: number
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
type RolloutLine = {
|
||||
type?: string
|
||||
timestamp?: string
|
||||
payload?: {
|
||||
type?: string
|
||||
turn_id?: string
|
||||
call_id?: string
|
||||
started_at?: number
|
||||
duration_ms?: number
|
||||
duration?: { secs?: number; nanos?: number } | string
|
||||
model?: string
|
||||
forked_from_id?: string
|
||||
info?: {
|
||||
last_token_usage?: TokenUsage
|
||||
total_token_usage?: TokenUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CHUNK_BYTES = 64 * 1024
|
||||
const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024
|
||||
const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__'
|
||||
|
||||
function rawString(source: string, field: string): string | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source)
|
||||
if (!match) return undefined
|
||||
try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined }
|
||||
}
|
||||
|
||||
function rawNumber(source: string, field: string): number | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source)
|
||||
if (!match) return undefined
|
||||
const value = Number(match[1])
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined {
|
||||
const index = source.indexOf(`"${field}"`)
|
||||
if (index < 0) return undefined
|
||||
const body = source.slice(index, index + 4096)
|
||||
return {
|
||||
output_tokens: rawNumber(body, 'output_tokens'),
|
||||
reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'),
|
||||
total_tokens: rawNumber(body, 'total_tokens'),
|
||||
}
|
||||
}
|
||||
|
||||
function parseRawDurationValue(value: string): number | undefined {
|
||||
const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value)
|
||||
if (objectMatch) {
|
||||
const seconds = Number(objectMatch[1])
|
||||
const nanos = Number(objectMatch[2])
|
||||
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value)
|
||||
if (stringMatch) {
|
||||
const parsed = Number(stringMatch[1])
|
||||
if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
|
||||
if (numberMatch) {
|
||||
const parsed = Number(numberMatch[1])
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function durationMs(payload: RolloutLine['payload']): number | undefined {
|
||||
if (!payload) return undefined
|
||||
if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms
|
||||
if (typeof payload.duration === 'object' && payload.duration) {
|
||||
const seconds = payload.duration.secs
|
||||
const nanos = payload.duration.nanos
|
||||
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
|
||||
return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
}
|
||||
if (typeof payload.duration === 'string') {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim())
|
||||
if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number {
|
||||
const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined)
|
||||
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
|
||||
const clipped = intervals.map(([start, end]) => [
|
||||
windowStart !== undefined ? Math.max(start, windowStart) : start,
|
||||
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
|
||||
] as [number, number]).filter(([start, end]) => end > start)
|
||||
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((result, interval) => {
|
||||
const previous = result.at(-1)
|
||||
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
|
||||
else result.push([...interval])
|
||||
return result
|
||||
}, [])
|
||||
return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0))
|
||||
}
|
||||
|
||||
function parseLine(line: string): RolloutLine | null {
|
||||
const payloadStart = line.indexOf('"payload"')
|
||||
const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line
|
||||
if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) {
|
||||
const payloadType = rawString(payloadHead, 'type')
|
||||
const infoStart = payloadHead.indexOf('"info"')
|
||||
const info = infoStart >= 0 ? payloadHead.slice(infoStart) : ''
|
||||
return {
|
||||
type: rawString(line, 'type'),
|
||||
timestamp: rawString(line, 'timestamp'),
|
||||
payload: {
|
||||
type: payloadType,
|
||||
turn_id: rawString(payloadHead, 'turn_id'),
|
||||
call_id: rawString(payloadHead, 'call_id'),
|
||||
started_at: rawNumber(payloadHead, 'started_at'),
|
||||
duration_ms: rawNumber(payloadHead, 'duration_ms'),
|
||||
duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined
|
||||
? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') }
|
||||
: undefined),
|
||||
model: rawString(payloadHead, 'model'),
|
||||
forked_from_id: rawString(payloadHead, 'forked_from_id'),
|
||||
info: {
|
||||
last_token_usage: compactUsage(info, 'last_token_usage'),
|
||||
total_token_usage: compactUsage(info, 'total_token_usage'),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(line) as RolloutLine
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate generated tokens/sec from a Codex rollout's persisted checkpoints.
|
||||
* Codex JSONL has no per-token timestamps, so this is deliberately a
|
||||
* checkpoint-to-checkpoint estimate, not live decode speed.
|
||||
*/
|
||||
type ThroughputState = {
|
||||
model?: string
|
||||
previousTotal?: number
|
||||
previousOutput: number
|
||||
previousReasoning: number
|
||||
previousTimestamp?: number
|
||||
currentTaskGenerated: number
|
||||
currentTaskToolIntervals: Array<[number, number]>
|
||||
currentTaskStartedAt?: number
|
||||
toolStarts: Map<string, number>
|
||||
latestPoint?: CodexThroughputPoint
|
||||
points: CodexThroughputPoint[]
|
||||
forkCutoffMs?: number
|
||||
}
|
||||
|
||||
function newThroughputState(): ThroughputState {
|
||||
return {
|
||||
previousOutput: 0,
|
||||
previousReasoning: 0,
|
||||
currentTaskGenerated: 0,
|
||||
currentTaskToolIntervals: [],
|
||||
toolStarts: new Map(),
|
||||
points: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally parses a rollout. Watch mode feeds only newly appended bytes
|
||||
* to this reader, so a growing JSONL file is not reparsed from byte zero.
|
||||
*/
|
||||
export class CodexThroughputReader {
|
||||
private offset = 0
|
||||
private pending = ''
|
||||
private decoder = new StringDecoder('utf8')
|
||||
private pendingDurationMs: number | undefined
|
||||
private scanDepth = 0
|
||||
private scanPayloadDepth: number | undefined
|
||||
private scanInString = false
|
||||
private scanEscape = false
|
||||
private scanString = ''
|
||||
private scanLastString = ''
|
||||
private scanAwaitingColon = false
|
||||
private scanCurrentKey: string | undefined
|
||||
private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined
|
||||
private state = newThroughputState()
|
||||
|
||||
reset(): void {
|
||||
this.offset = 0
|
||||
this.pending = ''
|
||||
this.decoder = new StringDecoder('utf8')
|
||||
this.pendingDurationMs = undefined
|
||||
this.scanDepth = 0
|
||||
this.scanPayloadDepth = undefined
|
||||
this.scanInString = false
|
||||
this.scanEscape = false
|
||||
this.scanString = ''
|
||||
this.scanLastString = ''
|
||||
this.scanAwaitingColon = false
|
||||
this.scanCurrentKey = undefined
|
||||
this.scanCapture = undefined
|
||||
this.state = newThroughputState()
|
||||
}
|
||||
|
||||
private finishDurationCapture(): void {
|
||||
if (!this.scanCapture) return
|
||||
const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text
|
||||
const parsed = parseRawDurationValue(value)
|
||||
if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed
|
||||
this.scanCapture = undefined
|
||||
}
|
||||
|
||||
private scanDurationSegment(source: string): void {
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i]!
|
||||
if (this.scanInString) {
|
||||
if (this.scanEscape) {
|
||||
this.scanEscape = false
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char
|
||||
else this.scanString += char
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
this.scanEscape = true
|
||||
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
this.scanInString = false
|
||||
if (this.scanCapture?.mode === 'string') this.finishDurationCapture()
|
||||
else if (this.scanCapture?.mode === 'object') {
|
||||
this.scanAwaitingColon = false
|
||||
this.scanCurrentKey = undefined
|
||||
} else {
|
||||
this.scanLastString = this.scanString
|
||||
this.scanAwaitingColon = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
|
||||
else this.scanString += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.scanCapture?.mode === 'primitive') {
|
||||
if (char === ',' || char === '}' || char === ']') this.finishDurationCapture()
|
||||
else { this.scanCapture.text += char; continue }
|
||||
}
|
||||
if (this.scanAwaitingColon) {
|
||||
if (/\s/.test(char)) continue
|
||||
if (char === ':') {
|
||||
this.scanCurrentKey = this.scanLastString
|
||||
this.scanAwaitingColon = false
|
||||
continue
|
||||
}
|
||||
this.scanAwaitingColon = false
|
||||
}
|
||||
if (char === '"') {
|
||||
this.scanString = ''
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
|
||||
this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth }
|
||||
this.scanCurrentKey = undefined
|
||||
}
|
||||
this.scanInString = true
|
||||
continue
|
||||
}
|
||||
if (char === '{' || char === '[') {
|
||||
if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) {
|
||||
this.scanPayloadDepth = this.scanDepth + 1
|
||||
}
|
||||
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
|
||||
this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 }
|
||||
this.scanCurrentKey = undefined
|
||||
} else if (this.scanCapture?.mode === 'object') {
|
||||
this.scanCapture.text += char
|
||||
}
|
||||
this.scanDepth++
|
||||
continue
|
||||
}
|
||||
if (char === '}' || char === ']') {
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
this.scanDepth = Math.max(0, this.scanDepth - 1)
|
||||
if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture()
|
||||
continue
|
||||
}
|
||||
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) {
|
||||
this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth }
|
||||
this.scanCurrentKey = undefined
|
||||
continue
|
||||
}
|
||||
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
|
||||
}
|
||||
}
|
||||
|
||||
private processLine(line: string, durationOverride?: number): void {
|
||||
const entry = parseLine(line)
|
||||
if (!entry) return
|
||||
if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) {
|
||||
entry.payload = { ...entry.payload, duration_ms: durationOverride }
|
||||
}
|
||||
const state = this.state
|
||||
if (entry.type === 'session_meta') {
|
||||
if (entry.payload?.model) state.model = entry.payload.model
|
||||
if (entry.payload?.forked_from_id && entry.timestamp) {
|
||||
const timestamp = Date.parse(entry.timestamp)
|
||||
if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000
|
||||
}
|
||||
return
|
||||
}
|
||||
if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model
|
||||
const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs
|
||||
if (isForkReplay && (
|
||||
entry.payload?.type === 'task_started' ||
|
||||
entry.payload?.type === 'task_complete' ||
|
||||
entry.payload?.type === 'function_call' ||
|
||||
entry.payload?.type === 'function_call_output' ||
|
||||
entry.payload?.type === 'custom_tool_call' ||
|
||||
entry.payload?.type === 'custom_tool_call_output' ||
|
||||
entry.payload?.type === 'mcp_tool_call_end' ||
|
||||
entry.payload?.type === 'patch_apply_end' ||
|
||||
entry.payload?.type === 'token_count'
|
||||
)) return
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
|
||||
state.currentTaskGenerated = 0
|
||||
state.currentTaskToolIntervals = []
|
||||
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
|
||||
state.toolStarts.clear()
|
||||
}
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) {
|
||||
const started = Date.parse(entry.timestamp)
|
||||
if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started)
|
||||
}
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) {
|
||||
const ended = Date.parse(entry.timestamp)
|
||||
const started = state.toolStarts.get(entry.payload.call_id)
|
||||
if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended])
|
||||
state.toolStarts.delete(entry.payload.call_id)
|
||||
}
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) {
|
||||
const ended = Date.parse(entry.timestamp)
|
||||
const elapsed = durationMs(entry.payload)
|
||||
if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended])
|
||||
}
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
|
||||
const taskDurationMs = durationMs(entry.payload)
|
||||
if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) {
|
||||
state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated
|
||||
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined
|
||||
const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined)
|
||||
const activeMs = taskDurationMs - toolWaitMs
|
||||
if (activeMs > 0) {
|
||||
state.latestPoint.activeDurationSeconds = activeMs / 1000
|
||||
state.latestPoint.toolWaitSeconds = toolWaitMs / 1000
|
||||
state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return
|
||||
const info = entry.payload.info
|
||||
if (!info || !entry.timestamp) return
|
||||
const last = info.last_token_usage
|
||||
const total = info.total_token_usage
|
||||
const cumulative = total?.total_tokens
|
||||
if (cumulative !== undefined && cumulative === state.previousTotal) return
|
||||
let outputTokens = last?.output_tokens ?? 0
|
||||
let reasoningTokens = last?.reasoning_output_tokens ?? 0
|
||||
if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) {
|
||||
outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput)
|
||||
reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning)
|
||||
}
|
||||
if (cumulative !== undefined) {
|
||||
state.previousTotal = cumulative
|
||||
state.previousOutput = total?.output_tokens ?? state.previousOutput
|
||||
state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning
|
||||
}
|
||||
const generatedTokens = outputTokens + reasoningTokens
|
||||
if (generatedTokens <= 0) return
|
||||
const timestampMs = Date.parse(entry.timestamp)
|
||||
if (!Number.isFinite(timestampMs)) return
|
||||
const point: CodexThroughputPoint = {
|
||||
timestamp: entry.timestamp,
|
||||
model: state.model,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
generatedTokens,
|
||||
}
|
||||
state.currentTaskGenerated += generatedTokens
|
||||
state.latestPoint = point
|
||||
if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) {
|
||||
const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000
|
||||
point.elapsedSeconds = elapsedSeconds
|
||||
point.generatedTokensPerSecond = generatedTokens / elapsedSeconds
|
||||
}
|
||||
state.previousTimestamp = timestampMs
|
||||
state.points.push(point)
|
||||
if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000)
|
||||
}
|
||||
|
||||
async update(filePath: string, limit = 10, finalize = false): Promise<CodexThroughputPoint[]> {
|
||||
const info = await stat(filePath)
|
||||
if (info.size < this.offset) this.reset()
|
||||
const bytesToRead = info.size - this.offset
|
||||
if (bytesToRead > 0) {
|
||||
const file = await open(filePath, 'r')
|
||||
try {
|
||||
let position = this.offset
|
||||
while (position < info.size) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position))
|
||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, position)
|
||||
if (bytesRead === 0) break
|
||||
position += bytesRead
|
||||
this.offset = position
|
||||
let chunk = this.decoder.write(buffer.subarray(0, bytesRead))
|
||||
while (chunk.length > 0) {
|
||||
const newlineIndex = chunk.search(/\r?\n/)
|
||||
const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk
|
||||
this.pending += segment
|
||||
this.scanDurationSegment(segment)
|
||||
if (newlineIndex < 0) break
|
||||
const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1
|
||||
const line = this.pending
|
||||
const durationOverride = this.pendingDurationMs
|
||||
this.pending = ''
|
||||
this.pendingDurationMs = undefined
|
||||
this.scanDepth = 0
|
||||
this.scanPayloadDepth = undefined
|
||||
this.scanInString = false
|
||||
this.scanEscape = false
|
||||
this.scanString = ''
|
||||
this.scanLastString = ''
|
||||
this.scanAwaitingColon = false
|
||||
this.scanCurrentKey = undefined
|
||||
this.scanCapture = undefined
|
||||
this.processLine(line, durationOverride)
|
||||
chunk = chunk.slice(newlineIndex + newlineLength)
|
||||
}
|
||||
if (this.pending.length > MAX_PENDING_LINE_CHARS) {
|
||||
const body = this.pending.startsWith(TRUNCATION_MARKER)
|
||||
? this.pending.slice(TRUNCATION_MARKER.length)
|
||||
: this.pending
|
||||
this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
if (finalize && this.pending) {
|
||||
this.processLine(this.pending, this.pendingDurationMs)
|
||||
this.pending = ''
|
||||
this.pendingDurationMs = undefined
|
||||
}
|
||||
return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice()
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCodexThroughput(filePath: string, limit = 10): Promise<CodexThroughputPoint[]> {
|
||||
return new CodexThroughputReader().update(filePath, limit, true)
|
||||
}
|
||||
|
||||
export async function newestCodexSession(sessions: Array<{ path: string }>): Promise<string | undefined> {
|
||||
let newest: { path: string; mtimeMs: number } | undefined
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const info = await stat(session.path)
|
||||
if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs }
|
||||
} catch {
|
||||
// A session can disappear while Codex rotates or archives it.
|
||||
}
|
||||
}
|
||||
return newest?.path
|
||||
}
|
||||
|
||||
export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string {
|
||||
const latest = points.at(-1)
|
||||
if (!latest) return `No token_count checkpoints found in ${filePath}.`
|
||||
const lines = [
|
||||
'CodeBurn Codex throughput estimate',
|
||||
`Session: ${filePath}`,
|
||||
`Latest checkpoint: ${latest.timestamp}`,
|
||||
`Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`,
|
||||
]
|
||||
if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`)
|
||||
if (latest.activeGeneratedTokensPerSecond !== undefined) {
|
||||
lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`)
|
||||
lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`)
|
||||
} else if (latest.generatedTokensPerSecond !== undefined) {
|
||||
lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`)
|
||||
} else {
|
||||
lines.push('Throughput: unavailable (waiting for a completed turn)')
|
||||
}
|
||||
lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
|
@ -150,6 +150,14 @@ export type DailyCache = {
|
|||
/// as incomplete and is fully re-backfilled. Absent on caches written before
|
||||
/// this field existed → treated as incomplete (one self-healing re-backfill).
|
||||
complete?: boolean
|
||||
/// True once a COMPLETE parse finalized this watermark. The pull-back below
|
||||
/// only distrusts caches WITHOUT this stamp: a degraded parse can no longer
|
||||
/// set `complete`, so a stamped cache whose watermark sits past its newest
|
||||
/// populated day is a legitimately idle tail (recent days had no activity),
|
||||
/// not a frozen hole, and re-deriving it every launch is pure waste. Absent
|
||||
/// on caches written before this field: distrusted once (one healing
|
||||
/// pull-back), then stamped.
|
||||
watermarkTrusted?: boolean
|
||||
}
|
||||
|
||||
function getCacheDir(): string {
|
||||
|
|
@ -257,7 +265,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
|
|||
if (!isRecord(raw)) return {}
|
||||
const out: NonNullable<DailyEntry['projects']> = {}
|
||||
for (const [name, p] of Object.entries(raw)) {
|
||||
if (name in Object.prototype || !isRecord(p)) continue
|
||||
// A project key is a directory basename, so it can legitimately be a
|
||||
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
|
||||
// as an own property via defineProperty, so keeping it is pollution-safe —
|
||||
// and dropping it would silently subtract that project's cost from a
|
||||
// --project/--exclude total (the day's split would no longer sum to its own
|
||||
// cost, which the filtered headline relies on).
|
||||
if (!isRecord(p)) continue
|
||||
setOwn(out, name, {
|
||||
cost: num(p.cost),
|
||||
calls: num(p.calls),
|
||||
|
|
@ -294,7 +308,7 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
|
|||
}))
|
||||
}
|
||||
|
||||
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
|
||||
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
|
||||
return {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: parsed.savingsConfigHash ?? '',
|
||||
|
|
@ -306,6 +320,9 @@ function migratedFrom(parsed: { version: number; lastComputedDate: string | null
|
|||
// Only a cache explicitly marked complete stays trusted; one written before
|
||||
// the marker existed reads false and is re-backfilled once.
|
||||
complete: parsed.complete === true,
|
||||
// Absent on a pre-fix cache: the watermark is distrusted once (healing
|
||||
// pull-back), then re-stamped by the finalize that follows.
|
||||
watermarkTrusted: parsed.watermarkTrusted === true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +426,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
// accounting: leave complete unset so the next hydration re-derives every
|
||||
// day whose sources survive (the merge keeps the rest).
|
||||
complete: rest.length === candidates.length ? false : base.complete,
|
||||
watermarkTrusted: rest.length === candidates.length ? false : base.watermarkTrusted,
|
||||
}
|
||||
await saveDailyCache(adopted).catch(() => {})
|
||||
return adopted
|
||||
|
|
@ -451,6 +469,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
|
|||
lastComputedDate: nextLast,
|
||||
days: applyRetention(merged, newestDate),
|
||||
complete: cache.complete,
|
||||
watermarkTrusted: cache.watermarkTrusted,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -671,6 +690,27 @@ export async function ensureCacheHydrated(
|
|||
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
|
||||
}
|
||||
|
||||
// A cache can claim `complete` while its watermark points PAST its newest
|
||||
// populated day — what a run finalizing off a degraded (read-only) parse
|
||||
// leaves behind: it advanced lastComputedDate over days the parse never
|
||||
// covered. Since gapStart is lastComputedDate + 1, that hole is invisible
|
||||
// to the gap logic forever. Trust the DATA over the marker: pull the
|
||||
// watermark back to the newest day actually present so the ordinary gap
|
||||
// parse re-derives the tail. Nothing is dropped — the cached days all stay.
|
||||
//
|
||||
// Only UNSTAMPED caches are distrusted here. A degraded parse can no longer
|
||||
// set `complete` (that is this fix), so the corrupt state can only be
|
||||
// written by pre-fix code: an unstamped cache. A stamped one whose watermark
|
||||
// outruns its newest day is a legitimately idle tail (recent days had no
|
||||
// activity), and re-deriving that empty tail on every launch is the
|
||||
// regression this guard avoids. A cache with NO days is exempt: it has no
|
||||
// newest day to trust, and a machine with no history at all must still be
|
||||
// able to finalize (below) rather than re-backfill on every launch.
|
||||
const newestCachedDate = c.days.reduce<string | null>((max, d) => (max === null || d.date > max ? d.date : max), null)
|
||||
if (c.watermarkTrusted !== true && newestCachedDate !== null && c.lastComputedDate !== null && c.lastComputedDate > newestCachedDate) {
|
||||
c = { ...c, lastComputedDate: newestCachedDate }
|
||||
}
|
||||
|
||||
// Three reasons to re-derive the whole retention window:
|
||||
// 1. Savings config changed — cached `savingsUSD` totals are stale.
|
||||
// 2. The cache was never finalized against a COMPLETE session parse (an old
|
||||
|
|
@ -691,6 +731,7 @@ export async function ensureCacheHydrated(
|
|||
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
|
||||
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
|
||||
const baseline = c.days
|
||||
const priorWatermark = c.lastComputedDate
|
||||
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
|
||||
let freshDays: DailyEntry[] = []
|
||||
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
|
|
@ -708,9 +749,18 @@ export async function ensureCacheHydrated(
|
|||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash,
|
||||
tzKey,
|
||||
lastComputedDate: yesterdayStr,
|
||||
// The watermark records how far history has actually been derived, so
|
||||
// only a COMPLETE parse may advance it. A partial one produced no data
|
||||
// for whatever it could not read; moving the watermark to yesterday
|
||||
// anyway would place those days behind the next run's gapStart and
|
||||
// freeze the hole in (retention still anchors on yesterdayStr — the
|
||||
// real calendar edge — so holding the watermark can't evict anything).
|
||||
lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark,
|
||||
days: applyRetention(merged, yesterdayStr),
|
||||
complete: parseWasComplete,
|
||||
// Stamp the watermark as trusted only when a COMPLETE parse produced it,
|
||||
// so a later idle tail under this watermark is not distrusted above.
|
||||
watermarkTrusted: parseWasComplete,
|
||||
}
|
||||
await saveDailyCache(c)
|
||||
return c
|
||||
|
|
@ -733,18 +783,23 @@ export async function ensureCacheHydrated(
|
|||
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
|
||||
const gapProjects = await parseSessions(gapRange)
|
||||
const gapDays = aggregateDays(gapProjects)
|
||||
const parseWasComplete = sessionComplete()
|
||||
const priorWatermark = c.lastComputedDate
|
||||
c = addNewDays(c, gapDays, yesterdayStr)
|
||||
// Finalize as complete ONLY when the session parse that produced these days
|
||||
// was itself complete. If it was partial, leave `complete: false` so the
|
||||
// next launch (once the session cache is whole) re-backfills instead of
|
||||
// freezing the partial history.
|
||||
c = { ...c, complete: sessionComplete() }
|
||||
// freezing the partial history — and hold the watermark where it was, for
|
||||
// the same reason as the re-derive path above: a partial parse cannot
|
||||
// vouch for the days it never read, and gapStart is the only thing that
|
||||
// will ever bring them back.
|
||||
c = { ...c, lastComputedDate: parseWasComplete ? c.lastComputedDate : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
|
||||
await saveDailyCache(c)
|
||||
} else if (c.complete !== true && sessionComplete()) {
|
||||
// No gap to fill (already current through yesterday) but not yet marked —
|
||||
// e.g. a brand-new machine whose only data is today. Finalize so future
|
||||
// launches don't re-backfill the whole window every time.
|
||||
c = { ...c, complete: true }
|
||||
c = { ...c, complete: true, watermarkTrusted: true }
|
||||
await saveDailyCache(c)
|
||||
}
|
||||
return c
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -83,6 +85,7 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||
opencode: '#A78BFA',
|
||||
pi: '#F472B6',
|
||||
kimi: '#B6E34A',
|
||||
kimicode: '#A3E635',
|
||||
all: '#FF8C42',
|
||||
}
|
||||
|
||||
|
|
@ -195,9 +198,9 @@ function nextTick(): Promise<void> {
|
|||
return new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
|
||||
function getLayout(columns?: number): Layout {
|
||||
export function getLayout(columns?: number): Layout {
|
||||
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
|
||||
const dashWidth = Math.min(160, termWidth)
|
||||
const wide = dashWidth >= MIN_WIDE
|
||||
|
|
@ -439,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
|
||||
|
||||
|
|
@ -448,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]) => ({
|
||||
|
|
@ -459,7 +467,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
|
||||
return (
|
||||
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}</Text>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}</Text>
|
||||
{sorted.map(([model, data], i) => {
|
||||
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
|
||||
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
|
||||
|
|
@ -468,6 +476,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
|
||||
? `${efficiency.oneShotRate.toFixed(1)}%`
|
||||
: '-'
|
||||
const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0
|
||||
? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1)
|
||||
: '-'
|
||||
return (
|
||||
<Text key={`${model}-${i}`} wrap="truncate-end">
|
||||
<HBar value={data.costUSD} max={maxCost} width={bw} />
|
||||
|
|
@ -476,6 +487,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
|
||||
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
|
||||
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
|
||||
{showTps && <Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
|
@ -487,6 +499,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
{anyEstimated && (
|
||||
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
|
||||
)}
|
||||
{showTps && (
|
||||
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
|
@ -670,6 +685,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
kimi: 'Kimi',
|
||||
kimicode: 'Kimi Code',
|
||||
}
|
||||
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
121
src/main.ts
121
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<CodeburnConfig['priceOverrides']>[string]
|
||||
|
||||
type PriceOverrideOptions = {
|
||||
|
|
@ -497,9 +515,17 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
|
|||
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
|
||||
}
|
||||
for (const call of turn.assistantCalls) {
|
||||
dailyMap[day].cost += call.costUSD
|
||||
dailyMap[day].savings += call.savingsUSD ?? 0
|
||||
dailyMap[day].calls += 1
|
||||
// Cost/savings/calls bucket under each call's OWN day — the same
|
||||
// per-call rule as the durable day set (day-aggregator.ts), so this
|
||||
// fallback and durable.days never diverge on a midnight-straddling
|
||||
// turn (issue #852). Turn counts/edit stats stay anchored on the
|
||||
// turn's day above. An unparseable call timestamp falls back to the
|
||||
// turn's day rather than producing a garbage date key.
|
||||
const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp)
|
||||
if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
|
||||
dailyMap[callDay].cost += call.costUSD
|
||||
dailyMap[callDay].savings += call.savingsUSD ?? 0
|
||||
dailyMap[callDay].calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -966,6 +992,7 @@ program
|
|||
cacheWriteTokens: durable.data.cacheWriteTokens,
|
||||
days: durable.days,
|
||||
carriedCostUSD: durable.carriedCostUSD,
|
||||
unattributedCostUSD: durable.unattributedCostUSD,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
|
@ -1843,6 +1870,94 @@ program
|
|||
await runContextCommand(session, opts)
|
||||
})
|
||||
|
||||
program
|
||||
.command('codex-tps [session]')
|
||||
.description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)')
|
||||
.option('--json', 'JSON output')
|
||||
.option('--limit <n>', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10)
|
||||
.option('--watch <seconds>', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0)
|
||||
.action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => {
|
||||
const intervalMs = Math.max(0, opts.watch) * 1000
|
||||
if (opts.json && intervalMs > 0) {
|
||||
process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n')
|
||||
process.exitCode = 2
|
||||
return
|
||||
}
|
||||
const provider = await getProvider('codex')
|
||||
if (!provider) {
|
||||
process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
let cachedPath: string | undefined = session
|
||||
let throughputReader: CodexThroughputReader | undefined
|
||||
let lastFileState: { size: number; mtimeMs: number } | undefined
|
||||
let lastDiscoveryMs = 0
|
||||
let refreshInFlight = false
|
||||
const render = async (): Promise<void> => {
|
||||
if (refreshInFlight) return
|
||||
refreshInFlight = true
|
||||
try {
|
||||
let filePath = session ?? cachedPath
|
||||
// Keep an idle watcher on its chosen rollout. A full active+archive
|
||||
// discovery can be hundreds of milliseconds on large histories, so
|
||||
// only re-scan slowly to notice rotation; disappearance still triggers
|
||||
// an immediate discovery on the next tick.
|
||||
if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) {
|
||||
lastDiscoveryMs = Date.now()
|
||||
filePath = await newestCodexSession(await provider.discoverSessions())
|
||||
}
|
||||
if (!filePath) {
|
||||
process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n')
|
||||
if (intervalMs === 0) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const previousPath = cachedPath
|
||||
cachedPath = filePath
|
||||
if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader()
|
||||
const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null)
|
||||
if (!fileInfo) {
|
||||
process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`)
|
||||
if (intervalMs === 0) process.exitCode = 1
|
||||
if (!session) cachedPath = undefined
|
||||
return
|
||||
}
|
||||
if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return
|
||||
lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs }
|
||||
const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0)
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n')
|
||||
} else {
|
||||
if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H')
|
||||
process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n'))
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight = false
|
||||
}
|
||||
}
|
||||
try {
|
||||
await render()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
|
||||
if (intervalMs === 0) {
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
if (intervalMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
void render().catch(error => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
|
||||
})
|
||||
}, intervalMs)
|
||||
process.once('SIGINT', () => { clearInterval(timer); resolve() })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('compare')
|
||||
.description('Compare two AI models side-by-side')
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export type ProviderCost = {
|
|||
import type { OptimizeResult } from './optimize.js'
|
||||
import { getCurrency } from './currency.js'
|
||||
import type { GranularHistory } from './granular-history.js'
|
||||
import { getShortModelName } from './models.js'
|
||||
import type { ReworkedFile } from './workflow-insights.js'
|
||||
import type { PrRow, BranchRow } from './sessions-report.js'
|
||||
|
||||
|
|
@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa
|
|||
}
|
||||
|
||||
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
|
||||
return models
|
||||
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
|
||||
// Day entries key models by the raw provider id (day-aggregator), so resolve
|
||||
// display names here — the menubar shows "Kimi K3" rather than "k3". Ids that
|
||||
// collapse to one display name (e.g. k3 and kimi-k3) merge into a single row.
|
||||
const merged = new Map<string, { cost: number; calls: number; savingsUSD: number; estimatedCostUSD: number }>()
|
||||
for (const m of models) {
|
||||
if (m.name === SYNTHETIC_MODEL_NAME) continue
|
||||
const name = getShortModelName(m.name)
|
||||
const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 }
|
||||
acc.cost += m.cost
|
||||
acc.calls += m.calls
|
||||
acc.savingsUSD += m.savingsUSD ?? 0
|
||||
acc.estimatedCostUSD += m.estimatedCostUSD ?? 0
|
||||
merged.set(name, acc)
|
||||
}
|
||||
return [...merged.entries()]
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.slice(0, TOP_MODELS_LIMIT)
|
||||
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
|
||||
.map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD }))
|
||||
}
|
||||
|
||||
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface ModelTotals {
|
|||
freshInput: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
activeDurationMs: number
|
||||
activeGeneratedTokens: number
|
||||
}
|
||||
|
||||
/// Aggregate per-model usage across every session, keyed by the friendly display
|
||||
|
|
@ -24,6 +26,7 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
|
|||
const name = getShortModelName(model)
|
||||
const totals = (modelTotals[name] ??= {
|
||||
calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0,
|
||||
activeDurationMs: 0, activeGeneratedTokens: 0,
|
||||
})
|
||||
totals.calls += data.calls
|
||||
totals.costUSD += data.costUSD
|
||||
|
|
@ -31,6 +34,8 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
|
|||
totals.freshInput += data.tokens.inputTokens
|
||||
totals.cacheRead += data.tokens.cacheReadInputTokens
|
||||
totals.cacheWrite += data.tokens.cacheCreationInputTokens
|
||||
totals.activeDurationMs += data.activeDurationMs ?? 0
|
||||
totals.activeGeneratedTokens += data.activeGeneratedTokens ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'kimi-auto': 'kimi-k2-thinking',
|
||||
'kimi-code': 'kimi-k2-thinking',
|
||||
'kimi-for-coding': 'kimi-k2-thinking',
|
||||
// Kimi Code wires report the bare `k3` id in llm.request.model; without an
|
||||
// alias those calls priced at $0 and the provider looked absent in the UI.
|
||||
'k3': 'kimi-k3',
|
||||
// Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`.
|
||||
'k3-agent': 'kimi-k3',
|
||||
'k2d6-agent': 'kimi-k2p6',
|
||||
'mimo-v2-flash': 'xiaomi/mimo-v2-flash',
|
||||
'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro',
|
||||
// Cursor emits dot-version tier-last names plus tier/reasoning suffixes
|
||||
|
|
@ -879,6 +885,8 @@ const SHORT_NAMES: Record<string, string> = {
|
|||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||||
'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo',
|
||||
'kimi-k2-thinking': 'Kimi K2 Thinking',
|
||||
'kimi-k3': 'Kimi K3',
|
||||
'kimi-k2p6': 'Kimi K2.6',
|
||||
'kimi-thinking-preview': 'Kimi Thinking',
|
||||
'kimi-k2.6': 'Kimi K2.6',
|
||||
'kimi-k2.5': 'Kimi K2.5',
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ export type OverviewDurable = {
|
|||
cacheWriteTokens: number
|
||||
days: DailyEntry[]
|
||||
carriedCostUSD: number
|
||||
/// Cost a --project/--exclude filter could not attribute (cached days with no
|
||||
/// per-project split). Optional so callers that never filter can omit it.
|
||||
unattributedCostUSD?: number
|
||||
}
|
||||
|
||||
export function renderOverview(
|
||||
|
|
@ -350,5 +353,12 @@ export function renderOverview(
|
|||
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
|
||||
}
|
||||
|
||||
// A project filter cannot claim days the cache holds without a project split
|
||||
// (recorded before that split existed), so they sit outside this total. Say how
|
||||
// much rather than let the filtered figure look inexplicably short.
|
||||
if (durable && (durable.unattributedCostUSD ?? 0) > 0) {
|
||||
out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
|
||||
}
|
||||
|
||||
return out.join('\n') + '\n'
|
||||
}
|
||||
|
|
|
|||
221
src/parser.ts
221
src/parser.ts
|
|
@ -8,7 +8,7 @@ import { normalizeContentBlocks } from './content-utils.js'
|
|||
import { discoverAllSessions, getProvider } from './providers/index.js'
|
||||
import { flushCodexCache } from './codex-cache.js'
|
||||
import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js'
|
||||
import { getDesktopSessionsDir } from './providers/claude.js'
|
||||
import { getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { isSqliteBusyError } from './sqlite.js'
|
||||
import {
|
||||
type CachedCall,
|
||||
|
|
@ -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,
|
||||
|
|
@ -81,9 +82,13 @@ function projectNameFromPath(projectPath: string, fallback: string): string {
|
|||
// In both cases the grouping key comes from the Cowork space name resolved in
|
||||
// claude.ts::discoverSessions().
|
||||
function isCoworkSession(cwd: string, filePath: string): boolean {
|
||||
const base = resolve(getDesktopSessionsDir())
|
||||
const inBase = (p: string) => p.startsWith(base + sep) || p.startsWith(base + '/')
|
||||
return inBase(resolve(cwd)) || inBase(resolve(filePath))
|
||||
const resolvedCwd = resolve(cwd)
|
||||
const resolvedFilePath = resolve(filePath)
|
||||
return getDesktopSessionsDirs().some(base => {
|
||||
const resolvedBase = resolve(base)
|
||||
const inBase = (p: string) => p.startsWith(resolvedBase + sep) || p.startsWith(resolvedBase + '/')
|
||||
return inBase(resolvedCwd) || inBase(resolvedFilePath)
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
|
||||
|
|
@ -1730,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 }
|
||||
|
|
@ -1927,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') {
|
||||
|
|
@ -1938,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++
|
||||
|
|
@ -2196,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] : []
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2389,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2425,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2537,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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2782,6 +2806,59 @@ export function createScanProgress(label: string, total: number) {
|
|||
}
|
||||
}
|
||||
|
||||
// Shared by the turn-range slicers below: which of a turn's calls actually
|
||||
// fall inside dateRange. Returns null when none do (the turn should be dropped
|
||||
// entirely, not kept with an empty call list).
|
||||
function callsInRange<T extends { timestamp: string }>(calls: T[], dateRange: DateRange): T[] | null {
|
||||
const inRange = calls.filter(c => {
|
||||
const ts = new Date(c.timestamp)
|
||||
return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end
|
||||
})
|
||||
return inRange.length > 0 ? inRange : null
|
||||
}
|
||||
|
||||
// A turn can span local midnight (e.g. a long-running autonomous Codex
|
||||
// session): dropping the whole turn because its FIRST call falls outside
|
||||
// dateRange discards every later call that lands in the requested day (issue
|
||||
// #852). Instead, keep only the calls actually inside the range. `timestamp`
|
||||
// is re-anchored to the first surviving call so downstream turn-anchored
|
||||
// bucketing (session day, report rollups) keys the slice under the day its
|
||||
// retained calls actually fall in, not the pre-slice turn's original
|
||||
// (possibly prior-day) start. Returns null when no call is in range.
|
||||
function turnSlicedToRange(turn: CachedTurn, dateRange: DateRange): CachedTurn | null {
|
||||
const inRangeCalls = callsInRange(turn.calls, dateRange)
|
||||
if (!inRangeCalls) return null
|
||||
if (inRangeCalls.length === turn.calls.length) return turn
|
||||
return { ...turn, calls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
|
||||
}
|
||||
|
||||
// Same slice, applied post-classification (scanProjectDirs classifies every
|
||||
// turn from its FULL call list up front, before date filtering — see the
|
||||
// carriedBranch/carriedPrRefs comments in scanProjectDirs — so this only
|
||||
// trims `assistantCalls` and re-anchors `timestamp`; `category`/`subCategory`/
|
||||
// `retries`/`hasEdits` stay exactly as classified from the complete turn.
|
||||
// Those are turn-level judgments about the whole exchange, not a per-call
|
||||
// sum, so they aren't recomputed from the partial call list.
|
||||
function classifiedTurnSlicedToRange(turn: ClassifiedTurn, dateRange: DateRange): ClassifiedTurn | null {
|
||||
const inRangeCalls = callsInRange(turn.assistantCalls, dateRange)
|
||||
if (!inRangeCalls) return null
|
||||
if (inRangeCalls.length === turn.assistantCalls.length) return turn
|
||||
return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
|
||||
}
|
||||
|
||||
// Day-set variant of classifiedTurnSlicedToRange for the menubar/history day
|
||||
// selection: keep only the calls whose own local day is selected and
|
||||
// re-anchor `timestamp` to the first survivor — the same split rule.
|
||||
function classifiedTurnSlicedToDays(turn: ClassifiedTurn, days: Set<string>): ClassifiedTurn | null {
|
||||
const inRangeCalls = turn.assistantCalls.filter(c => {
|
||||
const ts = new Date(c.timestamp)
|
||||
return !Number.isNaN(ts.getTime()) && days.has(dateKey(c.timestamp))
|
||||
})
|
||||
if (inRangeCalls.length === 0) return null
|
||||
if (inRangeCalls.length === turn.assistantCalls.length) return turn
|
||||
return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
|
||||
}
|
||||
|
||||
async function parseProviderSources(
|
||||
providerName: string,
|
||||
sources: SessionSource[],
|
||||
|
|
@ -2822,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2989,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)
|
||||
|
|
@ -3015,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 } : {}),
|
||||
|
|
@ -3038,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] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3149,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
|
||||
|
|
@ -3285,9 +3372,13 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
|
|||
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
|
||||
const survivingIdentities = new Set<string>()
|
||||
for (const session of project.sessions) {
|
||||
const turns = session.turns.filter(turn => {
|
||||
const ds = turnDayString(turn)
|
||||
return ds !== null && days.has(ds)
|
||||
// Slice turns per call by the selected days (not whole-turn keep/drop):
|
||||
// a midnight-straddling turn contributes the calls that actually
|
||||
// happened on each selected day (issue #852, same split rule as the
|
||||
// range slicers — see classifiedTurnSlicedToDays).
|
||||
const turns = session.turns.flatMap(turn => {
|
||||
const sliced = classifiedTurnSlicedToDays(turn, days)
|
||||
return sliced ? [sliced] : []
|
||||
})
|
||||
if (turns.length === 0) {
|
||||
if (isSpawnParent(session)) anchors.push(session)
|
||||
|
|
@ -3494,7 +3585,13 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
|
|||
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
|
||||
const survivingIdentities = new Set<string>()
|
||||
for (const session of project.sessions) {
|
||||
const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange))
|
||||
// Slice turns per call (not whole-turn keep/drop) so a midnight-
|
||||
// straddling turn keeps the calls that landed inside the range — the
|
||||
// same split rule as the parse-time slicers (issue #852).
|
||||
const turns = session.turns.flatMap(turn => {
|
||||
const sliced = classifiedTurnSlicedToRange(turn, dateRange)
|
||||
return sliced ? [sliced] : []
|
||||
})
|
||||
if (turns.length === 0) {
|
||||
if (isSpawnParent(session)) anchors.push(session)
|
||||
continue
|
||||
|
|
@ -3521,6 +3618,15 @@ export function isSessionHydrationComplete(): boolean {
|
|||
return sessionHydrationComplete
|
||||
}
|
||||
|
||||
// Set by the read-only serving paths when the snapshot they served did NOT
|
||||
// match what is on disk: in read-only mode a changed file is served at its
|
||||
// stale fingerprint and a file with no cache entry is skipped entirely. A
|
||||
// read-only run under which nothing changed is equivalent to a full parse and
|
||||
// stays trustworthy; one that skipped real data is a PARTIAL hydration, and
|
||||
// finalizing daily history off it freezes the days it never saw out of the
|
||||
// chart (gapStart = lastComputedDate + 1 never looks back at them).
|
||||
let readOnlyServedStale = false
|
||||
|
||||
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
|
||||
const key = cacheKey(dateRange, providerFilter)
|
||||
const cached = sessionCache.get(key)
|
||||
|
|
@ -3590,6 +3696,7 @@ async function runParse(
|
|||
options: RunParseOptions = {},
|
||||
): Promise<ProjectSummary[]> {
|
||||
const { isCold = false, readOnly = false, refreshLock } = options
|
||||
readOnlyServedStale = false
|
||||
const seenMsgIds = new Set<string>()
|
||||
const seenKeys = new Set<string>()
|
||||
const allSources = await discoverAllSessions(providerFilter)
|
||||
|
|
@ -3629,15 +3736,26 @@ async function runParse(
|
|||
? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind }
|
||||
: undefined,
|
||||
}))
|
||||
// Claude is scanned through scanProjectDirs rather than parseProviderSources, so
|
||||
// it needs the same provider-filter guard the durable-orphan loop below applies at
|
||||
// its own level. Without it a --provider <other> run still enters scanProjectDirs
|
||||
// with an empty dirs list, and the orphan pass there (which reads the whole cached
|
||||
// claude section) treats every cached file as "no longer discovered" and re-injects
|
||||
// it into the result. Note this is deliberately NOT a `claudeDirs.length > 0` check:
|
||||
// when claude IS in scope but every transcript has been pruned from disk, that
|
||||
// orphan pass is exactly what keeps PR-attributed spend from vanishing.
|
||||
const claudeInScope = !providerFilter || providerFilter === 'all' || providerFilter === 'claude'
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' })
|
||||
let claudeProjects: ProjectSummary[] = []
|
||||
try {
|
||||
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
|
||||
} catch (err) {
|
||||
if (!isPermissionError(err)) throw err
|
||||
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
|
||||
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
|
||||
if (claudeInScope) {
|
||||
try {
|
||||
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
|
||||
} catch (err) {
|
||||
if (!isPermissionError(err)) throw err
|
||||
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
|
||||
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
|
||||
}
|
||||
}
|
||||
|
||||
const otherProjects: ProjectSummary[] = []
|
||||
|
|
@ -3694,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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readdirSync, statSync } from 'fs'
|
||||
import { readFile, readdir, stat } from 'fs/promises'
|
||||
import { basename, delimiter as pathDelimiter, join, resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
|
@ -101,15 +102,72 @@ export async function discoverClaudeConfigSources(): Promise<ClaudeConfigSource[
|
|||
})))
|
||||
}
|
||||
|
||||
export function getDesktopSessionsDir(): string {
|
||||
// Filesystem changes under an unchanged input key intentionally do not invalidate this cache.
|
||||
const desktopSessionsDirsCache = new Map<string, string[]>()
|
||||
|
||||
function cacheDesktopSessionsDirs(key: string, candidates: string[]): string[] {
|
||||
const dirs = dedupeResolved(candidates.map(candidate => resolve(candidate)))
|
||||
desktopSessionsDirsCache.set(key, dirs)
|
||||
return [...dirs]
|
||||
}
|
||||
|
||||
export function getDesktopSessionsDirs(): string[] {
|
||||
const override = process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
|
||||
if (override) return override
|
||||
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env['APPDATA']?.trim()
|
||||
return join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions')
|
||||
const appDataInput = process.env['APPDATA']
|
||||
const localAppDataInput = process.env['LOCALAPPDATA']
|
||||
const platform = process.platform
|
||||
const cacheKey = JSON.stringify([platform, override ?? null, appDataInput ?? null, localAppDataInput ?? null])
|
||||
const cached = desktopSessionsDirsCache.get(cacheKey)
|
||||
if (cached) return [...cached]
|
||||
|
||||
if (override) return cacheDesktopSessionsDirs(cacheKey, [override])
|
||||
if (platform === 'darwin') {
|
||||
return cacheDesktopSessionsDirs(
|
||||
cacheKey,
|
||||
[join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')],
|
||||
)
|
||||
}
|
||||
return join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')
|
||||
if (platform === 'win32') {
|
||||
const appData = appDataInput?.trim()
|
||||
const candidates = [
|
||||
join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions'),
|
||||
]
|
||||
|
||||
const localAppData = localAppDataInput?.trim()
|
||||
const packagesDir = join(localAppData || join(homedir(), 'AppData', 'Local'), 'Packages')
|
||||
try {
|
||||
const entries = readdirSync(packagesDir, { withFileTypes: true })
|
||||
.filter(entry =>
|
||||
entry.isDirectory() &&
|
||||
(entry.name.startsWith('Claude_') || entry.name.includes('.Claude_')),
|
||||
)
|
||||
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
|
||||
for (const entry of entries) {
|
||||
const sessionsDir = join(
|
||||
packagesDir,
|
||||
entry.name,
|
||||
'LocalCache',
|
||||
'Roaming',
|
||||
'Claude',
|
||||
'local-agent-mode-sessions',
|
||||
)
|
||||
try {
|
||||
if (statSync(sessionsDir).isDirectory()) candidates.push(sessionsDir)
|
||||
} catch {
|
||||
// A package may disappear or be unreadable while Packages is scanned.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable Packages is equivalent to no MSIX candidates.
|
||||
}
|
||||
|
||||
return cacheDesktopSessionsDirs(cacheKey, candidates)
|
||||
}
|
||||
return cacheDesktopSessionsDirs(
|
||||
cacheKey,
|
||||
[join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')],
|
||||
)
|
||||
}
|
||||
|
||||
async function findDesktopProjectDirs(base: string): Promise<string[]> {
|
||||
|
|
@ -221,7 +279,7 @@ export const claude: Provider = {
|
|||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
const dirs = await getClaudeConfigDirs()
|
||||
const roots: ProbeRoot[] = dirs.map(dir => ({ path: join(dir, 'projects'), label: 'projects' }))
|
||||
roots.push({ path: getDesktopSessionsDir(), label: 'desktop' })
|
||||
roots.push(...getDesktopSessionsDirs().map(path => ({ path, label: 'desktop' })))
|
||||
return roots
|
||||
},
|
||||
|
||||
|
|
@ -282,51 +340,52 @@ export const claude: Provider = {
|
|||
)
|
||||
}
|
||||
|
||||
const desktopBase = getDesktopSessionsDir()
|
||||
const desktopDirs = await findDesktopProjectDirs(desktopBase)
|
||||
const sep = desktopBase.includes('\\') ? '\\' : '/'
|
||||
// Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a
|
||||
// distinct source so a per-config view can account for them as their own
|
||||
// "Claude Desktop" bucket instead of silently dropping them (which made
|
||||
// sum-of-configs < All).
|
||||
const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16)
|
||||
for (const dirPath of desktopDirs) {
|
||||
const resolved = resolve(dirPath)
|
||||
if (seenProjectDirs.has(resolved)) continue
|
||||
seenProjectDirs.add(resolved)
|
||||
for (const desktopBase of getDesktopSessionsDirs()) {
|
||||
const desktopDirs = await findDesktopProjectDirs(desktopBase)
|
||||
const sep = desktopBase.includes('\\') ? '\\' : '/'
|
||||
// Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a
|
||||
// distinct source so a per-config view can account for them as their own
|
||||
// "Claude Desktop" bucket instead of silently dropping them (which made
|
||||
// sum-of-configs < All).
|
||||
const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16)
|
||||
for (const dirPath of desktopDirs) {
|
||||
const resolved = resolve(dirPath)
|
||||
if (seenProjectDirs.has(resolved)) continue
|
||||
seenProjectDirs.add(resolved)
|
||||
|
||||
// For Claude Desktop local-agent-mode (Cowork) sessions, the project dir
|
||||
// lives inside local_<sessionId>/.claude/projects/. We resolve the space
|
||||
// name from the sibling .json and spaces.json so it groups correctly.
|
||||
// Path structure: <desktopBase>/<appId>/<workspaceId>/local_<id>/.claude/projects/<slug>
|
||||
let projectName = basename(dirPath)
|
||||
const resolvedBase = resolve(desktopBase)
|
||||
if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) {
|
||||
const rel = resolved.slice(resolvedBase.length + 1)
|
||||
const parts = rel.split(/[/\\]/)
|
||||
// parts = [appId, workspaceId, local_sessionId, .claude, projects, slug]
|
||||
if (
|
||||
parts.length >= 6 &&
|
||||
parts[2]?.startsWith('local_') &&
|
||||
parts[3] === '.claude' &&
|
||||
parts[4] === 'projects'
|
||||
) {
|
||||
const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!)
|
||||
const sessionId = parts[2]!
|
||||
const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId)
|
||||
if (spaceName) projectName = spaceName
|
||||
// For Claude Desktop local-agent-mode (Cowork) sessions, the project dir
|
||||
// lives inside local_<sessionId>/.claude/projects/. We resolve the space
|
||||
// name from the sibling .json and spaces.json so it groups correctly.
|
||||
// Path structure: <desktopBase>/<appId>/<workspaceId>/local_<id>/.claude/projects/<slug>
|
||||
let projectName = basename(dirPath)
|
||||
const resolvedBase = resolve(desktopBase)
|
||||
if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) {
|
||||
const rel = resolved.slice(resolvedBase.length + 1)
|
||||
const parts = rel.split(/[/\\]/)
|
||||
// parts = [appId, workspaceId, local_sessionId, .claude, projects, slug]
|
||||
if (
|
||||
parts.length >= 6 &&
|
||||
parts[2]?.startsWith('local_') &&
|
||||
parts[3] === '.claude' &&
|
||||
parts[4] === 'projects'
|
||||
) {
|
||||
const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!)
|
||||
const sessionId = parts[2]!
|
||||
const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId)
|
||||
if (spaceName) projectName = spaceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sources.push({
|
||||
path: dirPath,
|
||||
project: projectName,
|
||||
provider: 'claude',
|
||||
sourceId: desktopSourceId,
|
||||
sourceLabel: 'Claude Desktop',
|
||||
sourcePath: desktopBase,
|
||||
sourceKind: 'claude-desktop',
|
||||
})
|
||||
sources.push({
|
||||
path: dirPath,
|
||||
project: projectName,
|
||||
provider: 'claude',
|
||||
sourceId: desktopSourceId,
|
||||
sourceLabel: 'Claude Desktop',
|
||||
sourcePath: desktopBase,
|
||||
sourceKind: 'claude-desktop',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0
|
|||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
exec_command: 'Bash',
|
||||
// Codex Desktop's custom-tool transport uses the shorter `exec` name for
|
||||
// the same shell tool that CLI rollouts record as `exec_command`.
|
||||
exec: 'Bash',
|
||||
read_file: 'Read',
|
||||
write_file: 'Edit',
|
||||
apply_diff: 'Edit',
|
||||
|
|
@ -96,6 +99,11 @@ type CodexEntry = {
|
|||
timestamp?: string
|
||||
payload?: {
|
||||
type?: string
|
||||
turn_id?: string
|
||||
call_id?: string
|
||||
started_at?: number
|
||||
duration_ms?: number
|
||||
duration?: { secs?: number; nanos?: number } | string
|
||||
role?: string
|
||||
cwd?: string
|
||||
model_provider?: string
|
||||
|
|
@ -104,6 +112,7 @@ type CodexEntry = {
|
|||
forked_from_id?: string
|
||||
model?: string
|
||||
name?: string
|
||||
invocation?: { server?: string; tool?: string }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
info?: {
|
||||
model?: string
|
||||
|
|
@ -196,11 +205,128 @@ function getRawJsonStringField(head: string, field: string): string | undefined
|
|||
}
|
||||
}
|
||||
|
||||
function getRawJsonNumberField(head: string, field: string): number | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(head)
|
||||
if (!match) return undefined
|
||||
const value = Number(match[1])
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function getRawPayloadFieldWindow(source: Buffer, field: string, windowBytes = 4096): string | undefined {
|
||||
const payloadKey = Buffer.from('"payload"')
|
||||
const payloadIndex = source.indexOf(payloadKey)
|
||||
if (payloadIndex < 0) return undefined
|
||||
let payloadStart = source.indexOf(0x7b, payloadIndex + payloadKey.length) // {
|
||||
if (payloadStart < 0) return undefined
|
||||
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let i = payloadStart; i < source.length; i++) {
|
||||
const byte = source[i]!
|
||||
if (inString) {
|
||||
if (escaped) escaped = false
|
||||
else if (byte === 0x5c) escaped = true // \\
|
||||
else if (byte === 0x22) inString = false // "
|
||||
continue
|
||||
}
|
||||
if (byte === 0x22) {
|
||||
const keyStart = i + 1
|
||||
let keyEnd = keyStart
|
||||
let keyEscaped = false
|
||||
for (; keyEnd < source.length; keyEnd++) {
|
||||
const keyByte = source[keyEnd]!
|
||||
if (keyEscaped) { keyEscaped = false; continue }
|
||||
if (keyByte === 0x5c) { keyEscaped = true; continue }
|
||||
if (keyByte === 0x22) break
|
||||
}
|
||||
if (depth === 1 && keyEnd < source.length) {
|
||||
const key = source.subarray(keyStart, keyEnd).toString('utf-8')
|
||||
let valueStart = keyEnd + 1
|
||||
while (valueStart < source.length && (source[valueStart] === 0x20 || source[valueStart] === 0x09 || source[valueStart] === 0x0a || source[valueStart] === 0x0d)) valueStart++
|
||||
if (source[valueStart] === 0x3a && key === field) {
|
||||
return source.subarray(i, Math.min(source.length, i + windowBytes)).toString('utf-8')
|
||||
}
|
||||
}
|
||||
i = keyEnd
|
||||
inString = false
|
||||
continue
|
||||
}
|
||||
if (byte === 0x22) inString = true
|
||||
else if (byte === 0x7b || byte === 0x5b) depth++ // { or [
|
||||
else if (byte === 0x7d || byte === 0x5d) depth-- // } or ]
|
||||
if (depth < 0) break
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getRawDurationMs(head: string): number | undefined {
|
||||
const objectMatch = /"duration"\s*:\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(head)
|
||||
if (objectMatch) {
|
||||
const seconds = Number(objectMatch[1])
|
||||
const nanos = Number(objectMatch[2])
|
||||
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
const text = getRawJsonStringField(head, 'duration')
|
||||
if (text) {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(text.trim())
|
||||
if (match) {
|
||||
const value = Number(match[1])
|
||||
if (Number.isFinite(value)) return value * (match[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function durationValueMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'object' && value) {
|
||||
const record = value as Record<string, unknown>
|
||||
const seconds = record['secs']
|
||||
const nanos = record['nanos']
|
||||
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
|
||||
return seconds * 1000 + nanos / 1e6
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value.trim())
|
||||
if (match) {
|
||||
const parsed = Number(match[1])
|
||||
if (Number.isFinite(parsed)) return parsed * (match[2] === 's' ? 1000 : 1)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token_usage'): CodexTokenUsage | undefined {
|
||||
const match = new RegExp(`"${field}"\\s*:\\s*\\{([^}]*)\\}`).exec(head)
|
||||
if (!match) return undefined
|
||||
const body = match[1]!
|
||||
return {
|
||||
input_tokens: getRawJsonNumberField(body, 'input_tokens'),
|
||||
cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'),
|
||||
output_tokens: getRawJsonNumberField(body, 'output_tokens'),
|
||||
reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'),
|
||||
total_tokens: getRawJsonNumberField(body, 'total_tokens'),
|
||||
}
|
||||
}
|
||||
|
||||
function payloadHead(head: string): string {
|
||||
const idx = head.indexOf('"payload"')
|
||||
return idx === -1 ? head : head.slice(idx)
|
||||
}
|
||||
|
||||
function getRawInvocation(head: string): { server?: string; tool?: string } | undefined {
|
||||
const idx = head.indexOf('"invocation"')
|
||||
if (idx === -1) return undefined
|
||||
// Server/tool are shallow fields and precede the potentially huge arguments
|
||||
// object in Codex MCP records. Limit this scan to keep compact parsing cheap.
|
||||
const invocationHead = head.slice(idx, idx + 8192)
|
||||
const server = getRawJsonStringField(invocationHead, 'server')
|
||||
const tool = getRawJsonStringField(invocationHead, 'tool')
|
||||
return server || tool ? { server, tool } : undefined
|
||||
}
|
||||
|
||||
function countJsonStringBytes(source: Buffer, valueStart: number): number {
|
||||
let count = 0
|
||||
for (let i = valueStart; i < source.length; i++) {
|
||||
|
|
@ -270,6 +396,31 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
const pHead = payloadHead(head)
|
||||
const payloadType = getRawJsonStringField(pHead, 'type')
|
||||
const role = getRawJsonStringField(pHead, 'role')
|
||||
// task_complete appends the potentially huge final assistant message before
|
||||
// its duration fields. Fall back to the full Buffer only for this event so
|
||||
// timing metadata is not lost when the compact head stops early.
|
||||
const needsTimingTail = type === 'event_msg' && (payloadType === 'task_complete' || payloadType === 'mcp_tool_call_end')
|
||||
const timingTail = needsTimingTail && line.length > RAW_HEAD_BYTES
|
||||
? line.subarray(Math.max(0, line.length - 16 * 1024)).toString('utf-8')
|
||||
: pHead
|
||||
const timingNumber = (field: string): number | undefined =>
|
||||
getRawJsonNumberField(pHead, field) ?? getRawJsonNumberField(timingTail, field)
|
||||
// MCP records can place a large invocation.arguments object before duration
|
||||
// and a large result after it. Searching a small window around the field
|
||||
// avoids materializing the middle of the Buffer while still preserving wait
|
||||
// timing for those records.
|
||||
const payloadDuration = payloadType === 'mcp_tool_call_end'
|
||||
? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '')
|
||||
: undefined
|
||||
const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail)
|
||||
const compactModel = getRawJsonStringField(pHead, 'model')
|
||||
const compactModelName = getRawJsonStringField(pHead, 'model_name')
|
||||
const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage')
|
||||
const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage')
|
||||
const compactInfo = compactModel || compactModelName || compactLastUsage || compactTotalUsage
|
||||
? { model: compactModel, model_name: compactModelName, last_token_usage: compactLastUsage, total_token_usage: compactTotalUsage }
|
||||
: undefined
|
||||
const invocation = getRawInvocation(pHead) ?? getRawInvocation(timingTail)
|
||||
|
||||
const entry: CodexEntry = {
|
||||
type,
|
||||
|
|
@ -284,6 +435,16 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'),
|
||||
model: getRawJsonStringField(pHead, 'model'),
|
||||
name: getRawJsonStringField(pHead, 'name'),
|
||||
invocation,
|
||||
call_id: getRawJsonStringField(pHead, 'call_id'),
|
||||
turn_id: getRawJsonStringField(pHead, 'turn_id'),
|
||||
// On mcp_tool_call_end a coincidental `duration_ms` inside the large
|
||||
// invocation.arguments object can shadow the payload-level duration, so the
|
||||
// depth-aware value wins. The naive scan stays as the fallback for
|
||||
// task_complete, which records duration_ms at the payload level directly.
|
||||
duration_ms: timingDuration ?? timingNumber('duration_ms'),
|
||||
started_at: timingNumber('started_at'),
|
||||
info: compactInfo,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +461,8 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
|
|||
const s = await stat(filePath).catch(() => null)
|
||||
if (!s?.isFile()) return null
|
||||
|
||||
// Fast path: cached results already know the project, so avoid opening the
|
||||
// file. This keeps discovery cheap on large session directories.
|
||||
const cachedProject = await getCachedCodexProject(filePath)
|
||||
if (cachedProject) {
|
||||
return { path: filePath, project: cachedProject, provider: 'codex' }
|
||||
|
|
@ -314,6 +477,12 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
|
|||
|
||||
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
// Codex archives a session by moving it from sessions/YYYY/MM/DD/ to
|
||||
// archived_sessions/, keeping the same basename. Deduplicate by basename so
|
||||
// a session does not appear twice while it exists in both roots. This avoids
|
||||
// reading every file to extract session_id and preserves the cheap cached
|
||||
// fast path.
|
||||
const seenBasenames = new Set<string>()
|
||||
const sessionsDir = join(codexDir, 'sessions')
|
||||
|
||||
const years = await readdir(sessionsDir).catch(() => [] as string[])
|
||||
|
|
@ -335,8 +504,9 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
|
|||
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
|
||||
const filePath = join(dayDir, file)
|
||||
const source = await discoverSessionFile(filePath)
|
||||
if (seenBasenames.has(file)) continue
|
||||
seenBasenames.add(file)
|
||||
const source = await discoverSessionFile(join(dayDir, file))
|
||||
if (source) sources.push(source)
|
||||
}
|
||||
}
|
||||
|
|
@ -345,10 +515,14 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
|
|||
|
||||
// Codex moves archived sessions into a flat directory. Keep them in usage
|
||||
// reports so archiving a conversation does not erase its historical usage.
|
||||
// Call-level deduplication (seenKeys) already collapses any remaining
|
||||
// archived copies, while basename dedup above prevents double discovery.
|
||||
const archivedDir = join(codexDir, 'archived_sessions')
|
||||
const archivedFiles = await readdir(archivedDir).catch(() => [] as string[])
|
||||
for (const file of archivedFiles) {
|
||||
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
|
||||
if (seenBasenames.has(file)) continue
|
||||
seenBasenames.add(file)
|
||||
const source = await discoverSessionFile(join(archivedDir, file))
|
||||
if (source) sources.push(source)
|
||||
}
|
||||
|
|
@ -410,6 +584,16 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
let currentTurnId = `${sessionId}:t0`
|
||||
let sawAnyLine = false
|
||||
const results: ParsedProviderCall[] = []
|
||||
// Calls decoded since the last task_started, held back so task_complete can
|
||||
// stamp active/toolWait timing before they are appended to results. Emitting
|
||||
// a task only once its timing is known keeps single-pass and split/resume
|
||||
// decodes in agreement instead of back-patching already-emitted calls.
|
||||
// Bounded by one task's calls; flushed at the next task_started and at EOF.
|
||||
let pendingTaskCalls: ParsedProviderCall[] = []
|
||||
let taskGeneratedTokens = 0
|
||||
let taskToolIntervals: Array<[number, number]> = []
|
||||
let taskStartedAt: number | undefined
|
||||
const openToolStarts = new Map<string, number>()
|
||||
|
||||
// Stream the session file line by line. Heavy Codex sessions can exceed
|
||||
// 250 MB on disk; reading the entire file into a string would either hit
|
||||
|
|
@ -437,7 +621,32 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
const isForkReplay = Boolean(forkCutoff && entry.timestamp && entry.timestamp < forkCutoff)
|
||||
if (isForkReplay && (
|
||||
entry.payload?.type === 'task_started' ||
|
||||
entry.payload?.type === 'task_complete' ||
|
||||
entry.payload?.type === 'function_call' ||
|
||||
entry.payload?.type === 'function_call_output' ||
|
||||
entry.payload?.type === 'custom_tool_call' ||
|
||||
entry.payload?.type === 'custom_tool_call_output' ||
|
||||
entry.payload?.type === 'mcp_tool_call_end' ||
|
||||
entry.payload?.type === 'patch_apply_end'
|
||||
)) continue
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
|
||||
// Emit the previous task. If it never reached task_complete its timing
|
||||
// fields simply stay unset, matching the un-buffered behaviour.
|
||||
results.push(...pendingTaskCalls)
|
||||
pendingTaskCalls = []
|
||||
taskGeneratedTokens = 0
|
||||
taskToolIntervals = []
|
||||
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
|
||||
openToolStarts.clear()
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call')) {
|
||||
const rawName = entry.payload.name ?? ''
|
||||
const mapped = toolNameMap[rawName] ?? rawName
|
||||
pendingTools.push(mapped)
|
||||
|
|
@ -459,10 +668,52 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
pendingToolSequence.push([{ tool: mcpTool }])
|
||||
}
|
||||
}
|
||||
const callId = entry.payload.call_id
|
||||
const started = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
if (callId && Number.isFinite(started)) openToolStarts.set(callId, started)
|
||||
pendingToolSequence.push([call])
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output')) {
|
||||
const callId = entry.payload.call_id
|
||||
const ended = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const started = callId ? openToolStarts.get(callId) : undefined
|
||||
if (started !== undefined && Number.isFinite(ended) && ended > started) taskToolIntervals.push([started, ended])
|
||||
if (callId) openToolStarts.delete(callId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
|
||||
const durationMs = entry.payload.duration_ms
|
||||
if (typeof durationMs === 'number' && durationMs > 0 && taskGeneratedTokens > 0 && pendingTaskCalls.length > 0) {
|
||||
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const windowStart = taskStartedAt ?? (Number.isFinite(completedAt) ? completedAt - durationMs : undefined)
|
||||
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
|
||||
const clipped = taskToolIntervals.map(([start, end]) => [
|
||||
windowStart !== undefined ? Math.max(start, windowStart) : start,
|
||||
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
|
||||
] as [number, number]).filter(([start, end]) => end > start)
|
||||
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((acc, interval) => {
|
||||
const previous = acc.at(-1)
|
||||
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
|
||||
else acc.push([...interval])
|
||||
return acc
|
||||
}, [])
|
||||
const toolWaitMs = Math.min(durationMs, merged.reduce((sum, interval) => sum + interval[1] - interval[0], 0))
|
||||
const activeMs = durationMs - toolWaitMs
|
||||
if (activeMs <= 0) continue
|
||||
for (const call of pendingTaskCalls) {
|
||||
const generated = call.outputTokens + call.reasoningTokens
|
||||
if (generated <= 0) continue
|
||||
call.activeGeneratedTokens = generated
|
||||
call.activeDurationMs = activeMs * (generated / taskGeneratedTokens)
|
||||
call.toolWaitMs = toolWaitMs * (generated / taskGeneratedTokens)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') {
|
||||
pendingTools.push('Edit')
|
||||
const p = entry.payload as Record<string, unknown>
|
||||
|
|
@ -490,6 +741,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
// attributed. Rebuild the canonical `mcp__<server>__<tool>` name the
|
||||
// classifier recognizes.
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') {
|
||||
const endedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
const durationMs = entry.payload.duration_ms ?? durationValueMs(entry.payload.duration)
|
||||
if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(endedAt)) {
|
||||
taskToolIntervals.push([endedAt - durationMs, endedAt])
|
||||
}
|
||||
const inv = (entry.payload as Record<string, unknown>)['invocation'] as Record<string, unknown> | undefined
|
||||
const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : ''
|
||||
const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : ''
|
||||
|
|
@ -542,7 +798,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
|
||||
|
||||
results.push({
|
||||
pendingTaskCalls.push({
|
||||
provider: 'codex',
|
||||
model,
|
||||
inputTokens: estInput,
|
||||
|
|
@ -568,6 +824,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
})
|
||||
taskGeneratedTokens += estOutput
|
||||
|
||||
pendingTools = []
|
||||
pendingToolSequence = []
|
||||
|
|
@ -660,7 +917,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
0,
|
||||
)
|
||||
|
||||
results.push({
|
||||
pendingTaskCalls.push({
|
||||
provider: 'codex',
|
||||
model,
|
||||
inputTokens: uncachedInputTokens,
|
||||
|
|
@ -685,6 +942,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
|
||||
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
|
||||
})
|
||||
taskGeneratedTokens += outputTokens + reasoningTokens
|
||||
|
||||
pendingTools = []
|
||||
pendingToolSequence = []
|
||||
|
|
@ -701,6 +959,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
// result set against a fingerprint that would otherwise be re-parsed.
|
||||
if (!sawAnyLine) return
|
||||
|
||||
// Flush the final task, which has no following task_started to trigger it.
|
||||
results.push(...pendingTaskCalls)
|
||||
|
||||
await writeCachedCodexResults(source.path, source.project, results, fp)
|
||||
|
||||
for (const call of results) {
|
||||
|
|
|
|||
|
|
@ -71,8 +71,18 @@ function timestampIso(value: unknown): string {
|
|||
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
|
||||
}
|
||||
|
||||
function kimicodeHome(override?: string): string {
|
||||
return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code'))
|
||||
function kimicodeHomes(override?: string): string[] {
|
||||
const explicit = override || process.env['KIMI_CODE_HOME']
|
||||
if (explicit) return [resolve(explicit)]
|
||||
// Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep
|
||||
// the same wire layout under their own home (Kimi desktop app, Kimi Code
|
||||
// IDE); each home is scanned so embedded-agent usage is not invisible.
|
||||
const home = homedir()
|
||||
const homes = [
|
||||
join(home, '.kimi-code'),
|
||||
join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'),
|
||||
]
|
||||
return [...new Set(homes.map(h => resolve(h)))]
|
||||
}
|
||||
|
||||
async function directoryEntries(path: string) {
|
||||
|
|
@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise<SessionSource[]> {
|
|||
const workDirPath = join(sessionsDir, workDirEntry.name)
|
||||
|
||||
for (const sessionEntry of await directoryEntries(workDirPath)) {
|
||||
if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue
|
||||
// Session dir naming differs by host product: the CLI uses session_*,
|
||||
// embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory
|
||||
// is accepted; the agents/*/wire.jsonl probe below gates real sessions.
|
||||
if (!sessionEntry.isDirectory()) continue
|
||||
const sessionDir = join(workDirPath, sessionEntry.name)
|
||||
const state = await readState(sessionDir)
|
||||
const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name)
|
||||
|
|
@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider {
|
|||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }]
|
||||
return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSources(kimicodeHome(homeOverride))
|
||||
const all: SessionSource[] = []
|
||||
for (const home of kimicodeHomes(homeOverride)) {
|
||||
all.push(...await discoverSources(home))
|
||||
}
|
||||
return all.sort((a, b) => a.path.localeCompare(b.path))
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { readdir, stat } from 'fs/promises'
|
|||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { readSessionFile, readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { normalizeContentBlocks } from '../content-utils.js'
|
||||
|
|
@ -90,16 +90,30 @@ function getOmpSessionsDir(override?: string): string {
|
|||
return override ?? join(homedir(), '.omp', 'agent', 'sessions')
|
||||
}
|
||||
|
||||
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
|
||||
const content = await readSessionFile(filePath)
|
||||
if (content === null) return null
|
||||
const line = content.split('\n')[0]
|
||||
if (!line?.trim()) return null
|
||||
try {
|
||||
return JSON.parse(line) as PiEntry
|
||||
} catch {
|
||||
return null
|
||||
// OMP can write a fixed-width title metadata line (`type: "title"`) before the
|
||||
// `type: "session"` header (issue #845), and either provider may pad the
|
||||
// header with blank lines. Scan a bounded number of leading lines rather than
|
||||
// just the first one, and rather than the whole file (issue #846 read each
|
||||
// transcript in full to reach line 0), so discovery stays cheap even when a
|
||||
// file turns out to have no session record at all (a message-only file, or a
|
||||
// pathological run of blank/junk lines).
|
||||
const MAX_HEADER_LINES_SCANNED = 20
|
||||
|
||||
async function readSessionEntry(filePath: string): Promise<PiEntry | null> {
|
||||
let linesScanned = 0
|
||||
for await (const line of readSessionLines(filePath)) {
|
||||
if (linesScanned >= MAX_HEADER_LINES_SCANNED) break
|
||||
linesScanned++
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as PiEntry
|
||||
if (entry.type === 'session') return entry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise<SessionSource[]> {
|
||||
|
|
@ -130,10 +144,10 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string):
|
|||
const fileStat = await stat(filePath).catch(() => null)
|
||||
if (!fileStat?.isFile()) continue
|
||||
|
||||
const first = await readFirstEntry(filePath)
|
||||
if (!first || first.type !== 'session') continue
|
||||
const entry = await readSessionEntry(filePath)
|
||||
if (!entry) continue
|
||||
|
||||
const cwd = first.cwd ?? dirName
|
||||
const cwd = entry.cwd ?? dirName
|
||||
sources.push({ path: filePath, project: basename(cwd), provider: providerName })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ export type CachedCall = {
|
|||
toolErrors?: number
|
||||
// Codex: count of this call's patch applications with success === false.
|
||||
editFailed?: number
|
||||
activeDurationMs?: number
|
||||
activeGeneratedTokens?: number
|
||||
toolWaitMs?: number
|
||||
}
|
||||
|
||||
export type CachedTurn = {
|
||||
|
|
@ -213,7 +216,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// rich-session-capture-v1: per-call LOC deltas + editFailed from
|
||||
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
|
||||
// lockstep so the pre-session-cache layer re-parses too.)
|
||||
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1',
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
copilot: 'cli-shutdown-cost-v1-skills',
|
||||
|
|
@ -338,6 +341,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'])
|
||||
|
|
|
|||
|
|
@ -141,6 +141,42 @@ export interface CallbackResult {
|
|||
port: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Themed HTML for the OAuth callback landing page.
|
||||
*
|
||||
* Matches the CodeBurn dashboard theme (dash/src/index.css): warm paper
|
||||
* surfaces, ink text, forest-green accent. Everything is inline — this page
|
||||
* is served once from a throwaway localhost server, so it must not depend
|
||||
* on network fonts, external CSS, or dashboard assets.
|
||||
*/
|
||||
export function renderCallbackPage(ok: boolean, rawTitle: string, rawMessage: string): string {
|
||||
// Current call sites pass literals, but escape anyway so a future caller
|
||||
// interpolating IdP-influenced text (e.g. the callback `error` param) cannot
|
||||
// turn this localhost page into an XSS sink.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
|
||||
const title = escapeHtml(rawTitle)
|
||||
const message = escapeHtml(rawMessage)
|
||||
const accent = ok ? '#1f8a5b' : '#c8541f' // --primary / --chart-5 (terracotta)
|
||||
const mark = ok ? '✓' : '✕' // ✓ / ✕
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CodeBurn — ${title}</title>
|
||||
</head>
|
||||
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#e9e6de;color:#16181d;font-family:'Geist',system-ui,-apple-system,'Segoe UI',sans-serif;letter-spacing:-0.006em;">
|
||||
<main style="background:#ffffff;border:1px solid rgba(23,27,32,0.08);border-radius:8px;padding:48px 56px;max-width:420px;text-align:center;box-shadow:0 1px 3px rgba(23,27,32,0.06);">
|
||||
<div style="width:56px;height:56px;margin:0 auto 20px;border-radius:50%;background:${accent};color:#ffffff;font-size:28px;line-height:56px;font-weight:700;">${mark}</div>
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#2c5242;">${title}</h1>
|
||||
<p style="margin:0;font-size:15px;color:#5d626b;line-height:1.5;">${message}</p>
|
||||
<p style="margin:28px 0 0;font-size:12px;color:#8a857c;">CodeBurn <span style="color:${accent};">•</span> local sync login</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export function startCallbackServer(
|
||||
expectedState: string,
|
||||
timeoutMs: number = 300_000, // 5 minutes
|
||||
|
|
@ -179,8 +215,8 @@ export function startCallbackServer(
|
|||
// Connection: close on every response — the callback server is
|
||||
// single-purpose and must never leave pooled keep-alive sockets behind.
|
||||
if (error) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' })
|
||||
res.end('<html><body><h1>Login failed</h1><p>You can close this tab.</p></body></html>')
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
|
||||
res.end(renderCallbackPage(false, 'Login failed', 'The identity provider rejected the login. You can close this tab and retry from the terminal.'))
|
||||
clearTimeout(timer)
|
||||
shutdown()
|
||||
reject(new AuthError(`IdP returned error: ${error}`))
|
||||
|
|
@ -199,8 +235,8 @@ export function startCallbackServer(
|
|||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' })
|
||||
res.end('<html><body><h1>✓ Login successful</h1><p>You can close this tab.</p></body></html>')
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
|
||||
res.end(renderCallbackPage(true, 'Login successful', 'CodeBurn sync is now authenticated. You can close this tab and return to the terminal.'))
|
||||
clearTimeout(timer)
|
||||
shutdown()
|
||||
resolve({ code, port: resolvedPort })
|
||||
|
|
|
|||
288
src/sync/cli.ts
288
src/sync/cli.ts
|
|
@ -7,8 +7,9 @@
|
|||
import type { Command } from 'commander'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
import { fetchDiscoveryDoc } from './discovery.js'
|
||||
import { fetchDiscoveryDoc, DiscoveryError } from './discovery.js'
|
||||
import {
|
||||
AuthError,
|
||||
fetchOidcConfig,
|
||||
generatePkce,
|
||||
buildAuthUrl,
|
||||
|
|
@ -21,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
|
||||
|
|
@ -33,100 +35,120 @@ export function registerSyncCommands(program: Command): void {
|
|||
.command('setup <url>')
|
||||
.description('Configure sync with a remote endpoint (one-time)')
|
||||
.action(async (url: string) => {
|
||||
const baseUrl = url.replace(/\/$/, '')
|
||||
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
|
||||
|
||||
// 1. Fetch codeburn discovery doc
|
||||
const discovery = await fetchDiscoveryDoc(baseUrl)
|
||||
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
|
||||
process.stderr.write(` Client: ${discovery.client_id}\n`)
|
||||
|
||||
// 2. Fetch OIDC configuration from the issuer
|
||||
const oidc = await fetchOidcConfig(discovery.issuer)
|
||||
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
|
||||
|
||||
// 3. Resolve scopes
|
||||
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
|
||||
|
||||
// 4. Generate PKCE
|
||||
const pkce = generatePkce()
|
||||
const state = randomBytes(16).toString('hex')
|
||||
|
||||
// 5. Start callback server — await the actually-bound port (port
|
||||
// fallback means it may not be the first in CALLBACK_PORTS)
|
||||
const { promise: callbackPromise, ready } = startCallbackServer(state)
|
||||
const port = await ready
|
||||
const redirectUri = `http://127.0.0.1:${port}/callback`
|
||||
|
||||
// 6. Build auth URL and open browser
|
||||
const authUrl = buildAuthUrl({
|
||||
authorization_endpoint: oidc.authorization_endpoint,
|
||||
client_id: discovery.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
scopes,
|
||||
state,
|
||||
pkce,
|
||||
})
|
||||
|
||||
process.stderr.write(`\nOpening browser for login...\n`)
|
||||
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
|
||||
|
||||
// Open browser (best-effort, platform-specific).
|
||||
// execFileSync with args array — authUrl comes from the remote discovery
|
||||
// doc so it must never be shell-interpolated. Scheme is also validated.
|
||||
try {
|
||||
if (!/^https:\/\//.test(authUrl)) {
|
||||
throw new Error('auth URL must be https')
|
||||
const baseUrl = url.replace(/\/$/, '')
|
||||
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
|
||||
|
||||
// 1. Fetch codeburn discovery doc
|
||||
const discovery = await fetchDiscoveryDoc(baseUrl)
|
||||
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
|
||||
process.stderr.write(` Client: ${discovery.client_id}\n`)
|
||||
|
||||
// 2. Fetch OIDC configuration from the issuer
|
||||
const oidc = await fetchOidcConfig(discovery.issuer)
|
||||
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
|
||||
|
||||
// 3. Resolve scopes
|
||||
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
|
||||
|
||||
// 4. Generate PKCE
|
||||
const pkce = generatePkce()
|
||||
const state = randomBytes(16).toString('hex')
|
||||
|
||||
// 5. Start callback server — await the actually-bound port (port
|
||||
// fallback means it may not be the first in CALLBACK_PORTS)
|
||||
const { promise: callbackPromise, ready } = startCallbackServer(state)
|
||||
// If all ports are in use, BOTH `ready` and `callbackPromise` reject.
|
||||
// Mark callbackPromise as handled so the second rejection can't crash
|
||||
// the process as an unhandled rejection while we're throwing from
|
||||
// `await ready`. The later `await callbackPromise` still throws.
|
||||
callbackPromise.catch(() => {})
|
||||
const port = await ready
|
||||
const redirectUri = `http://127.0.0.1:${port}/callback`
|
||||
|
||||
// 6. Build auth URL and open browser
|
||||
const authUrl = buildAuthUrl({
|
||||
authorization_endpoint: oidc.authorization_endpoint,
|
||||
client_id: discovery.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
scopes,
|
||||
state,
|
||||
pkce,
|
||||
})
|
||||
|
||||
process.stderr.write(`\nOpening browser for login...\n`)
|
||||
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
|
||||
|
||||
// Open browser (best-effort, platform-specific).
|
||||
// execFileSync with args array — authUrl comes from the remote discovery
|
||||
// doc so it must never be shell-interpolated. Scheme is also validated.
|
||||
try {
|
||||
if (!/^https:\/\//.test(authUrl)) {
|
||||
throw new Error('auth URL must be https')
|
||||
}
|
||||
const { execFileSync } = await import('child_process')
|
||||
if (process.platform === 'darwin') {
|
||||
execFileSync('open', [authUrl], { stdio: 'ignore' })
|
||||
} else if (process.platform === 'win32') {
|
||||
// Do NOT use `cmd /c start` here: cmd.exe treats `&` as a command
|
||||
// separator, so the auth URL's query string gets truncated at the
|
||||
// first parameter and the IdP sees a bare /authorize request.
|
||||
// rundll32's FileProtocolHandler receives the URL as a plain
|
||||
// process argument with no shell parsing, so `&` survives intact.
|
||||
execFileSync('rundll32', ['url.dll,FileProtocolHandler', authUrl], { stdio: 'ignore' })
|
||||
} else {
|
||||
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
|
||||
}
|
||||
} catch {
|
||||
// Browser open failed — user sees the URL above
|
||||
}
|
||||
const { execFileSync } = await import('child_process')
|
||||
if (process.platform === 'darwin') {
|
||||
execFileSync('open', [authUrl], { stdio: 'ignore' })
|
||||
} else if (process.platform === 'win32') {
|
||||
// `start` is a cmd builtin; empty first arg is the window title
|
||||
execFileSync('cmd', ['/c', 'start', '', authUrl], { stdio: 'ignore' })
|
||||
|
||||
// 7. Wait for callback
|
||||
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
|
||||
const callback = await callbackPromise
|
||||
|
||||
// 8. Exchange code for tokens
|
||||
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
|
||||
const tokens = await exchangeCode(
|
||||
oidc.token_endpoint,
|
||||
callback.code,
|
||||
pkce.code_verifier,
|
||||
tokenRedirectUri,
|
||||
discovery.client_id,
|
||||
)
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
|
||||
}
|
||||
|
||||
// 9. Store credentials
|
||||
const store = createCredentialStore()
|
||||
if (tokens.refresh_token) {
|
||||
store.store(tokens.refresh_token)
|
||||
}
|
||||
|
||||
// 10. Write config
|
||||
writeSyncConfig({
|
||||
baseUrl,
|
||||
clientId: discovery.client_id,
|
||||
tracesPath: discovery.traces_path,
|
||||
issuer: discovery.issuer,
|
||||
})
|
||||
|
||||
process.stderr.write(`\n✓ Sync configured successfully.\n`)
|
||||
process.stderr.write(` Endpoint: ${baseUrl}\n`)
|
||||
process.stderr.write(` Token stored in: ${store.method()}\n`)
|
||||
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
|
||||
} catch (err) {
|
||||
// Known errors (login timeout, port exhaustion, discovery failures)
|
||||
// get a clean one-line message instead of a raw Node crash dump.
|
||||
if (err instanceof AuthError || err instanceof DiscoveryError) {
|
||||
process.stderr.write(`\nError: ${err.message}\n`)
|
||||
} else {
|
||||
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
|
||||
process.stderr.write(`\nError: ${(err as Error).stack ?? (err as Error).message}\n`)
|
||||
}
|
||||
} catch {
|
||||
// Browser open failed — user sees the URL above
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 7. Wait for callback
|
||||
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
|
||||
const callback = await callbackPromise
|
||||
|
||||
// 8. Exchange code for tokens
|
||||
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
|
||||
const tokens = await exchangeCode(
|
||||
oidc.token_endpoint,
|
||||
callback.code,
|
||||
pkce.code_verifier,
|
||||
tokenRedirectUri,
|
||||
discovery.client_id,
|
||||
)
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
|
||||
}
|
||||
|
||||
// 9. Store credentials
|
||||
const store = createCredentialStore()
|
||||
if (tokens.refresh_token) {
|
||||
store.store(tokens.refresh_token)
|
||||
}
|
||||
|
||||
// 10. Write config
|
||||
writeSyncConfig({
|
||||
baseUrl,
|
||||
clientId: discovery.client_id,
|
||||
tracesPath: discovery.traces_path,
|
||||
issuer: discovery.issuer,
|
||||
})
|
||||
|
||||
process.stderr.write(`\n✓ Sync configured successfully.\n`)
|
||||
process.stderr.write(` Endpoint: ${baseUrl}\n`)
|
||||
process.stderr.write(` Token stored in: ${store.method()}\n`)
|
||||
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
|
||||
})
|
||||
|
||||
// --- status ---
|
||||
|
|
@ -205,7 +227,8 @@ export function registerSyncCommands(program: Command): void {
|
|||
.description('Push unsent telemetry data to the configured endpoint')
|
||||
.option('--since <period>', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d')
|
||||
.option('--dry-run', 'Show what would be sent without sending')
|
||||
.action(async (opts: { since: string; dryRun?: boolean }) => {
|
||||
.option('--attribution', 'Also push git attribution spans (session→commit correlation from `codeburn yield`, plus PR links). Sends normalized repo remotes and commit SHAs to the endpoint.')
|
||||
.action(async (opts: { since: string; dryRun?: boolean; attribution?: boolean }) => {
|
||||
const config = readSyncConfig()
|
||||
if (!config) {
|
||||
process.stderr.write('Sync not configured. Run `codeburn sync setup <url>` first.\n')
|
||||
|
|
@ -256,6 +279,18 @@ export function registerSyncCommands(program: Command): void {
|
|||
// Flatten + filter against sent-ledger
|
||||
const { allCalls, unsent } = collectUnsentCalls(projects)
|
||||
|
||||
// Attribution records (opt-in): session→commit correlation computed
|
||||
// locally from the same parsed projects. Reuses the yield engine.
|
||||
let attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent'] = []
|
||||
let attributionTotal = 0
|
||||
if (opts.attribution) {
|
||||
const { computeAttributionRecords } = await import('../yield.js')
|
||||
const records = computeAttributionRecords(projects, range, process.cwd())
|
||||
const collected = collectUnsentAttribution(records)
|
||||
attributionUnsent = collected.unsent
|
||||
attributionTotal = collected.allItems.length
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
const toPushCount = Math.min(unsent.length, MAX_PER_PUSH)
|
||||
const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0)
|
||||
|
|
@ -264,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
|
||||
|
|
@ -281,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')
|
||||
|
|
@ -302,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`)
|
||||
}
|
||||
|
|
@ -316,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) {
|
||||
|
|
|
|||
173
src/sync/otlp.ts
173
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,16 @@
|
|||
import type { ProjectSummary } from '../types.js'
|
||||
import { assertHttps } from './discovery.js'
|
||||
import { ledgerKeySet, appendToLedger, type LedgerEntry } from './ledger.js'
|
||||
import { buildOtlpPayload, batchCalls, type CallWithSession } from './otlp.js'
|
||||
import {
|
||||
buildOtlpPayload,
|
||||
batchCalls,
|
||||
buildAttributionOtlpPayload,
|
||||
flattenAttributionRecords,
|
||||
type CallWithSession,
|
||||
type AttributionItem,
|
||||
type OtlpPayload,
|
||||
} from './otlp.js'
|
||||
import type { SessionAttributionRecord } from '../yield.js'
|
||||
|
||||
/**
|
||||
* Safety valve, not a routine cap — pushes now loop until all batches are
|
||||
|
|
@ -91,6 +100,23 @@ export function parseRetryAfterMs(value: string | null): number | null {
|
|||
* retry on the next push.
|
||||
*/
|
||||
export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult> {
|
||||
return sendBatchesCore({
|
||||
...opts,
|
||||
buildPayload: buildOtlpPayload,
|
||||
toOutbound: c => ({ key: c.call.deduplicationKey, ts: c.call.timestamp, costUSD: c.call.costUSD }),
|
||||
})
|
||||
}
|
||||
|
||||
/** How sendBatchesCore ledgers and prices a batch item. */
|
||||
type OutboundItem = { key: string; ts: string; costUSD: number }
|
||||
|
||||
type SendBatchesCoreOptions<T> = Omit<SendBatchesOptions, 'batches'> & {
|
||||
batches: T[][]
|
||||
buildPayload: (batch: T[]) => OtlpPayload
|
||||
toOutbound: (item: T) => OutboundItem
|
||||
}
|
||||
|
||||
async function sendBatchesCore<T>(opts: SendBatchesCoreOptions<T>): Promise<PushResult> {
|
||||
assertHttps(opts.endpoint, 'Traces endpoint')
|
||||
const log = opts.log ?? (() => {})
|
||||
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
||||
|
|
@ -107,7 +133,7 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
|
|||
|
||||
// Retry loop for the current batch (429 only)
|
||||
for (;;) {
|
||||
const payload = buildOtlpPayload(batch)
|
||||
const payload = opts.buildPayload(batch)
|
||||
|
||||
const response = await fetch(opts.endpoint, {
|
||||
method: 'POST',
|
||||
|
|
@ -158,13 +184,11 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
|
|||
totalRejected += rejected
|
||||
log(` Batch: ${rejected}/${batch.length} spans rejected — whole batch will retry on next push`)
|
||||
} else {
|
||||
const entries: LedgerEntry[] = batch.map(c => ({
|
||||
key: c.call.deduplicationKey,
|
||||
ts: c.call.timestamp,
|
||||
}))
|
||||
const outbound = batch.map(opts.toOutbound)
|
||||
const entries: LedgerEntry[] = outbound.map(o => ({ key: o.key, ts: o.ts }))
|
||||
appendToLedger(entries)
|
||||
totalSent += batch.length
|
||||
totalCostSent += batch.reduce((s, c) => s + c.call.costUSD, 0)
|
||||
totalCostSent += outbound.reduce((s, o) => s + o.costUSD, 0)
|
||||
}
|
||||
break // batch done (success or partial) — move to next batch
|
||||
}
|
||||
|
|
@ -173,4 +197,56 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
|
|||
return { outcome: 'complete', totalSent, totalRejected, totalCostSent, totalWaitMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety valve for attribution items, mirroring MAX_PER_PUSH: bounds a first
|
||||
* `--since all --attribution` push over a long history. Remaining facts are
|
||||
* sent on the next push (the ledger tracks progress).
|
||||
*/
|
||||
export const MAX_ATTRIBUTION_PER_PUSH = 10_000
|
||||
|
||||
/** Flatten attribution records into items and filter out already-sent ones. */
|
||||
export function collectUnsentAttribution(records: SessionAttributionRecord[]): {
|
||||
allItems: AttributionItem[]
|
||||
unsent: AttributionItem[]
|
||||
} {
|
||||
const sent = ledgerKeySet()
|
||||
|
||||
// Empty records (no commits, no PR links) exist only to RETRACT a session
|
||||
// span whose commits migrated to another session. Send one only when a
|
||||
// PRIOR state for that session was already ledgered — a session that was
|
||||
// never sent has nothing to retract.
|
||||
const sessionsWithPriorState = new Set<string>()
|
||||
for (const key of sent) {
|
||||
if (key.startsWith('attr:s:')) {
|
||||
const sessionId = key.slice('attr:s:'.length, key.lastIndexOf(':'))
|
||||
sessionsWithPriorState.add(sessionId)
|
||||
}
|
||||
}
|
||||
const sendable = records.filter(r =>
|
||||
r.commits.length > 0 || r.prLinks.length > 0 || sessionsWithPriorState.has(r.sessionId),
|
||||
)
|
||||
|
||||
const allItems = flattenAttributionRecords(sendable)
|
||||
const unsent = allItems.filter(i => !sent.has(i.dedupKey))
|
||||
return { allItems, unsent }
|
||||
}
|
||||
|
||||
export interface SendAttributionBatchesOptions extends Omit<SendBatchesOptions, 'batches'> {
|
||||
batches: AttributionItem[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Send attribution batches through the same retry/ledger pipeline as usage
|
||||
* batches. Items are ledgered by their state-encoding dedup keys, so an
|
||||
* identical attribution fact is sent once and a state transition (commit
|
||||
* merged to main, commit reverted) re-sends the updated fact.
|
||||
*/
|
||||
export async function sendAttributionBatches(opts: SendAttributionBatchesOptions): Promise<PushResult> {
|
||||
return sendBatchesCore({
|
||||
...opts,
|
||||
buildPayload: buildAttributionOtlpPayload,
|
||||
toOutbound: item => ({ key: item.dedupKey, ts: item.timestamp, costUSD: 0 }),
|
||||
})
|
||||
}
|
||||
|
||||
export { batchCalls }
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ export type ParsedApiCall = {
|
|||
/// Count of this call's tool results flagged `is_error` (Claude tool_result
|
||||
/// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0.
|
||||
toolErrors?: number
|
||||
activeDurationMs?: number
|
||||
activeGeneratedTokens?: number
|
||||
toolWaitMs?: number
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
|
|
@ -268,7 +271,7 @@ export type SessionSummary = {
|
|||
/// from a provider that never captures branches (→ contributes nothing).
|
||||
/// Claude only; absent otherwise.
|
||||
everHadBranch?: boolean
|
||||
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
|
||||
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number; activeDurationMs?: number; activeGeneratedTokens?: number; toolWaitMs?: number }>
|
||||
toolBreakdown: Record<string, { calls: number }>
|
||||
mcpBreakdown: Record<string, { calls: number }>
|
||||
bashBreakdown: Record<string, { calls: number }>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarP
|
|||
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js'
|
||||
import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
|
||||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
|
|
@ -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.
|
||||
|
|
@ -149,7 +149,11 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str
|
|||
// idle config or Claude Desktop is still selectable). Only worth it when a
|
||||
// second source is possible: more than one config dir, or a Claude Desktop
|
||||
// sessions dir exists. A plain single-config user skips it entirely.
|
||||
const desktopExists = await stat(getDesktopSessionsDir()).then(s => s.isDirectory()).catch(() => false)
|
||||
const desktopExists = (
|
||||
await Promise.all(
|
||||
getDesktopSessionsDirs().map(dir => stat(dir).then(s => s.isDirectory()).catch(() => false)),
|
||||
)
|
||||
).some(Boolean)
|
||||
if ((await getClaudeConfigDirs()).length > 1 || desktopExists) {
|
||||
for (const source of await claude.discoverSessions()) {
|
||||
if ((source.sourceKind !== 'claude-config' && source.sourceKind !== 'claude-desktop') || !source.sourceId || !source.sourceLabel || !source.sourcePath) continue
|
||||
|
|
@ -222,25 +226,130 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Does a cached day's project entry pass the active name filters? Mirrors
|
||||
/// parser.filterProjectsByName exactly — case-insensitive substring match
|
||||
/// against the project name OR its filesystem path, include first then exclude —
|
||||
/// so a filter selects the same projects whether it is resolved against a fresh
|
||||
/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is
|
||||
/// absent on entries whose sessions were gone before it could be recorded; the
|
||||
/// name is then all there is to match on, as it is for the display layers.
|
||||
function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean {
|
||||
const n = name.toLowerCase()
|
||||
const p = (path ?? '').toLowerCase()
|
||||
const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern))
|
||||
if (include.length > 0 && !include.some(hit)) return false
|
||||
if (exclude.length > 0 && exclude.some(hit)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/// Sum the per-project day stats that pass the filters. `defineProperty` so a
|
||||
/// project directory named "__proto__" stays an own key instead of mutating the
|
||||
/// prototype link (same reason day-aggregator does it when writing them).
|
||||
function sumMatchingProjects(
|
||||
projects: Record<string, ProjectDayStats>,
|
||||
include: string[],
|
||||
exclude: string[],
|
||||
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
|
||||
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, matched: 0 }
|
||||
for (const [name, p] of Object.entries(projects)) {
|
||||
if (!dayProjectMatches(name, p.path, include, exclude)) continue
|
||||
out.cost += p.cost
|
||||
out.calls += p.calls
|
||||
out.savingsUSD += p.savingsUSD ?? 0
|
||||
out.sessions += p.sessions ?? 0
|
||||
out.matched += 1
|
||||
Object.defineProperty(out.projects, name, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Collapse a day to the slice matching the active --project/--exclude filters,
|
||||
/// the project-level counterpart of sliceDayToProvider. Without this a
|
||||
/// project-filtered headline counted every historical day WHOLE — excluded
|
||||
/// projects included — while every detail panel (By Project / By Activity / By
|
||||
/// Model, all built from the name-filtered live parse) left them out, so the two
|
||||
/// could not be reconciled.
|
||||
///
|
||||
/// `DailyEntry.projects` (cache v15+) carries cost/calls/savingsUSD/sessions per
|
||||
/// project, so those four are recomputed EXACTLY. The day's tokens, models and
|
||||
/// categories have no per-project split to slice, so they are dropped here
|
||||
/// rather than reported as if they belonged to the surviving projects;
|
||||
/// buildDurablePeriod refills them from the project-filtered live parse, which
|
||||
/// is exact for every session that still exists.
|
||||
///
|
||||
/// A day recorded before v15 has no `projects` at all and nothing can
|
||||
/// reconstruct one once the sources are gone. Such a day cannot be attributed to
|
||||
/// any project, so it contributes nothing to a project-filtered total and its
|
||||
/// cost is surfaced as `unattributedCostUSD` instead of being silently folded in
|
||||
/// (understating with a stated figure beats overstating with excluded spend).
|
||||
function sliceDayToProject(day: DailyEntry, include: string[], exclude: string[]): DailyEntry {
|
||||
const zeroDay = (): DailyEntry => ({
|
||||
date: day.date, cost: 0, savingsUSD: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {},
|
||||
...(day.carried ? { carried: true as const } : {}),
|
||||
})
|
||||
if (!day.projects) return zeroDay()
|
||||
const totals = sumMatchingProjects(day.projects, include, exclude)
|
||||
if (totals.matched === 0) return zeroDay()
|
||||
|
||||
// Provider slices carry their own per-project split, so `--provider X` on top
|
||||
// of a project filter stays consistent with the day-level slice. A slice
|
||||
// adopted from a pre-v15 cache has no split and is dropped for the same
|
||||
// reason the day-level one is.
|
||||
const providers: Record<string, ProviderDaySlice> = {}
|
||||
for (const [name, slice] of Object.entries(day.providers)) {
|
||||
if (!slice.projects) continue
|
||||
const sliced = sumMatchingProjects(slice.projects, include, exclude)
|
||||
if (sliced.matched === 0) continue
|
||||
Object.defineProperty(providers, name, {
|
||||
value: { cost: sliced.cost, calls: sliced.calls, savingsUSD: sliced.savingsUSD, sessions: sliced.sessions, projects: sliced.projects },
|
||||
enumerable: true, writable: true, configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
date: day.date,
|
||||
cost: totals.cost,
|
||||
savingsUSD: totals.savingsUSD,
|
||||
calls: totals.calls,
|
||||
sessions: totals.sessions,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
|
||||
providers,
|
||||
projects: totals.projects,
|
||||
...(day.carried ? { carried: true as const } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The durable day set behind a period's headline: historical days from the
|
||||
/// carry-forward cache (up to yesterday, INCLUDING days whose session files have
|
||||
/// expired) unioned with today parsed live, then narrowed to the requested range
|
||||
/// and (when given) the heatmap day selection. Identical construction to the
|
||||
/// menubar's all-provider headline — this IS that construction, extracted.
|
||||
///
|
||||
/// `sliceHistorical` narrows the cache-sourced days only. Today's days come from
|
||||
/// a parse the caller already name-filtered, so re-slicing them would be a no-op
|
||||
/// at best and could only lose data the filter meant to keep.
|
||||
function unionDaysForPeriod(
|
||||
cache: DailyCache,
|
||||
todayAllDays: DailyEntry[],
|
||||
periodInfo: PeriodInfo,
|
||||
daysSelection: Set<string> | null,
|
||||
sliceHistorical?: (day: DailyEntry) => DailyEntry,
|
||||
): DailyEntry[] {
|
||||
const now = new Date()
|
||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
|
||||
const historicalDays = rangeStartStr <= historicalRangeEndStr
|
||||
const cacheDays = rangeStartStr <= historicalRangeEndStr
|
||||
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
|
||||
: []
|
||||
// Apply the day selection BEFORE slicing so a day the heatmap filtered out
|
||||
// never reaches the slicer (which tallies what it could not attribute).
|
||||
const selectedCacheDays = daysSelection ? cacheDays.filter(d => daysSelection.has(d.date)) : cacheDays
|
||||
const historicalDays = sliceHistorical ? selectedCacheDays.map(d => sliceHistorical(d)) : selectedCacheDays
|
||||
const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
||||
const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
||||
return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered
|
||||
|
|
@ -262,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[]
|
||||
|
|
@ -307,9 +422,14 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw
|
||||
scanRange = periodInfo.range
|
||||
// A period that reaches today contains today's turns already, so derive the
|
||||
// today slice from the same parse instead of scanning today again.
|
||||
// today slice from the same parse instead of scanning today again. Slice it
|
||||
// to today first (filterProjectsByDays re-anchors a midnight-straddling
|
||||
// turn to its surviving today calls), so today's category / turn count
|
||||
// lands on today rather than staying anchored on the turn's yesterday
|
||||
// start. Otherwise the post-midnight half vanishes from By Activity and the
|
||||
// JSON daily turn count while the per-call cost/calls still bucket to today.
|
||||
todayAllDays = rangeEndStr >= todayStr
|
||||
? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
|
||||
? aggregateProjectsIntoDays(filterProjectsByDays(raw, new Set([todayStr]))).filter(d => d.date === todayStr)
|
||||
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -321,7 +441,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)
|
||||
|
||||
|
|
@ -338,6 +484,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!]),
|
||||
)
|
||||
|
|
@ -348,7 +509,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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
317
src/yield.ts
317
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<YieldSummary> {
|
||||
const projects = await parseAllSessions(range, provider)
|
||||
|
||||
const summary: YieldSummary = {
|
||||
productive: { cost: 0, sessions: 0 },
|
||||
reverted: { cost: 0, sessions: 0 },
|
||||
abandoned: { cost: 0, sessions: 0 },
|
||||
ambiguous: { cost: 0, sessions: 0 },
|
||||
total: { cost: 0, sessions: 0 },
|
||||
details: [],
|
||||
}
|
||||
type RepoGroup = {
|
||||
commits: CommitInfo[]
|
||||
sessions: SessionSummary[]
|
||||
projectNames: string[]
|
||||
/** Parallel to `sessions`: true when the session's identity came from its
|
||||
* OWN project path; false when it inherited the cwd-fallback identity.
|
||||
* The attribution (sync) path must never egress fallback-derived repos. */
|
||||
ownIdentity: boolean[]
|
||||
/** A directory to run further git queries in (remote lookup); null when the group has no git identity. */
|
||||
gitDir: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Group sessions by canonical repository identity and load each group's
|
||||
* commits for the range. Shared by `computeYield` (categorization) and
|
||||
* `computeAttributionRecords` (sync). Grouping semantics are unchanged from
|
||||
* the original computeYield implementation: each commit is awarded at most
|
||||
* once across the whole repo; monorepo subdirectories and worktrees collapse
|
||||
* to one group; a project whose path is missing or not a git repo falls back
|
||||
* to the cwd repo (or an empty commit list when cwd is not a repo either).
|
||||
*/
|
||||
function buildRepoGroups(
|
||||
projects: ProjectSummary[],
|
||||
range: DateRange,
|
||||
cwd: string,
|
||||
): Map<string, RepoGroup> {
|
||||
const repoIdentityCache = new Map<string, RepoIdentity | null>()
|
||||
|
||||
// Get all commits in the date range for correlation
|
||||
const cwdIdentity = resolveRepoIdentity(cwd, repoIdentityCache)
|
||||
const cwdCommits = cwdIdentity
|
||||
? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd))
|
||||
: []
|
||||
|
||||
// Group sessions by canonical repository identity before attributing so that
|
||||
// each commit is awarded at most once across the whole repo. Two monorepo
|
||||
// subdirectory sessions, or two worktrees of one repo, resolve to the same
|
||||
// git-common-dir and share ONE group; keying on the raw path would double
|
||||
// count. A project whose path is missing or not a git repo falls back to the
|
||||
// cwd repo (or, when cwd is not a repo either, an empty commit list) exactly
|
||||
// as before.
|
||||
type RepoGroup = { commits: CommitInfo[]; sessions: SessionSummary[]; projectNames: string[] }
|
||||
const repoGroups = new Map<string, RepoGroup>()
|
||||
for (const project of projects) {
|
||||
const projectIdentity = project.projectPath
|
||||
|
|
@ -342,15 +438,35 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
|
|||
: getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)),
|
||||
sessions: [],
|
||||
projectNames: [],
|
||||
ownIdentity: [],
|
||||
gitDir: identity?.gitDir ?? null,
|
||||
}
|
||||
repoGroups.set(groupKey, group)
|
||||
}
|
||||
for (const session of project.sessions) {
|
||||
group.sessions.push(session)
|
||||
group.projectNames.push(project.project)
|
||||
group.ownIdentity.push(projectIdentity !== null)
|
||||
}
|
||||
}
|
||||
|
||||
return repoGroups
|
||||
}
|
||||
|
||||
export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise<YieldSummary> {
|
||||
const projects = await parseAllSessions(range, provider)
|
||||
|
||||
const summary: YieldSummary = {
|
||||
productive: { cost: 0, sessions: 0 },
|
||||
reverted: { cost: 0, sessions: 0 },
|
||||
abandoned: { cost: 0, sessions: 0 },
|
||||
ambiguous: { cost: 0, sessions: 0 },
|
||||
total: { cost: 0, sessions: 0 },
|
||||
details: [],
|
||||
}
|
||||
|
||||
const repoGroups = buildRepoGroups(projects, range, cwd)
|
||||
|
||||
for (const group of repoGroups.values()) {
|
||||
const attributions = attributeCommits(group.sessions, group.commits)
|
||||
for (const [index, session] of group.sessions.entries()) {
|
||||
|
|
@ -446,3 +562,160 @@ export function buildYieldJsonReport(
|
|||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sync attribution (codeburn sync push --attribution) ---
|
||||
|
||||
export type CommitAttribution = {
|
||||
sha: string
|
||||
/** Commit author time, ISO 8601. */
|
||||
timestamp: string
|
||||
inMain: boolean
|
||||
wasReverted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session git attribution record — the sync-facing projection of the
|
||||
* yield timestamp-window correlation. One record per session that produced
|
||||
* server-joinable evidence: attributed commits (requires a normalized remote)
|
||||
* and/or PR links.
|
||||
*/
|
||||
export type SessionAttributionRecord = {
|
||||
sessionId: string
|
||||
project: string
|
||||
/** Normalized origin remote (`host/org/repo`), the server-side join key. Null when only prLinks are available. */
|
||||
repo: string | null
|
||||
/** GitHub PR URLs captured for the session (already normalized upstream). */
|
||||
prLinks: string[]
|
||||
/** Commits attributed to this session. Empty when repo is null (SHAs without a repo identity cannot be joined). */
|
||||
commits: CommitAttribution[]
|
||||
/** Session window, ISO 8601 — lets the receiver reason about attribution recency. */
|
||||
firstTimestamp: string
|
||||
lastTimestamp: string
|
||||
}
|
||||
|
||||
/** Max PR links retained per session attribution record. */
|
||||
export const MAX_PR_LINKS_PER_SESSION = 20
|
||||
|
||||
/**
|
||||
* Shape-check PR links before they leave the machine. Upstream parsers only
|
||||
* verify truthiness, so arbitrary strings can land in `session.prLinks`.
|
||||
* Keep only https URLs shaped like a PR (`/org/repo/pull/N` — GitHub and
|
||||
* GitHub Enterprise), bounded in length, capped per session, sorted.
|
||||
*
|
||||
* Links are REBUILT from `origin + pathname`, never passed through verbatim:
|
||||
* userinfo (`https://alice:token@…`), query strings (copy-pasted GitHub
|
||||
* links routinely carry `?notification_referrer_id=…`), and fragments are
|
||||
* all dropped. Rebuilt links that collapse to the same URL dedupe.
|
||||
*/
|
||||
export function sanitizePrLinks(links: string[]): string[] {
|
||||
const valid = new Set<string>()
|
||||
for (const link of links) {
|
||||
if (typeof link !== 'string' || link.length === 0 || link.length > 512) continue
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(link)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (url.protocol !== 'https:') continue
|
||||
if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue
|
||||
const rebuilt = `${url.origin}${url.pathname}`
|
||||
if (rebuilt.length > 256) continue
|
||||
valid.add(rebuilt)
|
||||
}
|
||||
return [...valid].sort().slice(0, MAX_PR_LINKS_PER_SESSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-session attribution records for sync. Reuses the exact yield
|
||||
* repo grouping + tightest-window commit attribution (`methodology:
|
||||
* timestamp-window`), then joins in each repo group's normalized origin
|
||||
* remote and the session's sanitized PR links.
|
||||
*
|
||||
* Inclusion rules:
|
||||
* - Only sessions whose OWN project path resolved to a repo participate in
|
||||
* commit attribution; cwd-fallback sessions never carry a repo or commits
|
||||
* (privacy gate — see below) but still emit a record when they have PR links.
|
||||
* - Commits require a normalized remote; a SHA without a repo identity has
|
||||
* no server-side meaning.
|
||||
* - A session with no commits and no PR links is emitted ONLY when it lost a
|
||||
* commit to a tighter-window session in THIS computation (`lostCandidacy`)
|
||||
* — a retraction candidate. A session that merely aged its commits out of
|
||||
* the range lost them to nobody, and emitting an empty record for it would
|
||||
* permanently retract a still-correct server-side count.
|
||||
*
|
||||
* Takes already-parsed projects (sync push has them in hand) instead of
|
||||
* re-parsing like computeYield does.
|
||||
*/
|
||||
export function computeAttributionRecords(
|
||||
projects: ProjectSummary[],
|
||||
range: DateRange,
|
||||
cwd: string,
|
||||
): SessionAttributionRecord[] {
|
||||
const repoGroups = buildRepoGroups(projects, range, cwd)
|
||||
const records: SessionAttributionRecord[] = []
|
||||
|
||||
for (const group of repoGroups.values()) {
|
||||
const remote = group.gitDir ? getRepoRemote(group.gitDir) : null
|
||||
|
||||
// Privacy gate: only sessions whose identity came from their OWN project
|
||||
// path participate in commit attribution. A session whose project path no
|
||||
// longer resolves (deleted/renamed dir, non-repo session) inherits the
|
||||
// cwd-fallback identity in buildRepoGroups — attributing it here would
|
||||
// egress whatever repo the user happens to be pushing from, with commits
|
||||
// that session never touched. Fallback sessions get no repo and no
|
||||
// commits; they still emit a record when they carry PR links (which are
|
||||
// session-native and safe). Excluding them from the competition also
|
||||
// prevents a fallback window from stealing a commit that belongs to a
|
||||
// genuine session.
|
||||
const ownSessions = group.sessions.filter((_, i) => group.ownIdentity[i])
|
||||
const attributions = attributeCommits(ownSessions, group.commits)
|
||||
// Keyed by object reference: session objects are unique per group entry,
|
||||
// whereas sessionId strings could collide across projects.
|
||||
const attributionBySession = new Map<SessionSummary, CommitInfo[]>()
|
||||
const lostCandidacyBySession = new Map<SessionSummary, boolean>()
|
||||
for (const [i, session] of ownSessions.entries()) {
|
||||
attributionBySession.set(session, attributions[i]?.commits ?? [])
|
||||
lostCandidacyBySession.set(session, attributions[i]?.lostCandidacy ?? false)
|
||||
}
|
||||
|
||||
for (const [index, session] of group.sessions.entries()) {
|
||||
if (!session.firstTimestamp) continue
|
||||
|
||||
const isOwn = group.ownIdentity[index] === true
|
||||
const sessionRemote = isOwn ? remote : null
|
||||
const attributedCommits = sessionRemote
|
||||
? (attributionBySession.get(session) ?? [])
|
||||
: []
|
||||
const prLinks = sanitizePrLinks(session.prLinks ?? [])
|
||||
// Empty sessions are retraction candidates ONLY when they lost a commit
|
||||
// to a tighter-window session in THIS run: that commit's server-side
|
||||
// attribution is migrating, so the loser must re-emit commit_count=0.
|
||||
// An empty session whose commits merely aged out of the --since range
|
||||
// (rolling window, or a narrower window than a previous push) lost them
|
||||
// to NOBODY — emitting a retraction for it would permanently zero a
|
||||
// still-correct server-side count, because the original state key stays
|
||||
// ledgered and is never re-sent.
|
||||
const lostToTighterSession = sessionRemote !== null &&
|
||||
(lostCandidacyBySession.get(session) ?? false)
|
||||
if (attributedCommits.length === 0 && prLinks.length === 0 && !lostToTighterSession) continue
|
||||
|
||||
records.push({
|
||||
sessionId: session.sessionId,
|
||||
project: group.projectNames[index] ?? session.project,
|
||||
repo: sessionRemote,
|
||||
prLinks,
|
||||
commits: attributedCommits.map(c => ({
|
||||
sha: c.sha,
|
||||
timestamp: c.timestamp.toISOString(),
|
||||
inMain: c.inMain,
|
||||
wasReverted: c.wasReverted,
|
||||
})),
|
||||
firstTimestamp: session.firstTimestamp,
|
||||
lastTimestamp: session.lastTimestamp ?? session.firstTimestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
|
|
|||
121
tests/cache-refresh-lock-corrupt-body.test.ts
Normal file
121
tests/cache-refresh-lock-corrupt-body.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js'
|
||||
|
||||
// Recovering a corrupt session-refresh.lock must never cost the two design
|
||||
// commitments the lock exists for: it may not fail open into mutation, and it
|
||||
// may not be stolen from a live heartbeating owner. An earlier fix waived the
|
||||
// staleness gate for a corrupt body once the contender's wait expired, which
|
||||
// handed two processes the lock at the same time in both directions below.
|
||||
// Corruption is recovered only through the unmodified staleness gate.
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
while (roots.length) await rm(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function tempCase(prefix: string): Promise<{ cacheDir: string; barriers: string; lockPath: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix))
|
||||
roots.push(root)
|
||||
const cacheDir = join(root, 'cache')
|
||||
const barriers = join(root, 'barriers')
|
||||
await mkdir(cacheDir, { recursive: true })
|
||||
await mkdir(barriers, { recursive: true })
|
||||
return { cacheDir, barriers, lockPath: join(cacheDir, 'session-refresh.lock') }
|
||||
}
|
||||
|
||||
function spawnFixture(fixture: string, args: string[], env: NodeJS.ProcessEnv = {}): ChildProcess {
|
||||
return spawn(process.execPath, ['--import', 'tsx', join(process.cwd(), 'tests/fixtures', fixture), ...args], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForFile(path: string, timeoutMs = 15_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(path)) {
|
||||
if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`)
|
||||
await new Promise(resolve => { setTimeout(resolve, 5) })
|
||||
}
|
||||
}
|
||||
|
||||
const exited = (child: ChildProcess): Promise<unknown> => new Promise(resolve => child.once('exit', resolve))
|
||||
|
||||
describe('warm refresh lock: a corrupt body never displaces a live owner', () => {
|
||||
it('leaves the zero-byte lock alone while its creator is still inside createExclusive', async () => {
|
||||
const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-race-')
|
||||
// UV_THREADPOOL_SIZE=1 plus the fixture's pbkdf2 churn stretches the gap
|
||||
// between open(path,'wx') and the awaited body write, so the lock is
|
||||
// genuinely observable at zero bytes with no external corruption at all.
|
||||
const owner = spawnFixture('cache-refresh-slow-owner.ts', [cacheDir, barriers], { UV_THREADPOOL_SIZE: '1' })
|
||||
try {
|
||||
const deadline = Date.now() + 20_000
|
||||
let sawZeroByteLock = false
|
||||
while (Date.now() < deadline) {
|
||||
const info = await stat(lockPath).catch(() => null)
|
||||
if (info) { sawZeroByteLock = info.size === 0; break }
|
||||
await new Promise(resolve => { setTimeout(resolve, 1) })
|
||||
}
|
||||
expect(sawZeroByteLock, 'never caught the owner mid-createExclusive').toBe(true)
|
||||
|
||||
const contender = await acquireCacheRefreshLock({ cacheDir, waitMs: 40, pollMs: 5, staleMs: 90_000 })
|
||||
if (contender.outcome === 'acquired') await contender.handle.release()
|
||||
expect(contender.outcome).toBe('timed-out')
|
||||
} finally {
|
||||
await exited(owner)
|
||||
}
|
||||
// The owner kept the lock end to end, so its publication fence held.
|
||||
expect(existsSync(join(barriers, 'owner.acquired'))).toBe(true)
|
||||
expect(existsSync(join(barriers, 'owner.verify.true')), 'owner lost its own lock').toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
it('leaves a live owner alone after its body is truncated, however long the wait', async () => {
|
||||
const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-live-')
|
||||
const owner = spawnFixture('cache-refresh-corrupt-owner.ts', [cacheDir, barriers, '4000', '100'])
|
||||
try {
|
||||
await waitForFile(join(barriers, 'owner.acquired'))
|
||||
const ownerToken = await readFile(join(barriers, 'owner.acquired'), 'utf-8')
|
||||
|
||||
// The state a heartbeat leaves when writeFile() truncates and then fails
|
||||
// (ENOSPC/EIO, swallowed by the heartbeat's own catch). No guard is held,
|
||||
// and the body carries no token to compare against.
|
||||
await writeFile(lockPath, '')
|
||||
// This wait is shorter than one heartbeat period, so the body is still
|
||||
// truncated throughout: the contender has nothing but the fresh mtime to
|
||||
// go on, and that alone must keep it out.
|
||||
const early = await acquireCacheRefreshLock({ cacheDir, waitMs: 80, pollMs: 5, staleMs: 90_000 })
|
||||
if (early.outcome === 'acquired') await early.handle.release()
|
||||
expect(early.outcome).toBe('timed-out')
|
||||
|
||||
// Past the age gate, the successor SHOULD win. Only the heartbeat
|
||||
// advances mtime and it refuses to rewrite a body it cannot prove is
|
||||
// ours, so a corrupt body freezes its own mtime and ages out. That is the
|
||||
// intended end state, not a displacement to be prevented: an owner that
|
||||
// cannot prove ownership must not publish, and the alternative — letting
|
||||
// the heartbeat restamp its token over an unparseable body — resurrected
|
||||
// legitimately-replaced writers and let release() delete a live
|
||||
// successor's lock.
|
||||
await writeFile(lockPath, '')
|
||||
const late = await acquireCacheRefreshLock({ cacheDir, waitMs: 2_400, pollMs: 10, staleMs: 400 })
|
||||
expect(late.outcome).toBe('acquired')
|
||||
if (late.outcome === 'acquired') {
|
||||
// The successor owns it outright: the body carries its token, not the
|
||||
// original owner's.
|
||||
expect(JSON.parse(await readFile(lockPath, 'utf-8')).token).not.toBe(ownerToken)
|
||||
await late.handle.release()
|
||||
}
|
||||
} finally {
|
||||
await exited(owner)
|
||||
}
|
||||
// The displaced owner's fence must refuse. Discarding its parse is the
|
||||
// fail-safe direction; two writers believing they own the lock is not.
|
||||
expect(existsSync(join(barriers, 'owner.verify.false')), 'displaced owner still passed its fence').toBe(true)
|
||||
expect(existsSync(join(barriers, 'owner.verify.true'))).toBe(false)
|
||||
}, 30_000)
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
})
|
||||
})
|
||||
|
|
|
|||
46
tests/cli-codex-tps.test.ts
Normal file
46
tests/cli-codex-tps.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (homes.length) await rm(homes.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function runCli(args: string[], home: string) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' },
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('codex-tps CLI validation', () => {
|
||||
it('rejects sub-second watch intervals', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
|
||||
homes.push(home)
|
||||
const result = runCli(['codex-tps', '--watch', '0.1'], home)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('watch must be 0 or at least 1 second')
|
||||
})
|
||||
|
||||
it('rejects JSON watch output instead of concatenating invalid JSON documents', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
|
||||
homes.push(home)
|
||||
const result = runCli(['codex-tps', '--json', '--watch', '1'], home)
|
||||
expect(result.status).toBe(2)
|
||||
expect(result.stderr).toContain('--json cannot be combined with --watch')
|
||||
})
|
||||
|
||||
it('returns a nonzero status for a missing explicit rollout', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
|
||||
homes.push(home)
|
||||
const result = runCli(['codex-tps', join(home, 'missing.jsonl')], home)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('session file not found')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
buildPeriodData,
|
||||
getDailyCacheConfigHash,
|
||||
} from '../src/usage-aggregator.js'
|
||||
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from '../src/parser.js'
|
||||
import { renderOverview } from '../src/overview.js'
|
||||
import type { DateRange } from '../src/types.js'
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ import type { DateRange } from '../src/types.js'
|
|||
// queries, and in the plain live regime with no carried days at all.
|
||||
|
||||
const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
|
||||
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME', 'USERPROFILE', 'KIMI_CODE_HOME', 'CODEBURN_DESKTOP_SESSIONS_DIR'] as const
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
const CARRIED_COST = 100
|
||||
|
|
@ -120,11 +120,22 @@ beforeEach(async () => {
|
|||
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
||||
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
|
||||
await mkdir(join(ROOT, 'cache'), { recursive: true })
|
||||
await mkdir(join(ROOT, 'no-desktop-sessions'), { recursive: true })
|
||||
await mkdir(join(ROOT, 'no-kimi-home'), { recursive: true })
|
||||
process.env['HOME'] = join(ROOT, 'home')
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
|
||||
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
|
||||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
delete process.env['CODEX_HOME']
|
||||
// Keep real provider data on the machine out of every parse: absolute-count
|
||||
// assertions are meaningless when the host's own sessions leak in.
|
||||
// USERPROFILE matters on Windows, where os.homedir() ignores HOME;
|
||||
// KIMI_CODE_HOME / the desktop-sessions override redirect the two env-aware
|
||||
// discovery roots. (The codex provider captures its home at import time, so
|
||||
// it is redirected separately in vi.hoisted below.)
|
||||
process.env['USERPROFILE'] = join(ROOT, 'home')
|
||||
process.env['KIMI_CODE_HOME'] = join(ROOT, 'no-kimi-home')
|
||||
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(ROOT, 'no-desktop-sessions')
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
|
|
@ -259,3 +270,186 @@ describe('terminal overview carried-day footnote', () => {
|
|||
expect(noCarried).not.toContain('preserved from expired session logs')
|
||||
})
|
||||
})
|
||||
|
||||
// Issue #852 review: per-call slicing is only conservation-correct when day
|
||||
// bucketing attributes call-derived values to each call's own day. These
|
||||
// tests pin the straddling-turn case the review reproduced end-to-end through
|
||||
// buildDurablePeriod: a turn starting the previous day at 23:57 with one call
|
||||
// before and one after local midnight must keep BOTH calls across a multi-day
|
||||
// period (cache ≤ yesterday + live today union), each on its own day.
|
||||
//
|
||||
// The codex provider captures CODEX_HOME when its module is first imported,
|
||||
// so the redirect must happen before module evaluation (vi.hoisted) rather
|
||||
// than in beforeEach. The captured dir is per-test-process and empty except
|
||||
// for the fixture written below, which also shields the suite from any real
|
||||
// ~/.codex on the machine running it.
|
||||
const CODEX_ROOT = vi.hoisted(() => {
|
||||
const root = `${process.env['TMPDIR'] || '/tmp'}/codeburn-straddle-codex-${process.pid}-${Date.now()}`
|
||||
process.env['CODEX_HOME'] = `${root}/codex`
|
||||
return root
|
||||
})
|
||||
|
||||
describe('midnight-straddling turn conservation (issue #852)', () => {
|
||||
// Day N = 2026-07-27, day N+1 ("today") = 2026-07-28 — LOCAL dates built
|
||||
// from constructor args so the case is machine-TZ independent (dateKey /
|
||||
// toDateString use the same local getters).
|
||||
const NOW = new Date(2026, 6, 28, 12, 0, 0)
|
||||
const DAY_N = '2026-07-27'
|
||||
const DAY_N1 = '2026-07-28'
|
||||
|
||||
function fakeNow(): void {
|
||||
// Date only: the daily-cache lock and retry helpers must keep real timers.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(CODEX_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(CODEX_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// One codex turn (the issue's provider) with a token_count call on each
|
||||
// side of local midnight: 23:58 (input 1000/output 200), 00:10 (2000/400).
|
||||
async function seedStraddlingCodexTurn(): Promise<void> {
|
||||
const sessionDir = join(CODEX_ROOT, 'codex', 'sessions', '2026', '07', '27')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
const line = (obj: unknown): string => JSON.stringify(obj)
|
||||
await writeFile(join(sessionDir, 'rollout-straddle.jsonl'), [
|
||||
line({ type: 'session_meta', timestamp: new Date(2026, 6, 27, 23, 55, 0).toISOString(), payload: { session_id: 'sess-straddle', model: 'gpt-5.5', cwd: '/tmp/straddle-proj', originator: 'codex_cli_rs' } }),
|
||||
line({ type: 'response_item', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'work through midnight' }] } }),
|
||||
line({ type: 'event_msg', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 1000, output_tokens: 200 }, total_token_usage: { total_tokens: 1200 } } } }),
|
||||
line({ type: 'event_msg', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 2000, output_tokens: 400 }, total_token_usage: { total_tokens: 3600 } } } }),
|
||||
].join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
// The same straddle through the Claude Code path (scanProjectDirs).
|
||||
async function seedStraddlingClaudeTurn(): Promise<void> {
|
||||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'straddle-proj')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const line = (obj: unknown): string => JSON.stringify(obj)
|
||||
await writeFile(join(projectDir, 's-straddle.jsonl'), [
|
||||
line({ type: 'user', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { role: 'user', content: 'work through midnight' } }),
|
||||
line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
|
||||
line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm2', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 2000, output_tokens: 400, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
|
||||
].join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
// The two calls' exact costs from an unfiltered parse (pricing-agnostic truth).
|
||||
async function truthCosts(provider: string): Promise<[number, number]> {
|
||||
clearSessionCache()
|
||||
const projects = await parseAllSessions(undefined, provider)
|
||||
const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
|
||||
expect(calls).toHaveLength(2)
|
||||
return [calls[0]!.costUSD, calls[1]!.costUSD]
|
||||
}
|
||||
|
||||
it('keeps day-N + day-N+1 equal to the whole-range totals through buildDurablePeriod', async () => {
|
||||
fakeNow()
|
||||
try {
|
||||
await seedStraddlingCodexTurn()
|
||||
const [costN, costN1] = await truthCosts('codex')
|
||||
expect(costN + costN1).toBeGreaterThan(0)
|
||||
|
||||
const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() }
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' })
|
||||
|
||||
const dayN = durable.days.find(d => d.date === DAY_N)
|
||||
const dayN1 = durable.days.find(d => d.date === DAY_N1)
|
||||
// Each side of the turn lands on its own day...
|
||||
expect(dayN?.calls).toBe(1)
|
||||
expect(dayN1?.calls).toBe(1)
|
||||
expect(dayN!.cost).toBeCloseTo(costN, 8)
|
||||
expect(dayN1!.cost).toBeCloseTo(costN1, 8)
|
||||
// ...and the two sides conserve the whole-range totals (the review's
|
||||
// week/month leak returned 1 call and only the pre-midnight cost).
|
||||
expect(durable.data.calls).toBe(2)
|
||||
expect(dayN!.calls + dayN1!.calls).toBe(durable.data.calls)
|
||||
expect(durable.data.cost).toBeCloseTo(costN + costN1, 8)
|
||||
expect(dayN!.cost + dayN1!.cost).toBeCloseTo(durable.data.cost, 8)
|
||||
expect(durable.data.inputTokens).toBe(3000)
|
||||
expect(durable.data.outputTokens).toBe(600)
|
||||
expect(dayN!.inputTokens + dayN1!.inputTokens).toBe(durable.data.inputTokens)
|
||||
expect(dayN!.outputTokens + dayN1!.outputTokens).toBe(durable.data.outputTokens)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('reconciles By Activity (categories) and daily turns with the headline on the multi-day all-provider view', async () => {
|
||||
fakeNow()
|
||||
try {
|
||||
await seedStraddlingClaudeTurn()
|
||||
const [costN, costN1] = await truthCosts('claude')
|
||||
|
||||
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' })
|
||||
|
||||
// By Activity must sum to the whole-range headline. Before this fix the
|
||||
// today slice came from the unsliced whole-range parse, so the straddling
|
||||
// turn's category stayed anchored on its yesterday start and the
|
||||
// post-midnight half vanished from By Activity while cost / calls / By
|
||||
// Model were already correct; categories summed to only the pre-midnight
|
||||
// cost.
|
||||
const categoryTotal = durable.data.categories.reduce((s, c) => s + c.cost, 0)
|
||||
expect(categoryTotal).toBeCloseTo(costN + costN1, 8)
|
||||
expect(categoryTotal).toBeCloseTo(durable.data.cost, 8)
|
||||
|
||||
// Today's day entry carries its turn's category, not calls>0 with an empty
|
||||
// category map (which rendered as `turns: 0` in the JSON daily rows).
|
||||
const dayN1 = durable.days.find(d => d.date === DAY_N1)
|
||||
expect(dayN1?.calls).toBe(1)
|
||||
expect(Object.keys(dayN1?.categories ?? {}).length).toBeGreaterThan(0)
|
||||
} 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)
|
||||
})
|
||||
|
|
|
|||
124
tests/codex-throughput.test.ts
Normal file
124
tests/codex-throughput.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { appendFile, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CodexThroughputReader, readCodexThroughput, renderCodexThroughput } from '../src/codex-throughput.js'
|
||||
|
||||
describe('Codex throughput prototype', () => {
|
||||
it('estimates generated tokens/sec between token_count checkpoints', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:02.000Z', payload: { type: 'function_call', call_id: 'tool-1' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'function_call_output', call_id: 'tool-1' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'mcp_tool_call_end', duration: { secs: 3, nanos: 0 } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 80, reasoning_output_tokens: 20 }, total_token_usage: { total_tokens: 100, output_tokens: 80, reasoning_output_tokens: 20 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:15.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 40, reasoning_output_tokens: 10 }, total_token_usage: { total_tokens: 150, output_tokens: 120, reasoning_output_tokens: 30 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:16.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
|
||||
].join('\n'))
|
||||
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[1]).toMatchObject({ generatedTokens: 50, elapsedSeconds: 5, generatedTokensPerSecond: 10, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 21.428571428571427, toolWaitSeconds: 3, model: 'gpt-5.6-sol' })
|
||||
expect(renderCodexThroughput(points, path)).toContain('21.4 generated tokens/sec')
|
||||
})
|
||||
|
||||
it('parses only appended complete lines while watching a growing rollout', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-watch-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const first = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 8, reasoning_output_tokens: 2 }, total_token_usage: { total_tokens: 10, output_tokens: 8, reasoning_output_tokens: 2 } } } })
|
||||
const second = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:01.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 4, reasoning_output_tokens: 1 }, total_token_usage: { total_tokens: 15, output_tokens: 12, reasoning_output_tokens: 3 } } } })
|
||||
await writeFile(path, first.slice(0, 40))
|
||||
const reader = new CodexThroughputReader()
|
||||
expect(await reader.update(path)).toEqual([])
|
||||
await appendFile(path, first.slice(40) + '\n' + second + '\n')
|
||||
const points = await reader.update(path)
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[1]).toMatchObject({ generatedTokens: 5, generatedTokensPerSecond: 5 })
|
||||
})
|
||||
|
||||
it('ignores replayed pre-fork checkpoints before estimating new work', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-fork-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const line = (timestamp: string, payload: Record<string, unknown>) => JSON.stringify({ type: 'event_msg', timestamp, payload })
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol', forked_from_id: 'parent' } }),
|
||||
line('2026-07-25T00:00:01.000Z', { type: 'task_started' }),
|
||||
line('2026-07-25T00:00:02.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } }),
|
||||
line('2026-07-25T00:00:03.000Z', { type: 'task_complete', duration_ms: 1000 }),
|
||||
line('2026-07-25T00:00:06.000Z', { type: 'task_started' }),
|
||||
line('2026-07-25T00:00:08.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 20 }, total_token_usage: { total_tokens: 20, output_tokens: 20 } } }),
|
||||
line('2026-07-25T00:00:10.000Z', { type: 'task_complete', duration_ms: 4000 }),
|
||||
].join('\n'))
|
||||
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]).toMatchObject({ generatedTokens: 20, activeGeneratedTokensPerSecond: 5 })
|
||||
})
|
||||
|
||||
it('keeps oversized rollout lines bounded while extracting token usage', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-large-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const largeResult = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-07-25T00:00:01.000Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { last_token_usage: { output_tokens: 12 }, total_token_usage: { total_tokens: 12, output_tokens: 12 } },
|
||||
result: 'x'.repeat(5 * 1024 * 1024),
|
||||
},
|
||||
})
|
||||
await writeFile(path, largeResult)
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.generatedTokens).toBe(12)
|
||||
})
|
||||
|
||||
it('keeps MCP duration when arguments and result surround the middle field', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-large-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const mcp = JSON.stringify({
|
||||
type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
|
||||
},
|
||||
})
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
|
||||
mcp,
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
|
||||
].join('\n'))
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
|
||||
})
|
||||
|
||||
it('keeps a streamed string MCP duration when arguments and result surround the middle field', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-string-large-'))
|
||||
const path = join(dir, 'rollout.jsonl')
|
||||
const mcp = JSON.stringify({
|
||||
type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
|
||||
duration: '3s',
|
||||
result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
|
||||
},
|
||||
})
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
|
||||
mcp,
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
|
||||
].join('\n'))
|
||||
const points = await readCodexThroughput(path)
|
||||
expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
187
tests/daily-cache-degraded-completeness.test.ts
Normal file
187
tests/daily-cache-degraded-completeness.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, rm } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import type { DateRange, ProjectSummary } from '../src/types.js'
|
||||
|
||||
import {
|
||||
DAILY_CACHE_VERSION,
|
||||
type DailyCache,
|
||||
type DailyEntry,
|
||||
type ProviderDaySlice,
|
||||
currentTzKey,
|
||||
ensureCacheHydrated,
|
||||
saveDailyCache,
|
||||
} from '../src/daily-cache.js'
|
||||
|
||||
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-degraded-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
|
||||
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (existsSync(TMP_CACHE_ROOT)) {
|
||||
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function slice(cost: number, calls: number, extra: Partial<ProviderDaySlice> = {}): ProviderDaySlice {
|
||||
return { cost, calls, savingsUSD: 0, ...extra }
|
||||
}
|
||||
|
||||
function day(date: string, providers: Record<string, ProviderDaySlice>, overrides: Partial<DailyEntry> = {}): DailyEntry {
|
||||
const cost = Object.values(providers).reduce((s, p) => s + p.cost, 0)
|
||||
const calls = Object.values(providers).reduce((s, p) => s + p.calls, 0)
|
||||
return {
|
||||
date,
|
||||
cost,
|
||||
savingsUSD: 0,
|
||||
calls,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 0,
|
||||
oneShotTurns: 0,
|
||||
models: {},
|
||||
categories: {},
|
||||
providers,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function daysAgoStr(n: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const noSessions = async (): Promise<ProjectSummary[]> => []
|
||||
|
||||
/// The day whose session files are long gone: it exists in the daily cache and
|
||||
/// nowhere else, so every path below must still hand it back untouched.
|
||||
const VANISHED = day(daysAgoStr(40), { claude: slice(399.70, 1572) }, { carried: true })
|
||||
|
||||
async function seed(overrides: Partial<DailyCache> = {}): Promise<void> {
|
||||
await saveDailyCache({
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: 'cfg-A',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(4),
|
||||
days: [VANISHED, day(daysAgoStr(4), { claude: slice(120, 900) })],
|
||||
complete: true,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/** The vanished-sources day is still there, with its original accounting. */
|
||||
function expectPreserved(cache: DailyCache): void {
|
||||
const kept = cache.days.find(d => d.date === VANISHED.date)
|
||||
expect(kept).toMatchObject({ cost: 399.70, calls: 1572 })
|
||||
expect(kept!.providers['claude']!.cost).toBe(399.70)
|
||||
}
|
||||
|
||||
describe('daily cache: a degraded session parse never finalizes history', () => {
|
||||
it('does not publish complete, and does not advance the watermark past what it covered', async () => {
|
||||
await seed()
|
||||
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
|
||||
// The parse covered nothing it can vouch for, so the watermark stays put:
|
||||
// advancing it to yesterday would put the missed days behind gapStart
|
||||
// (lastComputedDate + 1) forever.
|
||||
expect(out.lastComputedDate).toBe(daysAgoStr(4))
|
||||
expect(out.complete).toBe(false)
|
||||
expectPreserved(out)
|
||||
})
|
||||
|
||||
it('does not advance the watermark on the full re-derive path either', async () => {
|
||||
await seed({ complete: false })
|
||||
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
|
||||
expect(out.lastComputedDate).toBe(daysAgoStr(4))
|
||||
expect(out.complete).toBe(false)
|
||||
expectPreserved(out)
|
||||
})
|
||||
|
||||
it('a later healthy run rebuilds the days the degraded run missed', async () => {
|
||||
await seed()
|
||||
await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
|
||||
const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) }))
|
||||
const healed = await ensureCacheHydrated(noSessions, () => missed, 'cfg-A', () => true)
|
||||
expect(healed.days.map(d => d.date)).toEqual([
|
||||
daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1),
|
||||
])
|
||||
expect(healed.lastComputedDate).toBe(daysAgoStr(1))
|
||||
expect(healed.complete).toBe(true)
|
||||
expectPreserved(healed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('daily cache: a complete cache that outruns its own data is not trusted', () => {
|
||||
it('re-derives the days between the newest entry and the watermark', async () => {
|
||||
// The field artifact: complete: true, lastComputedDate yesterday, entries
|
||||
// stopping four days earlier — written by a run that finalized off a parse
|
||||
// which never covered those days.
|
||||
await seed({ lastComputedDate: daysAgoStr(1) })
|
||||
const ranges: DateRange[] = []
|
||||
const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) }))
|
||||
const out = await ensureCacheHydrated(
|
||||
async (range) => { ranges.push(range); return [] },
|
||||
() => missed,
|
||||
'cfg-A',
|
||||
() => true,
|
||||
)
|
||||
expect(ranges).toHaveLength(1)
|
||||
expect(out.days.map(d => d.date)).toEqual([
|
||||
daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1),
|
||||
])
|
||||
expect(out.days.find(d => d.date === daysAgoStr(2))!.cost).toBe(20)
|
||||
expect(out.complete).toBe(true)
|
||||
expectPreserved(out)
|
||||
})
|
||||
|
||||
it('trusts a stamped watermark over an idle tail — no re-derive treadmill', async () => {
|
||||
// Same shape as the corrupt case above (watermark past the newest populated
|
||||
// day), but stamped by a COMPLETE parse: the recent days are genuinely
|
||||
// empty, not a frozen hole. A degraded parse can no longer produce this
|
||||
// state, so the stamp means the watermark is trustworthy and re-deriving the
|
||||
// empty tail on every launch (the perf regression) must not happen.
|
||||
await seed({ lastComputedDate: daysAgoStr(1), watermarkTrusted: true })
|
||||
let parses = 0
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => { parses += 1; return [] },
|
||||
() => [],
|
||||
'cfg-A',
|
||||
() => true,
|
||||
)
|
||||
expect(parses).toBe(0)
|
||||
expect(out.lastComputedDate).toBe(daysAgoStr(1))
|
||||
expect(out.complete).toBe(true)
|
||||
expectPreserved(out)
|
||||
})
|
||||
|
||||
it('a degraded re-derivation of those days still keeps every carried day', async () => {
|
||||
await seed({ lastComputedDate: daysAgoStr(1) })
|
||||
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
|
||||
expect(out.complete).toBe(false)
|
||||
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(4)])
|
||||
expectPreserved(out)
|
||||
})
|
||||
|
||||
it('an empty cache still finalizes — no re-parse treadmill on a machine with no history', async () => {
|
||||
await seed({ days: [], lastComputedDate: daysAgoStr(1) })
|
||||
let parses = 0
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => { parses += 1; return [] },
|
||||
() => [],
|
||||
'cfg-A',
|
||||
() => true,
|
||||
)
|
||||
expect(parses).toBe(0)
|
||||
expect(out.lastComputedDate).toBe(daysAgoStr(1))
|
||||
expect(out.complete).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,10 +74,12 @@ function makeSingleTurnProject(
|
|||
}
|
||||
|
||||
describe('aggregateProjectsIntoDays', () => {
|
||||
it('buckets a whole turn (all its calls) on the turn user-message date', () => {
|
||||
// Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly
|
||||
// on the day of its user-message timestamp — matching the live headline/
|
||||
// report rollup — instead of splitting per-call across two days.
|
||||
it("buckets call-derived values under each call's own date when a turn straddles midnight", () => {
|
||||
// Per-call bucketing (issue #852): a turn whose calls straddle midnight
|
||||
// puts each call's cost/calls/tokens on the day the call happened, so
|
||||
// day-N + day-N+1 reconcile with a range parse that sliced the turn at
|
||||
// the same boundary. Turn-level judgments (editTurns, category turns)
|
||||
// stay anchored on the turn's day.
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
|
|
@ -116,9 +118,17 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
]
|
||||
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-09'])
|
||||
expect(days[0]!.cost).toBe(10)
|
||||
expect(days[0]!.calls).toBe(2)
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
|
||||
expect(days[0]!.cost).toBe(4)
|
||||
expect(days[0]!.calls).toBe(1)
|
||||
expect(days[1]!.cost).toBe(6)
|
||||
expect(days[1]!.calls).toBe(1)
|
||||
// Turn-level stats anchor on the turn's day only — they describe the
|
||||
// whole exchange, not a per-call sum.
|
||||
expect(days[0]!.editTurns).toBe(1)
|
||||
expect(days[1]!.editTurns).toBe(0)
|
||||
expect(days[0]!.categories['coding']?.turns).toBe(1)
|
||||
expect(days[1]!.categories['coding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
|
||||
|
|
@ -406,17 +416,17 @@ describe('buildPeriodDataFromDays', () => {
|
|||
expect(pd.models).toEqual([])
|
||||
})
|
||||
|
||||
it('attributes a midnight-straddling turn to the user-message date, matching the live report', () => {
|
||||
// A turn whose user message sits on one side of midnight and whose assistant
|
||||
// response lands on the other must bucket by the USER-MESSAGE timestamp, so
|
||||
// the daily cache (history.daily + provider breakdown) reconciles exactly to
|
||||
// the live headline/report rollup (main.ts daily), which anchors on the same
|
||||
// turn timestamp. The prior per-call bucketing split such turns and left a
|
||||
// constant offset between the trend bars and current.cost.
|
||||
it("attributes a midnight-straddling turn's cost to the call's own date", () => {
|
||||
// A turn whose user message sits on one side of midnight and whose
|
||||
// assistant response lands on the other buckets its cost under the CALL's
|
||||
// day (issue #852's per-call rule), so the daily cache (history.daily +
|
||||
// provider breakdown) reconciles exactly to a range parse that slices the
|
||||
// same turn at the same boundary — and day-N + day-N+1 sum to the period
|
||||
// total with nothing lost on either side.
|
||||
const userTs = '2026-04-20T23:58:00Z'
|
||||
const assistantTs = '2026-04-21T00:30:00Z'
|
||||
const userLocal = new Date(userTs)
|
||||
const expectedDate = `${userLocal.getFullYear()}-${String(userLocal.getMonth() + 1).padStart(2, '0')}-${String(userLocal.getDate()).padStart(2, '0')}`
|
||||
const assistantLocal = new Date(assistantTs)
|
||||
const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}`
|
||||
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
|
|
@ -457,21 +467,22 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
|
|||
// headline (main.ts daily rollup) must bucket days by the SAME rule, or their
|
||||
// per-day totals drift and their period sums diverge from current.cost at
|
||||
// window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both
|
||||
// are now TURN-anchored: this asserts per-day equality against a reference
|
||||
// that mirrors main.ts:486-499 (turn.timestamp anchor), plus the invariant
|
||||
// history.daily Σ == report.daily Σ == total call cost.
|
||||
// are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats
|
||||
// still turn-anchored: this asserts per-day equality against a reference
|
||||
// that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its
|
||||
// own date), plus the invariant history.daily Σ == report.daily Σ == total
|
||||
// call cost.
|
||||
|
||||
// Mirrors the live report/headline daily rollup in src/main.ts (bucket the
|
||||
// whole turn — all its calls — on the turn's user-message date).
|
||||
// Mirrors the live report/headline daily rollup fallback in src/main.ts
|
||||
// (cost/savings/calls bucket under each call's own date).
|
||||
function reportDailyByDate(projects: ProjectSummary[]): Record<string, number> {
|
||||
const byDate: Record<string, number> = {}
|
||||
for (const p of projects) {
|
||||
for (const sess of p.sessions) {
|
||||
for (const turn of sess.turns) {
|
||||
if (turn.assistantCalls.length === 0) continue
|
||||
const ts = turn.timestamp || turn.assistantCalls[0]!.timestamp
|
||||
const day = dateKey(ts)
|
||||
for (const call of turn.assistantCalls) byDate[day] = (byDate[day] ?? 0) + call.costUSD
|
||||
for (const call of turn.assistantCalls) {
|
||||
byDate[dateKey(call.timestamp)] = (byDate[dateKey(call.timestamp)] ?? 0) + call.costUSD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -492,8 +503,8 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
|
|||
expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight
|
||||
|
||||
// A midnight-straddling turn (calls on both days) plus a same-day turn, so
|
||||
// per-CALL bucketing would produce DIFFERENT per-day totals than the turn-
|
||||
// anchored report — the case the old code got wrong.
|
||||
// whole-TURN anchoring would produce DIFFERENT per-day totals than the
|
||||
// per-call rule — the case the old code got wrong.
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
|
|
@ -541,8 +552,10 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
|
|||
const totalCallCost = 2 + 3 + 7
|
||||
expect(historySum).toBeCloseTo(totalCallCost, 10)
|
||||
expect(reportSum).toBeCloseTo(totalCallCost, 10)
|
||||
// Day A owns the WHOLE straddling turn (2+3=5), not just its first call (2).
|
||||
expect(historyByDate[dayA]).toBe(5)
|
||||
expect(historyByDate[dayB]).toBe(7)
|
||||
// Day A owns only the straddling turn's pre-midnight call (2); day B owns
|
||||
// the post-midnight call plus the same-day turn (3+7=10). Both paths agree
|
||||
// per day and the period total is conserved.
|
||||
expect(historyByDate[dayA]).toBe(2)
|
||||
expect(historyByDate[dayB]).toBe(10)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
22
tests/fixtures/cache-refresh-corrupt-owner.ts
vendored
Normal file
22
tests/fixtures/cache-refresh-corrupt-owner.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js'
|
||||
|
||||
// A plain owner in its own process. It records its outcome, its token, and the
|
||||
// result of the publication fence into the barrier directory so the parent can
|
||||
// assert what the owner itself believed while a contender was racing it.
|
||||
const [cacheDir, barrierDir, holdMs, heartbeatMs = '200'] = process.argv.slice(2)
|
||||
if (!cacheDir || !barrierDir || !holdMs) throw new Error('missing owner argument')
|
||||
|
||||
const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: Number(heartbeatMs) })
|
||||
if (refresh.outcome !== 'acquired') {
|
||||
await writeFile(join(barrierDir, `owner.${refresh.outcome}`), '')
|
||||
process.exit(0)
|
||||
}
|
||||
await writeFile(join(barrierDir, 'owner.acquired'), refresh.handle.token)
|
||||
await new Promise(resolve => { setTimeout(resolve, Number(holdMs)) })
|
||||
// The publication fence, exactly as parser.ts uses it before saving.
|
||||
await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '')
|
||||
await refresh.handle.release()
|
||||
await writeFile(join(barrierDir, 'owner.done'), '')
|
||||
25
tests/fixtures/cache-refresh-slow-owner.ts
vendored
Normal file
25
tests/fixtures/cache-refresh-slow-owner.ts
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { pbkdf2 } from 'crypto'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js'
|
||||
|
||||
// Saturate the (size-1) libuv threadpool so every fs operation inside
|
||||
// createExclusive queues behind a pbkdf2 round. No test hook and no patched
|
||||
// module: this is what an ordinary process looks like mid cold parse, and it
|
||||
// widens the window in which the lock exists at zero bytes -- between
|
||||
// open(path,'wx') and the awaited body write -- to something observable.
|
||||
const [cacheDir, barrierDir] = process.argv.slice(2)
|
||||
if (!cacheDir || !barrierDir) throw new Error('missing owner argument')
|
||||
|
||||
let stop = false
|
||||
const churn = (): void => { if (stop) return; pbkdf2('p', 's', 400_000, 32, 'sha512', () => churn()) }
|
||||
churn()
|
||||
|
||||
const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: 10_000 })
|
||||
stop = true
|
||||
await writeFile(join(barrierDir, `owner.${refresh.outcome}`), refresh.outcome === 'acquired' ? refresh.handle.token : '')
|
||||
if (refresh.outcome === 'acquired') {
|
||||
await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '')
|
||||
await refresh.handle.release()
|
||||
}
|
||||
17
tests/fixtures/mock-idp.ts
vendored
17
tests/fixtures/mock-idp.ts
vendored
|
|
@ -34,6 +34,8 @@ export interface MockIdp {
|
|||
revokedTokens: string[]
|
||||
/** Authorization codes that have been exchanged */
|
||||
exchangedCodes: string[]
|
||||
/** OTLP trace batches received at POST /v1/traces */
|
||||
tracesRequests: Array<{ auth: string | undefined; body: unknown }>
|
||||
}
|
||||
|
||||
export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp> {
|
||||
|
|
@ -52,6 +54,7 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp>
|
|||
issuedTokens: { access: [], refresh: [] },
|
||||
revokedTokens: [],
|
||||
exchangedCodes: [],
|
||||
tracesRequests: [],
|
||||
close: async () => {},
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +62,20 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp>
|
|||
const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`)
|
||||
const path = url.pathname
|
||||
|
||||
// --- OTLP traces collector (records batches for push tests) ---
|
||||
if (path === '/v1/traces' && req.method === 'POST') {
|
||||
let body = ''
|
||||
req.on('data', chunk => { body += chunk })
|
||||
req.on('end', () => {
|
||||
let parsed: unknown = null
|
||||
try { parsed = JSON.parse(body || '{}') } catch { /* keep null */ }
|
||||
state.tracesRequests.push({ auth: req.headers.authorization, body: parsed })
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end('{}')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// --- Discovery doc ---
|
||||
if (path === '/.well-known/codeburn-export.json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
|
|
|
|||
|
|
@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => {
|
|||
expect(payload.current.topModels[0].name).toBe('Model0')
|
||||
})
|
||||
|
||||
it('resolves raw model ids to display names in topModels and merges rows that collapse', () => {
|
||||
// Day entries key models by the raw wire id (day-aggregator); the menubar
|
||||
// must show friendly names and merge ids that share one (k3 + kimi-k3).
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: [],
|
||||
models: [
|
||||
{ name: 'k3', cost: 2.5, calls: 78 },
|
||||
{ name: 'kimi-k3', cost: 0.5, calls: 2 },
|
||||
{ name: 'kimi-for-coding', cost: 0.06, calls: 13 },
|
||||
{ name: 'k3-agent', cost: 1.2, calls: 40 },
|
||||
],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')!
|
||||
expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2)
|
||||
expect(kimiK3.calls).toBe(78 + 2 + 40)
|
||||
const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')!
|
||||
expect(k2.cost).toBeCloseTo(0.06)
|
||||
expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined()
|
||||
expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('caps topActivities at 20 so all task categories can surface', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => {
|
|||
const local = payload.current.topModels.find(m => m.name === 'Local Model')!
|
||||
expect(local.savingsUSD).toBe(5)
|
||||
expect(local.cost).toBe(0)
|
||||
const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')!
|
||||
const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')!
|
||||
expect(paid.savingsUSD).toBe(0)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -672,6 +672,9 @@ describe('observed provider model aliases', () => {
|
|||
const cases: Array<[string, string]> = [
|
||||
['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'],
|
||||
['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'],
|
||||
// Kimi Code wires report bare `k3` in llm.request.model; it must price
|
||||
// through the kimi-k3 table entry, not fall through to $0.
|
||||
['k3', 'kimi-k3'],
|
||||
]
|
||||
|
||||
for (const [input, expectedModel] of cases) {
|
||||
|
|
@ -685,6 +688,10 @@ describe('observed provider model aliases', () => {
|
|||
})
|
||||
}
|
||||
|
||||
it('k3 shows the Kimi K3 display name', () => {
|
||||
expect(getShortModelName('k3')).toBe('Kimi K3')
|
||||
})
|
||||
|
||||
it('does not map dated Qwen3 Max to a reseller price without provider context', () => {
|
||||
expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull()
|
||||
expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -488,3 +488,114 @@ describe('(g) skill attribution is independent of turn category', () => {
|
|||
expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (h) Provider filter isolates claude: a --provider <other> run must not
|
||||
// re-surface cached claude sessions through the orphan pass, while a run
|
||||
// that DOES include claude still preserves PR-bearing orphans.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(h) provider filter excludes claude from the orphan pass', () => {
|
||||
const SYNTH_SOURCE = (path: string): SessionSource[] =>
|
||||
[{ path, project: 'synth-proj', provider: 'test-synthetic' }]
|
||||
|
||||
// The provider lives on each parsed call, not on SessionSummary.
|
||||
const providersOf = (projects: Awaited<ReturnType<typeof parseAllSessions>>): Set<string> =>
|
||||
new Set(projects
|
||||
.flatMap(p => p.sessions)
|
||||
.flatMap(s => s.turns)
|
||||
.flatMap(t => t.assistantCalls)
|
||||
.map(c => c.provider))
|
||||
|
||||
const SYNTH_CALL: ParsedProviderCall = {
|
||||
provider: 'test-synthetic', model: 'gpt-4o',
|
||||
inputTokens: 10, outputTokens: 5,
|
||||
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costUSD: 0.25, tools: [], bashCommands: [],
|
||||
skills: [],
|
||||
timestamp: '2026-07-18T12:00:00.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'synth-isolation-call',
|
||||
userMessage: '', sessionId: 'synth-isolation-session',
|
||||
}
|
||||
|
||||
// A claude transcript carrying a pr-link: `prLinks` is exactly what lets a
|
||||
// cached entry survive the write-mode orphan gate, so it is the shape that
|
||||
// leaks. Cost is deliberately far larger than the synthetic call's, so a leak
|
||||
// is unmistakable rather than a rounding difference.
|
||||
async function writeClaudeSessionWithPrLink(): Promise<string> {
|
||||
const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const filePath = join(projectDir, 'session.jsonl')
|
||||
await writeFile(filePath, [
|
||||
JSON.stringify({
|
||||
type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z',
|
||||
message: { role: 'user', content: 'ship it' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z',
|
||||
message: {
|
||||
id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
usage: { input_tokens: 900_000, output_tokens: 90_000 },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z',
|
||||
prUrl: 'https://github.com/getagentseal/codeburn/pull/1',
|
||||
}),
|
||||
].join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
it('does not surface cached claude sessions when filtering to another provider', async () => {
|
||||
const synthFile = join(tmpHome, 'synth-isolation.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
await writeClaudeSessionWithPrLink()
|
||||
|
||||
_synthSources = SYNTH_SOURCE(synthFile)
|
||||
_synthYields = [SYNTH_CALL]
|
||||
|
||||
// Baseline: what the synthetic provider costs on its own, before anything
|
||||
// claude-shaped has ever entered the session cache. Self-calibrating, since
|
||||
// cost is re-derived from tokens by the pricing engine.
|
||||
const baseline = await parseAllSessions(undefined, 'test-synthetic')
|
||||
const synthOnlyCost = totalCost(baseline)
|
||||
expect([...providersOf(baseline)]).toEqual(['test-synthetic'])
|
||||
clearSessionCache()
|
||||
|
||||
// Warm the session cache so the claude file is persisted WITH its prLinks.
|
||||
const all = await parseAllSessions(undefined, 'all')
|
||||
expect(providersOf(all)).toContain('claude')
|
||||
expect(totalCost(all)).toBeGreaterThan(synthOnlyCost)
|
||||
|
||||
clearSessionCache()
|
||||
|
||||
// Filtering to the synthetic provider must yield ONLY its own spend. Before
|
||||
// the fix, claudeDirs was empty yet scanProjectDirs still ran, so every
|
||||
// cached PR-bearing claude file was treated as a pruned orphan and re-added.
|
||||
const filtered = await parseAllSessions(undefined, 'test-synthetic')
|
||||
|
||||
expect([...providersOf(filtered)]).toEqual(['test-synthetic'])
|
||||
expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10)
|
||||
})
|
||||
|
||||
it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => {
|
||||
const filePath = await writeClaudeSessionWithPrLink()
|
||||
_synthSources = []
|
||||
_synthYields = []
|
||||
|
||||
const before = await parseAllSessions(undefined, 'all')
|
||||
const costBefore = totalCost(before)
|
||||
expect(costBefore).toBeGreaterThan(0)
|
||||
|
||||
// Every claude transcript disappears from disk. Claude is still in scope, so
|
||||
// the orphan pass must keep the PR-attributed spend alive — this is the case
|
||||
// a naive `claudeDirs.length > 0` guard would silently break.
|
||||
await unlink(filePath)
|
||||
clearSessionCache()
|
||||
|
||||
const after = await parseAllSessions(undefined, 'all')
|
||||
expect(totalCost(after)).toBeCloseTo(costBefore, 10)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
374
tests/project-filter-durable-totals.test.ts
Normal file
374
tests/project-filter-durable-totals.test.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
|
||||
import { renderOverview } from '../src/overview.js'
|
||||
import type { DateRange } from '../src/types.js'
|
||||
|
||||
// The durable headline (overview / report Overview panel / menubar current) is
|
||||
// built from the carry-forward daily cache unioned with today's live parse, so
|
||||
// days whose session files expired still count. Historical days were sliced to
|
||||
// the requested PROVIDER but never to the requested PROJECT, so
|
||||
// `--project` / `--exclude` were silently ignored for every day except today:
|
||||
// the Overview total counted excluded projects while every detail panel (By
|
||||
// Project / By Activity / By Model, all built from the name-filtered live
|
||||
// parse) left them out, and the two panels could not be reconciled.
|
||||
//
|
||||
// Per-project day stats exist in the cache since v15 (DailyEntry.projects), so
|
||||
// the historical days CAN be sliced — that is what these tests pin down.
|
||||
|
||||
const ROOT = join(tmpdir(), `codeburn-project-filter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
// One carried day, split across two projects. The day totals are the sum, so a
|
||||
// correct project slice returns strictly less than the whole day.
|
||||
const KEEP = { cost: 30, calls: 10, sessions: 1 }
|
||||
const DROP = { cost: 70, calls: 30, sessions: 2 }
|
||||
const DAY_COST = KEEP.cost + DROP.cost
|
||||
const DAY_CALLS = KEEP.calls + DROP.calls
|
||||
const DAY_SESSIONS = KEEP.sessions + DROP.sessions
|
||||
|
||||
function daysAgoStr(n: number): string {
|
||||
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** A day only the cache holds (sources aged out), split across two projects. */
|
||||
function carriedDayWithProjects(date: string): DailyEntry {
|
||||
const projects = {
|
||||
'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' },
|
||||
'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' },
|
||||
}
|
||||
return {
|
||||
date,
|
||||
cost: DAY_COST,
|
||||
savingsUSD: 0,
|
||||
calls: DAY_CALLS,
|
||||
sessions: DAY_SESSIONS,
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 4,
|
||||
oneShotTurns: 2,
|
||||
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
||||
providers: {
|
||||
claude: {
|
||||
calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, sessions: DAY_SESSIONS,
|
||||
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 4, oneShotTurns: 2,
|
||||
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
||||
projects,
|
||||
},
|
||||
},
|
||||
projects,
|
||||
carried: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** A carried day recorded before v15: totals, but no per-project split at all. */
|
||||
function carriedDayWithoutProjects(date: string): DailyEntry {
|
||||
const day = carriedDayWithProjects(date)
|
||||
delete day.projects
|
||||
delete day.providers['claude']!.projects
|
||||
return day
|
||||
}
|
||||
|
||||
async function seedCache(...days: DailyEntry[]): Promise<void> {
|
||||
const cache: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: getDailyCacheConfigHash(),
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(1),
|
||||
days,
|
||||
complete: true,
|
||||
}
|
||||
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
|
||||
}
|
||||
|
||||
/** A real, priced Claude session dated TODAY, under project dir `live-proj`. */
|
||||
async function seedLiveTodaySession(): Promise<void> {
|
||||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'live-proj')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
// Timestamps a few minutes OLD, clamped into today: a wall-clock hour (12:00)
|
||||
// is in the future whenever the suite runs before noon, and the periods here
|
||||
// end at `new Date()`, so a fixed hour made the live half of the union vanish
|
||||
// for a morning run. Relative-and-clamped keeps the session inside today and
|
||||
// in the past at every hour.
|
||||
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const minutesAgo = (m: number): string => new Date(Math.max(midnight, now.getTime() - m * 60_000)).toISOString()
|
||||
const line = (id: string, t: string): string => JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: t,
|
||||
sessionId: 's-today',
|
||||
message: {
|
||||
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
|
||||
content: [],
|
||||
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
|
||||
},
|
||||
})
|
||||
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', minutesAgo(10)), line('m2', minutesAgo(5))].join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/** What the name-filtered live parse alone reports — the By Project panel's source. */
|
||||
async function liveOnly(range: DateRange, include: string[], exclude: string[]): Promise<{ cost: number; calls: number }> {
|
||||
clearSessionCache()
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), include, exclude)
|
||||
const data = buildPeriodData('live', projects)
|
||||
return { cost: data.cost, calls: data.calls }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPricing()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
||||
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
|
||||
await mkdir(join(ROOT, 'cache'), { recursive: true })
|
||||
process.env['HOME'] = join(ROOT, 'home')
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
|
||||
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
|
||||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
delete process.env['CODEX_HOME']
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearSessionCache()
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = savedEnv[k]
|
||||
}
|
||||
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// A fixed 20-day window back from now: it always spans the carried day (10 days
|
||||
// ago) and today, so the period never depends on where "now" falls in the
|
||||
// calendar. A real `month` range drops the 10-days-ago day whenever today is
|
||||
// within 10 days of the 1st, which made these tests flake early in each month.
|
||||
const coveringRange = (): DateRange => ({
|
||||
start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
|
||||
end: new Date(),
|
||||
})
|
||||
|
||||
describe('durable headline honours --project / --exclude on carried days', () => {
|
||||
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
|
||||
// The headline counts the surviving sessions plus ONLY the kept project's
|
||||
// carried cost — not the whole carried day.
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
|
||||
// And it reconciles with the detail panels, which see the same live parse.
|
||||
const byProject = durable.liveProjects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
expect(durable.data.cost).toBeCloseTo(byProject + KEEP.cost, 6)
|
||||
})
|
||||
|
||||
it('keeps only the named project when --project is given', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, ['keep-me'], [])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', project: ['keep-me'] })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
|
||||
})
|
||||
|
||||
it('matches a filter against the project path as well as its name', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/Users/gone/drop-me'] })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||
})
|
||||
|
||||
it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
|
||||
// A project directory can legitimately be named "constructor"/"valueOf"/etc.
|
||||
// The day cache must carry it as an own key: if the load path drops it, the
|
||||
// day's per-project split no longer sums to the day cost, and the sliced
|
||||
// headline silently loses that project's spend.
|
||||
const day = carriedDayWithProjects(daysAgoStr(10))
|
||||
const protoName = 'constructor'
|
||||
Object.defineProperty(day.projects!, protoName, {
|
||||
value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
|
||||
enumerable: true, writable: true, configurable: true,
|
||||
})
|
||||
day.cost += 25
|
||||
day.calls += 5
|
||||
await seedCache(day)
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
// Keep everything (exclude a non-matching term): the constructor project's
|
||||
// $25 must be present alongside keep-me and drop-me.
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
|
||||
const live = await liveOnly(range, [], ['zzz-nomatch'])
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
|
||||
expect(durable.unattributedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('contributes nothing from a carried day whose every project is excluded', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['keep-me', 'drop-me'] })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
|
||||
expect(durable.carriedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the project filter underneath a provider filter', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
// Stated as the delta the filter must produce, so the assertion holds
|
||||
// whatever the provider-scoped live parse contributes.
|
||||
clearSessionCache()
|
||||
const unfiltered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude' })
|
||||
clearSessionCache()
|
||||
const filtered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
|
||||
|
||||
expect(unfiltered.data.cost - filtered.data.cost).toBeCloseTo(DROP.cost, 6)
|
||||
expect(unfiltered.data.calls - filtered.data.calls).toBe(DROP.calls)
|
||||
})
|
||||
|
||||
it('keeps the menubar payload in step with the report under a project filter', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
clearSessionCache()
|
||||
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
|
||||
// Both surfaces route the headline through the one shared builder, so a
|
||||
// project filter must land on them identically.
|
||||
expect(menubar.current.cost).toBe(durable.data.cost)
|
||||
expect(menubar.current.calls).toBe(durable.data.calls)
|
||||
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
|
||||
})
|
||||
|
||||
it('leaves the unfiltered headline exactly as it was', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], [])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
|
||||
expect(durable.data.calls).toBe(live.calls + DAY_CALLS)
|
||||
expect(durable.carriedCostUSD).toBeCloseTo(DAY_COST, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carried days with no per-project split (pre-v15)', () => {
|
||||
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
|
||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
|
||||
// The day cannot be attributed to any project, so a project-filtered total
|
||||
// cannot claim it. It is reported separately rather than silently folded in.
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
|
||||
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
|
||||
})
|
||||
|
||||
it('reports a provider slice with no project split as unattributed too', async () => {
|
||||
// A v14-era provider slice carried into a v15 day: the day knows its project
|
||||
// split, that provider's slice does not. Under --provider the headline reads
|
||||
// the slice, so the day drops out — say how much rather than lose it quietly.
|
||||
const day = carriedDayWithProjects(daysAgoStr(10))
|
||||
delete day.providers['claude']!.projects
|
||||
await seedCache(day)
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
|
||||
|
||||
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
|
||||
expect(durable.carriedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('says so in the overview instead of just showing a short total', async () => {
|
||||
// Two cached days: one that cannot be attributed (the footnote's subject) and
|
||||
// one that can. The attributable day keeps the headline non-zero from the
|
||||
// CACHE alone, so the assertion tests the footnote rather than depending on
|
||||
// the live parse to keep renderOverview off its "No usage found" path.
|
||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)), carriedDayWithProjects(daysAgoStr(5)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
expect(durable.unattributedCostUSD).toBeGreaterThan(0)
|
||||
expect(durable.data.cost).toBeGreaterThan(0)
|
||||
|
||||
const rendered = renderOverview(durable.liveProjects, {
|
||||
label: 'This month',
|
||||
color: false,
|
||||
durable: {
|
||||
cost: durable.data.cost,
|
||||
savingsUSD: durable.data.savingsUSD,
|
||||
calls: durable.data.calls,
|
||||
sessions: durable.data.sessions,
|
||||
inputTokens: durable.data.inputTokens,
|
||||
outputTokens: durable.data.outputTokens,
|
||||
cacheReadTokens: durable.data.cacheReadTokens,
|
||||
cacheWriteTokens: durable.data.cacheWriteTokens,
|
||||
days: durable.days,
|
||||
carriedCostUSD: durable.carriedCostUSD,
|
||||
unattributedCostUSD: durable.unattributedCostUSD,
|
||||
},
|
||||
})
|
||||
expect(rendered).toContain('no per-project history')
|
||||
})
|
||||
|
||||
it('still counts the day in full when no project filter is active', async () => {
|
||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], [])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
|
||||
expect(durable.unattributedCostUSD).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -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']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { delimiter as pathDelimiter, join } from 'path'
|
||||
import { delimiter as pathDelimiter, join, resolve, sep } from 'path'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { claude, getDesktopSessionsDir } from '../../src/providers/claude.js'
|
||||
import { claude, getDesktopSessionsDirs } from '../../src/providers/claude.js'
|
||||
import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js'
|
||||
|
||||
let tmpRoot: string
|
||||
|
|
@ -13,6 +13,7 @@ const savedEnv = {
|
|||
CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'],
|
||||
CODEBURN_DESKTOP_SESSIONS_DIR: process.env['CODEBURN_DESKTOP_SESSIONS_DIR'],
|
||||
APPDATA: process.env['APPDATA'],
|
||||
LOCALAPPDATA: process.env['LOCALAPPDATA'],
|
||||
HOME: process.env['HOME'],
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ beforeEach(async () => {
|
|||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
|
||||
delete process.env['APPDATA']
|
||||
delete process.env['LOCALAPPDATA']
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -218,30 +220,154 @@ describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => {
|
|||
})
|
||||
|
||||
describe('claude provider — Desktop sessions dir', () => {
|
||||
it('uses APPDATA as the Windows Claude Desktop sessions root', () => {
|
||||
async function makeMsixSessionsDir(localAppData: string, packageName: string): Promise<string> {
|
||||
const sessionsDir = join(
|
||||
localAppData,
|
||||
'Packages',
|
||||
packageName,
|
||||
'LocalCache',
|
||||
'Roaming',
|
||||
'Claude',
|
||||
'local-agent-mode-sessions',
|
||||
)
|
||||
await mkdir(sessionsDir, { recursive: true })
|
||||
return resolve(sessionsDir)
|
||||
}
|
||||
|
||||
it('returns the classic Windows root first and discovers an MSIX sessions root', async () => {
|
||||
const appData = join(tmpRoot, 'roaming-profile')
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
const classic = join(appData, 'Claude', 'local-agent-mode-sessions')
|
||||
const msix = await makeMsixSessionsDir(localAppData, 'Claude_fresh9k2m')
|
||||
await mkdir(classic, { recursive: true })
|
||||
process.env['APPDATA'] = appData
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([resolve(classic), msix])
|
||||
})
|
||||
})
|
||||
|
||||
it('filters MSIX package names and keeps both supported Claude name shapes', async () => {
|
||||
const appData = join(tmpRoot, 'roaming-profile')
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
const classic = resolve(join(appData, 'Claude', 'local-agent-mode-sessions'))
|
||||
const direct = await makeMsixSessionsDir(localAppData, 'Claude_new7v4p')
|
||||
const publisher = await makeMsixSessionsDir(localAppData, 'SomePublisher.Claude_q8w3e')
|
||||
await makeMsixSessionsDir(localAppData, 'ClaudeXtra_foo')
|
||||
await makeMsixSessionsDir(localAppData, 'NotClaude_x')
|
||||
process.env['APPDATA'] = appData
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([classic, direct, publisher])
|
||||
})
|
||||
})
|
||||
|
||||
it('returns only the classic root when LOCALAPPDATA is unset and Packages is missing', () => {
|
||||
const appData = join(tmpRoot, 'roaming-profile')
|
||||
process.env['APPDATA'] = appData
|
||||
delete process.env['LOCALAPPDATA']
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDir()).toBe(join(appData, 'Claude', 'local-agent-mode-sessions'))
|
||||
expect(() => getDesktopSessionsDirs()).not.toThrow()
|
||||
expect(getDesktopSessionsDirs()).toEqual([
|
||||
resolve(join(appData, 'Claude', 'local-agent-mode-sessions')),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the legacy Windows roaming profile path when APPDATA is unset', () => {
|
||||
it('falls back to the legacy Windows roaming path when APPDATA is unset or empty', () => {
|
||||
const expected = resolve(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions'))
|
||||
delete process.env['APPDATA']
|
||||
delete process.env['LOCALAPPDATA']
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDir()).toBe(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions'))
|
||||
expect(getDesktopSessionsDirs()).toEqual([expected])
|
||||
process.env['APPDATA'] = ''
|
||||
expect(getDesktopSessionsDirs()).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps CODEBURN_DESKTOP_SESSIONS_DIR ahead of Windows APPDATA discovery', () => {
|
||||
it('excludes an MSIX package whose sessions subpath is missing', async () => {
|
||||
const appData = join(tmpRoot, 'roaming-profile')
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
await mkdir(join(localAppData, 'Packages', 'Claude_empty6r1t', 'LocalCache'), { recursive: true })
|
||||
process.env['APPDATA'] = appData
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([
|
||||
resolve(join(appData, 'Claude', 'local-agent-mode-sessions')),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('returns exactly the resolved override without platform discovery', async () => {
|
||||
const override = join(tmpRoot, 'desktop-override')
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
await makeMsixSessionsDir(localAppData, 'Claude_shouldnotappear5n8d')
|
||||
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override
|
||||
process.env['APPDATA'] = join(tmpRoot, 'roaming-profile')
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDir()).toBe(override)
|
||||
expect(getDesktopSessionsDirs()).toEqual([resolve(override)])
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves an override containing a parent segment and trailing separator', () => {
|
||||
const override = join(tmpRoot, 'desktop-parent', 'nested') + `${sep}..${sep}sessions${sep}`
|
||||
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([resolve(override)])
|
||||
})
|
||||
})
|
||||
|
||||
it('returns one platform root on darwin and linux without including MSIX packages', async () => {
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
await makeMsixSessionsDir(localAppData, 'Claude_crossplatform3b7j')
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('darwin', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([
|
||||
resolve(join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')),
|
||||
])
|
||||
})
|
||||
withPlatform('linux', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([
|
||||
resolve(join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts two valid MSIX roots by package entry name', async () => {
|
||||
const appData = join(tmpRoot, 'roaming-profile')
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
const later = await makeMsixSessionsDir(localAppData, 'Claude_zulu4c9x')
|
||||
const earlier = await makeMsixSessionsDir(localAppData, 'Alpha.Claude_alpha2m6v')
|
||||
process.env['APPDATA'] = appData
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([
|
||||
resolve(join(appData, 'Claude', 'local-agent-mode-sessions')),
|
||||
earlier,
|
||||
later,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('dedupes classic and MSIX candidates that resolve to the same path', async () => {
|
||||
const localAppData = join(tmpRoot, 'local-profile')
|
||||
const packageName = 'Claude_duplicate8h3s'
|
||||
const sessionsDir = await makeMsixSessionsDir(localAppData, packageName)
|
||||
process.env['APPDATA'] = join(localAppData, 'Packages', packageName, 'LocalCache', 'Roaming')
|
||||
process.env['LOCALAPPDATA'] = localAppData
|
||||
|
||||
withPlatform('win32', () => {
|
||||
expect(getDesktopSessionsDirs()).toEqual([sessionsDir])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -160,6 +160,56 @@ describe('codex provider - session discovery', () => {
|
|||
}])
|
||||
})
|
||||
|
||||
it('deduplicates the same session_id across active and archived roots', async () => {
|
||||
const sharedLines = [
|
||||
sessionMeta({ cwd: '/Users/test/shared', session_id: 'sess-shared' }),
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
]
|
||||
const activePath = await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
|
||||
const archivedCopyPath = await writeArchivedSession(tmpDir, 'rollout-shared.jsonl', sharedLines)
|
||||
const distinctPath = await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
|
||||
sessionMeta({ cwd: '/Users/test/distinct', session_id: 'sess-distinct' }),
|
||||
tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const paths = sessions.map(session => session.path)
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(paths).toEqual(expect.arrayContaining([activePath, distinctPath]))
|
||||
expect(paths).not.toContain(archivedCopyPath)
|
||||
})
|
||||
|
||||
it('does not double-count usage for an archived copy while counting distinct sessions', async () => {
|
||||
const sharedLines = [
|
||||
sessionMeta({ session_id: 'sess-shared' }),
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
]
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
|
||||
await writeArchivedSession(tmpDir, 'rollout-shared-copy.jsonl', sharedLines)
|
||||
await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-distinct' }),
|
||||
tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const seenKeys = new Set<string>()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const session of sessions) {
|
||||
for await (const call of provider.createSessionParser(session, seenKeys).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-distinct', 'sess-shared'])
|
||||
expect(calls.reduce(
|
||||
(total, call) => total + call.inputTokens + call.cachedInputTokens + call.outputTokens + call.reasoningTokens,
|
||||
0,
|
||||
)).toBe(400)
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
|
@ -332,6 +382,89 @@ describe('codex provider - JSONL parsing', () => {
|
|||
expect(call.deduplicationKey).toContain('codex:')
|
||||
})
|
||||
|
||||
it('parses large rollout lines and computes active timing for custom tool calls', async () => {
|
||||
const largeTokenLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:10Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
last_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
|
||||
total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
|
||||
},
|
||||
rate_limits: { filler: 'x'.repeat(40_000) },
|
||||
},
|
||||
})
|
||||
const largeCompleteLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:11Z',
|
||||
payload: { type: 'task_complete', last_agent_message: 'x'.repeat(40_000), duration_ms: 10_000 },
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-timing', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }),
|
||||
userMessage('run the tool'),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
|
||||
largeTokenLine,
|
||||
largeCompleteLine,
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
outputTokens: 100,
|
||||
reasoningTokens: 20,
|
||||
tools: ['Bash'],
|
||||
activeDurationMs: 7000,
|
||||
activeGeneratedTokens: 120,
|
||||
toolWaitMs: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps estimated output parsing for large token lines without usage info', async () => {
|
||||
// Some rollout variants put token_count metadata beyond the compact head
|
||||
// or omit `info` entirely. The line must still reach the character-based
|
||||
// estimate path rather than being interpreted as an empty usage object.
|
||||
const largeTokenLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:01:10Z',
|
||||
payload: { type: 'token_count' },
|
||||
filler: 'x'.repeat(40_000),
|
||||
})
|
||||
const assistantLine = JSON.stringify({
|
||||
type: 'response_item',
|
||||
timestamp: '2026-04-14T10:01:05Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'generated response '.repeat(100) }],
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-estimated-large.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-estimated-large', model: 'gpt-5.5' }),
|
||||
userMessage('summarize the result'),
|
||||
assistantLine,
|
||||
largeTokenLine,
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
model: 'gpt-5.5',
|
||||
costIsEstimated: true,
|
||||
})
|
||||
expect(calls[0]!.outputTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }),
|
||||
|
|
@ -356,6 +489,154 @@ describe('codex provider - JSONL parsing', () => {
|
|||
expect(calls[0]!.tools).toEqual(['mcp__github__get_issue'])
|
||||
})
|
||||
|
||||
it('subtracts native MCP wait time from active timing', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-timing', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-1',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: {} },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
},
|
||||
}),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:00:08Z',
|
||||
last: { input: 300, output: 100 },
|
||||
total: { total: 400 },
|
||||
}),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('keeps MCP attribution on large result lines', async () => {
|
||||
const largeMcpLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-large',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { duration: '1s', body: 'x'.repeat(100_000) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-large.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-large', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
largeMcpLine,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => {
|
||||
// Regression guard: a naive first-match regex would pick up the
|
||||
// `duration_ms: 9999` inside invocation.arguments instead of the payload-level
|
||||
// `duration: { secs: 3 }`. The depth-aware payload scan must win.
|
||||
const largeMcpLine = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-04-14T10:00:05Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'mcp-duration-collision',
|
||||
invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } },
|
||||
duration: { secs: 3, nanos: 0 },
|
||||
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('look up the issue'),
|
||||
largeMcpLine,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
|
||||
})
|
||||
|
||||
it('attributes a task_complete over everything since the last task_started, even across a suppressed one', async () => {
|
||||
// A mid-file session_meta carrying forked_from_id re-arms the fork-replay
|
||||
// cutoff, which swallows the task_started right behind it while its
|
||||
// task_complete lands past the cutoff. Attribution then has to span both
|
||||
// turns, exactly as it did before calls were buffered per task.
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-suppressed-task-start.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-suppressed-start', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('first ask'),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:05Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
sessionMeta({ timestamp: '2026-04-14T10:00:11Z', session_id: 'sess-suppressed-start', model: 'gpt-5.5', forked_from_id: 'sess-parent' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:12Z', payload: { type: 'task_started' } }),
|
||||
userMessage('second ask', '2026-04-14T10:00:18Z'),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:20Z', last: { input: 300, output: 300 }, total: { total: 1000 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:25Z', payload: { type: 'task_complete', duration_ms: 5_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
// The second task_complete re-attributes the first turn too, so the 5s
|
||||
// window is split across both by generated tokens rather than leaving the
|
||||
// first turn pinned to its own 10s window.
|
||||
expect(calls[0]!.activeDurationMs).toBeCloseTo(1250, 6)
|
||||
expect(calls[1]!.activeDurationMs).toBeCloseTo(3750, 6)
|
||||
expect(calls[0]!.activeDurationMs! + calls[1]!.activeDurationMs!).toBeCloseTo(5000, 6)
|
||||
})
|
||||
|
||||
it('omits active timing when recorded tool wait consumes the task duration', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
|
||||
userMessage('wait for the tool'),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:13Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.activeDurationMs).toBeUndefined()
|
||||
expect(calls[0]!.toolWaitMs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => {
|
||||
const execStr = (command: string) => JSON.stringify({
|
||||
type: 'response_item',
|
||||
|
|
|
|||
|
|
@ -167,6 +167,40 @@ describe('Kimi Code provider', () => {
|
|||
expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => {
|
||||
// Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs
|
||||
// conv-*/ctitle-* instead of session_*; their wires carry the same event
|
||||
// format and must be discovered and priced the same way.
|
||||
for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) {
|
||||
const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main')
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({
|
||||
createdAt: '2026-07-01T10:00:00.000Z',
|
||||
updatedAt: '2026-07-01T10:05:00.000Z',
|
||||
workDir: '/workspace/neutral-project',
|
||||
}))
|
||||
await writeFile(join(agentDir, 'wire.jsonl'), [
|
||||
JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }),
|
||||
JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)),
|
||||
JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)),
|
||||
JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })),
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const provider = createKimicodeProvider(fixtureHome)
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources.every(source => source.project === 'neutral-project')).toBe(true)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.every(call => call.model === 'k3-agent')).toBe(true)
|
||||
// k3-agent prices through the kimi-k3 alias, never $0.
|
||||
expect(calls.every(call => call.costUSD > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('parses single-turn usage with the real model id and estimated token pricing', async () => {
|
||||
const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [
|
||||
prompt('Summarize the neutral module.', 1782900000000),
|
||||
|
|
|
|||
|
|
@ -110,13 +110,30 @@ describe('omp provider - session discovery', () => {
|
|||
expect(sessions[0]!.project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('discovers title-first sessions', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'title-first.jsonl', [
|
||||
JSON.stringify({ type: 'title', title: 'OMP title-first session' }),
|
||||
'',
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('omp')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createOmpProvider('/nonexistent/omp/path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips files whose first line is not a session entry', async () => {
|
||||
it('skips files without a session entry', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'bad.jsonl', [
|
||||
JSON.stringify({ type: 'message', id: 'x' }),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
|||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createPiProvider } from '../../src/providers/pi.js'
|
||||
import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
import { classifyTurn } from '../../src/classifier.js'
|
||||
import type { ParsedApiCall, ParsedTurn } from '../../src/types.js'
|
||||
|
|
@ -59,6 +59,18 @@ function sessionMeta(opts: { id?: string; cwd?: string } = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
// Oh My Pi (issue #845): a fixed-width title metadata line written before the
|
||||
// `type: "session"` header, per upstream can1357/oh-my-pi@0ce330a.
|
||||
function titleSlot(opts: { title?: string; pad?: string } = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'title',
|
||||
v: 1,
|
||||
title: opts.title ?? 'My Session',
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
pad: opts.pad ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(text: string, timestamp?: string) {
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
|
|
@ -165,7 +177,7 @@ describe('pi provider - session discovery', () => {
|
|||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips files whose first line is not a session entry', async () => {
|
||||
it('skips files without a session entry', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'bad.jsonl', [
|
||||
JSON.stringify({ type: 'message', id: 'x' }),
|
||||
|
|
@ -185,6 +197,119 @@ describe('pi provider - session discovery', () => {
|
|||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
// Issue #845: OMP writes a `type: "title"` metadata line before the
|
||||
// `type: "session"` header. Discovery must scan past it instead of only
|
||||
// checking the first physical line.
|
||||
it('discovers an OMP transcript with a title slot before the session record', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'omp-session.jsonl', [
|
||||
titleSlot({ title: 'Fix the bug' }),
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('omp')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('Pi and OMP discover an identical title-slot transcript the same way', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const lines = [titleSlot(), sessionMeta({ cwd: '/Users/test/myproject' }), assistantMessage({})]
|
||||
await writeSession(projectDir, 'shared.jsonl', lines)
|
||||
|
||||
const piSessions = await createPiProvider(tmpDir).discoverSessions()
|
||||
const ompSessions = await createOmpProvider(tmpDir).discoverSessions()
|
||||
|
||||
expect(piSessions).toHaveLength(1)
|
||||
expect(ompSessions).toHaveLength(1)
|
||||
expect(piSessions[0]!.project).toBe(ompSessions[0]!.project)
|
||||
})
|
||||
|
||||
it('tolerates blank lines before the session record', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'blank-padded.jsonl', [
|
||||
titleSlot(),
|
||||
'',
|
||||
'',
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips a malformed leading JSON line without throwing', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'malformed-head.jsonl', [
|
||||
'{not valid json',
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
await expect(provider.discoverSessions()).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes a message-only file with no session record', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'messages-only.jsonl', [
|
||||
titleSlot(),
|
||||
userMessage('hello'),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('does not discover a session record beyond the bounded leading-line scan', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
// 25 non-session leading lines exceeds MAX_HEADER_LINES_SCANNED (20), so
|
||||
// the session record on line 26 must NOT be found. This proves the scan
|
||||
// is bounded rather than silently degrading into a full-file read.
|
||||
const junkLines = Array.from({ length: 25 }, (_, i) => JSON.stringify({ type: 'message', id: `junk-${i}` }))
|
||||
await writeSession(projectDir, 'too-deep.jsonl', [
|
||||
...junkLines,
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('does not read an unreasonable amount of a large message-only file', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
// ~20MB of message lines with no session record anywhere. A bounded
|
||||
// scan rejects this after its leading-line cap, not after reading the
|
||||
// whole file; this keeps discovery cheap for provider directories full
|
||||
// of large, irrelevant transcripts.
|
||||
const bigPad = 'x'.repeat(50_000)
|
||||
const junkLines = Array.from({ length: 400 }, (_, i) =>
|
||||
JSON.stringify({ type: 'message', id: `junk-${i}`, pad: bigPad }))
|
||||
await writeSession(projectDir, 'huge.jsonl', junkLines)
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const start = performance.now()
|
||||
const sessions = await provider.discoverSessions()
|
||||
const elapsedMs = performance.now() - start
|
||||
|
||||
expect(sessions).toEqual([])
|
||||
// Generous ceiling: a genuinely bounded scan (20 lines) finishes in low
|
||||
// single-digit milliseconds; reading the full ~20MB file would still be
|
||||
// fast on local disk, so this mainly guards against a regression to an
|
||||
// unbounded per-line-parse scan across the entire file.
|
||||
expect(elapsedMs).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi provider - JSONL parsing', () => {
|
||||
|
|
|
|||
157
tests/sync-attribution-cli.test.ts
Normal file
157
tests/sync-attribution-cli.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* CLI-level tests for `codeburn sync push --attribution`.
|
||||
*
|
||||
* Drives the real commander action against a mock IdP + collector:
|
||||
* - --dry-run --attribution: NO telemetry reaches the traces endpoint
|
||||
* - push WITHOUT the flag: no attribution span names on the wire
|
||||
* - push WITH the flag: attribution spans arrive alongside usage spans
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { Command } from 'commander'
|
||||
|
||||
import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js'
|
||||
import type { ProjectSummary, SessionSummary, ParsedApiCall, TokenUsage } from '../src/types.js'
|
||||
|
||||
const { parseAllSessionsMock } = vi.hoisted(() => ({ parseAllSessionsMock: vi.fn() }))
|
||||
vi.mock('../src/parser.js', () => ({ parseAllSessions: parseAllSessionsMock }))
|
||||
|
||||
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, ...env } }).trim()
|
||||
}
|
||||
|
||||
function makeUsage(): TokenUsage {
|
||||
return { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }
|
||||
}
|
||||
|
||||
function makeCall(key: string, ts: string): ParsedApiCall {
|
||||
return {
|
||||
provider: 'test', model: 'test-model', usage: makeUsage(), costUSD: 0.01,
|
||||
tools: [], mcpTools: [], skills: [], subagentTypes: [], hasAgentSpawn: false,
|
||||
hasPlanMode: false, speed: 'standard', timestamp: ts, bashCommands: [],
|
||||
deduplicationKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(id: string, first: string, last: string, calls: ParsedApiCall[]): SessionSummary {
|
||||
return {
|
||||
sessionId: id, project: 'app', firstTimestamp: first, lastTimestamp: last,
|
||||
totalCostUSD: 0.01, totalSavingsUSD: 0, totalInputTokens: 10, totalOutputTokens: 5,
|
||||
totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: calls.length,
|
||||
turns: [{ userMessage: 'x', assistantCalls: calls, timestamp: first, sessionId: id, category: 'coding', retries: 0, hasEdits: false }],
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'], skillBreakdown: {}, subagentBreakdown: {},
|
||||
}
|
||||
}
|
||||
|
||||
let idp: MockIdp
|
||||
let tmpHome: string
|
||||
let repoDir: string
|
||||
const originalHome = process.env.HOME
|
||||
const originalXdg = process.env.XDG_CACHE_HOME
|
||||
const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE
|
||||
|
||||
async function runPush(args: string[]): Promise<void> {
|
||||
const { registerSyncCommands } = await import('../src/sync/cli.js')
|
||||
const program = new Command()
|
||||
program.exitOverride() // throw instead of process.exit on commander errors
|
||||
registerSyncCommands(program)
|
||||
await program.parseAsync(['node', 'codeburn', 'sync', 'push', ...args])
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
idp = await startMockIdp({ rotateTokens: false })
|
||||
process.env.CODEBURN_SYNC_TOKEN_STORE = 'file'
|
||||
|
||||
// Real git repo with a remote — a recent commit inside the session window
|
||||
repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-repo-'))
|
||||
git(repoDir, ['init', '-b', 'main'])
|
||||
git(repoDir, ['config', 'user.email', 't@e.com'])
|
||||
git(repoDir, ['config', 'user.name', 'T'])
|
||||
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/cli-widget.git'])
|
||||
await writeFile(join(repoDir, 'f.txt'), 'x\n')
|
||||
const commitIso = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
||||
git(repoDir, ['add', '.'])
|
||||
git(repoDir, ['commit', '-m', 'feat: recent'], { GIT_AUTHOR_DATE: commitIso, GIT_COMMITTER_DATE: commitIso })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await idp.close()
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE
|
||||
else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-'))
|
||||
process.env.HOME = tmpHome
|
||||
process.env.XDG_CACHE_HOME = join(tmpHome, '.cache')
|
||||
idp.tracesRequests.length = 0
|
||||
|
||||
// Configure sync against the mock IdP + store the refresh token
|
||||
const { writeSyncConfig } = await import('../src/sync/config.js')
|
||||
const { createCredentialStore } = await import('../src/sync/credentials.js')
|
||||
writeSyncConfig({ baseUrl: idp.baseUrl, clientId: 'mock-client-id', tracesPath: '/v1/traces', issuer: idp.baseUrl })
|
||||
createCredentialStore().store('mock-refresh-token-v1')
|
||||
|
||||
// One session in the last hour, working in the real repo
|
||||
const now = Date.now()
|
||||
const first = new Date(now - 90 * 60 * 1000).toISOString()
|
||||
const last = new Date(now - 30 * 60 * 1000).toISOString()
|
||||
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
|
||||
parseAllSessionsMock.mockResolvedValue([
|
||||
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
|
||||
])
|
||||
|
||||
// The push action reads process.cwd() for the attribution cwd — run from
|
||||
// a neutral non-repo dir so nothing can come from a cwd fallback.
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tmpHome)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = 0
|
||||
process.env.HOME = originalHome
|
||||
if (originalXdg === undefined) delete process.env.XDG_CACHE_HOME
|
||||
else process.env.XDG_CACHE_HOME = originalXdg
|
||||
await rm(tmpHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const spanNames = (): string[] =>
|
||||
idp.tracesRequests.flatMap(r => {
|
||||
const body = r.body as { resourceSpans?: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
|
||||
return (body.resourceSpans ?? []).flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.map(s => s.name)))
|
||||
})
|
||||
|
||||
describe('sync push --attribution (CLI level)', () => {
|
||||
it('--dry-run --attribution sends nothing to the traces endpoint', async () => {
|
||||
await runPush(['--dry-run', '--attribution'])
|
||||
expect(idp.tracesRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('push WITHOUT --attribution never emits attribution span names', async () => {
|
||||
await runPush([])
|
||||
expect(idp.tracesRequests.length).toBeGreaterThan(0)
|
||||
const names = spanNames()
|
||||
expect(names.length).toBeGreaterThan(0)
|
||||
expect(names).not.toContain('codeburn.session.attribution')
|
||||
expect(names).not.toContain('codeburn.commit')
|
||||
expect(JSON.stringify(idp.tracesRequests)).not.toContain('git.sha')
|
||||
})
|
||||
|
||||
it('push WITH --attribution emits usage + attribution spans', async () => {
|
||||
await runPush(['--attribution'])
|
||||
const names = spanNames()
|
||||
expect(names).toContain('test/test-model') // usage span
|
||||
expect(names).toContain('codeburn.session.attribution') // session span
|
||||
expect(names).toContain('codeburn.commit') // commit span
|
||||
const wire = JSON.stringify(idp.tracesRequests)
|
||||
expect(wire).toContain('github.com/acme/cli-widget')
|
||||
})
|
||||
})
|
||||
753
tests/sync-attribution.test.ts
Normal file
753
tests/sync-attribution.test.ts
Normal file
|
|
@ -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, string> = {}): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...env },
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function initRepo(dir: string): void {
|
||||
git(dir, ['init', '-b', 'main'])
|
||||
git(dir, ['config', 'user.email', 'test@example.com'])
|
||||
git(dir, ['config', 'user.name', 'Test'])
|
||||
}
|
||||
|
||||
function commitAt(dir: string, message: string, iso: string): void {
|
||||
git(dir, ['add', '.'])
|
||||
git(dir, ['commit', '-m', message], {
|
||||
GIT_AUTHOR_DATE: iso,
|
||||
GIT_COMMITTER_DATE: iso,
|
||||
})
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<SessionSummary>): SessionSummary {
|
||||
return {
|
||||
sessionId: 'session',
|
||||
project: 'app',
|
||||
firstTimestamp: '2026-01-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-01-01T11:00:00.000Z',
|
||||
totalCostUSD: 1,
|
||||
totalSavingsUSD: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
||||
skillBreakdown: {},
|
||||
subagentBreakdown: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const range = {
|
||||
start: new Date('2026-01-01T00:00:00.000Z'),
|
||||
end: new Date('2026-01-02T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
// ── normalizeRemoteUrl ────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeRemoteUrl', () => {
|
||||
it('normalizes scp-like ssh remotes', () => {
|
||||
expect(normalizeRemoteUrl('git@github.com:acme/widget.git')).toBe('github.com/acme/widget')
|
||||
expect(normalizeRemoteUrl('git@GitHub.com:Acme/Widget')).toBe('github.com/Acme/Widget')
|
||||
})
|
||||
|
||||
it('normalizes ssh:// remotes, dropping user and port', () => {
|
||||
expect(normalizeRemoteUrl('ssh://git@github.com/acme/widget.git')).toBe('github.com/acme/widget')
|
||||
expect(normalizeRemoteUrl('ssh://git@gitlab.example.com:2222/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
|
||||
})
|
||||
|
||||
it('normalizes https remotes and strips embedded credentials', () => {
|
||||
expect(normalizeRemoteUrl('https://github.com/acme/widget.git')).toBe('github.com/acme/widget')
|
||||
expect(normalizeRemoteUrl('https://user:s3cret-token@github.com/acme/widget.git')).toBe('github.com/acme/widget')
|
||||
expect(normalizeRemoteUrl('https://github.com/acme/widget/')).toBe('github.com/acme/widget')
|
||||
})
|
||||
|
||||
it('returns null for local paths and file:// remotes', () => {
|
||||
expect(normalizeRemoteUrl('/home/dev/repos/widget')).toBeNull()
|
||||
expect(normalizeRemoteUrl('file:///home/dev/repos/widget')).toBeNull()
|
||||
expect(normalizeRemoteUrl('../relative/repo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects Windows drive-letter paths (never a remote identity)', () => {
|
||||
expect(normalizeRemoteUrl('C:/Users/alice/private/repo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('C:\\Users\\alice\\private\\repo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('c:/repo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('Z:\\work\\nda-client-repo')).toBeNull()
|
||||
// Drive-relative (no slash after colon) — single-char host rejection
|
||||
expect(normalizeRemoteUrl('C:repo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('c:relative\\path')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects other adversarial forms without over-rejecting real remotes', () => {
|
||||
// Single-character "host" is never a real remote host
|
||||
expect(normalizeRemoteUrl('a:path/to/repo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('git@C:/foo')).toBeNull()
|
||||
// Dotless intranet hosts remain valid (2+ chars)
|
||||
expect(normalizeRemoteUrl('gitserver:team/repo.git')).toBe('gitserver/team/repo')
|
||||
expect(normalizeRemoteUrl('git@gitbox:org/repo.git')).toBe('gitbox/org/repo')
|
||||
})
|
||||
|
||||
it('never leaks credentials via scp-branch backtracking on malformed remotes', () => {
|
||||
// Credential-prefixed remotes: the userinfo split happens BEFORE host
|
||||
// matching, so a token can never be re-parsed as host:path.
|
||||
expect(normalizeRemoteUrl('x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git')).toBeNull()
|
||||
expect(normalizeRemoteUrl('oauth2:glpat-TOKEN@gitlab.com/org/repo.git')).toBeNull()
|
||||
// One dropped slash: not a URL, must not fall through as host "https"
|
||||
expect(normalizeRemoteUrl('https:/user:ghp_TOKEN@github.com/org/repo.git')).toBeNull()
|
||||
// Multiple @: split at the first, residual @ fails the allow-list
|
||||
expect(normalizeRemoteUrl('a@b@github.com:org/repo.git')).toBeNull()
|
||||
expect(normalizeRemoteUrl('user@host:path@with-at')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects transport-helper remotes and enforces shape + length on the identity', () => {
|
||||
// git-remote-ext: embeds a local SSH key path
|
||||
expect(normalizeRemoteUrl('ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git')).toBeNull()
|
||||
expect(normalizeRemoteUrl('ext::sh -c whatever')).toBeNull()
|
||||
// git-remote-codecommit: embeds an AWS profile name
|
||||
expect(normalizeRemoteUrl('codecommit::us-east-1://MyAwsProfile@MyRepo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('codecommit::us-east-1://MyRepo')).toBeNull()
|
||||
// Length bound
|
||||
expect(normalizeRemoteUrl(`git@github.com:org/${'a'.repeat(300)}.git`)).toBeNull()
|
||||
// Path segments must be repo-shaped (no spaces, colons, @)
|
||||
expect(normalizeRemoteUrl('gitserver:has space/repo.git')).toBeNull()
|
||||
// Legit multi-segment (GitLab subgroup) paths survive the allow-list
|
||||
expect(normalizeRemoteUrl('https://gitlab.example.com/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
|
||||
})
|
||||
|
||||
it('normalizes .GIT case-insensitively and collapses doubled slashes to one join key', () => {
|
||||
expect(normalizeRemoteUrl('git@github.com:acme/Repo.GIT')).toBe('github.com/acme/Repo')
|
||||
expect(normalizeRemoteUrl('https://github.com/acme/Repo.Git')).toBe('github.com/acme/Repo')
|
||||
expect(normalizeRemoteUrl('https://github.com/acme//repo.git')).toBe('github.com/acme/repo')
|
||||
expect(normalizeRemoteUrl('git@github.com:acme//repo.git')).toBe('github.com/acme/repo')
|
||||
})
|
||||
})
|
||||
|
||||
// ── computeAttributionRecords ─────────────────────────────────────────
|
||||
|
||||
describe('computeAttributionRecords', () => {
|
||||
it('attributes commits with normalized remote, inMain, and timestamps', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-repo-'))
|
||||
try {
|
||||
initRepo(repoDir)
|
||||
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
commitAt(repoDir, 'feat: shipped', '2026-01-01T10:30:00Z')
|
||||
const sha = git(repoDir, ['rev-parse', 'HEAD'])
|
||||
|
||||
const session = makeSession({
|
||||
sessionId: 'sess-a',
|
||||
prLinks: ['https://github.com/acme/widget/pull/12'],
|
||||
})
|
||||
const projects = [
|
||||
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
|
||||
]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, repoDir)
|
||||
|
||||
expect(records).toHaveLength(1)
|
||||
const record = records[0]!
|
||||
expect(record.sessionId).toBe('sess-a')
|
||||
expect(record.repo).toBe('github.com/acme/widget')
|
||||
expect(record.prLinks).toEqual(['https://github.com/acme/widget/pull/12'])
|
||||
expect(record.commits).toHaveLength(1)
|
||||
expect(record.commits[0]).toMatchObject({ sha, inMain: true, wasReverted: false })
|
||||
expect(new Date(record.commits[0]!.timestamp).toISOString()).toBe('2026-01-01T10:30:00.000Z')
|
||||
expect(record.firstTimestamp).toBe('2026-01-01T10:00:00.000Z')
|
||||
expect(record.lastTimestamp).toBe('2026-01-01T11:00:00.000Z')
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('omits empty sessions that lost nothing — commits aged out of range are NOT retracted', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-empty-'))
|
||||
try {
|
||||
initRepo(repoDir)
|
||||
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
// Commit outside every session window: no session competes for it, so
|
||||
// nobody "lost" it. Even if this session previously synced a commit
|
||||
// (now outside the --since range), emitting an empty record here would
|
||||
// permanently zero a still-correct server-side count.
|
||||
commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z')
|
||||
|
||||
const session = makeSession({ sessionId: 'sess-idle' })
|
||||
const projects = [
|
||||
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
|
||||
]
|
||||
|
||||
expect(computeAttributionRecords(projects, range, repoDir)).toEqual([])
|
||||
|
||||
// …and therefore nothing can be sent, even with prior ledger state for
|
||||
// this session (simulating an earlier wider---since push).
|
||||
const { writeLedger } = await import('../src/sync/ledger.js')
|
||||
writeLedger([{ key: 'attr:s:sess-idle:0123456789abcdef', ts: '2026-01-01T10:00:00.000Z' }])
|
||||
const { collectUnsentAttribution } = await import('../src/sync/push.js')
|
||||
expect(collectUnsentAttribution(computeAttributionRecords(projects, range, repoDir)).unsent).toEqual([])
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('emits a retraction candidate for a session that lost its commit to a tighter window', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-lost-'))
|
||||
try {
|
||||
initRepo(repoDir)
|
||||
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
commitAt(repoDir, 'feat: contested', '2026-01-01T10:30:00Z')
|
||||
|
||||
const tight = makeSession({
|
||||
sessionId: 'sess-tight',
|
||||
firstTimestamp: '2026-01-01T10:15:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:45:00.000Z',
|
||||
})
|
||||
const broadLoser = makeSession({ sessionId: 'sess-broad-loser' })
|
||||
const projects = [
|
||||
{ project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary,
|
||||
]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, repoDir)
|
||||
const loser = records.find(r => r.sessionId === 'sess-broad-loser')!
|
||||
// The loser IS emitted (retraction candidate: lostCandidacy) with zero
|
||||
// commits — the sync layer decides whether a prior state warrants
|
||||
// actually sending it.
|
||||
expect(loser).toBeDefined()
|
||||
expect(loser.commits).toEqual([])
|
||||
expect(loser.repo).toBe('github.com/acme/widget')
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('drops commits when the repo has no remote, but keeps PR-linked sessions', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-noremote-'))
|
||||
try {
|
||||
initRepo(repoDir) // no origin remote
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
commitAt(repoDir, 'feat: local only', '2026-01-01T10:30:00Z')
|
||||
|
||||
const withPr = makeSession({
|
||||
sessionId: 'sess-pr',
|
||||
prLinks: ['https://github.com/acme/widget/pull/7'],
|
||||
firstTimestamp: '2026-01-01T10:15:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:45:00.000Z',
|
||||
})
|
||||
const withoutPr = makeSession({ sessionId: 'sess-nopr' })
|
||||
const projects = [
|
||||
{ project: 'app', projectPath: repoDir, sessions: [withPr, withoutPr] } as ProjectSummary,
|
||||
]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, repoDir)
|
||||
|
||||
// sess-pr wins the commit window but has no repo identity, so commits
|
||||
// are dropped; the PR link alone justifies the record. sess-nopr has
|
||||
// nothing joinable and is omitted.
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0]!.sessionId).toBe('sess-pr')
|
||||
expect(records[0]!.repo).toBeNull()
|
||||
expect(records[0]!.commits).toEqual([])
|
||||
expect(records[0]!.prLinks).toEqual(['https://github.com/acme/widget/pull/7'])
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('never egresses the cwd repo for sessions whose project path did not resolve (fallback)', async () => {
|
||||
// The reviewer's repro: push from inside a private repo while a session's
|
||||
// project path no longer resolves. The fallback identity must NOT leak
|
||||
// the cwd repo's remote or commits into that session's attribution.
|
||||
const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-privatecwd-'))
|
||||
try {
|
||||
initRepo(cwdRepo)
|
||||
git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:secret-org/nda-client-repo.git'])
|
||||
await writeFile(join(cwdRepo, 'file.txt'), 'confidential\n')
|
||||
commitAt(cwdRepo, 'feat: private work', '2026-01-01T10:30:00Z')
|
||||
|
||||
// Session A: project path is gone (deleted dir) — falls back to cwd
|
||||
const orphanNoPr = makeSession({ sessionId: 'orphan-nopr', ...{ firstTimestamp: '2026-01-01T10:15:00.000Z', lastTimestamp: '2026-01-01T10:45:00.000Z' } })
|
||||
// Session B: also fallback, but carries a PR link (session-native, safe)
|
||||
const orphanWithPr = makeSession({
|
||||
sessionId: 'orphan-pr',
|
||||
prLinks: ['https://github.com/acme/widget/pull/9'],
|
||||
firstTimestamp: '2026-01-01T12:00:00.000Z',
|
||||
lastTimestamp: '2026-01-01T12:30:00.000Z',
|
||||
})
|
||||
// Session C: genuinely belongs to the cwd repo (own path resolves)
|
||||
const genuine = makeSession({ sessionId: 'genuine-cwd', firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' })
|
||||
|
||||
const projects = [
|
||||
{ project: 'ghost', projectPath: join(cwdRepo, 'no-such-dir-anymore-xyz'), sessions: [orphanNoPr] },
|
||||
{ project: 'ghost2', projectPath: '', sessions: [orphanWithPr] },
|
||||
{ project: 'real', projectPath: cwdRepo, sessions: [genuine] },
|
||||
] as ProjectSummary[]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, cwdRepo)
|
||||
|
||||
// orphan-nopr: nothing joinable -> no record at all
|
||||
expect(records.find(r => r.sessionId === 'orphan-nopr')).toBeUndefined()
|
||||
// orphan-pr: PR link only — no repo, no commits
|
||||
const pr = records.find(r => r.sessionId === 'orphan-pr')!
|
||||
expect(pr.repo).toBeNull()
|
||||
expect(pr.commits).toEqual([])
|
||||
// genuine cwd session keeps full attribution
|
||||
const own = records.find(r => r.sessionId === 'genuine-cwd')!
|
||||
expect(own.repo).toBe('github.com/secret-org/nda-client-repo')
|
||||
expect(own.commits).toHaveLength(1)
|
||||
// The private repo identity appears ONLY on the genuine record
|
||||
const leaked = records.filter(r => r.sessionId !== 'genuine-cwd' && JSON.stringify(r).includes('secret-org'))
|
||||
expect(leaked).toEqual([])
|
||||
} finally {
|
||||
await rm(cwdRepo, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fallback sessions cannot steal a commit from a genuine session', async () => {
|
||||
const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-steal-'))
|
||||
try {
|
||||
initRepo(cwdRepo)
|
||||
git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
|
||||
await writeFile(join(cwdRepo, 'file.txt'), 'x\n')
|
||||
commitAt(cwdRepo, 'feat: mine', '2026-01-01T10:30:00Z')
|
||||
|
||||
// Fallback session has the TIGHTER window (would win under old logic);
|
||||
// genuine session has the broader window.
|
||||
const fallbackTight = makeSession({
|
||||
sessionId: 'fallback-tight',
|
||||
prLinks: ['https://github.com/acme/widget/pull/2'],
|
||||
firstTimestamp: '2026-01-01T10:25:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:35:00.000Z',
|
||||
})
|
||||
const genuineBroad = makeSession({ sessionId: 'genuine-broad' })
|
||||
|
||||
const projects = [
|
||||
{ project: 'ghost', projectPath: '', sessions: [fallbackTight] },
|
||||
{ project: 'real', projectPath: cwdRepo, sessions: [genuineBroad] },
|
||||
] as ProjectSummary[]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, cwdRepo)
|
||||
|
||||
expect(records.find(r => r.sessionId === 'fallback-tight')!.commits).toEqual([])
|
||||
expect(records.find(r => r.sessionId === 'genuine-broad')!.commits).toHaveLength(1)
|
||||
} finally {
|
||||
await rm(cwdRepo, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('awards each commit to a single session (tightest window)', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-overlap-'))
|
||||
try {
|
||||
initRepo(repoDir)
|
||||
git(repoDir, ['remote', 'add', 'origin', 'https://github.com/acme/widget.git'])
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
commitAt(repoDir, 'feat: shared window', '2026-01-01T10:30:00Z')
|
||||
|
||||
const tight = makeSession({
|
||||
sessionId: 'sess-tight',
|
||||
firstTimestamp: '2026-01-01T10:15:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:45:00.000Z',
|
||||
})
|
||||
const broad = makeSession({ sessionId: 'sess-broad' })
|
||||
const projects = [
|
||||
{ project: 'app', projectPath: repoDir, sessions: [tight, broad] } as ProjectSummary,
|
||||
]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, repoDir)
|
||||
|
||||
// Both sessions get records (the loser is a retraction candidate),
|
||||
// but the commit is awarded exactly once — to the tighter window.
|
||||
expect(records).toHaveLength(2)
|
||||
const tightRecord = records.find(r => r.sessionId === 'sess-tight')!
|
||||
const broadRecord = records.find(r => r.sessionId === 'sess-broad')!
|
||||
expect(tightRecord.commits).toHaveLength(1)
|
||||
expect(broadRecord.commits).toEqual([])
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Dedup keys and flattening ─────────────────────────────────────────
|
||||
|
||||
function makeRecord(overrides: Partial<SessionAttributionRecord> = {}): SessionAttributionRecord {
|
||||
return {
|
||||
sessionId: 'sess-1',
|
||||
project: 'app',
|
||||
repo: 'github.com/acme/widget',
|
||||
prLinks: ['https://github.com/acme/widget/pull/3'],
|
||||
commits: [
|
||||
{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false },
|
||||
],
|
||||
firstTimestamp: '2026-01-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-01-01T11:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('attribution dedup keys', () => {
|
||||
it('encodes commit state so a state transition mints a new key', () => {
|
||||
const before = commitAttributionKey('sess-1', 'abc123', false, false)
|
||||
const merged = commitAttributionKey('sess-1', 'abc123', true, false)
|
||||
const reverted = commitAttributionKey('sess-1', 'abc123', true, true)
|
||||
expect(new Set([before, merged, reverted]).size).toBe(3)
|
||||
// Same state = same key (ledger dedupes repeats)
|
||||
expect(commitAttributionKey('sess-1', 'abc123', true, false)).toBe(merged)
|
||||
})
|
||||
|
||||
it('session key is stable for identical state and changes with commit state', () => {
|
||||
const record = makeRecord()
|
||||
expect(sessionAttributionKey(record)).toBe(sessionAttributionKey(makeRecord()))
|
||||
|
||||
const mutated = makeRecord({
|
||||
commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
|
||||
})
|
||||
expect(sessionAttributionKey(mutated)).not.toBe(sessionAttributionKey(record))
|
||||
})
|
||||
|
||||
it('session key changes when the window or project changes (ongoing sessions re-emit)', () => {
|
||||
const base = makeRecord()
|
||||
const grown = makeRecord({ lastTimestamp: '2026-01-01T12:00:00.000Z' })
|
||||
const renamed = makeRecord({ project: 'app-renamed' })
|
||||
expect(sessionAttributionKey(grown)).not.toBe(sessionAttributionKey(base))
|
||||
expect(sessionAttributionKey(renamed)).not.toBe(sessionAttributionKey(base))
|
||||
})
|
||||
|
||||
it('flattens one session item plus one item per commit', () => {
|
||||
const items = flattenAttributionRecords([makeRecord()])
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]).toMatchObject({ kind: 'session', commitCount: 1, endTimestamp: '2026-01-01T11:00:00.000Z' })
|
||||
expect(items[1]).toMatchObject({ kind: 'commit', sha: 'a'.repeat(40), inMain: true, wasReverted: false })
|
||||
expect(items.map(i => i.dedupKey)).toEqual([
|
||||
sessionAttributionKey(makeRecord()),
|
||||
commitAttributionKey('sess-1', 'a'.repeat(40), true, false),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── PR link sanitization ──────────────────────────────────────────────
|
||||
|
||||
describe('sanitizePrLinks', () => {
|
||||
it('keeps only https URLs shaped like org/repo/pull/N', () => {
|
||||
expect(sanitizePrLinks([
|
||||
'https://github.com/acme/widget/pull/12',
|
||||
'https://ghe.corp.example.com/team/svc/pull/3', // GHE hosts allowed
|
||||
'http://github.com/acme/widget/pull/12', // not https
|
||||
'javascript:alert(1)', // not a URL shape we accept
|
||||
'https://github.com/acme/widget/issues/12', // not a PR path
|
||||
'https://github.com/acme/widget/pull/12/files', // extra path segment
|
||||
'not a url at all',
|
||||
'',
|
||||
'https://github.com/acme/widget/pull/notanumber',
|
||||
])).toEqual([
|
||||
'https://ghe.corp.example.com/team/svc/pull/3',
|
||||
'https://github.com/acme/widget/pull/12',
|
||||
])
|
||||
})
|
||||
|
||||
it('rebuilds links from origin + pathname: userinfo, query, and fragment never survive', () => {
|
||||
expect(sanitizePrLinks([
|
||||
'https://alice:ghp_TOKEN@github.com/acme/widget/pull/5',
|
||||
'https://github.com/acme/widget/pull/7?notification_referrer_id=xyz',
|
||||
'https://github.com/acme/widget/pull/6#pullrequestreview-123',
|
||||
])).toEqual([
|
||||
'https://github.com/acme/widget/pull/5',
|
||||
'https://github.com/acme/widget/pull/6',
|
||||
'https://github.com/acme/widget/pull/7',
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes links that collapse to the same rebuilt URL', () => {
|
||||
expect(sanitizePrLinks([
|
||||
'https://github.com/acme/widget/pull/9',
|
||||
'https://github.com/acme/widget/pull/9?ref=a',
|
||||
'https://github.com/acme/widget/pull/9#comment',
|
||||
])).toEqual(['https://github.com/acme/widget/pull/9'])
|
||||
})
|
||||
|
||||
it('drops oversized inputs and caps the count per session', () => {
|
||||
// A long referrer query is stripped, so the link survives...
|
||||
const longQuery = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}`
|
||||
expect(sanitizePrLinks([longQuery])).toEqual(['https://github.com/acme/widget/pull/1'])
|
||||
// ...but pathological inputs beyond the input bound are dropped outright
|
||||
const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(600)}`
|
||||
expect(sanitizePrLinks([huge])).toEqual([])
|
||||
// And a rebuilt link that is itself oversized is dropped
|
||||
const longPath = `https://github.com/${'o'.repeat(150)}/${'r'.repeat(80)}/pull/1`
|
||||
expect(sanitizePrLinks([longPath])).toEqual([])
|
||||
|
||||
const many = Array.from({ length: 30 }, (_, i) => `https://github.com/acme/widget/pull/${i + 1}`)
|
||||
expect(sanitizePrLinks(many)).toHaveLength(MAX_PR_LINKS_PER_SESSION)
|
||||
})
|
||||
})
|
||||
|
||||
// ── OTLP payload ──────────────────────────────────────────────────────
|
||||
|
||||
function attrMap(attributes: OtlpAttribute[]): Record<string, unknown> {
|
||||
return Object.fromEntries(attributes.map(a => [a.key, a.value]))
|
||||
}
|
||||
|
||||
describe('buildAttributionOtlpPayload', () => {
|
||||
it('builds session and commit spans sharing the session traceId', () => {
|
||||
const items = flattenAttributionRecords([makeRecord()])
|
||||
const payload = buildAttributionOtlpPayload(items)
|
||||
|
||||
const resource = payload.resourceSpans[0]!
|
||||
const resourceAttrs = attrMap(resource.resource.attributes)
|
||||
expect(resourceAttrs['codeburn.attribution_methodology']).toEqual({ stringValue: 'timestamp-window' })
|
||||
expect(resourceAttrs['codeburn.device_id']).toBeDefined()
|
||||
|
||||
const spans = resource.scopeSpans[0]!.spans
|
||||
expect(spans).toHaveLength(2)
|
||||
|
||||
const sessionSpan = spans.find(s => s.name === SESSION_ATTRIBUTION_SPAN_NAME)!
|
||||
const commitSpan = spans.find(s => s.name === COMMIT_ATTRIBUTION_SPAN_NAME)!
|
||||
expect(sessionSpan.traceId).toBe(deriveTraceId('sess-1'))
|
||||
expect(commitSpan.traceId).toBe(deriveTraceId('sess-1'))
|
||||
expect(sessionSpan.spanId).not.toBe(commitSpan.spanId)
|
||||
|
||||
const sessionAttrs = attrMap(sessionSpan.attributes)
|
||||
expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' })
|
||||
expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' })
|
||||
expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
|
||||
expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' })
|
||||
expect(sessionAttrs['git.pr_links']).toEqual({
|
||||
arrayValue: { values: [{ stringValue: 'https://github.com/acme/widget/pull/3' }] },
|
||||
})
|
||||
// Session span carries the real window as its duration
|
||||
expect(sessionSpan.startTimeUnixNano).toBe((BigInt(new Date('2026-01-01T10:00:00.000Z').getTime()) * 1_000_000n).toString())
|
||||
expect(sessionSpan.endTimeUnixNano).toBe((BigInt(new Date('2026-01-01T11:00:00.000Z').getTime()) * 1_000_000n).toString())
|
||||
|
||||
const commitAttrs = attrMap(commitSpan.attributes)
|
||||
expect(commitAttrs['git.sha']).toEqual({ stringValue: 'a'.repeat(40) })
|
||||
expect(commitAttrs['git.in_main']).toEqual({ boolValue: true })
|
||||
expect(commitAttrs['git.was_reverted']).toEqual({ boolValue: false })
|
||||
expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
|
||||
})
|
||||
|
||||
it('omits git.repo when null and pr_links when empty', () => {
|
||||
const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })])
|
||||
const payload = buildAttributionOtlpPayload(items)
|
||||
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
|
||||
expect(spans).toHaveLength(1)
|
||||
const attrs = attrMap(spans[0]!.attributes)
|
||||
expect(attrs['git.repo']).toBeUndefined()
|
||||
expect(attrs['git.pr_links']).toBeUndefined()
|
||||
expect(attrs['git.commit_count']).toEqual({ intValue: '0' })
|
||||
})
|
||||
|
||||
it('batches items by maxBatchSize', () => {
|
||||
const items = flattenAttributionRecords([makeRecord(), makeRecord({ sessionId: 'sess-2' })])
|
||||
expect(batchAttributionItems(items, 3).map(b => b.length)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('clamps span end time: never 0, never earlier than start + 1ms', () => {
|
||||
// Session window ends BEFORE it starts (out-of-order provider timestamps)
|
||||
const outOfOrder = flattenAttributionRecords([makeRecord({
|
||||
firstTimestamp: '2026-01-01T11:00:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:00:00.000Z',
|
||||
})])
|
||||
const span1 = buildAttributionOtlpPayload(outOfOrder).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
|
||||
expect(BigInt(span1.endTimeUnixNano)).toBe(BigInt(span1.startTimeUnixNano) + 1_000_000n)
|
||||
|
||||
// Malformed end timestamp (toUnixNano -> 0)
|
||||
const malformed = flattenAttributionRecords([makeRecord({ lastTimestamp: 'not-a-date' })])
|
||||
const span2 = buildAttributionOtlpPayload(malformed).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
|
||||
expect(span2.endTimeUnixNano).not.toBe('0')
|
||||
expect(BigInt(span2.endTimeUnixNano)).toBe(BigInt(span2.startTimeUnixNano) + 1_000_000n)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Send + ledger pipeline ────────────────────────────────────────────
|
||||
|
||||
type MockResponse = { status: number; body?: unknown; headers?: Record<string, string> }
|
||||
|
||||
function startMockOtlp(responses: MockResponse[]): Promise<{
|
||||
url: string
|
||||
server: Server
|
||||
requests: Array<{ auth: string | undefined; body: unknown }>
|
||||
}> {
|
||||
const requests: Array<{ auth: string | undefined; body: unknown }> = []
|
||||
let idx = 0
|
||||
|
||||
return new Promise(resolve => {
|
||||
const server = createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', c => { raw += c })
|
||||
req.on('end', () => {
|
||||
requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') })
|
||||
const r = responses[Math.min(idx, responses.length - 1)]!
|
||||
idx++
|
||||
res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers })
|
||||
res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}')
|
||||
})
|
||||
})
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as { port: number }
|
||||
resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let tmpDir: string
|
||||
const originalHome = process.env.HOME
|
||||
const originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-push-'))
|
||||
process.env.HOME = tmpDir
|
||||
process.env.XDG_CACHE_HOME = join(tmpDir, '.cache')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = originalHome
|
||||
if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME
|
||||
else process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('sendAttributionBatches + collectUnsentAttribution', () => {
|
||||
it('sends attribution spans, ledgers dedup keys, and filters them on the next collect', async () => {
|
||||
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
|
||||
const { readLedger } = await import('../src/sync/ledger.js')
|
||||
|
||||
const record = makeRecord()
|
||||
const first = collectUnsentAttribution([record])
|
||||
expect(first.unsent).toHaveLength(2)
|
||||
|
||||
const mock = await startMockOtlp([{ status: 200 }])
|
||||
try {
|
||||
const result = await sendAttributionBatches({
|
||||
endpoint: mock.url,
|
||||
accessToken: 'token-1',
|
||||
batches: [first.unsent],
|
||||
})
|
||||
|
||||
expect(result.outcome).toBe('complete')
|
||||
expect(result.totalSent).toBe(2)
|
||||
expect(result.totalCostSent).toBe(0)
|
||||
expect(mock.requests).toHaveLength(1)
|
||||
expect(mock.requests[0]!.auth).toBe('Bearer token-1')
|
||||
|
||||
const body = mock.requests[0]!.body as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
|
||||
const names = body.resourceSpans[0]!.scopeSpans[0]!.spans.map(s => s.name).sort()
|
||||
expect(names).toEqual([COMMIT_ATTRIBUTION_SPAN_NAME, SESSION_ATTRIBUTION_SPAN_NAME])
|
||||
|
||||
const ledgered = readLedger().map(e => e.key).sort()
|
||||
expect(ledgered).toEqual(first.unsent.map(i => i.dedupKey).sort())
|
||||
|
||||
// Identical state on the next push: nothing unsent
|
||||
expect(collectUnsentAttribution([record]).unsent).toEqual([])
|
||||
|
||||
// State transition (commit reverted): the changed facts re-send
|
||||
const mutated = makeRecord({
|
||||
commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
|
||||
})
|
||||
const after = collectUnsentAttribution([mutated])
|
||||
expect(after.unsent.map(i => i.kind).sort()).toEqual(['commit', 'session'])
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('retracts a session span when its commit migrates to a tighter-window session', async () => {
|
||||
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
|
||||
|
||||
const sha = 'b'.repeat(40)
|
||||
const commit = { sha, timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false }
|
||||
|
||||
// Push 1: session A (broad window) owns the commit
|
||||
const push1 = collectUnsentAttribution([makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [commit] })])
|
||||
expect(push1.unsent).toHaveLength(2)
|
||||
const mock = await startMockOtlp([{ status: 200 }])
|
||||
try {
|
||||
await sendAttributionBatches({ endpoint: mock.url, accessToken: 't', batches: [push1.unsent] })
|
||||
|
||||
// Push 2: a later-parsed tighter session B now wins the commit; A is empty
|
||||
const push2 = collectUnsentAttribution([
|
||||
makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [] }), // loser: retraction candidate
|
||||
makeRecord({ sessionId: 'sess-B', prLinks: [], commits: [commit] }), // winner
|
||||
])
|
||||
|
||||
// A re-emits with commit_count 0 (retraction), B emits session + commit
|
||||
const kinds = push2.unsent.map(i => `${i.sessionId}:${i.kind}`).sort()
|
||||
expect(kinds).toEqual(['sess-A:session', 'sess-B:commit', 'sess-B:session'])
|
||||
const retraction = push2.unsent.find(i => i.sessionId === 'sess-A')!
|
||||
expect(retraction.commitCount).toBe(0)
|
||||
expect(retraction.dedupKey).not.toBe(push1.unsent.find(i => i.kind === 'session')!.dedupKey)
|
||||
|
||||
// A session that was NEVER sent stays excluded when empty
|
||||
const neverSent = collectUnsentAttribution([
|
||||
makeRecord({ sessionId: 'sess-never', prLinks: [], commits: [] }),
|
||||
])
|
||||
expect(neverSent.unsent).toEqual([])
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not ledger on server error', async () => {
|
||||
const { sendAttributionBatches } = await import('../src/sync/push.js')
|
||||
const { readLedger } = await import('../src/sync/ledger.js')
|
||||
|
||||
const items = flattenAttributionRecords([makeRecord()])
|
||||
const mock = await startMockOtlp([{ status: 500 }])
|
||||
try {
|
||||
const result = await sendAttributionBatches({
|
||||
endpoint: mock.url,
|
||||
accessToken: 'token-1',
|
||||
batches: [items],
|
||||
})
|
||||
expect(result.outcome).toBe('server-error')
|
||||
expect(readLedger()).toEqual([])
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue