mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-17 12:34:26 +00:00
Merge main; declare openclaude in the env-declaration guard file map
This commit is contained in:
commit
27eac2cca4
55 changed files with 4019 additions and 387 deletions
38
.github/workflows/tests.yml
vendored
Normal file
38
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
# The cache-refresh-lock files exercise a cross-process file lock and are
|
||||
# parallelism-sensitive (they fail under full worker pressure and pass serially -
|
||||
# reproduced repeatedly on unmodified main), so they run in their own serial step
|
||||
# below instead of making every PR roll dice.
|
||||
# Scoped to tests/: the Electron app's renderer tests under app/ carry
|
||||
# their own vitest config and jsdom dependency (app/node_modules) and
|
||||
# cannot run from the root install - the root default glob picking them
|
||||
# up is exactly what failed run #2 with ERR_MODULE_NOT_FOUND: jsdom.
|
||||
- name: Test suite (parallel)
|
||||
run: npx vitest run tests --exclude "tests/cache-refresh-lock*"
|
||||
# Single forked worker, so lock contention comes only from the child processes the
|
||||
# tests spawn deliberately. Quarantined (reports, never gates): the process
|
||||
# suite still races its own takeover window even serially on slow runners -
|
||||
# tracked in #904; drop continue-on-error once that race is settled.
|
||||
- name: Cache-lock suite (serial, quarantined)
|
||||
continue-on-error: true
|
||||
run: npx vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts --poolOptions.forks.singleFork=true
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,7 @@
|
|||
node_modules/
|
||||
# No trailing slash: the slash form only ignores directories, so a
|
||||
# node_modules SYMLINK (common in linked worktrees) slips into git add -A.
|
||||
# One did exactly that in db018f7 and had to be removed again in c642787.
|
||||
node_modules
|
||||
dist/
|
||||
*.tgz
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
- `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)
|
||||
- **Copilot CLI sessions report their input and cache tokens.** The Copilot CLI writes the same `producer: 'copilot-agent'` in its `session.start` events that VS Code transcripts carry, so content-based detection classified every CLI session as a transcript and skipped its `session.shutdown` rollup — the only place the CLI records input, cache-read and cache-write tokens — leaving cache hit rate at 0.0% and dramatically underreporting cost. Whether a file is a transcript is now decided by where discovery found it, never by its contents. Resumed sessions, whose legs each append a cumulative rollup, are billed as per-leg deltas so a growing session never double-counts or goes stale; the GitHub Copilot desktop app writes the same session store, so its usage is covered by the same fix. The copilot session cache takes a parse-version bump and the daily cache bumps from v16 to v17 for the one-time re-parse that heals already-recorded days whose logs still exist. (#944)
|
||||
- **Copilot CLI subagent runs are attributed to their agent.** Newer CLIs announce delegation with `subagent.started`/`subagent.completed` rather than `subagent.selected`, so delegated turns lost their agent label; the label now also clears when the subagent completes instead of bleeding onto the parent's later turns. Rides the #944 re-parse, so already-cached sessions gain the attribution. (#944)
|
||||
- **`--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)
|
||||
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
- **`--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)
|
||||
- **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included, because declaring its overrides would force a re-parse that can drop OTel history only the cache still holds; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920)
|
||||
|
||||
### Fixed
|
||||
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also
|
|||
| `codeburn report -p all` | Every recorded session |
|
||||
| `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range |
|
||||
| `codeburn report --format json` | Full dashboard data as JSON, printed to stdout |
|
||||
| `codeburn report --refresh 60` | Auto-refresh every 60s (default 30s; `--refresh 0` disables) |
|
||||
| `codeburn report --refresh 60` | Auto-refresh every 60s (the minimum and default; `--refresh 0` disables) |
|
||||
|
||||
**Status & export**
|
||||
|
||||
|
|
@ -481,7 +481,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
|
|||
| `codeburn models --task feature` | Filter to feature-development work |
|
||||
| `codeburn models --provider claude` | Filter to a single provider |
|
||||
|
||||
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects.
|
||||
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
111
SUBMISSION.md
Normal file
111
SUBMISSION.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Submission Statement
|
||||
|
||||
## Proposed title
|
||||
|
||||
Stabilize TUI refresh, scrolling, responsive layout, and dashboard data density
|
||||
|
||||
## Summary
|
||||
|
||||
This pull request repairs the terminal dashboard as one coherent rendering surface. Background refresh no longer replaces the active Optimize view or resets the viewport. The full application can scroll. The eight dashboard panels retain their order while reflowing through one, two, and three columns. Metric headings and values remain visible before labels are shortened, and Daily Activity grows to match the relevant neighboring panels.
|
||||
|
||||
The branch is rebased on upstream `main` at `2c3319b`. The implementation reuses Ink and the existing dashboard state rather than adding a dependency or a second layout engine.
|
||||
|
||||
## Maintainer review reconciliation
|
||||
|
||||
The maintainer review identified a Windows ConPTY risk in the branch's custom synchronized-update write. A maintainer supplied a narrower escape-chunk fix in `7716f95`; this reconciliation preserves its intended Windows safety while removing the application-owned terminal protocol entirely:
|
||||
|
||||
- `src/ink-win.ts` is restored to the upstream implementation.
|
||||
- The dashboard emits no manual begin/end synchronized-update sequence and no manual clear-and-home write.
|
||||
- Ink remains the sole owner of terminal synchronization.
|
||||
- CodeBurn's prepended resize handler only captures the new column count and rerenders React before Ink's ordinary resize listener paints.
|
||||
|
||||
This removes the reviewed ConPTY failure path instead of maintaining another platform-specific escape protocol. The Windows filter was checked with a mocked `win32` source-path test, and the pull request's AppX job remains the authoritative Windows package gate because no physical Windows host was available locally.
|
||||
|
||||
The same reconciliation restored the existing heavy-period refresh policy and made the CLI help truthful: Today, 7 Days, and concrete-day views may refresh automatically; 30 Days, Month, All, and Lifetime remain static between deliberate navigation changes. Every enabled interval is clamped to at least 60 seconds, and `--refresh 0` disables it.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
### Stable refresh and navigation
|
||||
|
||||
- A background result cannot replace the Optimize view after the user enters it.
|
||||
- Background work retains the current frame instead of replacing it with a loading or blank screen.
|
||||
- Refresh and resize rerenders preserve the application scroll offset.
|
||||
- Up and down move one application row, Page Up and Page Down move one viewport, and Home and End jump to the bounds.
|
||||
- Deliberate navigation to a different view, period, provider, or day begins at the top.
|
||||
|
||||
### Responsive dashboard
|
||||
|
||||
- The eight panels retain source order through one column at 89 characters or fewer, two columns from 90 through 134, and three columns from 135 upward.
|
||||
- Three-column rows use the requested 3/3/2 arrangement.
|
||||
- All three panels in a row widen equally by one character for every three additional terminal characters.
|
||||
- Growth stops at the lesser of 256 characters or the widest row the current source data can render.
|
||||
- Windows wider than 256 characters retain a populated capped dashboard.
|
||||
- Colored bars remain at the left edge of every data section; Daily Activity places its bar before the date.
|
||||
|
||||
### Complete, compact data rows
|
||||
|
||||
- Metric widths are derived from their full headings and rendered values.
|
||||
- Adjacent metric cells use exactly one separating character.
|
||||
- `Tok/s` and every other metric column always render; unavailable values display `-`.
|
||||
- Costs, including the estimated-cost `~` marker, render in full whenever the panel can hold them.
|
||||
- The project heading spells out `session`.
|
||||
- Project labels yield space before any heading or metric. Shortening removes the parent-folder prefix first, then the year in a date folder, and only then truncates the project title with a macOS-style ellipsis.
|
||||
|
||||
### Adaptive Daily Activity history
|
||||
|
||||
- One-column layout displays 10 dates.
|
||||
- Two-column layout displays `MAX(10, visible By Project rows)`.
|
||||
- Three-column layout displays `MAX(10, visible By Project rows, visible By Activity rows)`.
|
||||
- Day mode remains one date, and available history remains the upper bound.
|
||||
- Rendering, `j`/`k`, Space paging, `g`/`G`, final-page clamping, and the `Showing X-Y of Z` status share the same page-size calculation.
|
||||
- By Activity row counting and rendering share the same aggregation, so the calculated height cannot drift from the displayed panel.
|
||||
|
||||
## TDDRGR and post-implementation bug-fix rounds
|
||||
|
||||
The adaptive-row contract first failed for the intended reason: a two-column lifetime fixture with 14 visible projects rendered 10 dates. The smallest production change introduced one shared page-size calculation. After the first green run, the refactor reused the existing project-row limit and Activity aggregation, and the focused contract stayed green.
|
||||
|
||||
The maintainer reconciliation also began red. Tests proved that the maintainer head still contained application-owned synchronized writes, scheduled refreshes for four heavy periods, and advertised a 30-second interval in three CLI help surfaces. Removing the writes, restoring the period gate, and updating the help produced 59 passing focused tests.
|
||||
|
||||
Dedicated bug-fix rounds then repeated the relevant regression checks and real user path:
|
||||
|
||||
1. Daily Activity paging and bounds used the calculated 10/14/18-row sizes.
|
||||
2. Full-application End scrolling remained at the bottom after a live 89-to-100-column resize.
|
||||
3. Optimize remained mounted across live 100-to-89-column reflow, while its fake-timer refresh regression retained the view with no loading frame.
|
||||
4. An unsuccessful `incrementalRendering` experiment was removed after measurement showed no improvement; the smaller Ink-owned design remained.
|
||||
|
||||
Correctness review found no issue in the final production diff. Ponytail review concluded: `Lean already. Ship.`
|
||||
|
||||
## Validation
|
||||
|
||||
### Deterministic and build gates
|
||||
|
||||
- Focused refresh, resize, layout, scrolling, metric, and CLI-help matrix: **59/59**.
|
||||
- Relevant dashboard, model, overview, and CLI-help matrix: **72/72**.
|
||||
- Complete dashboard suite: **56/56**.
|
||||
- Desktop application suite: **462/462**.
|
||||
- Root `tests/` suite: **2,481 passed**, **3 failed**, and **5 skipped**. The same three failures reproduce at unmodified upstream `2c3319b`: two Copilot durable-orphan assertions and one provider-filter durable-total assertion. None touches this dashboard diff.
|
||||
- TypeScript checks for the CLI and desktop application: passed.
|
||||
- CLI, browser dashboard, and desktop application production builds: passed. The existing Vite warning for a browser chunk above 500 KB is unchanged.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
Running root Vitest without limiting it to `tests/` also discovers the nested desktop tests under the root configuration. That unsupported combined invocation lacks the desktop setup and produces matcher/environment failures; the canonical desktop command above passes all 462 tests.
|
||||
|
||||
### Native Ghostty inspection
|
||||
|
||||
- **241** deterministic width frames from 60 through 300 columns confirmed the 89/90 and 134/135 breakpoints, symmetric three-column growth, the 256-character cap, and populated frames above the cap.
|
||||
- **40** window-bounded Ghostty captures covered two font zoom levels, multiple window shapes, top, scrolled, and Optimize states, with most captures below 260 columns as requested.
|
||||
- **105** final settled captures shrank one column at a time from 146 through 42. All contained rendered content; no settled frame was blank.
|
||||
- **20** repeated 120-to-110-column shrink cycles rendered successfully.
|
||||
- Final live screenshots confirmed scroll-position preservation across a one-to-two-column resize and Optimize preservation across the reverse breakpoint.
|
||||
|
||||
All visual evidence used the Ghostty window ID with native `screencapture -l`; no full-display capture and no Computer Use session was used. The user's Ghostty shell was returned to its original `~` prompt, size, and position after validation.
|
||||
|
||||
## Deliberate non-changes
|
||||
|
||||
- Compare keeps its existing two-column composition; redesigning it is outside this dashboard repair.
|
||||
- The status/help bar remains part of the scrollable content, as requested during review.
|
||||
- Existing aggregation memoization and viewport-measurement behavior remain unchanged where the accepted design did not require them.
|
||||
|
||||
## Reviewer focus
|
||||
|
||||
The highest-value review is the interaction among the shared metric row, the calculated Daily Activity page size, and existing scroll state. Acceptance requires that background refresh never changes the active view or position, supported widths never lose a metric, each settled resize preserves panel order and content, and Daily Activity navigation uses the same page size shown on screen.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App'
|
||||
import { sanitizeProps } from '../electron/telemetry'
|
||||
|
|
@ -52,6 +52,16 @@ function setVisibility(state: 'visible' | 'hidden') {
|
|||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
|
||||
}
|
||||
|
||||
// The shortcut code (lib/platform.ts) reads `window.codeburn.platform` at call
|
||||
// time; stub it per test and always restore so no test leaks platform state.
|
||||
function setPlatform(platform: string): void {
|
||||
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
|
||||
}
|
||||
|
||||
function clearPlatform(): void {
|
||||
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
|
||||
}
|
||||
|
||||
function overviewPayload(): MenubarPayload {
|
||||
const now = new Date()
|
||||
return {
|
||||
|
|
@ -181,6 +191,11 @@ describe('App shortcuts', () => {
|
|||
// the app-wide default ('today'); tests that exercise the default set it.
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
setPlatform('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPlatform()
|
||||
})
|
||||
|
||||
it('applies the persisted theme on app boot before Settings mounts', async () => {
|
||||
|
|
@ -211,46 +226,59 @@ describe('App shortcuts', () => {
|
|||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => {
|
||||
it.each([
|
||||
['darwin', { metaKey: true }, '⌘'],
|
||||
['win32', { ctrlKey: true }, 'Ctrl+'],
|
||||
] as const)('keeps %s navigation, settings, and refresh shortcuts active without stale hints', async (platform, chord, mod) => {
|
||||
setPlatform(platform)
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
expect(screen.getByText('⌘1-8')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('⌘R')).toBeInTheDocument()
|
||||
expect(screen.getByText(`${mod}1-8`)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(`${mod},`).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(`${mod}R`)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Command')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Export view')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '2', ...chord })
|
||||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '3', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '3', ...chord })
|
||||
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '4', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '4', ...chord })
|
||||
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '5', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '5', ...chord })
|
||||
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '6', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '6', ...chord })
|
||||
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '7', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '7', ...chord })
|
||||
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '8', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '8', ...chord })
|
||||
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: ',', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: ',', ...chord })
|
||||
expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Back')).not.toBeInTheDocument()
|
||||
|
||||
const overviewCalls = mocks.getOverview.mock.calls.length
|
||||
fireEvent.keyDown(document, { key: 'r', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: 'r', ...chord })
|
||||
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
|
||||
})
|
||||
|
||||
it('ignores Ctrl+2 on mac', async () => {
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', ctrlKey: true })
|
||||
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('re-polls visible section data when period or provider changes', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
|
@ -481,6 +509,48 @@ describe('App shortcuts', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('win32 shortcut chords', () => {
|
||||
beforeEach(() => {
|
||||
installDefaultMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
setPlatform('win32')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPlatform()
|
||||
})
|
||||
|
||||
it('navigates with Ctrl+2 and refreshes with Ctrl+R', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', ctrlKey: true })
|
||||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
|
||||
const overviewCalls = mocks.getOverview.mock.calls.length
|
||||
fireEvent.keyDown(document, { key: 'r', ctrlKey: true })
|
||||
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
|
||||
})
|
||||
|
||||
it('ignores Meta+2 on win32', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', metaKey: true })
|
||||
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores Ctrl+Alt+2 (the AltGr shape) on win32', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', ctrlKey: true, altKey: true })
|
||||
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider prefetch storm', () => {
|
||||
const PROVIDERS = [
|
||||
'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
|
||||
|
|
@ -618,6 +688,11 @@ describe('currency correctness', () => {
|
|||
// independent of the app-wide default ('today').
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
__resetPolledMemo()
|
||||
setPlatform('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPlatform()
|
||||
})
|
||||
|
||||
it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { readDailyBudget } from './lib/budget'
|
|||
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
|
||||
import { motionClass } from './lib/motion'
|
||||
import { codeburn } from './lib/ipc'
|
||||
import { isModifierChord, shortcutLabel } from './lib/platform'
|
||||
import { localDateKey } from './lib/period'
|
||||
import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence'
|
||||
import { OverviewContent } from './sections/Overview'
|
||||
|
|
@ -440,7 +441,7 @@ function AppMain() {
|
|||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
|
||||
if (!isModifierChord(event)) return
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === '1') navigate('overview')
|
||||
else if (key === '2') navigate('sessions')
|
||||
|
|
@ -577,9 +578,9 @@ function AppMain() {
|
|||
{section !== 'settings' && (
|
||||
<Hint
|
||||
items={[
|
||||
{ k: '⌘1-8', label: 'Navigate' },
|
||||
{ k: '⌘,', label: 'Settings' },
|
||||
{ k: '⌘R', label: 'Refresh' },
|
||||
{ k: shortcutLabel('1-8'), label: 'Navigate' },
|
||||
{ k: shortcutLabel(','), label: 'Settings' },
|
||||
{ k: shortcutLabel('R'), label: 'Refresh' },
|
||||
]}
|
||||
right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,31 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
function setPlatform(platform: string): void {
|
||||
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders all nine nav items in the desktop order', () => {
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', '⌘'],
|
||||
['win32', 'Ctrl+'],
|
||||
] as const)('renders all nine nav items in the desktop order with %s keycaps', (platform, mod) => {
|
||||
setPlatform(platform)
|
||||
render(<Sidebar active="overview" onNavigate={() => {}} />)
|
||||
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, ''))
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,]/, ''))
|
||||
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
|
||||
expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Pull requests.*⌘3/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Compare.*⌘7/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Plans.*⌘8/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Sessions.*${esc(mod)}2`) })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Pull requests.*${esc(mod)}3`) })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Compare.*${esc(mod)}7`) })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Plans.*${esc(mod)}8`) })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onNavigate with the section id when a nav item is clicked', () => {
|
||||
|
|
|
|||
|
|
@ -1,37 +1,38 @@
|
|||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { shortcutLabel } from '../lib/platform'
|
||||
import { AboutModal, type SocialLink } from './AboutModal'
|
||||
import { FlameMark } from './FlameMark'
|
||||
|
||||
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [
|
||||
{ id: 'overview', label: 'Overview', key: '⌘1', icon: (
|
||||
{ id: 'overview', label: 'Overview', key: '1', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1" /><rect x="14" y="3" width="7" height="5" rx="1" /><rect x="14" y="12" width="7" height="9" rx="1" /><rect x="3" y="16" width="7" height="5" rx="1" /></svg>
|
||||
) },
|
||||
{ id: 'sessions', label: 'Sessions', key: '⌘2', icon: (
|
||||
{ id: 'sessions', label: 'Sessions', key: '2', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
|
||||
) },
|
||||
{ id: 'pullRequests', label: 'Pull requests', key: '⌘3', icon: (
|
||||
{ id: 'pullRequests', label: 'Pull requests', key: '3', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
|
||||
) },
|
||||
{ id: 'spend', label: 'Spend', key: '⌘4', icon: (
|
||||
{ id: 'spend', label: 'Spend', key: '4', icon: (
|
||||
<svg viewBox="0 0 24 24"><line x1="6" y1="20" x2="6" y2="13" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="18" y1="20" x2="18" y2="9" /></svg>
|
||||
) },
|
||||
{ id: 'optimize', label: 'Optimize', key: '⌘5', icon: (
|
||||
{ id: 'optimize', label: 'Optimize', key: '5', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="3.4"/><path d="M10.5 3v1.7M10.5 16.3V18M3 10.5h1.7M16.3 10.5H18M5.3 5.3l1.2 1.2M14.5 14.5l1.2 1.2M15.7 5.3l-1.2 1.2M6.5 14.5l-1.2 1.2"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg>
|
||||
) },
|
||||
{ id: 'models', label: 'Models', key: '⌘6', icon: (
|
||||
{ id: 'models', label: 'Models', key: '6', icon: (
|
||||
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><path d="M3.3 7 12 12l8.7-5M12 22V12" /></svg>
|
||||
) },
|
||||
{ id: 'compare', label: 'Compare', key: '⌘7', icon: (
|
||||
{ id: 'compare', label: 'Compare', key: '7', icon: (
|
||||
<svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="M16 21l4-4-4-4"/><path d="M20 17H4"/></svg>
|
||||
) },
|
||||
{ id: 'plans', label: 'Plans', key: '⌘8', icon: (
|
||||
{ id: 'plans', label: 'Plans', key: '8', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></svg>
|
||||
) },
|
||||
{ id: 'settings', label: 'Settings', key: '⌘,', icon: (
|
||||
{ id: 'settings', label: 'Settings', key: ',', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
|
||||
) },
|
||||
]
|
||||
|
|
@ -75,7 +76,7 @@ export function Sidebar({
|
|||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
<span className="k">{item.key}</span>
|
||||
<span className="k">{shortcutLabel(item.key)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="push" />
|
||||
|
|
|
|||
46
app/renderer/lib/platform.ts
Normal file
46
app/renderer/lib/platform.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Single source of truth for platform-aware shortcut behaviour. The preload
|
||||
// exposes `window.codeburn.platform` (process.platform); when the bridge is
|
||||
// absent (unit tests, vite in a plain browser) fall back to the user agent.
|
||||
// All functions read platform state at call time, never at module load, so
|
||||
// the preload bridge may appear after this module is imported.
|
||||
|
||||
function bridgePlatform(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
return (window as unknown as { codeburn?: { platform?: string } }).codeburn?.platform
|
||||
}
|
||||
|
||||
function userAgentPlatform(): string | undefined {
|
||||
if (typeof navigator === 'undefined') return undefined
|
||||
if (/mac/i.test(navigator.userAgent)) return 'darwin'
|
||||
const platform = navigator.platform
|
||||
if (typeof platform === 'string' && /mac/i.test(platform)) return 'darwin'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** True when the Electron preload reports darwin (or the UA matches a Mac). */
|
||||
export function isMacPlatform(): boolean {
|
||||
const platform = bridgePlatform()
|
||||
if (platform) return platform === 'darwin'
|
||||
return userAgentPlatform() === 'darwin'
|
||||
}
|
||||
|
||||
/** The modifier keycap label: '⌘' on mac, 'Ctrl+' elsewhere. */
|
||||
export function modKeyLabel(): string {
|
||||
return isMacPlatform() ? '⌘' : 'Ctrl+'
|
||||
}
|
||||
|
||||
/** A full shortcut label, e.g. '⌘R' on mac, 'Ctrl+R' on Windows. */
|
||||
export function shortcutLabel(key: string): string {
|
||||
return modKeyLabel() + key
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the event is the platform's modifier chord and no other modifier
|
||||
* is held. On mac: Meta (Cmd) without Ctrl. Elsewhere: Ctrl without Meta.
|
||||
* altKey stays rejected on every platform: AltGr on European layouts arrives
|
||||
* as Ctrl+Alt, and Ctrl+Alt+<key> must not hijack a typed character.
|
||||
*/
|
||||
export function isModifierChord(event: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean {
|
||||
if (event.altKey || event.shiftKey) return false
|
||||
return isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { version as appVersion } from '../../package.json'
|
|||
import { readDailyBudget } from '../lib/budget'
|
||||
import { formatConverted, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { shortcutLabel } from '../lib/platform'
|
||||
import { motionClass } from '../lib/motion'
|
||||
import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
|
@ -123,7 +124,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
|
|||
{pane === 'privacy' && <PrivacyPane />}
|
||||
</main>
|
||||
</div>
|
||||
<Hint items={[{ k: '⌘1-7', label: 'Navigate' }, { k: '⌘R', label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
|
||||
<Hint items={[{ k: shortcutLabel('1-8'), label: 'Navigate' }, { k: shortcutLabel('R'), label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -203,7 +204,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
|
|||
</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-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on {shortcutLabel('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>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,8 +60,13 @@ async function countMcpTools(projectPath?: string): Promise<number> {
|
|||
}
|
||||
|
||||
async function countSkills(projectPath?: string): Promise<number> {
|
||||
const dirs = [join(homedir(), '.claude', 'skills')]
|
||||
if (projectPath) dirs.push(join(projectPath, '.claude', 'skills'))
|
||||
// Dedupe by resolved path: when the project IS the home dir, the home and
|
||||
// project skills dirs are the same directory, and counting both double-counts
|
||||
// every skill (and inflates the context budget).
|
||||
const dirs = [...new Set([
|
||||
join(homedir(), '.claude', 'skills'),
|
||||
...(projectPath ? [join(projectPath, '.claude', 'skills')] : []),
|
||||
])]
|
||||
|
||||
let count = 0
|
||||
for (const dir of dirs) {
|
||||
|
|
@ -91,7 +96,12 @@ async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: stri
|
|||
paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' })
|
||||
}
|
||||
|
||||
// Dedupe by path so a project that IS the home dir does not read (and count)
|
||||
// ~/.claude/CLAUDE.md twice.
|
||||
const seenPaths = new Set<string>()
|
||||
for (const { path, name } of paths) {
|
||||
if (seenPaths.has(path)) continue
|
||||
seenPaths.add(path)
|
||||
if (!existsSync(path)) continue
|
||||
const content = await readSessionFile(path)
|
||||
if (content === null) continue
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import { homedir } from 'os'
|
|||
import { join } from 'path'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
// Bumped to 16: Codex discovery is structural instead of originator-gated
|
||||
// Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts
|
||||
// (#944), so days finalized at v16 or earlier carry output-only copilot costs —
|
||||
// the session.shutdown rollup's input/cache tokens were dropped. Raising
|
||||
// MIN_SUPPORTED_VERSION forces the one-time re-derivation under the
|
||||
// provenance-based classification; sourceless days carry forward as-is.
|
||||
//
|
||||
// v16: Codex discovery is structural instead of originator-gated
|
||||
// (#873/#626), so rollouts written by third-party frontends driving
|
||||
// `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now
|
||||
// contribute usage that v15 rollups never contained. Those files were rejected
|
||||
|
|
@ -67,8 +73,8 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// that older binaries skipped. v8 added local-model savings to the daily
|
||||
// rollup; the `savingsConfigHash` field is invalidated separately when the
|
||||
// user changes their `localModelSavings` mapping.
|
||||
export const DAILY_CACHE_VERSION = 16
|
||||
const MIN_SUPPORTED_VERSION = 16
|
||||
export const DAILY_CACHE_VERSION = 17
|
||||
const MIN_SUPPORTED_VERSION = 17
|
||||
// Version-suffixed so different binaries each own a distinct file and never
|
||||
// clobber an incompatible schema. Bumping the version mints a fresh filename;
|
||||
// adoptOlderDailyCaches then unions days out of every previous file (including
|
||||
|
|
@ -520,19 +526,32 @@ function emptyModelStats(): ModelDayStats {
|
|||
/// day but whose turns all landed on another) only contributes its session
|
||||
/// count, deduplicated by max — the same real session may be counted on both
|
||||
/// sides.
|
||||
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice): void {
|
||||
/// `residual` marks a slice that came out of the tz subtraction (issue #770):
|
||||
/// the subtraction already removed the placeholder's sessions (the ones the
|
||||
/// fresh parse explained), so the residual sessions are all distinct from the
|
||||
/// placeholder's and must ADD to it, not max-dedup against it. Max would clamp
|
||||
/// max(placeholder, residual) and permanently drop the source-gone sessions the
|
||||
/// residual still carries.
|
||||
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice, residual = false): void {
|
||||
// Reads keyed by names from foreign caches use hasOwn throughout: a plain
|
||||
// lookup of "__proto__" returns the prototype object, and accumulating into
|
||||
// it pollutes every object in the process.
|
||||
const placeholder = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
|
||||
const placeholderSessions = placeholder?.sessions ?? 0
|
||||
const merged = structuredClone(slice)
|
||||
if (placeholderSessions > (merged.sessions ?? 0)) merged.sessions = placeholderSessions
|
||||
if (residual) {
|
||||
// The subtraction removed the placeholder's sessions from this residual, so
|
||||
// every remaining session is distinct from the placeholder's - add, don't
|
||||
// max (max would clamp 1 + 1 to 1 and lose the source-gone session).
|
||||
merged.sessions = placeholderSessions + (merged.sessions ?? 0)
|
||||
} else if (placeholderSessions > (merged.sessions ?? 0)) {
|
||||
merged.sessions = placeholderSessions
|
||||
}
|
||||
setOwn(day.providers, provider, merged)
|
||||
day.cost += slice.cost
|
||||
day.calls += slice.calls
|
||||
day.savingsUSD += slice.savingsUSD ?? 0
|
||||
day.sessions += Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
|
||||
day.sessions += residual ? (slice.sessions ?? 0) : Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
|
||||
day.inputTokens += slice.inputTokens ?? 0
|
||||
day.outputTokens += slice.outputTokens ?? 0
|
||||
day.cacheReadTokens += slice.cacheReadTokens ?? 0
|
||||
|
|
@ -572,7 +591,7 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
|
|||
// project sessions were already counted into the day when the fresh day
|
||||
// was built, so only the excess is added.
|
||||
const placeholderProjectSessions = Object.hasOwn(placeholderProjects, name) ? num(placeholderProjects[name]?.sessions) : 0
|
||||
acc.sessions += Math.max(0, num(p.sessions) - placeholderProjectSessions)
|
||||
acc.sessions += residual ? num(p.sessions) : Math.max(0, num(p.sessions) - placeholderProjectSessions)
|
||||
setOwn(dayProjects, name, acc)
|
||||
}
|
||||
// Placeholder-only projects (session counted fresh, calls landed elsewhere)
|
||||
|
|
@ -582,7 +601,11 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
|
|||
for (const [name, p] of Object.entries(placeholderProjects)) {
|
||||
if (!p || typeof p !== 'object') continue
|
||||
if (Object.hasOwn(mergedProjects, name)) {
|
||||
if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions)
|
||||
if (residual) {
|
||||
mergedProjects[name]!.sessions = num(mergedProjects[name]!.sessions) + num(p.sessions)
|
||||
} else if (num(p.sessions) > num(mergedProjects[name]!.sessions)) {
|
||||
mergedProjects[name]!.sessions = num(p.sessions)
|
||||
}
|
||||
} else {
|
||||
setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) })
|
||||
}
|
||||
|
|
@ -598,6 +621,246 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
|
|||
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
|
||||
// --- tz-aware carry subtraction (issue #770) ---------------------------------
|
||||
//
|
||||
// After a timezone change the full re-derive re-aggregates the same session
|
||||
// parse under the CURRENT tz and merges it over the cached (old-tz) days.
|
||||
// mergeDayEntries carries a baseline slice only when the fresh day has no data
|
||||
// slice for that (date, provider), so a turn that re-bucketed across local
|
||||
// midnight leaves its old day sliceless, gets carried there, AND counts again on
|
||||
// its new day. The fix subtracts from each carried baseline slice the content
|
||||
// the fresh parse still attributes to that (date, provider) under the OLD
|
||||
// bucketing (`freshUnderOldTz`): exactly the re-bucketed turns, nothing else.
|
||||
// A sources-gone slice has no such content and survives untouched; a slice fully
|
||||
// explained away is dropped.
|
||||
|
||||
/// Reduce `base` by `sub` at the slice level, clamping every field at 0 and
|
||||
/// dropping nested entries that reduce to nothing. Returns null when no positive
|
||||
/// data remains; the merge then drops the slice instead of carrying an empty
|
||||
/// one. `sub` is always a subset of `base` in practice (same parse, old bucketing
|
||||
/// vs cached baseline), so the clamp only guards rounding and cache/baseline skew.
|
||||
function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderDaySlice | null {
|
||||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||||
const inputTokens = Math.max(0, (base.inputTokens ?? 0) - (sub.inputTokens ?? 0))
|
||||
const outputTokens = Math.max(0, (base.outputTokens ?? 0) - (sub.outputTokens ?? 0))
|
||||
const cacheReadTokens = Math.max(0, (base.cacheReadTokens ?? 0) - (sub.cacheReadTokens ?? 0))
|
||||
const cacheWriteTokens = Math.max(0, (base.cacheWriteTokens ?? 0) - (sub.cacheWriteTokens ?? 0))
|
||||
const editTurns = Math.max(0, (base.editTurns ?? 0) - (sub.editTurns ?? 0))
|
||||
const oneShotTurns = Math.max(0, (base.oneShotTurns ?? 0) - (sub.oneShotTurns ?? 0))
|
||||
const models = subtractModels(base.models, sub.models)
|
||||
const categories = subtractCategories(base.categories, sub.categories)
|
||||
const projects = subtractProjects(base.projects, sub.projects)
|
||||
const out: ProviderDaySlice = {
|
||||
calls, cost, savingsUSD,
|
||||
...(sessions > 0 ? { sessions } : {}),
|
||||
...(inputTokens > 0 ? { inputTokens } : {}),
|
||||
...(outputTokens > 0 ? { outputTokens } : {}),
|
||||
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
||||
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
|
||||
...(editTurns > 0 ? { editTurns } : {}),
|
||||
...(oneShotTurns > 0 ? { oneShotTurns } : {}),
|
||||
...(models ? { models } : {}),
|
||||
...(categories ? { categories } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
}
|
||||
return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null
|
||||
}
|
||||
|
||||
function subtractModelStats(base: ModelDayStats, sub: ModelDayStats): ModelDayStats | null {
|
||||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const inputTokens = Math.max(0, base.inputTokens - (sub.inputTokens ?? 0))
|
||||
const outputTokens = Math.max(0, base.outputTokens - (sub.outputTokens ?? 0))
|
||||
const cacheReadTokens = Math.max(0, base.cacheReadTokens - (sub.cacheReadTokens ?? 0))
|
||||
const cacheWriteTokens = Math.max(0, base.cacheWriteTokens - (sub.cacheWriteTokens ?? 0))
|
||||
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
|
||||
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
|
||||
}
|
||||
|
||||
function subtractModels(base: DailyEntry['models'] | undefined, sub: DailyEntry['models'] | undefined): DailyEntry['models'] | undefined {
|
||||
if (!base) return undefined
|
||||
const out: DailyEntry['models'] = {}
|
||||
for (const [name, stats] of Object.entries(base)) {
|
||||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||||
const reduced = s ? subtractModelStats(stats, s) : stats
|
||||
if (reduced) setOwn(out, name, reduced)
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
function subtractCategoryStats(base: CategoryDayStats, sub: CategoryDayStats): CategoryDayStats | null {
|
||||
const turns = Math.max(0, base.turns - (sub.turns ?? 0))
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const editTurns = Math.max(0, base.editTurns - (sub.editTurns ?? 0))
|
||||
const oneShotTurns = Math.max(0, base.oneShotTurns - (sub.oneShotTurns ?? 0))
|
||||
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
|
||||
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
|
||||
}
|
||||
|
||||
function subtractCategories(base: DailyEntry['categories'] | undefined, sub: DailyEntry['categories'] | undefined): DailyEntry['categories'] | undefined {
|
||||
if (!base) return undefined
|
||||
const out: DailyEntry['categories'] = {}
|
||||
for (const [name, stats] of Object.entries(base)) {
|
||||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||||
const reduced = s ? subtractCategoryStats(stats, s) : stats
|
||||
if (reduced) setOwn(out, name, reduced)
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
function subtractProjectStats(base: ProjectDayStats, sub: ProjectDayStats): ProjectDayStats | null {
|
||||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||||
return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}) }
|
||||
}
|
||||
|
||||
function subtractProjects(base: DailyEntry['projects'] | undefined, sub: DailyEntry['projects'] | undefined): DailyEntry['projects'] | undefined {
|
||||
if (!base) return undefined
|
||||
const out: DailyEntry['projects'] = {}
|
||||
for (const [name, stats] of Object.entries(base)) {
|
||||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||||
const reduced = s ? subtractProjectStats(stats, s) : stats
|
||||
if (reduced) setOwn(out, name, reduced)
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
/// How much a nested stat entry actually lost: `base` before minus `reduced`
|
||||
/// after, or null when nothing was lost. The raw `sub` is only a lower bound -
|
||||
/// with tz skew it can exceed the slice, and subtracting it would eat OTHER
|
||||
/// providers' share of the day-level breakdown.
|
||||
function modelStatsDelta(base: ModelDayStats, reduced: ModelDayStats): ModelDayStats | null {
|
||||
const calls = base.calls - reduced.calls
|
||||
const cost = base.cost - reduced.cost
|
||||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||||
const inputTokens = base.inputTokens - reduced.inputTokens
|
||||
const outputTokens = base.outputTokens - reduced.outputTokens
|
||||
const cacheReadTokens = base.cacheReadTokens - reduced.cacheReadTokens
|
||||
const cacheWriteTokens = base.cacheWriteTokens - reduced.cacheWriteTokens
|
||||
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
|
||||
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
|
||||
}
|
||||
|
||||
function categoryStatsDelta(base: CategoryDayStats, reduced: CategoryDayStats): CategoryDayStats | null {
|
||||
const turns = base.turns - reduced.turns
|
||||
const cost = base.cost - reduced.cost
|
||||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||||
const editTurns = base.editTurns - reduced.editTurns
|
||||
const oneShotTurns = base.oneShotTurns - reduced.oneShotTurns
|
||||
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
|
||||
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
|
||||
}
|
||||
|
||||
function projectStatsDelta(base: ProjectDayStats, reduced: ProjectDayStats): ProjectDayStats | null {
|
||||
const cost = base.cost - reduced.cost
|
||||
const calls = base.calls - reduced.calls
|
||||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||||
const sessions = (base.sessions ?? 0) - (reduced.sessions ?? 0)
|
||||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||||
return { cost, calls, savingsUSD, sessions }
|
||||
}
|
||||
|
||||
/// Remove `sub`'s contribution from a carried baseline day (the baseline-only
|
||||
/// date branch of the merge, where the whole day clones over). Reduces the
|
||||
/// provider's slice, the day-level totals, and the day-level models/categories/
|
||||
/// projects maps that `addSliceIntoDay` would have grown them by.
|
||||
///
|
||||
/// Every day-level subtraction uses the EFFECTIVE removal - what the provider
|
||||
/// slice actually lost (current before minus reduced after) - not the raw `sub`.
|
||||
/// With tz skew (`freshUnderOldTz` content larger than the baseline slice), the
|
||||
/// raw sub exceeds the slice and subtracting it would over-remove the day's
|
||||
/// totals and its nested maps, eating unrelated providers' carried history and
|
||||
/// breaking the invariant that a day's totals sum to its slices. A provider
|
||||
/// slice that was absent has an effective removal of zero: nothing is subtracted
|
||||
/// from the day.
|
||||
function subtractSliceFromDay(day: DailyEntry, provider: string, sub: ProviderDaySlice): void {
|
||||
const current = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
|
||||
if (!current) return
|
||||
const reduced = subtractSlice(current, sub)
|
||||
if (reduced) setOwn(day.providers, provider, reduced)
|
||||
else delete day.providers[provider]
|
||||
|
||||
day.cost = Math.max(0, day.cost - (current.cost - (reduced?.cost ?? 0)))
|
||||
day.calls = Math.max(0, day.calls - (current.calls - (reduced?.calls ?? 0)))
|
||||
day.savingsUSD = Math.max(0, (day.savingsUSD ?? 0) - ((current.savingsUSD ?? 0) - (reduced?.savingsUSD ?? 0)))
|
||||
day.sessions = Math.max(0, day.sessions - ((current.sessions ?? 0) - (reduced?.sessions ?? 0)))
|
||||
day.inputTokens = Math.max(0, day.inputTokens - ((current.inputTokens ?? 0) - (reduced?.inputTokens ?? 0)))
|
||||
day.outputTokens = Math.max(0, day.outputTokens - ((current.outputTokens ?? 0) - (reduced?.outputTokens ?? 0)))
|
||||
day.cacheReadTokens = Math.max(0, day.cacheReadTokens - ((current.cacheReadTokens ?? 0) - (reduced?.cacheReadTokens ?? 0)))
|
||||
day.cacheWriteTokens = Math.max(0, day.cacheWriteTokens - ((current.cacheWriteTokens ?? 0) - (reduced?.cacheWriteTokens ?? 0)))
|
||||
day.editTurns = Math.max(0, day.editTurns - ((current.editTurns ?? 0) - (reduced?.editTurns ?? 0)))
|
||||
day.oneShotTurns = Math.max(0, day.oneShotTurns - ((current.oneShotTurns ?? 0) - (reduced?.oneShotTurns ?? 0)))
|
||||
|
||||
for (const [name, m] of Object.entries(current.models ?? {})) {
|
||||
const rm = reduced?.models && Object.hasOwn(reduced.models, name) ? reduced.models[name] : undefined
|
||||
const removed = rm ? modelStatsDelta(m, rm) : m
|
||||
if (!removed) continue
|
||||
const acc = Object.hasOwn(day.models, name) ? day.models[name] : undefined
|
||||
if (!acc) continue
|
||||
const reducedM = subtractModelStats(acc, removed)
|
||||
if (reducedM) setOwn(day.models, name, reducedM)
|
||||
else delete day.models[name]
|
||||
}
|
||||
for (const [cat, c] of Object.entries(current.categories ?? {})) {
|
||||
const rc = reduced?.categories && Object.hasOwn(reduced.categories, cat) ? reduced.categories[cat] : undefined
|
||||
const removed = rc ? categoryStatsDelta(c, rc) : c
|
||||
if (!removed) continue
|
||||
const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat] : undefined
|
||||
if (!acc) continue
|
||||
const reducedC = subtractCategoryStats(acc, removed)
|
||||
if (reducedC) setOwn(day.categories, cat, reducedC)
|
||||
else delete day.categories[cat]
|
||||
}
|
||||
if (!day.projects) return
|
||||
for (const [name, p] of Object.entries(current.projects ?? {})) {
|
||||
const rp = reduced?.projects && Object.hasOwn(reduced.projects, name) ? reduced.projects[name] : undefined
|
||||
const removed = rp ? projectStatsDelta(p, rp) : p
|
||||
if (!removed) continue
|
||||
const acc = Object.hasOwn(day.projects, name) ? day.projects[name] : undefined
|
||||
if (!acc) continue
|
||||
const reducedP = subtractProjectStats(acc, removed)
|
||||
if (reducedP) setOwn(day.projects, name, reducedP)
|
||||
else delete day.projects[name]
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the tz subtraction leave any positive data on a carried baseline day?
|
||||
/// Mirrors the merge's own carry criterion (`hasSliceData` or sessions) at the
|
||||
/// day level, extended to the day's other scalar and nested content.
|
||||
function hasPositiveDayContent(day: DailyEntry): boolean {
|
||||
if (day.cost > 0 || day.calls > 0 || (day.savingsUSD ?? 0) > 0 || day.sessions > 0) return true
|
||||
if (day.inputTokens > 0 || day.outputTokens > 0 || day.cacheReadTokens > 0 || day.cacheWriteTokens > 0) return true
|
||||
if (day.editTurns > 0 || day.oneShotTurns > 0) return true
|
||||
if (Object.keys(day.providers).length > 0) return true
|
||||
if (Object.keys(day.models).length > 0 || Object.keys(day.categories).length > 0) return true
|
||||
if (day.projects && Object.keys(day.projects).length > 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/// Index `freshUnderOldTz` (the same parse re-aggregated under the cache's OLD
|
||||
/// tzKey) by date then provider, so the merge can subtract exactly what the
|
||||
/// fresh parse still explains under the old bucketing.
|
||||
function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> {
|
||||
const byDate = new Map<string, Map<string, ProviderDaySlice>>()
|
||||
for (const day of days) {
|
||||
if (Object.keys(day.providers).length === 0) continue
|
||||
const byProvider = new Map<string, ProviderDaySlice>()
|
||||
for (const [provider, slice] of Object.entries(day.providers)) {
|
||||
byProvider.set(provider, slice)
|
||||
}
|
||||
byDate.set(day.date, byProvider)
|
||||
}
|
||||
return byDate
|
||||
}
|
||||
|
||||
/// Merge two day lists per (date, provider): `primary` wins wherever both have
|
||||
/// data; `secondary` only fills dates primary lacks entirely and provider
|
||||
/// slices primary lacks on shared dates. Nothing in secondary can overwrite or
|
||||
|
|
@ -613,13 +876,36 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
|
|||
/// A primary slice blocks a secondary one only when it carries DATA; a
|
||||
/// zero-data placeholder (sessions only) is merged into, not treated as a
|
||||
/// re-derivation of the provider's day.
|
||||
export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], markSecondaryCarried: boolean): DailyEntry[] {
|
||||
/// `subtract`, present ONLY on the tz-change re-derive, maps (date, provider)
|
||||
/// to the content the fresh parse still attributes there under the OLD
|
||||
/// bucketing. Every baseline slice the merge would otherwise carry has that
|
||||
/// content subtracted first (clamped at 0, dropped when nothing positive
|
||||
/// remains), so turns that re-bucketed across local midnight are not counted on
|
||||
/// both their old and new days. Absent (undefined) on every other path, which
|
||||
/// keeps those merges byte-identical to the pre-fix behavior.
|
||||
export function mergeDayEntries(
|
||||
primary: DailyEntry[],
|
||||
secondary: DailyEntry[],
|
||||
markSecondaryCarried: boolean,
|
||||
subtract?: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>>,
|
||||
): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
for (const day of primary) byDate.set(day.date, structuredClone(day))
|
||||
for (const day of secondary) {
|
||||
const existing = byDate.get(day.date)
|
||||
if (!existing) {
|
||||
const copy = structuredClone(day)
|
||||
if (subtract) {
|
||||
const subForDate = subtract.get(day.date)
|
||||
if (subForDate) {
|
||||
for (const [provider, slice] of Object.entries(copy.providers)) {
|
||||
const subSlice = subForDate.get(provider)
|
||||
if (!subSlice) continue
|
||||
subtractSliceFromDay(copy, provider, subSlice)
|
||||
}
|
||||
if (!hasPositiveDayContent(copy)) continue
|
||||
}
|
||||
}
|
||||
if (markSecondaryCarried) copy.carried = true
|
||||
byDate.set(day.date, copy)
|
||||
continue
|
||||
|
|
@ -631,7 +917,22 @@ export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[],
|
|||
if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue
|
||||
const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined
|
||||
if (existingSlice && hasSliceData(existingSlice)) continue
|
||||
addSliceIntoDay(existing, provider, slice)
|
||||
let toAdd = slice
|
||||
let residual = false
|
||||
if (subtract) {
|
||||
const subSlice = subtract.get(day.date)?.get(provider)
|
||||
if (subSlice) {
|
||||
const reduced = subtractSlice(slice, subSlice)
|
||||
if (!reduced) continue
|
||||
toAdd = reduced
|
||||
// The subtraction already removed the sessions the fresh parse
|
||||
// explained, so the residual's sessions are distinct from the fresh
|
||||
// placeholder's: merging over it must ADD, not max-dedup (fix round
|
||||
// 1 - max would drop the source-gone sessions the residual carries).
|
||||
residual = true
|
||||
}
|
||||
}
|
||||
addSliceIntoDay(existing, provider, toAdd, residual)
|
||||
if (markSecondaryCarried) existing.carried = true
|
||||
}
|
||||
}
|
||||
|
|
@ -679,6 +980,12 @@ export async function ensureCacheHydrated(
|
|||
/// So the backfill is only marked `complete` when this returns true. Defaults
|
||||
/// to a trusting `true` for callers that don't (or can't) supply it.
|
||||
sessionComplete: () => boolean = () => true,
|
||||
/// Re-aggregate the SAME parsed projects under an explicit timezone instead of
|
||||
/// the machine's local one. Used only on a tz-change re-derive: the result is
|
||||
/// compared against the fresh local-tz days to subtract the turns that
|
||||
/// re-bucketed across local midnight from the carried baseline (issue #770).
|
||||
/// Absent, the tz-change path carries forward exactly as it did before.
|
||||
aggregateDaysInTz?: (projects: ProjectSummary[], tz: string) => DailyEntry[],
|
||||
): Promise<DailyCache> {
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
|
@ -744,16 +1051,50 @@ export async function ensureCacheHydrated(
|
|||
const priorWatermark = c.lastComputedDate
|
||||
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
|
||||
let freshDays: DailyEntry[] = []
|
||||
let projects: ProjectSummary[] = []
|
||||
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd }))
|
||||
// Hoisted so a tz-change re-derive can aggregate the SAME parse twice
|
||||
// (once under the current tz as freshDays, once under the cache's old
|
||||
// tzKey as freshUnderOldTz) without a second session parse.
|
||||
//
|
||||
// The parse stops at yesterdayEnd. Keeping it a HISTORY parse is what
|
||||
// makes the parser slice a midnight-straddling turn at the yesterday
|
||||
// boundary: day-N's turn-level category/counts then carry only the
|
||||
// pre-midnight half and today's live parse carries the rest, so the two
|
||||
// sides reconcile (issue #852). Widening THIS parse through now would
|
||||
// leave the full turn on day N while today's half was excluded from the
|
||||
// cache, breaking that reconciliation - so the subtraction below gets
|
||||
// its own through-now parse instead.
|
||||
projects = await parseSessions({ start: backfillStart, end: yesterdayEnd })
|
||||
freshDays = aggregateDays(projects)
|
||||
}
|
||||
const parseWasComplete = sessionComplete()
|
||||
// A PARTIAL parse must not overwrite finalized baseline days with
|
||||
// undercounts (if their sources die before the next complete parse, the
|
||||
// undercount would be what survives). Partial fresh data only fills days
|
||||
// and slices the baseline lacks; the next complete parse gets to win.
|
||||
//
|
||||
// On a complete-parse TZ re-derive (savings config untouched), subtract
|
||||
// from each carried baseline slice the content the fresh parse still
|
||||
// attributes to that (date, provider) under the OLD bucketing: the turns
|
||||
// that re-bucketed across local midnight. That is the issue #770
|
||||
// double-count; re-pricing drift (a savings-hash change) must never be
|
||||
// subtracted, so a hash change in the same re-derive skips this entirely.
|
||||
let tzSubtraction: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> | undefined
|
||||
if (parseWasComplete && tzChanged && c.savingsConfigHash === savingsConfigHash && aggregateDaysInTz && c.tzKey !== undefined) {
|
||||
// The subtraction re-parses THROUGH NOW (fix round 1): a call bucketed
|
||||
// to OLD-tz yesterday that re-buckets to NEW-tz TODAY sits past the
|
||||
// history parse's yesterdayEnd, so `freshUnderOldTz` built from `projects`
|
||||
// would never see it - the baseline slice would be carried un-subtracted
|
||||
// while today's live parse counts it again. This second parse exists
|
||||
// ONLY for the subtraction; it never feeds freshDays, so the merged
|
||||
// days written to the cache stay exactly the history days and today is
|
||||
// still owned by the caller's live parse.
|
||||
const wideProjects = await parseSessions({ start: backfillStart, end: now })
|
||||
tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey))
|
||||
}
|
||||
const merged = parseWasComplete
|
||||
? mergeDayEntries(freshDays, baseline, true)
|
||||
? mergeDayEntries(freshDays, baseline, true, tzSubtraction)
|
||||
: mergeDayEntries(baseline, freshDays, false)
|
||||
c = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { homedir } from 'os'
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
|
||||
import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement } from 'ink'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
|
|
@ -27,6 +27,15 @@ export type DailyActivityRow = {
|
|||
calls: number
|
||||
}
|
||||
|
||||
export const DAILY_ACTIVITY_PAGE_SIZE = 10
|
||||
export const INTERACTIVE_RENDER_OPTIONS = { alternateScreen: true } as const
|
||||
|
||||
export function getDailyActivityPageSize(columnCount: 1 | 2 | 3, projectRows: number, activityRows: number, dayMode = false): number {
|
||||
if (dayMode) return 1
|
||||
if (columnCount === 1) return DAILY_ACTIVITY_PAGE_SIZE
|
||||
return Math.max(DAILY_ACTIVITY_PAGE_SIZE, projectRows, columnCount === 3 ? activityRows : 0)
|
||||
}
|
||||
|
||||
export function pageHistoryCursor(cursor: number, direction: -1 | 1, pageSize: number, rowCount: number): number {
|
||||
const maxCursor = Math.max(0, rowCount - pageSize)
|
||||
return Math.max(0, Math.min(cursor + direction * pageSize, maxCursor))
|
||||
|
|
@ -56,9 +65,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 MAX_DASHBOARD_WIDTH = 256
|
||||
const ORANGE = '#FF8C42'
|
||||
const DIM = '#555555'
|
||||
const GOLD = '#FFD700'
|
||||
|
|
@ -214,16 +222,20 @@ function nextTick(): Promise<void> {
|
|||
return new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
export type Layout = { dashWidth: number; columnCount: 1 | 2 | 3; panelWidth: number; barWidth: number }
|
||||
|
||||
export function getLayout(columns?: number): Layout {
|
||||
export function getLayout(columns?: number, maxContentWidth = MAX_DASHBOARD_WIDTH): Layout {
|
||||
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
|
||||
const dashWidth = Math.min(160, termWidth)
|
||||
const wide = dashWidth >= MIN_WIDE
|
||||
const halfWidth = wide ? Math.floor(dashWidth / 2) : dashWidth
|
||||
const inner = halfWidth - 4
|
||||
const barWidth = Math.max(6, Math.min(10, inner - 30))
|
||||
return { dashWidth, wide, halfWidth, barWidth }
|
||||
const dashWidth = Math.min(MAX_DASHBOARD_WIDTH, maxContentWidth, termWidth)
|
||||
const columnCount = dashWidth >= 135 ? 3 : dashWidth >= MIN_WIDE ? 2 : 1
|
||||
const panelWidth = Math.floor(dashWidth / columnCount)
|
||||
const inner = panelWidth - 4
|
||||
const barWidth = Math.max(6, Math.min(10, Math.floor(inner / 6)))
|
||||
return { dashWidth, columnCount, panelWidth, barWidth }
|
||||
}
|
||||
|
||||
export function getRefreshIntervalMs(seconds: number): number {
|
||||
return seconds <= 0 ? 0 : Math.max(60, seconds) * 1000
|
||||
}
|
||||
|
||||
function HBar({ value, max, width }: { value: number; max: number; width: number }) {
|
||||
|
|
@ -256,6 +268,55 @@ function fit(s: string, n: number): string {
|
|||
return s.length > n ? s.slice(0, n) : s.padEnd(n)
|
||||
}
|
||||
|
||||
type MetricCell = { text: string; color?: string; dimColor?: boolean }
|
||||
|
||||
function getMetricWidths(headers: string[], rows: string[][]): number[] {
|
||||
return headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index]?.length ?? 0)))
|
||||
}
|
||||
|
||||
function getMetricGroupWidth(metricWidths: number[]): number {
|
||||
return metricWidths.reduce((sum, width) => sum + width, 0) + Math.max(0, metricWidths.length - 1)
|
||||
}
|
||||
|
||||
function getDataRowLayout(panelWidth: number, requestedBarWidth: number, metricWidths: number[]) {
|
||||
const innerWidth = panelWidth - PANEL_CHROME
|
||||
const metricsWidth = getMetricGroupWidth(metricWidths)
|
||||
const barWidth = Math.max(1, Math.min(requestedBarWidth, innerWidth - metricsWidth - 3))
|
||||
const labelWidth = Math.max(1, innerWidth - barWidth - metricsWidth - 2)
|
||||
return { innerWidth, barWidth, labelWidth }
|
||||
}
|
||||
|
||||
function DataRow({ panelWidth, barWidth: requestedBarWidth, label, metrics, metricWidths, bar, labelColor, dimColor }: {
|
||||
panelWidth: number
|
||||
barWidth: number
|
||||
label: string
|
||||
metrics: MetricCell[]
|
||||
metricWidths: number[]
|
||||
bar?: { value: number; max: number }
|
||||
labelColor?: string
|
||||
dimColor?: boolean
|
||||
}) {
|
||||
const { innerWidth, barWidth, labelWidth } = getDataRowLayout(panelWidth, requestedBarWidth, metricWidths)
|
||||
const labelNode = <Text color={labelColor} dimColor={dimColor} wrap="truncate-end">{fit(label, labelWidth)}</Text>
|
||||
const barNode = bar ? <HBar value={bar.value} max={bar.max} width={barWidth} /> : <Text>{' '.repeat(barWidth)}</Text>
|
||||
return (
|
||||
<Box width={innerWidth}>
|
||||
{barNode}<Text> </Text>{labelNode}
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
{metrics.map((metric, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <Text> </Text>}
|
||||
<Box width={metricWidths[index]} justifyContent="flex-end" flexShrink={0}>
|
||||
<Text color={metric.color} dimColor={metric.dimColor} wrap="truncate-end">{metric.text}</Text>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPlanBar(percentUsed: number, width: number): string {
|
||||
if (percentUsed <= 100) {
|
||||
const capped = Math.max(0, percentUsed)
|
||||
|
|
@ -384,20 +445,27 @@ function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor
|
|||
const orderedRows = scrollable ? [...allRows].reverse() : allRows
|
||||
const rows = scrollable ? orderedRows.slice(cursor, cursor + days) : orderedRows.slice(-days)
|
||||
const maxCost = Math.max(0, ...(scrollable ? orderedRows : rows).map(row => row.cost))
|
||||
const headers = ['cost', 'calls']
|
||||
const values = rows.map(row => [formatCost(row.cost), String(row.calls)])
|
||||
const metricWidths = getMetricWidths(headers, values)
|
||||
|
||||
return (
|
||||
<Panel title="Daily Activity" color={PANEL_COLORS.daily} width={pw}>
|
||||
{loading
|
||||
? <Text dimColor>Loading daily history...</Text>
|
||||
: <>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd((scrollable ? 11 : 6) + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)}</Text>
|
||||
{rows.map(row => (
|
||||
<Text key={row.day} wrap="truncate-end">
|
||||
<Text dimColor>{scrollable ? row.day : row.day.slice(5)} </Text>
|
||||
<HBar value={row.cost} max={maxCost} width={bw} />
|
||||
<Text color={GOLD}>{formatCost(row.cost).padStart(8)}</Text>
|
||||
<Text>{String(row.calls).padStart(6)}</Text>
|
||||
</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" dimColor metrics={headers.map(text => ({ text, dimColor: true }))} metricWidths={metricWidths} />
|
||||
{rows.map((row, index) => (
|
||||
<DataRow
|
||||
key={row.day}
|
||||
panelWidth={pw}
|
||||
barWidth={bw}
|
||||
label={scrollable ? row.day : row.day.slice(5)}
|
||||
labelColor={DIM}
|
||||
bar={{ value: row.cost, max: maxCost }}
|
||||
metrics={[{ text: values[index]![0]!, color: GOLD }, { text: values[index]![1]! }]}
|
||||
metricWidths={metricWidths}
|
||||
/>
|
||||
))}
|
||||
{scrollable && orderedRows.length > 0 && (
|
||||
<Text dimColor wrap="truncate-end">{dailyActivityFooter(cursor, days, orderedRows.length)}</Text>
|
||||
|
|
@ -410,7 +478,14 @@ function DailyActivity({ projects, days = 14, pw, bw, scrollable = false, cursor
|
|||
const _home = homedir()
|
||||
const _homePrefix = _home.endsWith('/') ? _home : _home + '/'
|
||||
|
||||
export function shortProject(absPath: string): string {
|
||||
function ellipsizeEnd(value: string, width: number): string {
|
||||
if (value.length <= width) return value
|
||||
if (width <= 0) return ''
|
||||
if (width === 1) return '…'
|
||||
return `${value.slice(0, width - 1)}…`
|
||||
}
|
||||
|
||||
export function shortProject(absPath: string, width = Infinity): string {
|
||||
const normalized = absPath.replace(/\\/g, '/')
|
||||
let path: string
|
||||
if (normalized === _home) path = ''
|
||||
|
|
@ -420,49 +495,100 @@ export function shortProject(absPath: string): string {
|
|||
path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '')
|
||||
if (!path) return 'home'
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length <= 3) return parts.join('/')
|
||||
return parts.slice(-3).join('/')
|
||||
const visible = parts.length <= 3 ? parts : parts.slice(-3)
|
||||
const full = visible.join('/')
|
||||
if (full.length <= width) return full
|
||||
|
||||
const title = visible.at(-1)!
|
||||
const date = visible.slice(0, -1).find(part => /^\d{4}-\d{2}-\d{2}$/.test(part))
|
||||
const folderElided = date ? `…/${date}/${title}` : `…/${title}`
|
||||
if (folderElided.length <= width) return folderElided
|
||||
|
||||
const dateElided = date ? `…/…${date.slice(4)}/${title}` : folderElided
|
||||
if (dateElided.length <= width) return dateElided
|
||||
|
||||
const prefix = date ? `…/…${date.slice(4)}/` : '…/'
|
||||
if (width > prefix.length) return prefix + ellipsizeEnd(title, width - prefix.length)
|
||||
|
||||
const compactPrefix = '…/…/'
|
||||
if (width > compactPrefix.length) return compactPrefix + ellipsizeEnd(title, width - compactPrefix.length)
|
||||
return ellipsizeEnd(title, width)
|
||||
}
|
||||
|
||||
const PROJECT_COL_AVG = 7
|
||||
const PROJECT_COL_BASE_WIDTH = 30
|
||||
const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40
|
||||
export function getDashboardMaxWidth(projects: ProjectSummary[], budgets?: Map<string, ContextBudget>, activeProvider?: string): number {
|
||||
const sessions = projects.flatMap(project => project.sessions)
|
||||
const longest = (values: string[]) => Math.max(1, ...values.map(value => value.length))
|
||||
const rowWidth = (labels: string[], metricCount: number, metricWidth = 7) =>
|
||||
PANEL_CHROME + 10 + 1 + longest(labels) + metricCount * metricWidth
|
||||
const modelTotals = aggregateModelTotals(projects)
|
||||
const modelMetricWidth = Math.max(7, ...Object.values(modelTotals).map(model =>
|
||||
markEstimated(formatCost(model.costUSD), model.estimatedCostUSD > 0).length
|
||||
))
|
||||
const categoryLabels = sessions.flatMap(session => Object.keys(session.categoryBreakdown).map(category => CATEGORY_LABELS[category as TaskCategory] ?? category))
|
||||
const skillLabels = sessions.flatMap(session => Object.keys(session.skillBreakdown))
|
||||
const agentLabels = sessions.flatMap(session => Object.keys(session.subagentBreakdown))
|
||||
const widestPanel = Math.max(
|
||||
rowWidth(['2026-00-00'], 2),
|
||||
rowWidth(projects.map(project => shortProject(project.projectPath)), budgets?.size ? 4 : 3, budgets?.size ? 9 : 7),
|
||||
rowWidth(Object.keys(modelTotals), 5, modelMetricWidth),
|
||||
rowWidth([...categoryLabels, ...skillLabels.map(skill => ` /${skill}`)], 3),
|
||||
rowWidth(sessions.flatMap(session => Object.keys(session.mcpBreakdown)), 1),
|
||||
rowWidth(sessions.flatMap(session => Object.keys(session.toolBreakdown).filter(tool => activeProvider === 'cursor' ? tool.startsWith('lang:') : !tool.startsWith('lang:'))), 1),
|
||||
rowWidth(sessions.flatMap(session => Object.keys(session.bashBreakdown)), 1),
|
||||
rowWidth([...skillLabels, ...agentLabels], 2),
|
||||
)
|
||||
return Math.min(MAX_DASHBOARD_WIDTH, Math.max(135, widestPanel * 3))
|
||||
}
|
||||
|
||||
function getProjectBreakdownRowLimit(period: Period, dayMode = false): number {
|
||||
return dayMode ? 8 : period === 'all' || period === 'lifetime' || period === 'month' || period === '30days' ? 14 : 8
|
||||
}
|
||||
|
||||
function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget>; rows?: number }) {
|
||||
const maxCost = Math.max(...projects.map(p => p.totalCostUSD))
|
||||
const hasBudgets = budgets && budgets.size > 0
|
||||
const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH))
|
||||
const headers = ['cost', 'avg/s', 'session', ...(hasBudgets ? ['overhead'] : [])]
|
||||
const visibleProjects = projects.slice(0, rows)
|
||||
const values = visibleProjects.map(project => {
|
||||
const budget = budgets?.get(project.project)
|
||||
return [
|
||||
formatCost(project.totalCostUSD),
|
||||
project.sessions.length > 0 ? formatCost(project.totalCostUSD / project.sessions.length) : '-',
|
||||
String(project.sessions.length),
|
||||
...(hasBudgets ? [budget ? formatTokens(budget.total) : '-'] : []),
|
||||
]
|
||||
})
|
||||
const metricWidths = getMetricWidths(headers, values)
|
||||
const desiredLabelWidth = 8
|
||||
const projectBarWidth = Math.max(1, Math.min(bw, pw - PANEL_CHROME - getMetricGroupWidth(metricWidths) - 2 - desiredLabelWidth))
|
||||
const { labelWidth } = getDataRowLayout(pw, projectBarWidth, metricWidths)
|
||||
return (
|
||||
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
|
||||
</Text>
|
||||
{projects.slice(0, rows).map((project, i) => {
|
||||
const budget = budgets?.get(project.project)
|
||||
const avgCost = project.sessions.length > 0
|
||||
? formatCost(project.totalCostUSD / project.sessions.length)
|
||||
: '-'
|
||||
<DataRow panelWidth={pw} barWidth={projectBarWidth} label="" metrics={headers.map(text => ({ text, dimColor: true }))} metricWidths={metricWidths} />
|
||||
{visibleProjects.map((project, i) => {
|
||||
const row = values[i]!
|
||||
return (
|
||||
<Text key={`${project.project}-${i}`} wrap="truncate-end">
|
||||
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(shortProject(project.projectPath), nw)}</Text>
|
||||
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
|
||||
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
|
||||
<Text>{String(project.sessions.length).padStart(6)}</Text>
|
||||
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
|
||||
</Text>
|
||||
<DataRow
|
||||
key={`${project.project}-${i}`}
|
||||
panelWidth={pw}
|
||||
barWidth={projectBarWidth}
|
||||
label={shortProject(project.projectPath, labelWidth)}
|
||||
labelColor={DIM}
|
||||
bar={{ value: project.totalCostUSD, max: maxCost }}
|
||||
metrics={[
|
||||
{ text: row[0]!, color: GOLD },
|
||||
{ text: row[1]!, color: GOLD },
|
||||
{ text: row[2]! },
|
||||
...(hasBudgets ? [{ text: row[3]!, color: '#7B9EF5' }] : []),
|
||||
]}
|
||||
metricWidths={metricWidths}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
|
|
@ -471,11 +597,27 @@ 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 costLabels = sorted.map(([, data]) => markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0))
|
||||
const headers = ['cost', 'cache', 'calls', '1-shot', 'Tok/s']
|
||||
const values = sorted.map(([model, data], index) => {
|
||||
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
|
||||
const efficiency = modelEfficiency.get(model)
|
||||
return [
|
||||
costLabels[index]!,
|
||||
totalInput > 0 ? `${((data.cacheRead / totalInput) * 100).toFixed(1)}%` : '-',
|
||||
String(data.calls),
|
||||
efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
|
||||
? `${efficiency.oneShotRate.toFixed(1)}%`
|
||||
: '-',
|
||||
data.activeDurationMs > 0 && data.activeGeneratedTokens > 0
|
||||
? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1)
|
||||
: '-',
|
||||
]
|
||||
})
|
||||
const metricWidths = getMetricWidths(headers, values)
|
||||
const desiredLabelWidth = 5
|
||||
const modelBarWidth = Math.max(1, Math.min(bw, pw - PANEL_CHROME - getMetricGroupWidth(metricWidths) - 2 - desiredLabelWidth))
|
||||
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
|
||||
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
|
||||
model,
|
||||
|
|
@ -486,28 +628,25 @@ 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)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={modelBarWidth} label="" metrics={headers.map(text => ({ text, dimColor: true }))} metricWidths={metricWidths} />
|
||||
{sorted.map(([model, data], i) => {
|
||||
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
|
||||
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
|
||||
const cacheLabel = totalInput > 0 ? `${cacheHit.toFixed(1)}%` : '-'
|
||||
const efficiency = modelEfficiency.get(model)
|
||||
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)
|
||||
: '-'
|
||||
const row = values[i]!
|
||||
return (
|
||||
<Text key={`${model}-${i}`} wrap="truncate-end">
|
||||
<HBar value={data.costUSD} max={maxCost} width={bw} />
|
||||
<Text> {fit(model, MODEL_NAME_WIDTH)}</Text>
|
||||
<Text color={GOLD}>{markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)}</Text>
|
||||
<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>
|
||||
<DataRow
|
||||
key={`${model}-${i}`}
|
||||
panelWidth={pw}
|
||||
barWidth={modelBarWidth}
|
||||
label={model}
|
||||
bar={{ value: data.costUSD, max: maxCost }}
|
||||
metrics={[
|
||||
{ text: row[0]!, color: GOLD },
|
||||
{ text: row[1]! },
|
||||
{ text: row[2]! },
|
||||
{ text: row[3]! },
|
||||
{ text: row[4]! },
|
||||
]}
|
||||
metricWidths={metricWidths}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{unpriced.length > 0 && (
|
||||
|
|
@ -518,16 +657,14 @@ 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>
|
||||
)}
|
||||
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const SKILL_SUB_ROWS_LIMIT = 5
|
||||
|
||||
function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
function aggregateActivityBreakdown(projects: ProjectSummary[]) {
|
||||
const categoryTotals: Record<string, { turns: number; costUSD: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
const skillTotals: Record<string, { turns: number; costUSD: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
for (const project of projects) {
|
||||
|
|
@ -550,32 +687,58 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p
|
|||
}
|
||||
const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
|
||||
const sortedSkills = Object.entries(skillTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD).slice(0, SKILL_SUB_ROWS_LIMIT)
|
||||
return { sorted, sortedSkills }
|
||||
}
|
||||
|
||||
function getActivityBreakdownRowCount(projects: ProjectSummary[]): number {
|
||||
const { sorted, sortedSkills } = aggregateActivityBreakdown(projects)
|
||||
return sorted.length + (sorted.some(([category]) => category === 'general') ? sortedSkills.length : 0)
|
||||
}
|
||||
|
||||
function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const { sorted, sortedSkills } = aggregateActivityBreakdown(projects)
|
||||
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
|
||||
const headers = ['cost', 'turns', '1-shot']
|
||||
const values = [
|
||||
...sorted.map(([, data]) => [formatCost(data.costUSD), String(data.turns), data.editTurns > 0 ? `${Math.round((data.oneShotTurns / data.editTurns) * 100)}%` : '-']),
|
||||
...sortedSkills.map(([, data]) => [formatCost(data.costUSD), String(data.turns), data.editTurns > 0 ? `${Math.round((data.oneShotTurns / data.editTurns) * 100)}%` : '-']),
|
||||
]
|
||||
const metricWidths = getMetricWidths(headers, values)
|
||||
return (
|
||||
<Panel title="By Activity" color={PANEL_COLORS.activity} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" metrics={headers.map(text => ({ text, dimColor: true }))} metricWidths={metricWidths} />
|
||||
{sorted.flatMap(([cat, data]) => {
|
||||
const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-'
|
||||
const rows = [
|
||||
<Text key={cat} wrap="truncate-end">
|
||||
<HBar value={data.costUSD} max={maxCost} width={bw} />
|
||||
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}> {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}</Text>
|
||||
<Text color={GOLD}>{formatCost(data.costUSD).padStart(8)}</Text>
|
||||
<Text>{String(data.turns).padStart(6)}</Text>
|
||||
<Text color={data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE}>{String(oneShotPct).padStart(7)}</Text>
|
||||
</Text>,
|
||||
const rows: React.ReactNode[] = [
|
||||
<DataRow
|
||||
key={cat}
|
||||
panelWidth={pw}
|
||||
barWidth={bw}
|
||||
label={CATEGORY_LABELS[cat as TaskCategory] ?? cat}
|
||||
labelColor={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}
|
||||
bar={{ value: data.costUSD, max: maxCost }}
|
||||
metrics={[
|
||||
{ text: formatCost(data.costUSD), color: GOLD },
|
||||
{ text: String(data.turns) },
|
||||
{ text: oneShotPct, color: data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE },
|
||||
]}
|
||||
metricWidths={metricWidths}
|
||||
/>,
|
||||
]
|
||||
if (cat === 'general' && sortedSkills.length > 0) {
|
||||
for (const [skill, sd] of sortedSkills) {
|
||||
const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-'
|
||||
rows.push(
|
||||
<Text key={`${cat}:${skill}`} wrap="truncate-end" dimColor>
|
||||
<HBar value={sd.costUSD} max={maxCost} width={bw} />
|
||||
<Text> {fit(` /${skill}`, 13)}</Text>
|
||||
<Text>{formatCost(sd.costUSD).padStart(8)}</Text>
|
||||
<Text>{String(sd.turns).padStart(6)}</Text>
|
||||
<Text>{String(subPct).padStart(7)}</Text>
|
||||
</Text>,
|
||||
<DataRow
|
||||
key={`${cat}:${skill}`}
|
||||
panelWidth={pw}
|
||||
barWidth={bw}
|
||||
label={` /${skill}`}
|
||||
dimColor
|
||||
bar={{ value: sd.costUSD, max: maxCost }}
|
||||
metrics={[{ text: formatCost(sd.costUSD), dimColor: true }, { text: String(sd.turns), dimColor: true }, { text: subPct, dimColor: true }]}
|
||||
metricWidths={metricWidths}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -597,19 +760,15 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
|
|||
}
|
||||
const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a)
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
const metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)]))
|
||||
return (
|
||||
<Panel title={title ?? 'Core Tools'} color={PANEL_COLORS.tools} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" metrics={[{ text: 'calls', dimColor: true }]} metricWidths={metricWidths} />
|
||||
{sorted.slice(0, 10).map(([tool, calls]) => {
|
||||
const raw = filterPrefix ? tool.slice(filterPrefix.length) : tool
|
||||
const display = filterPrefix ? (LANG_DISPLAY_NAMES[raw] ?? raw) : raw
|
||||
return (
|
||||
<Text key={tool} wrap="truncate-end">
|
||||
<HBar value={calls} max={maxCalls} width={bw} />
|
||||
<Text> {fit(display, nw)}</Text>
|
||||
<Text>{String(calls).padStart(7)}</Text>
|
||||
</Text>
|
||||
<DataRow key={tool} panelWidth={pw} barWidth={bw} label={display} bar={{ value: calls, max: maxCalls }} metrics={[{ text: String(calls) }]} metricWidths={metricWidths} />
|
||||
)
|
||||
})}
|
||||
</Panel>
|
||||
|
|
@ -623,12 +782,12 @@ function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: nu
|
|||
const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a)
|
||||
if (sorted.length === 0) return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
const metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)]))
|
||||
return (
|
||||
<Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" metrics={[{ text: 'calls', dimColor: true }]} metricWidths={metricWidths} />
|
||||
{sorted.slice(0, 8).map(([server, calls]) => (
|
||||
<Text key={server} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(server, nw)}</Text><Text>{String(calls).padStart(6)}</Text></Text>
|
||||
<DataRow key={server} panelWidth={pw} barWidth={bw} label={server} bar={{ value: calls, max: maxCalls }} metrics={[{ text: String(calls) }]} metricWidths={metricWidths} />
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -640,12 +799,12 @@ function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: n
|
|||
const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a)
|
||||
if (sorted.length === 0) return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
const metricWidths = getMetricWidths(['calls'], sorted.map(([, calls]) => [String(calls)]))
|
||||
return (
|
||||
<Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" metrics={[{ text: 'calls', dimColor: true }]} metricWidths={metricWidths} />
|
||||
{sorted.slice(0, 10).map(([cmd, calls]) => (
|
||||
<Text key={cmd} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(cmd, nw)}</Text><Text>{String(calls).padStart(7)}</Text></Text>
|
||||
<DataRow key={cmd} panelWidth={pw} barWidth={bw} label={cmd} bar={{ value: calls, max: maxCalls }} metrics={[{ text: String(calls) }]} metricWidths={metricWidths} />
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -660,12 +819,13 @@ function SkillsAndAgents({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost)
|
||||
if (sorted.length === 0) return <Panel title="Skills & Agents" color={PANEL_COLORS.skills} width={pw}><Text dimColor>No skill/agent usage</Text></Panel>
|
||||
const maxCost = sorted[0]?.[1]?.cost ?? 0
|
||||
const nw = Math.max(6, pw - bw - 22)
|
||||
const headers = ['uses', 'cost']
|
||||
const metricWidths = getMetricWidths(headers, sorted.map(([, data]) => [String(data.uses), formatCost(data.cost)]))
|
||||
return (
|
||||
<Panel title="Skills & Agents" color={PANEL_COLORS.skills} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'uses'.padStart(6)}{'cost'.padStart(8)}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" metrics={headers.map(text => ({ text, dimColor: true }))} metricWidths={metricWidths} />
|
||||
{sorted.slice(0, 10).map(([name, d]) => (
|
||||
<Text key={name} wrap="truncate-end"><HBar value={d.cost} max={maxCost} width={bw} /><Text> {fit(name, nw)}</Text><Text>{String(d.uses).padStart(6)}</Text><Text color={GOLD}>{formatCost(d.cost).padStart(8)}</Text></Text>
|
||||
<DataRow key={name} panelWidth={pw} barWidth={bw} label={name} bar={{ value: d.cost, max: maxCost }} metrics={[{ text: String(d.uses) }, { text: formatCost(d.cost), color: GOLD }]} metricWidths={metricWidths} />
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -684,12 +844,13 @@ function ClaudeAgentTypes({ projects, pw, bw }: { projects: ProjectSummary[]; pw
|
|||
const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost)
|
||||
if (sorted.length === 0) return null
|
||||
const maxCost = sorted[0]?.[1]?.cost ?? 0
|
||||
const nw = Math.max(6, pw - bw - 22)
|
||||
const headers = ['calls', 'cost']
|
||||
const metricWidths = getMetricWidths(headers, sorted.map(([, data]) => [String(data.uses), formatCost(data.cost)]))
|
||||
return (
|
||||
<Panel title="Claude Agent Types" color={PANEL_COLORS.skills} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}{'cost'.padStart(8)}</Text>
|
||||
<DataRow panelWidth={pw} barWidth={bw} label="" metrics={headers.map(text => ({ text, dimColor: true }))} metricWidths={metricWidths} />
|
||||
{sorted.slice(0, 10).map(([name, d]) => (
|
||||
<Text key={name} wrap="truncate-end"><HBar value={d.cost} max={maxCost} width={bw} /><Text> {fit(name, nw)}</Text><Text>{String(d.uses).padStart(6)}</Text><Text color={GOLD}>{formatCost(d.cost).padStart(8)}</Text></Text>
|
||||
<DataRow key={name} panelWidth={pw} barWidth={bw} label={name} bar={{ value: d.cost, max: maxCost }} metrics={[{ text: String(d.uses) }, { text: formatCost(d.cost), color: GOLD }]} metricWidths={metricWidths} />
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -860,28 +1021,25 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable,
|
|||
)}
|
||||
{!isOptimize && !customRange && !dayMode && view === 'dashboard' && (
|
||||
<>
|
||||
<Text dimColor> </Text><Text color={PANEL_COLORS.daily} bold>↑</Text><Text dimColor>/</Text><Text color={PANEL_COLORS.daily} bold>↓</Text><Text dimColor> daily </Text>
|
||||
<Text color={PANEL_COLORS.daily} bold>PgUp</Text><Text dimColor>/</Text><Text color={PANEL_COLORS.daily} bold>PgDn</Text><Text dimColor> page</Text>
|
||||
<Text dimColor> </Text><Text color={PANEL_COLORS.daily} bold>j</Text><Text dimColor>/</Text><Text color={PANEL_COLORS.daily} bold>k</Text><Text dimColor> daily </Text>
|
||||
<Text color={PANEL_COLORS.daily} bold>Space</Text><Text dimColor> daily page</Text>
|
||||
</>
|
||||
)}
|
||||
{showProvider && (<><Text dimColor> </Text><Text color={ORANGE} bold>p</Text><Text dimColor> provider</Text></>)}
|
||||
<Text dimColor> </Text><Text color={ORANGE} bold>↑</Text><Text dimColor>/</Text><Text color={ORANGE} bold>↓</Text><Text dimColor> scroll </Text>
|
||||
<Text color={ORANGE} bold>PgUp</Text><Text dimColor>/</Text><Text color={ORANGE} bold>PgDn</Text><Text dimColor> page</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) {
|
||||
if (wide) return <Box width={width}>{children}</Box>
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false, durable }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) {
|
||||
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
||||
function DashboardContent({ projects, period, columns, maxContentWidth, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, dailyHistoryPageSize, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false, durable }: { projects: ProjectSummary[]; period: Period; columns?: number; maxContentWidth: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; dailyHistoryPageSize?: number; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) {
|
||||
const { dashWidth, columnCount, panelWidth, barWidth } = getLayout(columns, maxContentWidth)
|
||||
const isCursor = activeProvider === 'cursor'
|
||||
const activeLabel = label ?? PERIOD_LABELS[period]
|
||||
if (showEmptyState(projects.length, scrollableDailyHistory, (dailyHistoryProjects ?? []).length, dailyHistoryLoading)) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {activeLabel}.</Text></Panel>
|
||||
const pw = wide ? halfWidth : dashWidth
|
||||
const days = dayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
const projectRows = Math.min(projects.length, getProjectBreakdownRowLimit(period, dayMode))
|
||||
const days = dailyHistoryPageSize ?? getDailyActivityPageSize(columnCount, projectRows, getActivityBreakdownRowCount(projects), dayMode)
|
||||
// A provider-scoped plan (e.g. SuperGrok) only makes sense on its own
|
||||
// provider tab, where the shown cost matches the plan's spend. Hide it on
|
||||
// every other tab, including All, so its budget isn't compared to spend it
|
||||
|
|
@ -890,18 +1048,58 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
|
|||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<Overview projects={projects} label={activeLabel} width={dashWidth} planUsages={visiblePlanUsages} durable={durable} />
|
||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={scrollableDailyHistory ? (dailyHistoryProjects ?? []) : projects} days={days} pw={pw} bw={barWidth} scrollable={scrollableDailyHistory} cursor={dailyHistoryCursor} loading={dailyHistoryLoading} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} rows={dayMode ? 8 : period === 'all' || period === 'lifetime' ? 14 : period === 'month' || period === '30days' ? 14 : 8} /></Row>
|
||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||
{isCursor ? (
|
||||
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
|
||||
) : (
|
||||
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><SkillsAndAgents projects={projects} pw={pw} bw={barWidth} /><McpBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><ClaudeAgentTypes projects={projects} pw={pw} bw={barWidth} /></Row></>
|
||||
)}
|
||||
<Box flexWrap="wrap" width={dashWidth}>
|
||||
<DailyActivity projects={scrollableDailyHistory ? (dailyHistoryProjects ?? []) : projects} days={days} pw={panelWidth} bw={barWidth} scrollable={scrollableDailyHistory} cursor={dailyHistoryCursor} loading={dailyHistoryLoading} />
|
||||
<ProjectBreakdown projects={projects} pw={panelWidth} bw={barWidth} budgets={budgets} rows={getProjectBreakdownRowLimit(period, dayMode)} />
|
||||
<ActivityBreakdown projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
<ModelBreakdown projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
{isCursor
|
||||
? <ToolBreakdown projects={projects} pw={panelWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
|
||||
: <>
|
||||
<McpBreakdown projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
<ToolBreakdown projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
<BashBreakdown projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
<SkillsAndAgents projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
<ClaudeAgentTypes projects={projects} pw={panelWidth} bw={barWidth} />
|
||||
</>}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
|
||||
function ScrollableViewport({ children, width, lineScroll = true }: { children: React.ReactNode; width: number; lineScroll?: boolean }) {
|
||||
const { rows } = useWindowSize()
|
||||
const height = Math.max(1, rows - 1)
|
||||
const contentRef = useRef<DOMElement>(null)
|
||||
const [maxOffset, setMaxOffset] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
const nextMaxOffset = Math.max(0, measureElement(contentRef.current).height - height)
|
||||
setMaxOffset(current => current === nextMaxOffset ? current : nextMaxOffset)
|
||||
setOffset(current => Math.min(current, nextMaxOffset))
|
||||
})
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (lineScroll && key.downArrow) setOffset(current => Math.min(current + 1, maxOffset))
|
||||
else if (lineScroll && key.upArrow) setOffset(current => Math.max(current - 1, 0))
|
||||
else if (key.pageDown) setOffset(current => Math.min(current + height, maxOffset))
|
||||
else if (key.pageUp) setOffset(current => Math.max(current - height, 0))
|
||||
else if (key.home) setOffset(0)
|
||||
else if (key.end) setOffset(maxOffset)
|
||||
})
|
||||
|
||||
return (
|
||||
<Box width={width} height={height} overflowY="hidden">
|
||||
<Box ref={contentRef} flexDirection="column" position="absolute" top={-Math.min(offset, maxOffset)} left={0}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, windowColumns }: {
|
||||
initialProjects: ProjectSummary[]
|
||||
initialDailyHistoryProjects?: ProjectSummary[]
|
||||
initialPeriod: Period
|
||||
|
|
@ -914,6 +1112,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
customRange?: DateRange | null
|
||||
customRangeLabel?: string
|
||||
initialDay?: string
|
||||
windowColumns: number
|
||||
}) {
|
||||
const { exit } = useApp()
|
||||
const [period, setPeriod] = useState<Period>(initialPeriod)
|
||||
|
|
@ -937,9 +1136,18 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
const isDayMode = dayDate != null
|
||||
const isCustomRange = customRange != null && !isDayMode
|
||||
const scrollableDailyHistory = !isCustomRange && !isDayMode
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
const dailyHistoryPageSize = isDayMode ? 1 : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
const columns = windowColumns
|
||||
const maxContentWidth = useMemo(
|
||||
() => getDashboardMaxWidth(projects, projectBudgets, activeProvider),
|
||||
[projects, projectBudgets, activeProvider],
|
||||
)
|
||||
const { dashWidth, columnCount } = getLayout(columns, maxContentWidth)
|
||||
const dailyHistoryPageSize = getDailyActivityPageSize(
|
||||
columnCount,
|
||||
Math.min(projects.length, getProjectBreakdownRowLimit(period, isDayMode)),
|
||||
getActivityBreakdownRowCount(projects),
|
||||
isDayMode,
|
||||
)
|
||||
const dailyHistoryRowCount = getDailyActivityRows(dailyHistoryProjects).length
|
||||
const dailyHistoryMaxCursor = Math.max(0, dailyHistoryRowCount - dailyHistoryPageSize)
|
||||
const multipleProviders = detectedProviders.length > 1
|
||||
|
|
@ -948,11 +1156,13 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown)))
|
||||
).size
|
||||
const compareAvailable = modelCount >= 2
|
||||
const viewRef = useRef(view)
|
||||
viewRef.current = view
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reloadGenerationRef = useRef(0)
|
||||
const reloadInFlightRef = useRef(false)
|
||||
const currentReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
|
||||
const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
|
||||
const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null; background: boolean } | null>(null)
|
||||
const findingCount = optimizeResult?.findings.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -981,7 +1191,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
return () => { cancelled = true }
|
||||
}, [projects])
|
||||
|
||||
const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null) => {
|
||||
const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null, background = false) => {
|
||||
if (reloadInFlightRef.current) {
|
||||
const current = currentReloadRef.current
|
||||
if (current?.period === p && current.provider === prov && current.day === day) {
|
||||
|
|
@ -989,18 +1199,20 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
return
|
||||
}
|
||||
reloadGenerationRef.current++
|
||||
pendingReloadRef.current = { period: p, provider: prov, day }
|
||||
pendingReloadRef.current = { period: p, provider: prov, day, background }
|
||||
return
|
||||
}
|
||||
reloadInFlightRef.current = true
|
||||
currentReloadRef.current = { period: p, provider: prov, day }
|
||||
const shouldLoadHistory = !day && customRange == null
|
||||
const generation = ++reloadGenerationRef.current
|
||||
setLoading(true)
|
||||
setOptimizeLoading(false)
|
||||
setOptimizeResult(null)
|
||||
if (!background) {
|
||||
setLoading(true)
|
||||
setOptimizeLoading(false)
|
||||
setOptimizeResult(null)
|
||||
}
|
||||
try {
|
||||
if (!day && isHeavyPeriod(p)) {
|
||||
if (!background && !day && isHeavyPeriod(p)) {
|
||||
setProjects([])
|
||||
setProjectBudgets(new Map())
|
||||
// Drop the previous period's durable headline so it can't flash on the
|
||||
|
|
@ -1016,21 +1228,24 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter)
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
|
||||
if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects)
|
||||
setProjects(selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory))
|
||||
const selectedProjects = selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory)
|
||||
// Durable headline totals (carry-forward cache + today), matching the
|
||||
// menubar/report. Computed after the live parse so the panel paints
|
||||
// immediately; the durable figure replaces the live one when it resolves.
|
||||
// menubar/report.
|
||||
const durableTotals = await computeDurableOverview(p, prov, projectFilter, excludeFilter, customRange, day)
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
setDurable(durableTotals)
|
||||
const usage = await getPlanUsages()
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
if (background && viewRef.current !== 'dashboard') return
|
||||
|
||||
if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects)
|
||||
setProjects(selectedProjects)
|
||||
setDurable(durableTotals)
|
||||
setPlanUsages(usage)
|
||||
if (background) setOptimizeResult(null)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (reloadGenerationRef.current === generation) {
|
||||
if (!background && reloadGenerationRef.current === generation) {
|
||||
setLoading(false)
|
||||
}
|
||||
reloadInFlightRef.current = false
|
||||
|
|
@ -1038,7 +1253,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
const pending = pendingReloadRef.current
|
||||
pendingReloadRef.current = null
|
||||
if (pending) {
|
||||
void reloadData(pending.period, pending.provider, pending.day)
|
||||
void reloadData(pending.period, pending.provider, pending.day, pending.background)
|
||||
}
|
||||
}
|
||||
}, [projectFilter, excludeFilter, customRange])
|
||||
|
|
@ -1066,11 +1281,13 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
}, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult])
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshSeconds || refreshSeconds <= 0) return
|
||||
const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0)
|
||||
if (refreshIntervalMs === 0) return
|
||||
if (view !== 'dashboard') return
|
||||
if (!dayDate && isHeavyPeriod(period)) return
|
||||
const id = setInterval(() => { void reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000)
|
||||
const id = setInterval(() => { void reloadData(period, activeProvider, dayDate, true) }, refreshIntervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [refreshSeconds, period, activeProvider, dayDate, reloadData])
|
||||
}, [refreshSeconds, period, activeProvider, dayDate, reloadData, view])
|
||||
|
||||
const switchPeriod = useCallback((np: Period) => {
|
||||
if (np === period && !dayDate) return
|
||||
|
|
@ -1124,17 +1341,17 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
if (view === 'optimize') {
|
||||
const total = optimizeResult?.findings.length ?? 0
|
||||
const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE)
|
||||
if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return }
|
||||
if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return }
|
||||
if (input === 'j') { setFindingsCursor(c => Math.min(c + 1, maxStart)); return }
|
||||
if (input === 'k') { setFindingsCursor(c => Math.max(c - 1, 0)); return }
|
||||
return
|
||||
}
|
||||
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return }
|
||||
if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return }
|
||||
if (view === 'dashboard' && scrollableDailyHistory) {
|
||||
if (key.pageDown || (input === ' ' && !key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (key.pageUp || (input === ' ' && key.shift)) { setDailyHistoryCursor(c => pageHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === 'j' || key.downArrow) { setDailyHistoryCursor(c => scrollHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === 'k' || key.upArrow) { setDailyHistoryCursor(c => scrollHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === ' ' && !key.shift) { setDailyHistoryCursor(c => pageHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === ' ' && key.shift) { setDailyHistoryCursor(c => pageHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === 'j') { setDailyHistoryCursor(c => scrollHistoryCursor(c, 1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === 'k') { setDailyHistoryCursor(c => scrollHistoryCursor(c, -1, dailyHistoryPageSize, dailyHistoryRowCount)); return }
|
||||
if (input === 'g') { setDailyHistoryCursor(0); return }
|
||||
if (input === 'G') { setDailyHistoryCursor(dailyHistoryMaxCursor); return }
|
||||
}
|
||||
|
|
@ -1191,8 +1408,8 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
|
||||
const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period]
|
||||
|
||||
if (loading || optimizeLoading) {
|
||||
return (
|
||||
const content = loading || optimizeLoading
|
||||
? (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
{!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
|
||||
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
|
||||
|
|
@ -1211,20 +1428,28 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in
|
|||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} dayMode={isDayMode} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
: (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
{!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />}
|
||||
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
|
||||
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
|
||||
{view === 'compare'
|
||||
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
||||
: view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} maxContentWidth={maxContentWidth} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} dailyHistoryPageSize={dailyHistoryPageSize} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} durable={durable} />}
|
||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
{!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />}
|
||||
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
|
||||
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
|
||||
{view === 'compare'
|
||||
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
||||
: view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} durable={durable} />}
|
||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
|
||||
</Box>
|
||||
<ScrollableViewport
|
||||
key={`${view}:${period}:${activeProvider}:${dayDate ?? ''}:${customRangeLabel ?? ''}`}
|
||||
width={dashWidth}
|
||||
lineScroll={view !== 'compare'}
|
||||
>
|
||||
{content}
|
||||
</ScrollableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1247,11 +1472,12 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) {
|
|||
|
||||
function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode, durable }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; durable?: DurableOverview }) {
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
const maxContentWidth = getDashboardMaxWidth(projects, undefined, activeProvider)
|
||||
const { dashWidth } = getLayout(columns, maxContentWidth)
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
{dayMode ? <DayBanner label={label ?? PERIOD_LABELS[period]} width={dashWidth} /> : <PeriodTabs active={period} />}
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} durable={durable} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} maxContentWidth={maxContentWidth} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} durable={durable} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1259,7 +1485,7 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label,
|
|||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise<void> {
|
||||
// Interactive Ink UI: it renders to the same terminal and has its own in-frame
|
||||
// loading state, so the CLI scan-progress line must stay silent for its whole
|
||||
// lifetime (initial scan and every 30s auto-refresh, including the
|
||||
// lifetime (initial scan and every enabled auto-refresh, including the
|
||||
// getPlanUsages → parseAllSessions path). Plain CLI commands are unaffected.
|
||||
setInteractiveScanUI()
|
||||
await loadPricing()
|
||||
|
|
@ -1277,10 +1503,24 @@ export async function renderDashboard(period: Period = 'week', provider: string
|
|||
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
|
||||
patchStdoutForWindows()
|
||||
if (isTTY) {
|
||||
const { waitUntilExit } = render(
|
||||
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
|
||||
let windowColumns = process.stdout.columns
|
||||
const dashboard = () => (
|
||||
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} windowColumns={windowColumns} />
|
||||
)
|
||||
await waitUntilExit()
|
||||
const app = render(
|
||||
dashboard(),
|
||||
INTERACTIVE_RENDER_OPTIONS,
|
||||
)
|
||||
const resize = () => {
|
||||
windowColumns = process.stdout.columns
|
||||
app.rerender(dashboard())
|
||||
}
|
||||
process.stdout.prependListener('resize', resize)
|
||||
try {
|
||||
await app.waitUntilExit()
|
||||
} finally {
|
||||
process.stdout.off('resize', resize)
|
||||
}
|
||||
} else {
|
||||
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} durable={initialDurable} />, { patchConsole: false })
|
||||
// Non-interactive one-shot output: ink schedules the frame through a
|
||||
|
|
|
|||
|
|
@ -26,6 +26,23 @@ export function dateKey(iso: string): string {
|
|||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/// Bucket an ISO timestamp under an explicit IANA timezone instead of the
|
||||
/// machine's local one. `en-CA` emits the ISO-ish YYYY-MM-DD layout directly,
|
||||
/// so formatToParts under the given `timeZone` yields exactly that shape. Used
|
||||
/// to re-aggregate the same parse under a cache's OLD tzKey when a timezone
|
||||
/// change forces a full re-derive (issue #770): comparing that bucketing to the
|
||||
/// fresh one shows exactly which turns re-bucketed across local midnight.
|
||||
export function dateKeyInTz(iso: string, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso))
|
||||
let year = '', month = '', day = ''
|
||||
for (const p of parts) {
|
||||
if (p.type === 'year') year = p.value
|
||||
else if (p.type === 'month') month = p.value
|
||||
else if (p.type === 'day') day = p.value
|
||||
}
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function emptySlice(): ProviderDaySlice {
|
||||
return {
|
||||
calls: 0, cost: 0, savingsUSD: 0,
|
||||
|
|
@ -34,7 +51,7 @@ function emptySlice(): ProviderDaySlice {
|
|||
}
|
||||
}
|
||||
|
||||
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
|
||||
export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn: (iso: string) => string = dateKey): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
const ensure = (date: string): DailyEntry => {
|
||||
let d = byDate.get(date)
|
||||
|
|
@ -61,7 +78,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
const sessionDate = dateKey(session.firstTimestamp)
|
||||
const sessionDate = dateKeyFn(session.firstTimestamp)
|
||||
const sessionDay = ensure(sessionDate)
|
||||
sessionDay.sessions += 1
|
||||
ensureProject(sessionDay, session.project, project.projectPath).sessions += 1
|
||||
|
|
@ -94,7 +111,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
// 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 turnDate = dateKeyFn(turn.timestamp || turn.assistantCalls[0]!.timestamp)
|
||||
const turnDay = ensure(turnDate)
|
||||
|
||||
const editTurns = turn.hasEdits ? 1 : 0
|
||||
|
|
@ -154,7 +171,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
// 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 callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKeyFn(call.timestamp)
|
||||
const callDay = ensure(callDate)
|
||||
|
||||
callDay.cost += call.costUSD
|
||||
|
|
|
|||
|
|
@ -99,10 +99,37 @@ const PARSE_CALL_CAP = 500
|
|||
// (readdir/stat only) still runs, so session counts stay meaningful.
|
||||
const PARSE_SPAWNS = new Set(['antigravity'])
|
||||
|
||||
// CodeBurn's own cache location: listed in PROVIDER_ENV_VARS for cache
|
||||
// fingerprinting, but it is not a discovery path, so it must never be blamed
|
||||
// in a NOTHING FOUND hint.
|
||||
const NON_DISCOVERY_ENV_VARS = new Set(['CODEBURN_CACHE_DIR'])
|
||||
// Vars listed in PROVIDER_ENV_VARS for cache fingerprinting that are NOT
|
||||
// discovery paths: a change to them can never explain "nothing was
|
||||
// discovered", so they must never be blamed in a NOTHING FOUND hint.
|
||||
// - CODEBURN_CACHE_DIR: CodeBurn's own cache location — where the cache
|
||||
// file lives, not where sessions are discovered.
|
||||
// - CODEBURN_CURSOR_MAX_BUBBLES: caps how many bubbles Cursor parses
|
||||
// (src/providers/cursor.ts:692) — a parse budget, not a discovery root.
|
||||
// - KIMI_MODEL_NAME: renames the model attributed to Kimi sessions
|
||||
// (src/providers/kimi.ts:155) — attribution, not discovery.
|
||||
// All three still appear in the Details block; only the verdict's blame line
|
||||
// is cleared of them.
|
||||
const NON_DISCOVERY_ENV_VARS = new Set(['CODEBURN_CACHE_DIR', 'CODEBURN_CURSOR_MAX_BUBBLES', 'KIMI_MODEL_NAME'])
|
||||
|
||||
// Ambient platform paths (set by the OS or desktop session for everyone), not
|
||||
// deliberate user overrides: Windows sets APPDATA and LOCALAPPDATA for every
|
||||
// process, so they carry no user intent and doctor must not name them as an
|
||||
// override. The XDG_* vars are the opposite — they are opt-in on Linux, so a
|
||||
// set value IS a deliberate user override and stays visible: with XDG_DATA_HOME
|
||||
// pointed at a missing dir, blaming the install instead of the override
|
||||
// (the pre-#920 behavior) told the user the tool was missing when they had
|
||||
// deliberately relocated it. All of them are still fingerprinted — a change
|
||||
// to any of them does move the discovery root, so the cache must invalidate —
|
||||
// and the probed paths doctor already prints show exactly where CodeBurn
|
||||
// looked.
|
||||
const AMBIENT_ENV_VARS = new Set(['APPDATA', 'LOCALAPPDATA'])
|
||||
|
||||
// Credential names whose VALUE must never be printed: knowing whether the
|
||||
// credential is set is a useful diagnostic, but the value is a live secret.
|
||||
// Redact at collect time so BOTH the text render and the JSON report are
|
||||
// covered, and doctor can never leak a key into a bug report or a paste.
|
||||
const SECRET_ENV_VARS = new Set(['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN'])
|
||||
|
||||
// ── Collect (pure, testable) ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -110,8 +137,11 @@ function collectEnvOverrides(providerName: string): DoctorEnvOverride[] {
|
|||
const vars = PROVIDER_ENV_VARS[providerName] ?? []
|
||||
const out: DoctorEnvOverride[] = []
|
||||
for (const name of vars) {
|
||||
if (AMBIENT_ENV_VARS.has(name)) continue
|
||||
const value = process.env[name]
|
||||
if (value !== undefined && value !== '') out.push({ name, value })
|
||||
if (value !== undefined && value !== '') {
|
||||
out.push(SECRET_ENV_VARS.has(name) ? { name, value: '<set>' } : { name, value })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,42 @@
|
|||
const BSU = '\x1b[?2026h'
|
||||
const ESU = '\x1b[?2026l'
|
||||
// Begin/End Synchronized Update (DEC private mode 2026); exported so callers
|
||||
// can emit them; on Windows the filter below strips them from every write, so
|
||||
// even a concatenated BSU+payload write cannot reach ConPTY (#195).
|
||||
export const BSU = '\x1b[?2026h'
|
||||
export const ESU = '\x1b[?2026l'
|
||||
let patched = false
|
||||
|
||||
// split/join removes every occurrence and is hot-path cheap because the
|
||||
// includes() gate below runs first.
|
||||
export function stripSyncUpdateEscapes(chunk: string): string {
|
||||
return chunk.split(BSU).join('').split(ESU).join('')
|
||||
}
|
||||
|
||||
export function patchStdoutForWindows(): void {
|
||||
if (process.platform !== 'win32' || patched) return
|
||||
patched = true
|
||||
|
||||
const origWrite = process.stdout.write.bind(process.stdout)
|
||||
process.stdout.write = function (chunk: unknown, ...args: unknown[]): boolean {
|
||||
if (chunk === BSU || chunk === ESU) return true
|
||||
return (origWrite as Function)(chunk, ...args)
|
||||
// Non-string chunks pass straight through unchanged; Buffers never carry
|
||||
// these escapes in this codebase, so scanning them is not worth the copy.
|
||||
if (typeof chunk !== 'string') {
|
||||
return (origWrite as Function)(chunk, ...args)
|
||||
}
|
||||
// Neither escape present: pass straight through.
|
||||
if (!chunk.includes(BSU) && !chunk.includes(ESU)) {
|
||||
return (origWrite as Function)(chunk, ...args)
|
||||
}
|
||||
const stripped = stripSyncUpdateEscapes(chunk)
|
||||
if (stripped.length > 0) {
|
||||
return (origWrite as Function)(stripped, ...args)
|
||||
}
|
||||
// The chunk was swallowed entirely. The old exact-match filter dropped the
|
||||
// callback too, which could wedge a callback-style writer; invoke it
|
||||
// asynchronously so a caller awaiting the callback never hangs.
|
||||
const last = args[args.length - 1]
|
||||
if (typeof last === 'function') {
|
||||
queueMicrotask(() => (last as () => void)())
|
||||
}
|
||||
return true
|
||||
} as typeof process.stdout.write
|
||||
}
|
||||
|
|
|
|||
|
|
@ -772,7 +772,7 @@ program
|
|||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'report')
|
||||
assertProvider(opts.provider, 'report')
|
||||
|
|
@ -1203,7 +1203,7 @@ program
|
|||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'today')
|
||||
assertProvider(opts.provider, 'today')
|
||||
|
|
@ -1221,7 +1221,7 @@ program
|
|||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (minimum 60; 0 to disable)', parseInteger, 60)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'month')
|
||||
assertProvider(opts.provider, 'month')
|
||||
|
|
|
|||
|
|
@ -164,7 +164,11 @@ function safePerTokenRate(n: number | undefined): number | null {
|
|||
return n
|
||||
}
|
||||
|
||||
function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
|
||||
export function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
|
||||
// The live LiteLLM map is remote JSON; a null (or non-object) value for a
|
||||
// model would make the field reads below throw and abort the whole pricing
|
||||
// load. Treat it as unparseable, like any other bad entry.
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const inputCost = safePerTokenRate(entry.input_cost_per_token)
|
||||
const outputCost = safePerTokenRate(entry.output_cost_per_token)
|
||||
if (inputCost === null || outputCost === null) return null
|
||||
|
|
|
|||
|
|
@ -2965,9 +2965,23 @@ export function computeInputCostRate(projects: ProjectSummary[]): number {
|
|||
type CacheEntry = { data: OptimizeResult; ts: number }
|
||||
const resultCache = new Map<string, CacheEntry>()
|
||||
|
||||
function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string {
|
||||
export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string {
|
||||
const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all'
|
||||
const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
// Fingerprint enough of the dataset that two materially different inputs
|
||||
// cannot collide onto one cached OptimizeResult. Project count + api-call
|
||||
// sum alone collided any two datasets sharing those two numbers, and served
|
||||
// stale findings when cost/tokens moved (e.g. a re-price) while call count
|
||||
// held - reachable in the long-lived menubar process within the 60s TTL.
|
||||
// Cost is scaled to whole micro-dollars so float jitter cannot thrash the key.
|
||||
let calls = 0, cost = 0, savings = 0, proxied = 0
|
||||
for (const p of projects) {
|
||||
calls += p.totalApiCalls
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
proxied += p.totalProxiedCostUSD
|
||||
}
|
||||
// Costs scaled to whole micro-dollars so float jitter cannot thrash the key.
|
||||
const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}`
|
||||
return `${dr}:${fingerprint}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { stat } from 'fs/promises'
|
|||
import { homedir } from 'os'
|
||||
import { basename, join } from 'path'
|
||||
|
||||
import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePaths } from './vscode-cline-parser.js'
|
||||
import type { Provider, SessionSource, SessionParser } from './types.js'
|
||||
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
|
||||
|
||||
const EXTENSION_ID = 'saoudrizwan.claude-dev'
|
||||
|
||||
|
|
@ -38,6 +38,14 @@ async function dedupeTaskSources(sources: SessionSource[]): Promise<SessionSourc
|
|||
|
||||
export function createClineProvider(overrideDirs?: string | string[]): Provider {
|
||||
const configuredDirs = normalizeOverrideDirs(overrideDirs)
|
||||
// Cline may be installed in any VS Code variant (stable, Insiders, VSCodium),
|
||||
// so every globalStorage root is scanned - same as the Roo Code and KiloCode
|
||||
// siblings - plus Cline's own home-data root. Shared by discovery and
|
||||
// probeRoots so doctor can never report a root discovery does not read.
|
||||
const taskRoots = (): string[] => configuredDirs ?? [
|
||||
...clineTaskRoots(EXTENSION_ID),
|
||||
getClineDataPath(),
|
||||
]
|
||||
|
||||
return {
|
||||
name: 'cline',
|
||||
|
|
@ -51,14 +59,12 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return taskRoots().map(path => ({ path, label: 'tasks' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
// Cline may be installed in any VS Code variant (stable, Insiders,
|
||||
// VSCodium), so every globalStorage root is scanned - same as the Roo Code
|
||||
// and KiloCode siblings - plus Cline's own home-data root.
|
||||
const baseDirs = configuredDirs ?? [
|
||||
...getVSCodeGlobalStoragePaths(EXTENSION_ID),
|
||||
getClineDataPath(),
|
||||
]
|
||||
const baseDirs = taskRoots()
|
||||
|
||||
return dedupeTaskSources(await discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', baseDirs))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -194,6 +194,9 @@ type SubagentSelectedData = {
|
|||
agentName: string
|
||||
agentDisplayName?: string
|
||||
tools?: string[]
|
||||
// Present on subagent.started/completed (CLI ≥ ~1.0.7x): the delegation
|
||||
// tool call that launched the run, used to pair completed with started.
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
// Per-model usage rollup the CLI writes into session.shutdown. inputTokens is
|
||||
|
|
@ -217,6 +220,8 @@ type CopilotEvent =
|
|||
| { type: 'user.message'; data: UserMessageData; timestamp?: string }
|
||||
| { type: 'assistant.message'; data: AssistantMessageData; timestamp?: string }
|
||||
| { type: 'subagent.selected'; data: SubagentSelectedData; timestamp?: string }
|
||||
| { type: 'subagent.started'; data: SubagentSelectedData; timestamp?: string }
|
||||
| { type: 'subagent.completed'; data: SubagentSelectedData; timestamp?: string }
|
||||
| { type: 'session.shutdown'; data: SessionShutdownData; timestamp?: string }
|
||||
|
||||
type ChatJournalPathSegment = string | number
|
||||
|
|
@ -693,50 +698,56 @@ function inferTranscriptModel(lines: string[]): string {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSONL parser (handles both regular session-state events and VS Code
|
||||
// transcript format via session.start { producer: 'copilot-agent' })
|
||||
// JSONL parser (handles both regular CLI session-state events and the VS Code
|
||||
// transcript format — the same event vocabulary, but transcripts carry no
|
||||
// token counts and no session.shutdown rollup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `isTranscript` comes from discovery (where the file lives), never from
|
||||
* content: the Copilot CLI writes the same session.start producer
|
||||
* ('copilot-agent') that VS Code transcripts carry, so producer sniffing
|
||||
* misread every CLI session as a transcript and dropped its session.shutdown
|
||||
* input/cache rollup (#944).
|
||||
*/
|
||||
function createJsonlParser(
|
||||
source: SessionSource,
|
||||
seenKeys: Set<string>
|
||||
seenKeys: Set<string>,
|
||||
isTranscript: boolean
|
||||
): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const content = await readSessionFile(source.path)
|
||||
if (!content) return
|
||||
const sessionId = basename(dirname(source.path))
|
||||
// CLI session-state files live at <sessionId>/events.jsonl; transcripts
|
||||
// at transcripts/<sessionId>.jsonl — keying the latter on the parent dir
|
||||
// would collapse every transcript into one "transcripts" session (and
|
||||
// one shared dedup namespace).
|
||||
const sessionId = isTranscript
|
||||
? basename(source.path, '.jsonl')
|
||||
: basename(dirname(source.path))
|
||||
const lines = content.split('\n').filter((l) => l.trim())
|
||||
|
||||
// Detect VS Code transcript format: the first session.start event has
|
||||
// { producer: 'copilot-agent' } and no outputTokens in messages.
|
||||
let isTranscript = false
|
||||
let currentModel = ''
|
||||
let pendingUserMessage = ''
|
||||
// Track the active subagent for this session (from subagent.selected events).
|
||||
// Resets when a new subagent is selected.
|
||||
let currentSubagentType: string | undefined
|
||||
|
||||
// First pass: detect format and infer transcript model if needed.
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const ev = JSON.parse(line) as CopilotEvent
|
||||
if (ev.type === 'session.start') {
|
||||
const data = ev.data as SessionStartData & { producer?: string }
|
||||
if (data.producer === 'copilot-agent') {
|
||||
isTranscript = true
|
||||
}
|
||||
break
|
||||
}
|
||||
if (ev.type === 'session.model_change') break // regular format
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Subagent attribution. Older CLIs write subagent.selected — sticky
|
||||
// until replaced, never cleared. CLI ≥ ~1.0.7x brackets each run with
|
||||
// started/completed instead; runs can nest or overlap, so completed
|
||||
// removes ONLY its own toolCallId's entry and the label falls back to
|
||||
// the still-active run (or the sticky selected value) rather than
|
||||
// wiping attribution for everything in flight.
|
||||
let selectedSubagentType: string | undefined
|
||||
const activeSubagents: Array<{ toolCallId: string; name: string }> = []
|
||||
const currentSubagentType = (): string | undefined =>
|
||||
activeSubagents[activeSubagents.length - 1]?.name ?? selectedSubagentType
|
||||
|
||||
if (isTranscript) {
|
||||
// Tool-call-id prefix inference seeds the model; it must not gate the
|
||||
// whole file, or a transcript carrying explicit model info
|
||||
// (session.model_change / per-message model) but no tool calls would
|
||||
// yield nothing. Messages that still end up modelless are skipped
|
||||
// individually below.
|
||||
currentModel = inferTranscriptModel(lines)
|
||||
if (!currentModel) return // no toolCallIds to infer model from
|
||||
}
|
||||
|
||||
// Shutdown rollups may lack their own timestamp; remember the last
|
||||
|
|
@ -744,6 +755,15 @@ function createJsonlParser(
|
|||
// timestamp, which the date-range filters silently drop.
|
||||
let lastEventTimestamp = ''
|
||||
|
||||
// A resumed session appends one session.shutdown PER LEG, each carrying
|
||||
// CUMULATIVE per-model totals. Emitting each rollup whole would need the
|
||||
// cache to update a prior call in place — the durable merge is
|
||||
// append-only by dedup key — so we emit per-leg DELTAS keyed by
|
||||
// occurrence instead: re-parses of a growing file append only the new
|
||||
// leg, and each leg lands on its own timestamp.
|
||||
const prevShutdownUsage = new Map<string, ShutdownModelUsage>()
|
||||
const shutdownCountByModel = new Map<string, number>()
|
||||
|
||||
for (const line of lines) {
|
||||
let event: CopilotEvent
|
||||
try {
|
||||
|
|
@ -766,7 +786,34 @@ function createJsonlParser(
|
|||
}
|
||||
|
||||
if (event.type === 'subagent.selected') {
|
||||
currentSubagentType = (event.data as SubagentSelectedData).agentName
|
||||
selectedSubagentType = (event.data as SubagentSelectedData).agentName
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'subagent.started') {
|
||||
const data = event.data as SubagentSelectedData
|
||||
activeSubagents.push({ toolCallId: data.toolCallId ?? '', name: data.agentName })
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'subagent.completed') {
|
||||
const id = (event.data as SubagentSelectedData).toolCallId ?? ''
|
||||
if (!id) {
|
||||
// ID-less completion (transitional CLIs that key nothing, like
|
||||
// subagent.selected): end the most recently started run; explicit
|
||||
// no-op on an empty stack.
|
||||
activeSubagents.pop()
|
||||
continue
|
||||
}
|
||||
for (let i = activeSubagents.length - 1; i >= 0; i--) {
|
||||
if (activeSubagents[i]!.toolCallId === id) {
|
||||
activeSubagents.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
// A non-empty id that matches nothing refers to a run we never saw
|
||||
// start — leave the active runs alone rather than evicting an
|
||||
// unrelated one.
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -783,11 +830,12 @@ function createJsonlParser(
|
|||
// is gated to the CLI (non-transcript) format, leaving VS Code,
|
||||
// JetBrains and OTel sources untouched.
|
||||
//
|
||||
// We emit one supplementary call per model carrying ONLY the
|
||||
// input/cache tokens the per-turn events lack; output is excluded so
|
||||
// the assistant.message output (and its cost) is not double-counted.
|
||||
// Combined with the per-turn output cost, this yields the full,
|
||||
// CLI-measured session cost.
|
||||
// We emit one supplementary call per model PER SHUTDOWN LEG (resumed
|
||||
// sessions write one cumulative rollup per leg; see the delta
|
||||
// tracking above) carrying ONLY the input/cache tokens the per-turn
|
||||
// events lack; output is excluded so the assistant.message output
|
||||
// (and its cost) is not double-counted. Combined with the per-turn
|
||||
// output cost, this yields the full, CLI-measured session cost.
|
||||
if (isTranscript) continue
|
||||
const shutdownData = event.data as SessionShutdownData
|
||||
const modelMetrics = shutdownData.modelMetrics
|
||||
|
|
@ -801,23 +849,49 @@ function createJsonlParser(
|
|||
const usage = metrics['usage']
|
||||
if (!isRecord(usage)) continue
|
||||
|
||||
const cacheReadTokens = numberOrZero(usage['cacheReadTokens'])
|
||||
const cacheWriteTokens = numberOrZero(usage['cacheWriteTokens'])
|
||||
const reasoningTokens = numberOrZero(usage['reasoningTokens'])
|
||||
const cumulative: Required<ShutdownModelUsage> = {
|
||||
inputTokens: numberOrZero(usage['inputTokens']),
|
||||
outputTokens: numberOrZero(usage['outputTokens']),
|
||||
cacheReadTokens: numberOrZero(usage['cacheReadTokens']),
|
||||
cacheWriteTokens: numberOrZero(usage['cacheWriteTokens']),
|
||||
reasoningTokens: numberOrZero(usage['reasoningTokens']),
|
||||
}
|
||||
const prevRaw = prevShutdownUsage.get(model)
|
||||
prevShutdownUsage.set(model, cumulative)
|
||||
const n = (shutdownCountByModel.get(model) ?? 0) + 1
|
||||
shutdownCountByModel.set(model, n)
|
||||
|
||||
// A cumulative total BELOW the previous rollup means the CLI reset
|
||||
// its counters (a fresh accounting epoch): delta from zero, else
|
||||
// this leg's post-reset usage would be clamped away entirely.
|
||||
// inputTokens is the monotonic sentinel — it is cache-inclusive,
|
||||
// so any usage at all grows it.
|
||||
const prev =
|
||||
prevRaw && cumulative.inputTokens < numberOrZero(prevRaw.inputTokens)
|
||||
? undefined
|
||||
: prevRaw
|
||||
|
||||
// This leg's contribution: cumulative minus the previous rollup.
|
||||
// The clamp guards any remaining non-monotonic field.
|
||||
const delta = (k: keyof ShutdownModelUsage): number =>
|
||||
Math.max(0, cumulative[k] - numberOrZero(prev?.[k]))
|
||||
const cacheReadTokens = delta('cacheReadTokens')
|
||||
const cacheWriteTokens = delta('cacheWriteTokens')
|
||||
const reasoningTokens = delta('reasoningTokens')
|
||||
// usage.inputTokens is cache-INCLUSIVE (input + cache_read +
|
||||
// cache_write). calculateCost expects the uncached input alone with
|
||||
// cache tokens billed separately, so subtract the cache components.
|
||||
// Clamp at 0 in case a future schema reports input non-inclusively.
|
||||
const inputTokens = Math.max(
|
||||
0,
|
||||
numberOrZero(usage['inputTokens']) - cacheReadTokens - cacheWriteTokens
|
||||
delta('inputTokens') - cacheReadTokens - cacheWriteTokens
|
||||
)
|
||||
|
||||
// Nothing this call would add over the per-turn events, so skip it
|
||||
// to avoid an empty $0 row (output is intentionally excluded).
|
||||
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue
|
||||
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0 && reasoningTokens === 0) continue
|
||||
|
||||
const dedupKey = `copilot:${sessionId}:shutdown:${model}`
|
||||
const dedupKey = `copilot:${sessionId}:shutdown:${model}:${n}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
|
|
@ -898,6 +972,7 @@ function createJsonlParser(
|
|||
// Cost will be lower than actual API cost. This is the original
|
||||
// behaviour — OTel data (below) replaces it when available.
|
||||
const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0)
|
||||
const subagentType = currentSubagentType()
|
||||
|
||||
yield {
|
||||
provider: 'copilot',
|
||||
|
|
@ -914,7 +989,7 @@ function createJsonlParser(
|
|||
tools,
|
||||
bashCommands,
|
||||
skills: skills.length > 0 ? skills : undefined,
|
||||
subagentTypes: currentSubagentType ? [currentSubagentType] : undefined,
|
||||
subagentTypes: subagentType ? [subagentType] : undefined,
|
||||
timestamp: event.timestamp ?? '',
|
||||
speed: 'standard' as const,
|
||||
deduplicationKey: dedupKey,
|
||||
|
|
@ -1837,6 +1912,12 @@ interface JsonlSessionSource extends SessionSource {
|
|||
sourceType: 'jsonl'
|
||||
}
|
||||
|
||||
// A VS Code workspaceStorage transcript. Distinct from 'jsonl' (CLI
|
||||
// session-state) so classification rides provenance, not file contents (#944).
|
||||
interface TranscriptSessionSource extends SessionSource {
|
||||
sourceType: 'transcript'
|
||||
}
|
||||
|
||||
interface ChatSessionSource extends SessionSource {
|
||||
sourceType: 'chatsession'
|
||||
}
|
||||
|
|
@ -1874,6 +1955,10 @@ function isJetBrainsSource(source: SessionSource): source is JetBrainsSessionSou
|
|||
return (source as JetBrainsSessionSource).sourceType === 'jetbrains'
|
||||
}
|
||||
|
||||
function isTranscriptSource(source: SessionSource): source is TranscriptSessionSource {
|
||||
return (source as TranscriptSessionSource).sourceType === 'transcript'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session discovery: JSONL (original)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2242,8 +2327,8 @@ async function discoverEmptyWindowChatSessions(
|
|||
*/
|
||||
async function discoverTranscriptSessions(
|
||||
workspaceStorageDirs: string[]
|
||||
): Promise<JsonlSessionSource[]> {
|
||||
const sources: JsonlSessionSource[] = []
|
||||
): Promise<TranscriptSessionSource[]> {
|
||||
const sources: TranscriptSessionSource[] = []
|
||||
|
||||
for (const wsDir of workspaceStorageDirs) {
|
||||
let hashDirs: string[]
|
||||
|
|
@ -2275,7 +2360,7 @@ async function discoverTranscriptSessions(
|
|||
path: join(transcriptsDir, file),
|
||||
project,
|
||||
provider: 'copilot',
|
||||
sourceType: 'jsonl',
|
||||
sourceType: 'transcript',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2418,7 +2503,7 @@ export function createCopilotProvider(
|
|||
if (isJetBrainsSource(source)) {
|
||||
return createJetBrainsParser(source, seenKeys)
|
||||
}
|
||||
return createJsonlParser(source, seenKeys)
|
||||
return createJsonlParser(source, seenKeys, isTranscriptSource(source))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { homedir } from 'os'
|
|||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
// Grok Build (xAI's coding CLI) stores one session per directory at
|
||||
// <grok-home>/sessions/<url-encoded-cwd>/<uuid>/, where grok-home is $GROK_HOME
|
||||
|
|
@ -257,6 +257,10 @@ export function createGrokProvider(sessionsDir?: string): Provider {
|
|||
name: 'grok',
|
||||
displayName: 'Grok Build',
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: dir, label: 'sessions' }]
|
||||
},
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model.startsWith('grok-build')) return 'Grok Build'
|
||||
return getShortModelName(model)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
|
||||
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
|
||||
import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js'
|
||||
import type { Provider, SessionSource, SessionParser } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
|
||||
|
||||
const EXTENSION_ID = 'kilocode.kilo-code'
|
||||
const PROVIDER_NAME = 'kilo-code'
|
||||
|
|
@ -33,6 +33,14 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
// Both halves of discovery: the legacy task tree and the SQLite store.
|
||||
return [
|
||||
...clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' })),
|
||||
{ path: sqliteConfig.dbDir, label: 'sqlite' },
|
||||
]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
const [oldSessions, dbSessions] = await Promise.all([
|
||||
discoverClineTasks(EXTENSION_ID, PROVIDER_NAME, 'KiloCode', overrideDir),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { homedir } from 'os'
|
|||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
|
|
@ -346,6 +346,10 @@ export function createKimiProvider(overrideDir?: string): Provider {
|
|||
name: 'kimi',
|
||||
displayName: 'Kimi',
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: join(shareDir, 'sessions'), label: 'sessions' }]
|
||||
},
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return getShortModelName(model)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -203,12 +203,20 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
|
|||
if (modelId === 'auto' || !modelId) modelId = 'kiro-auto'
|
||||
|
||||
let pendingUserMessage = ''
|
||||
// Accumulate every human turn's full length for the input-token estimate,
|
||||
// mirroring the modern-execution path (which sums inputChars). The prior
|
||||
// code estimated input tokens from pendingUserMessage.length alone - the
|
||||
// LAST human turn truncated to 500 chars - so a multi-turn session, or any
|
||||
// final prompt over 500 chars, undercounted input tokens (and therefore
|
||||
// costUSD) severalfold, while output correctly summed all bot chars.
|
||||
let inputChars = 0
|
||||
const allTools: string[] = []
|
||||
const toolSequence: ToolCall[][] = []
|
||||
|
||||
for (const msg of chat) {
|
||||
if (msg.role === 'human') {
|
||||
if (msg.content.startsWith('<identity>')) continue
|
||||
inputChars += msg.content.length
|
||||
pendingUserMessage = msg.content.slice(0, 500)
|
||||
}
|
||||
if (msg.role === 'bot') {
|
||||
|
|
@ -226,7 +234,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
|
|||
if (seenKeys.has(dedupKey)) return results
|
||||
|
||||
const outputTokens = estimateTokensFromChars(totalOutputChars)
|
||||
const inputTokens = estimateTokensFromChars(pendingUserMessage.length)
|
||||
const inputTokens = estimateTokensFromChars(inputChars)
|
||||
const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0)
|
||||
const tsDate = parseKiroTimestamp(metadata.startTime)
|
||||
if (!tsDate) return results
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { readSessionFile, readSessionLines } from '../fs-utils.js'
|
|||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { normalizeContentBlocks } from '../content-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'gpt-5.4': 'GPT-5.4',
|
||||
|
|
@ -272,6 +272,10 @@ export function createPiProvider(sessionsDir?: string): Provider {
|
|||
|
||||
return {
|
||||
name: 'pi',
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: dir, label: 'sessions' }]
|
||||
},
|
||||
displayName: 'Pi',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
|
|
@ -302,6 +306,10 @@ export function createOmpProvider(sessionsDir?: string): Provider {
|
|||
|
||||
return {
|
||||
name: 'omp',
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: dir, label: 'sessions' }]
|
||||
},
|
||||
displayName: 'OMP',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
|
||||
import type { Provider, SessionSource, SessionParser } from './types.js'
|
||||
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
|
||||
|
||||
const EXTENSION_ID = 'rooveterinaryinc.roo-cline'
|
||||
|
||||
|
|
@ -16,6 +16,10 @@ export function createRooCodeProvider(overrideDir?: string | string[]): Provider
|
|||
return rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,11 +42,18 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string {
|
|||
return getVSCodeGlobalStoragePaths(extensionId)[0]!
|
||||
}
|
||||
|
||||
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> {
|
||||
const baseDirs = overrideDir
|
||||
// The roots discoverClineTasks scans: an explicit override wins, otherwise
|
||||
// every VS Code variant's globalStorage. Exported so a provider's probeRoots()
|
||||
// can report exactly what discovery reads by calling the same function, rather
|
||||
// than mirroring this logic and drifting from it.
|
||||
export function clineTaskRoots(extensionId: string, overrideDir?: string | string[]): string[] {
|
||||
return overrideDir
|
||||
? (Array.isArray(overrideDir) ? overrideDir : [overrideDir])
|
||||
: getVSCodeGlobalStoragePaths(extensionId)
|
||||
return discoverClineTasksInBaseDirs(baseDirs, providerName, displayName)
|
||||
}
|
||||
|
||||
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> {
|
||||
return discoverClineTasksInBaseDirs(clineTaskRoots(extensionId, overrideDir), providerName, displayName)
|
||||
}
|
||||
|
||||
export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise<SessionSource[]> {
|
||||
|
|
@ -192,7 +199,11 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
|
|||
|
||||
if (tokensIn === 0 && tokensOut === 0) continue
|
||||
|
||||
const timestamp = entry.ts ? new Date(entry.ts).toISOString() : ''
|
||||
// entry.ts is truthy-checked but not validity-checked: a malformed
|
||||
// ts (garbage string, out-of-range number) makes new Date().toISOString()
|
||||
// throw RangeError, which would abort the whole session's parse. Guard it.
|
||||
const tsDate = entry.ts ? new Date(entry.ts) : null
|
||||
const timestamp = tsDate && !Number.isNaN(tsDate.getTime()) ? tsDate.toISOString() : ''
|
||||
const costUSD = cost ?? calculateCost(model, tokensIn, tokensOut, cacheWrites, cacheReads, 0)
|
||||
|
||||
yield {
|
||||
|
|
|
|||
|
|
@ -171,26 +171,62 @@ const CACHE_FILE = `session-cache.v${CACHE_VERSION}.json`
|
|||
const LEGACY_CACHE_FILE = 'session-cache.json'
|
||||
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
|
||||
|
||||
// Env vars that change what a provider discovers or how its sessions parse.
|
||||
// computeEnvFingerprint hashes exactly these to decide when a provider's cache
|
||||
// section is stale; a var read by the provider but missing here means changing
|
||||
// it serves the old section silently, reporting nothing from the new root.
|
||||
// One read in src/providers/ is deliberately absent: CODEBURN_VERBOSE
|
||||
// (sqlite-session-parser.ts:276) only changes logging verbosity, never parsed
|
||||
// output.
|
||||
//
|
||||
// Copilot is deliberately NOT declared here. Declaring any CODEBURN_COPILOT_*
|
||||
// var would change its fingerprint, and on a fingerprint change
|
||||
// getOrCreateProviderSection (src/parser.ts:2650) keeps only the cached
|
||||
// entries whose source path no longer exists — but copilot's OTel discovery
|
||||
// returns one source per DB file ({ path: dbPath }, src/providers/copilot.ts:1935)
|
||||
// and that DB keeps existing, so its cached entry would be dropped and
|
||||
// re-parsed, destroying conversations Copilot has since pruned from the DB
|
||||
// that only the cache still holds (see DURABLE_PROVIDER_NAMES below). Do not
|
||||
// "complete" the map for copilot until the durable carry-forward learns to
|
||||
// merge instead of drop.
|
||||
export const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
||||
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'],
|
||||
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'APPDATA', 'LOCALAPPDATA'],
|
||||
'cline-cli': ['CLINE_SESSION_DATA_DIR', 'CLINE_DATA_DIR', 'CLINE_DIR'],
|
||||
codebuff: ['CODEBUFF_DATA_DIR'],
|
||||
codewhale: ['CODEWHALE_HOME'],
|
||||
codex: ['CODEX_HOME'],
|
||||
hermes: ['HERMES_HOME'],
|
||||
'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'],
|
||||
droid: ['FACTORY_DIR'],
|
||||
cursor: ['XDG_DATA_HOME'],
|
||||
cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'],
|
||||
// XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately
|
||||
// kept: removing it would force a re-parse to fix nothing.
|
||||
'cursor-agent': ['XDG_DATA_HOME'],
|
||||
'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'],
|
||||
openclaude: ['CODEBURN_OPENCLAUDE_DIR'],
|
||||
opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'],
|
||||
goose: ['XDG_DATA_HOME'],
|
||||
crush: ['XDG_DATA_HOME'],
|
||||
goose: ['XDG_DATA_HOME', 'GOOSE_PATH_ROOT'],
|
||||
grok: ['GROK_HOME'],
|
||||
crush: ['XDG_DATA_HOME', 'CRUSH_GLOBAL_DATA', 'LOCALAPPDATA'],
|
||||
warp: ['WARP_DB_PATH'],
|
||||
antigravity: ['CODEBURN_CACHE_DIR'],
|
||||
'kilo-code': ['XDG_DATA_HOME'],
|
||||
kimi: ['KIMI_SHARE_DIR', 'KIMI_MODEL_NAME'],
|
||||
kiro: ['KIRO_HOME'],
|
||||
'mistral-vibe': ['VIBE_HOME'],
|
||||
mux: ['MUX_ROOT', 'CODEBURN_MUX_DIR'],
|
||||
qwen: ['QWEN_DATA_DIR'],
|
||||
'ibm-bob': ['XDG_CONFIG_HOME'],
|
||||
'ibm-bob': ['XDG_CONFIG_HOME', 'APPDATA'],
|
||||
quickdesk: ['QUICKWORK_HOME'],
|
||||
kimicode: ['KIMI_CODE_HOME'],
|
||||
zerostack: ['ZS_DATA_DIR', 'XDG_DATA_HOME'],
|
||||
// The gateway credential is a deliberate user override and MUST move the
|
||||
// fingerprint: a read-only refresh (the refresh-lock fallback) serves the
|
||||
// cached report straight from the section (parser.ts:2875 seeds servedSources
|
||||
// before the network re-fetch at parser.ts:2888, which only runs when
|
||||
// !readOnly), so an undeclared credential would keep serving the previous
|
||||
// account's usage after a swap — the exact #920 defect.
|
||||
'vercel-gateway': ['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN'],
|
||||
}
|
||||
|
||||
// Names of providers whose cache entries are never evicted when source files
|
||||
|
|
@ -225,7 +261,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
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',
|
||||
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
|
||||
// transcripts (both carry producer 'copilot-agent'), skipping the shutdown
|
||||
// input/cache rollup; this bump re-parses them so the missing tokens land.
|
||||
copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1',
|
||||
grok: 'estimated-cost-v1',
|
||||
hermes: 'reasoning-output-accounting-v1-est-cost',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
|
|
@ -608,34 +647,65 @@ async function retryCacheFileMutation(operation: () => Promise<void>): Promise<b
|
|||
// append-only transcripts keep changing. Fixing this properly means
|
||||
// multi-file fingerprints per source.
|
||||
|
||||
// SQLite database files by extension. Bare-db sources (copilot's
|
||||
// agent-traces.db) and the virtual-suffix bases below all match one of these.
|
||||
const SQLITE_DB_PATH = /\.(db|sqlite3?|vscdb)$/i
|
||||
|
||||
/// Fingerprint a SQLite database file together with its `-wal` sibling.
|
||||
///
|
||||
/// A database in WAL mode parks committed writes in `<db>-wal`; the main
|
||||
/// file's stat only moves on checkpoint, and a long-lived writer connection
|
||||
/// (hermes, cursor and opencode keep their state DBs open for the life of
|
||||
/// the agent process) can defer checkpoints for hours or days. A fingerprint
|
||||
/// built from the main file alone then (a) carries an mtime older than the
|
||||
/// newest committed data, so the date-range mtime pre-filter in
|
||||
/// parseProviderSources skips the source and sessions committed after the
|
||||
/// last checkpoint never parse (issue #913: today's Hermes sessions missing
|
||||
/// from every report), and (b) does not change between checkpoints, so
|
||||
/// reconcileFile keeps serving stale cached turns for sessions that grew.
|
||||
/// Folding the WAL sibling in fixes both: the newest mtime wins, and the
|
||||
/// sizes add so both WAL growth and a checkpoint (db grows, wal truncates)
|
||||
/// move the fingerprint. `-shm` is deliberately ignored — it mutates on
|
||||
/// reads too and would churn the fingerprint without any data change.
|
||||
async function fingerprintSqliteFile(dbPath: string): Promise<FileFingerprint | null> {
|
||||
try {
|
||||
const s = await stat(dbPath)
|
||||
const wal = await stat(dbPath + '-wal').catch(() => null)
|
||||
return {
|
||||
dev: s.dev,
|
||||
ino: s.ino,
|
||||
mtimeMs: wal ? Math.max(s.mtimeMs, wal.mtimeMs) : s.mtimeMs,
|
||||
sizeBytes: s.size + (wal?.size ?? 0),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fingerprintFile(filePath: string): Promise<FileFingerprint | null> {
|
||||
try {
|
||||
const s = await stat(filePath)
|
||||
// A source path that IS a SQLite database (copilot OTel's agent-traces.db)
|
||||
// needs the same WAL fold as the virtual-suffix forms below.
|
||||
if (SQLITE_DB_PATH.test(filePath)) return fingerprintSqliteFile(filePath)
|
||||
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
||||
} catch {
|
||||
// Providers encode extra context into source paths using virtual suffixes:
|
||||
// - Cursor: `<dbPath>#cursor-ws=<workspace>` (workspace-aware routing)
|
||||
// - OpenCode: `<dbPath>:<sessionId>` (session scoping)
|
||||
// - Hermes: `<dbPath>#hermes-session=<sessionId>` (session scoping)
|
||||
// These compound paths don't exist on disk; strip the suffix to stat the
|
||||
// underlying file. Try `#` first (rare in real paths), then `:` (must use
|
||||
// lastIndexOf to tolerate Windows drive letters like C:\...).
|
||||
// underlying database. Try `#` first (rare in real paths), then `:` (must
|
||||
// use lastIndexOf to tolerate Windows drive letters like C:\...).
|
||||
const hashIdx = filePath.indexOf('#')
|
||||
if (hashIdx > 0) {
|
||||
try {
|
||||
const s = await stat(filePath.slice(0, hashIdx))
|
||||
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
||||
} catch {
|
||||
// fall through to colon check
|
||||
}
|
||||
const fp = await fingerprintSqliteFile(filePath.slice(0, hashIdx))
|
||||
if (fp) return fp
|
||||
// fall through to colon check
|
||||
}
|
||||
const colonIdx = filePath.lastIndexOf(':')
|
||||
if (colonIdx > 0) {
|
||||
try {
|
||||
const s = await stat(filePath.slice(0, colonIdx))
|
||||
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return fingerprintSqliteFile(filePath.slice(0, colonIdx))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,11 +73,22 @@ export class ShareServer {
|
|||
}
|
||||
|
||||
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', 'https://localhost')
|
||||
const json = (code: number, body: unknown): void => {
|
||||
res.writeHead(code, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
// handle() is dispatched with `void` (see the createServer callback), so a
|
||||
// throw here is an UNHANDLED rejection, not a caught 500. A request target
|
||||
// the HTTP parser accepts but the WHATWG URL parser rejects - e.g. an
|
||||
// unterminated IPv6 host like `//[::1` - would otherwise crash this
|
||||
// LAN-facing server. Parse inside the guard and answer 400 instead.
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(req.url ?? '/', 'https://localhost')
|
||||
} catch {
|
||||
json(400, { error: 'malformed request URL' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.route(url, req, res, json)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesCo
|
|||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { aggregateModels } from './models-report.js'
|
||||
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
|
||||
|
|
@ -95,6 +95,10 @@ async function hydrateCache(): Promise<DailyCache> {
|
|||
// Never finalize the daily history off a partial (interrupted) session
|
||||
// hydration — that is what froze empty older days into the chart.
|
||||
isSessionHydrationComplete,
|
||||
// On a tz-change re-derive the same parse is re-aggregated under the old
|
||||
// tzKey so carried slices can be reduced by the turns that re-bucketed
|
||||
// across local midnight (issue #770).
|
||||
(projects, tz) => aggregateProjectsIntoDays(projects, (iso) => dateKeyInTz(iso, tz)),
|
||||
)
|
||||
} catch (err) {
|
||||
// Previously swallowed silently, which turned any backfill failure into an
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ describe('warm session-cache refresh lock', () => {
|
|||
// run (fs 'unavailable' makes the fence fail CLOSED, which is correct but
|
||||
// not what this test measures); the actual race fails ~6% per verify, so a
|
||||
// mutated build cannot pass any attempt.
|
||||
it('the fence never loses to its own heartbeat (in-process serialization)', { retry: 5 }, async () => {
|
||||
it('the fence never loses to its own heartbeat (in-process serialization)', { retry: 10 }, async () => {
|
||||
// Regression: verifyStillOwner and the heartbeat tick both take the
|
||||
// takeover guard; without in-process serialization the fence could observe
|
||||
// its own heartbeat's guard file and abort a legitimate publication.
|
||||
|
|
@ -228,7 +228,11 @@ describe('warm session-cache refresh lock', () => {
|
|||
// 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', () => {
|
||||
// Real-fs recovery tests: under a saturated full-suite run an fs op can starve
|
||||
// and the acquire fails closed (correct, but not what these measure), so they
|
||||
// retry to ride out the environmental blip. A real regression fails every
|
||||
// attempt because the takeover assertion is deterministic given the fixture.
|
||||
describe('warm session-cache refresh lock: corrupt lock recovery', { retry: 6 }, () => {
|
||||
it('takes over a stale zero-byte lock', async () => {
|
||||
const dir = await tempDir()
|
||||
const clock = fakeClock(100_000)
|
||||
|
|
|
|||
|
|
@ -89,8 +89,16 @@ async function seedLiveTodaySession(): Promise<void> {
|
|||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString()
|
||||
const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString()
|
||||
// Timestamps a few minutes OLD, clamped into today: a fixed wall-clock hour
|
||||
// (12:00) is in the future whenever the suite runs before noon, and the
|
||||
// instant-granular provider-filtered path drops future calls while the
|
||||
// day-granular all-provider path keeps them, so the parity assertion failed
|
||||
// for every before-noon run (ubuntu CI at 00:17 UTC included). Same fix as
|
||||
// project-filter-durable-totals got in 1596220.
|
||||
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 ts = minutesAgo(40)
|
||||
const ts2 = minutesAgo(10)
|
||||
const line = (id: string, t: string): string => JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: t,
|
||||
|
|
|
|||
15
tests/cli-refresh-help.test.ts
Normal file
15
tests/cli-refresh-help.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('CLI refresh help', () => {
|
||||
it.each(['report', 'today', 'month'])('%s discloses the refresh floor and disable value', command => {
|
||||
const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', command, '--help'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toMatch(/Auto-refresh interval in seconds \(minimum 60; 0 to\s+disable\)/)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,12 @@ import { tmpdir } from 'node:os'
|
|||
import { delimiter as pathDelimiter, join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Every case here spawns the real CLI and does genuine multi-provider parse
|
||||
// work; the 5s default is fine on a dev laptop and not on a shared 2-core
|
||||
// runner, where individual cases have been observed needing 6-8s.
|
||||
vi.setConfig({ testTimeout: 30_000 })
|
||||
|
||||
function runCli(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
|
|
@ -58,8 +63,13 @@ describe('codeburn status --format menubar-json', () => {
|
|||
await mkdir(projectDir, { recursive: true })
|
||||
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
// Two hours back, clamped inside the current UTC day (runCli pins
|
||||
// TZ=UTC): a plain now-2h leaves today during the first two hours of
|
||||
// the day, and the old hour-guard still escaped into yesterday during
|
||||
// the first five minutes of hours 0 and 1, zeroing every "today" query
|
||||
// on runs that started just past the top of those hours.
|
||||
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
|
|
@ -420,8 +430,13 @@ describe('codeburn status --format menubar-json', () => {
|
|||
}))
|
||||
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
// Two hours back, clamped inside the current UTC day (runCli pins
|
||||
// TZ=UTC): a plain now-2h leaves today during the first two hours of
|
||||
// the day, and the old hour-guard still escaped into yesterday during
|
||||
// the first five minutes of hours 0 and 1, zeroing every "today" query
|
||||
// on runs that started just past the top of those hours.
|
||||
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
|
|
@ -478,8 +493,13 @@ describe('codeburn status --format menubar-json', () => {
|
|||
await mkdir(projectDir, { recursive: true })
|
||||
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
// Two hours back, clamped inside the current UTC day (runCli pins
|
||||
// TZ=UTC): a plain now-2h leaves today during the first two hours of
|
||||
// the day, and the old hour-guard still escaped into yesterday during
|
||||
// the first five minutes of hours 0 and 1, zeroing every "today" query
|
||||
// on runs that started just past the top of those hours.
|
||||
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
|
||||
|
|
@ -636,8 +656,13 @@ describe('codeburn status --format menubar-json', () => {
|
|||
const projectDir = join(home, '.claude', 'projects', 'myapp')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
// Two hours back, clamped inside the current UTC day (runCli pins
|
||||
// TZ=UTC): a plain now-2h leaves today during the first two hours of
|
||||
// the day, and the old hour-guard still escaped into yesterday during
|
||||
// the first five minutes of hours 0 and 1, zeroing every "today" query
|
||||
// on runs that started just past the top of those hours.
|
||||
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n'))
|
||||
|
|
|
|||
46
tests/context-budget-home.test.ts
Normal file
46
tests/context-budget-home.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
// Mock homedir to a temp dir so "project == home" is reproducible.
|
||||
import { vi } from 'vitest'
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
const fs = await vi.importActual<typeof import('fs')>('fs')
|
||||
const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/cb-ctxbudget-home-')
|
||||
process.env['CB_CTXBUDGET_FAKE_HOME'] = fakeHome
|
||||
return { ...actual, homedir: () => fakeHome }
|
||||
})
|
||||
|
||||
const HOME = process.env['CB_CTXBUDGET_FAKE_HOME']!
|
||||
|
||||
import { estimateContextBudget } from '../src/context-budget.js'
|
||||
|
||||
describe('context budget: no double-count when the project IS the home dir', () => {
|
||||
beforeEach(() => {
|
||||
rmSync(join(HOME, '.claude'), { recursive: true, force: true })
|
||||
mkdirSync(join(HOME, '.claude', 'skills', 'my-skill'), { recursive: true })
|
||||
writeFileSync(join(HOME, '.claude', 'skills', 'my-skill', 'SKILL.md'), '# Skill')
|
||||
writeFileSync(join(HOME, '.claude', 'CLAUDE.md'), 'home memory')
|
||||
})
|
||||
|
||||
it('counts the one home skill once, not twice, when projectPath is home', async () => {
|
||||
// With projectPath === home, the home and project skills dirs resolve to
|
||||
// the same directory; the unfixed code pushed both and counted every skill
|
||||
// twice (and read ~/.claude/CLAUDE.md twice).
|
||||
const budget = await estimateContextBudget(HOME)
|
||||
expect(budget.skills.count).toBe(1)
|
||||
// ~/.claude/CLAUDE.md must appear once in the memory file list.
|
||||
const homeMemory = budget.memory.files.filter(f => f.name.includes('.claude/CLAUDE.md'))
|
||||
expect(homeMemory).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still counts a distinct project skill separately from a home skill', async () => {
|
||||
const proj = mkdtempSync(join(HOME, '..', 'cb-ctxbudget-proj-'))
|
||||
mkdirSync(join(proj, '.claude', 'skills', 'proj-skill'), { recursive: true })
|
||||
writeFileSync(join(proj, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '# Proj')
|
||||
const budget = await estimateContextBudget(proj)
|
||||
expect(budget.skills.count).toBe(2) // home skill + project skill
|
||||
rmSync(proj, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
|
@ -56,8 +56,12 @@ describe('web dashboard /api/context/tree: session id prefix', () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
await rm(homeDir, { recursive: true, force: true })
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
// close() only stops new connections; a request handler's fire-and-forget
|
||||
// cache save can still land a file mid-recursive-rm, which surfaces as
|
||||
// ENOTEMPTY on slower runners. fs.rm's built-in retries absorb exactly
|
||||
// that window.
|
||||
await rm(homeDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 })
|
||||
await rm(cacheDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 })
|
||||
})
|
||||
|
||||
it('resolves a full session id (control case)', async () => {
|
||||
|
|
|
|||
488
tests/daily-cache-tz-dedup.test.ts
Normal file
488
tests/daily-cache-tz-dedup.test.ts
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { 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 { aggregateProjectsIntoDays, dateKey, dateKeyInTz } from '../src/day-aggregator.js'
|
||||
|
||||
import {
|
||||
DAILY_CACHE_VERSION,
|
||||
type DailyCache,
|
||||
type DailyEntry,
|
||||
type ProviderDaySlice,
|
||||
currentTzKey,
|
||||
ensureCacheHydrated,
|
||||
mergeDayEntries,
|
||||
saveDailyCache,
|
||||
toDateString,
|
||||
} from '../src/daily-cache.js'
|
||||
|
||||
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-tz-dedup-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-15T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
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 makeCall(timestamp: string, costUSD: number, provider = 'codex') {
|
||||
return {
|
||||
provider,
|
||||
model: 'codex-1',
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 50,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
costUSD,
|
||||
tools: [],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard' as const,
|
||||
timestamp,
|
||||
bashCommands: [],
|
||||
deduplicationKey: `dk-${timestamp}-${costUSD}`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(calls: ReturnType<typeof makeCall>[]): ProjectSummary {
|
||||
const timestamp = calls[0]!.timestamp
|
||||
const totalCostUSD = calls.reduce((s, c) => s + c.costUSD, 0)
|
||||
return {
|
||||
project: 'p',
|
||||
projectPath: '/p',
|
||||
totalCostUSD,
|
||||
totalApiCalls: calls.length,
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: timestamp,
|
||||
lastTimestamp: calls.at(-1)!.timestamp,
|
||||
totalCostUSD,
|
||||
totalInputTokens: calls.reduce((s, c) => s + c.usage.inputTokens, 0),
|
||||
totalOutputTokens: calls.reduce((s, c) => s + c.usage.outputTokens, 0),
|
||||
totalCacheReadTokens: calls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0),
|
||||
totalCacheWriteTokens: calls.reduce((s, c) => s + c.usage.cacheCreationInputTokens, 0),
|
||||
apiCalls: calls.length,
|
||||
turns: [{
|
||||
userMessage: 'hi',
|
||||
timestamp,
|
||||
sessionId: 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: calls,
|
||||
}],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as never,
|
||||
skillBreakdown: {} as never,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// A real IANA zone guaranteed to differ from the machine's current one, so the
|
||||
/// seeded cache reads as a genuine tz change. Kiritimati (UTC+14) differs from
|
||||
/// every other zone; if the machine itself is Kiritimati, Pago Pago (UTC-11) is
|
||||
/// 25h away, so a straddling timestamp still exists.
|
||||
function otherTz(): string {
|
||||
return currentTzKey() === 'Pacific/Kiritimati' ? 'Pacific/Pago_Pago' : 'Pacific/Kiritimati'
|
||||
}
|
||||
|
||||
/// A 2026-06-13 UTC timestamp that lands on DIFFERENT calendar days under the
|
||||
/// machine's local tz and `tz` (i.e. a turn that migrates across local midnight
|
||||
/// when the timezone changes). Deterministic for any machine; two zones with
|
||||
/// different UTC offsets always have a straddle somewhere in the day.
|
||||
function straddlingTimestamp(tz: string): string {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const iso = `2026-06-13T${String(h).padStart(2, '0')}:30:00.000Z`
|
||||
if (dateKey(iso) !== dateKeyInTz(iso, tz)) return iso
|
||||
}
|
||||
throw new Error(`no straddling timestamp between local tz and ${tz}`)
|
||||
}
|
||||
|
||||
/// The production-shaped tz-aware aggregator: re-aggregate under an explicit tz.
|
||||
function aggregateInTz(projects: ProjectSummary[], tz: string): DailyEntry[] {
|
||||
return aggregateProjectsIntoDays(projects, (iso) => dateKeyInTz(iso, tz))
|
||||
}
|
||||
|
||||
const OLD_TZ = otherTz()
|
||||
// A fixed day whose sources are entirely gone (no fixture turn buckets to it
|
||||
// under either tz): the issue #770 "sources-gone day" that must survive.
|
||||
const GONE_DAY = '2026-06-10'
|
||||
|
||||
async function seed(days: DailyEntry[], overrides: Partial<DailyCache> = {}): Promise<void> {
|
||||
await saveDailyCache({
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: 'cfg-A',
|
||||
tzKey: OLD_TZ,
|
||||
lastComputedDate: '2026-06-13',
|
||||
days,
|
||||
complete: true,
|
||||
watermarkTrusted: true,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/// A real IANA zone guaranteed to be BEHIND the machine's local timezone, so a
|
||||
/// call early in the NEW tz's today is still the OLD tz's YESTERDAY - the
|
||||
/// boundary-day direction the history parse range excludes (its calls fall past
|
||||
/// yesterdayEnd). Etc/GMT+N == UTC-N; pick one ~6h behind so a straddling gap
|
||||
/// timestamp always exists inside the fake-time window.
|
||||
function behindTz(): string {
|
||||
const offsetHours = -new Date().getTimezoneOffset() / 60
|
||||
const gmtIndex = Math.max(-12, Math.min(12, 6 - offsetHours))
|
||||
return `Etc/GMT${gmtIndex < 0 ? '-' : '+'}${Math.abs(gmtIndex)}`
|
||||
}
|
||||
|
||||
/// A timestamp in the re-derive's GAP: dated TODAY under the new tz (so the
|
||||
/// history parse through yesterday excludes it) but YESTERDAY under `tz` (so
|
||||
/// the baseline cache holds it), and still <= the fake `now` (so a parse
|
||||
/// through now includes it).
|
||||
function gapTimestamp(tz: string): { ts: string; oldDate: string } {
|
||||
const now = new Date()
|
||||
const todayStr = toDateString(now)
|
||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
for (let h = 0; h <= now.getUTCHours(); h++) {
|
||||
const iso = `2026-06-15T${String(h).padStart(2, '0')}:00:00.000Z`
|
||||
if (dateKey(iso) !== todayStr) continue
|
||||
const oldDate = dateKeyInTz(iso, tz)
|
||||
if (oldDate === yesterdayStr) return { ts: iso, oldDate }
|
||||
}
|
||||
throw new Error(`no gap timestamp for ${tz} (today=${todayStr} yesterday=${yesterdayStr})`)
|
||||
}
|
||||
|
||||
/// A parse mock that RESPECTS its range: calls whose timestamps fall outside
|
||||
/// [start, end] are dropped. The real parser slices straddling turns per range;
|
||||
/// this keeps the test's assertion that the boundary call is excluded from a
|
||||
/// history-only parse honest.
|
||||
function rangeAwareParse(projects: ProjectSummary[]) {
|
||||
return async (range: DateRange): Promise<ProjectSummary[]> => {
|
||||
const startMs = range.start.getTime()
|
||||
const endMs = range.end.getTime()
|
||||
const inRange: ProjectSummary[] = []
|
||||
for (const p of projects) {
|
||||
const sessions = p.sessions
|
||||
.map(s => ({
|
||||
...s,
|
||||
turns: s.turns
|
||||
.map(t => ({
|
||||
...t,
|
||||
assistantCalls: t.assistantCalls.filter(c => {
|
||||
const ms = new Date(c.timestamp).getTime()
|
||||
return ms >= startMs && ms <= endMs
|
||||
}),
|
||||
}))
|
||||
.filter(t => t.assistantCalls.length > 0),
|
||||
}))
|
||||
.filter(s => s.turns.length > 0)
|
||||
if (sessions.length > 0) inRange.push({ ...p, sessions })
|
||||
}
|
||||
return inRange
|
||||
}
|
||||
}
|
||||
|
||||
describe('dateKeyInTz', () => {
|
||||
it('buckets a timestamp under an explicit timezone (machine tz irrelevant)', () => {
|
||||
// 23:30Z on 06-13 is still 06-13 in New York (19:30 EDT) but already
|
||||
// 06-14 in Kiritimati (01:30, UTC+14).
|
||||
expect(dateKeyInTz('2026-06-13T23:30:00.000Z', 'America/New_York')).toBe('2026-06-13')
|
||||
expect(dateKeyInTz('2026-06-13T23:30:00.000Z', 'Pacific/Kiritimati')).toBe('2026-06-14')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tz-change re-derive: subtract what the fresh parse re-bucketed (issue #770)', () => {
|
||||
it('(a) a turn that migrated across local midnight counts once, not twice', async () => {
|
||||
const ts = straddlingTimestamp(OLD_TZ)
|
||||
const oldDay = dateKeyInTz(ts, OLD_TZ)
|
||||
const newDay = dateKey(ts)
|
||||
expect(newDay).not.toBe(oldDay)
|
||||
|
||||
const fixture = [makeProject([makeCall(ts, 10)])]
|
||||
await seed([day(oldDay, { codex: slice(10, 1) })])
|
||||
|
||||
let parseCalls = 0
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => { parseCalls += 1; return fixture },
|
||||
aggregateProjectsIntoDays,
|
||||
'cfg-A',
|
||||
() => true,
|
||||
aggregateInTz,
|
||||
)
|
||||
|
||||
// The history parse was aggregated twice (current tz + old tz); the fix
|
||||
// round 1 subtraction adds a second through-now parse scoped to the
|
||||
// subtraction, so the tz path parses twice total.
|
||||
expect(parseCalls).toBe(2)
|
||||
const total = out.days.reduce((s, d) => s + d.cost, 0)
|
||||
const codexTotal = out.days.reduce((s, d) => s + (d.providers['codex']?.cost ?? 0), 0)
|
||||
expect(total).toBeCloseTo(10, 5)
|
||||
expect(codexTotal).toBeCloseTo(10, 5)
|
||||
// The old day is fully explained away (its only turn migrated) → dropped.
|
||||
expect(out.days.find(d => d.date === oldDay)).toBeUndefined()
|
||||
const newDayEntry = out.days.find(d => d.date === newDay)
|
||||
expect(newDayEntry).toBeDefined()
|
||||
expect(newDayEntry!.providers['codex']!.cost).toBeCloseTo(10, 5)
|
||||
})
|
||||
|
||||
it('(b) a sources-gone day survives a tz re-derive unchanged', async () => {
|
||||
const ts = straddlingTimestamp(OLD_TZ)
|
||||
const oldDay = dateKeyInTz(ts, OLD_TZ)
|
||||
const newDay = dateKey(ts)
|
||||
|
||||
const fixture = [makeProject([makeCall(ts, 10)])]
|
||||
await seed([
|
||||
day(GONE_DAY, { claude: slice(399.70, 1572) }),
|
||||
day(oldDay, { codex: slice(10, 1) }),
|
||||
])
|
||||
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => fixture,
|
||||
aggregateProjectsIntoDays,
|
||||
'cfg-A',
|
||||
() => true,
|
||||
aggregateInTz,
|
||||
)
|
||||
|
||||
// The vanished-source day is untouched, carried exactly as before.
|
||||
const gone = out.days.find(d => d.date === GONE_DAY)
|
||||
expect(gone).toMatchObject({ cost: 399.70, calls: 1572, carried: true })
|
||||
expect(gone!.providers['claude']!.cost).toBe(399.70)
|
||||
// The migrated turn left its old day entirely; it now lives on newDay only.
|
||||
expect(out.days.find(d => d.date === oldDay)).toBeUndefined()
|
||||
const newDayEntry = out.days.find(d => d.date === newDay)
|
||||
expect(newDayEntry!.providers['codex']!.cost).toBeCloseTo(10, 5)
|
||||
const total = out.days.reduce((s, d) => s + d.cost, 0)
|
||||
expect(total).toBeCloseTo(399.70 + 10, 5)
|
||||
})
|
||||
|
||||
it('(c) a mixed slice subtracts only the migrated part; the remainder is carried', async () => {
|
||||
const ts = straddlingTimestamp(OLD_TZ)
|
||||
const oldDay = dateKeyInTz(ts, OLD_TZ)
|
||||
const newDay = dateKey(ts)
|
||||
|
||||
// Baseline day holds TWO codex turns' worth (20): one is the live turn that
|
||||
// migrates to newDay, the other's source is gone. Only the live 10 is
|
||||
// subtracted; the sources-gone 10 is carried forward.
|
||||
const fixture = [makeProject([makeCall(ts, 10)])]
|
||||
await seed([day(oldDay, { codex: slice(20, 2) })])
|
||||
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => fixture,
|
||||
aggregateProjectsIntoDays,
|
||||
'cfg-A',
|
||||
() => true,
|
||||
aggregateInTz,
|
||||
)
|
||||
|
||||
const carried = out.days.find(d => d.date === oldDay)
|
||||
expect(carried).toBeDefined()
|
||||
expect(carried!.carried).toBe(true)
|
||||
expect(carried!.providers['codex']!.cost).toBeCloseTo(10, 5)
|
||||
expect(carried!.providers['codex']!.calls).toBe(1)
|
||||
const migrated = out.days.find(d => d.date === newDay)
|
||||
expect(migrated!.providers['codex']!.cost).toBeCloseTo(10, 5)
|
||||
const total = out.days.reduce((s, d) => s + d.cost, 0)
|
||||
expect(total).toBeCloseTo(20, 5)
|
||||
})
|
||||
|
||||
it('(d) a non-tz re-derive (savings-hash change) preserves a mid-range source hole exactly', async () => {
|
||||
// No tz change: seed under the machine's own tz. A savings-hash change
|
||||
// re-derives; the mid-range hole (codex sources gone) must carry at exactly
|
||||
// 50, byte-identical to the pre-fix behavior.
|
||||
const fixture = [makeProject([makeCall('2026-06-12T10:00:00.000Z', 100, 'claude')])]
|
||||
const aggregateToJune12 = (projects: ProjectSummary[]): DailyEntry[] =>
|
||||
aggregateProjectsIntoDays(projects, () => '2026-06-12')
|
||||
const unexpectedTzAggregation = (): DailyEntry[] => {
|
||||
throw new Error('aggregateDaysInTz must not be called on a non-tz re-derive')
|
||||
}
|
||||
await seed(
|
||||
[day('2026-06-12', { claude: slice(100, 100), codex: slice(50, 50) })],
|
||||
{ tzKey: currentTzKey() },
|
||||
)
|
||||
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => fixture,
|
||||
aggregateToJune12,
|
||||
'cfg-B',
|
||||
() => true,
|
||||
unexpectedTzAggregation,
|
||||
)
|
||||
|
||||
expect(out.savingsConfigHash).toBe('cfg-B')
|
||||
const kept = out.days.find(d => d.date === '2026-06-12')!
|
||||
expect(kept.providers['claude']!.cost).toBe(100)
|
||||
expect(kept.providers['codex']!.cost).toBe(50)
|
||||
expect(kept.cost).toBeCloseTo(150, 5)
|
||||
expect(kept.carried).toBe(true)
|
||||
})
|
||||
|
||||
it('(e) tzChanged AND savingsConfigHash changed together: no subtraction', async () => {
|
||||
const ts = straddlingTimestamp(OLD_TZ)
|
||||
const oldDay = dateKeyInTz(ts, OLD_TZ)
|
||||
const newDay = dateKey(ts)
|
||||
|
||||
const fixture = [makeProject([makeCall(ts, 10)])]
|
||||
await seed([day(oldDay, { codex: slice(10, 1) })])
|
||||
|
||||
const out = await ensureCacheHydrated(
|
||||
async () => fixture,
|
||||
aggregateProjectsIntoDays,
|
||||
'cfg-B', // hash changed in the same re-derive
|
||||
() => true,
|
||||
aggregateInTz,
|
||||
)
|
||||
|
||||
// Re-pricing drift must not masquerade as re-bucketing spend: the carry is
|
||||
// unchanged (the double count stays, exactly as on main today).
|
||||
const carried = out.days.find(d => d.date === oldDay)
|
||||
expect(carried).toBeDefined()
|
||||
expect(carried!.providers['codex']!.cost).toBeCloseTo(10, 5)
|
||||
const migrated = out.days.find(d => d.date === newDay)
|
||||
expect(migrated!.providers['codex']!.cost).toBeCloseTo(10, 5)
|
||||
const total = out.days.reduce((s, d) => s + d.cost, 0)
|
||||
expect(total).toBeCloseTo(20, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fix round 1', () => {
|
||||
it('(f) a call that re-buckets to TODAY (past the history parse) is subtracted from its old day', async () => {
|
||||
// The boundary-day direction the history parse misses: OLD_TZ is BEHIND the
|
||||
// machine, so a call early in NEW-tz today is still OLD-tz YESTERDAY - a
|
||||
// date the baseline cache holds. The re-derive parse used to stop at
|
||||
// yesterdayEnd, which is BEFORE this call's timestamp, so the old-tz
|
||||
// re-aggregation never saw it: the baseline slice was carried un-subtracted
|
||||
// while today's live parse counted it again. The fix parses through NOW for
|
||||
// the subtraction; the merged cache still stops at yesterday.
|
||||
const oldTz = behindTz()
|
||||
const { ts, oldDate } = gapTimestamp(oldTz)
|
||||
const now = new Date()
|
||||
const todayStr = toDateString(now)
|
||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
expect(dateKey(ts)).toBe(todayStr)
|
||||
expect(dateKeyInTz(ts, oldTz)).toBe(oldDate)
|
||||
|
||||
const fixture = [makeProject([makeCall(ts, 10)])]
|
||||
await seed([day(oldDate, { codex: slice(10, 1) })], { tzKey: oldTz })
|
||||
|
||||
const out = await ensureCacheHydrated(
|
||||
rangeAwareParse(fixture),
|
||||
aggregateProjectsIntoDays,
|
||||
'cfg-A',
|
||||
() => true,
|
||||
aggregateInTz,
|
||||
)
|
||||
|
||||
// The migrated call was explained away from its old day: nothing on oldDate
|
||||
// is carried to be double-counted by today's live parse.
|
||||
expect(out.days.find(d => d.date === oldDate)).toBeUndefined()
|
||||
// The cache still holds ONLY history days - today is not finalized, and the
|
||||
// watermark did not move.
|
||||
expect(out.days.some(d => d.date >= todayStr)).toBe(false)
|
||||
expect(out.lastComputedDate).toBe(yesterdayStr)
|
||||
expect(out.days.reduce((s, d) => s + d.cost, 0)).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
it('(g) subtraction residual sessions ADD to a fresh sessions-only placeholder (source-gone sessions survive)', () => {
|
||||
// A fresh day carries a sessions-only placeholder (sessions=1, cost=0) for a
|
||||
// session that started on that day; the baseline slice held TWO sessions (that
|
||||
// one plus a source-gone one). The tz subtraction removes the fresh-explained
|
||||
// session from the carried slice, leaving a residual of sessions=1. The
|
||||
// placeholder max-dedup clamps max(1, 1) = 1, permanently dropping the
|
||||
// source-gone session; the residual must ADD instead.
|
||||
const fresh = day('2026-06-13', { codex: slice(0, 0, { sessions: 1 }) }, { sessions: 1 })
|
||||
const baseline = day('2026-06-13', { codex: slice(0, 0, { sessions: 2 }) }, { sessions: 2 })
|
||||
const subtract = new Map<string, Map<string, ProviderDaySlice>>([
|
||||
['2026-06-13', new Map([['codex', { sessions: 1, cost: 0, calls: 0 }]])],
|
||||
])
|
||||
const merged = mergeDayEntries([fresh], [baseline], true, subtract)
|
||||
const m = merged[0]!
|
||||
expect(m.providers['codex']!.sessions).toBe(2)
|
||||
expect(m.sessions).toBe(2)
|
||||
})
|
||||
|
||||
it('(h) day totals subtract the EFFECTIVE removal, not the raw sub (skew)', () => {
|
||||
// Skew: the fresh-old-tz content for provider A (cost 10) EXCEEDS what the
|
||||
// cached baseline slice holds (cost 5). The slice clamps to zero, so the day
|
||||
// loses exactly 5 - NOT 10, which would eat provider B's carried history at
|
||||
// the day level and leave the day total failing to sum to its surviving
|
||||
// slices (2 with B still 7).
|
||||
const a = slice(5, 1, {
|
||||
models: { 'shared-model': { calls: 1, cost: 5, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
})
|
||||
const b = slice(7, 1, {
|
||||
models: { 'shared-model': { calls: 1, cost: 7, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
})
|
||||
const baseline = day('2026-06-13', { A: a, B: b }, {
|
||||
models: {
|
||||
'shared-model': { calls: 2, cost: 12, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
},
|
||||
})
|
||||
const subtract = new Map<string, Map<string, ProviderDaySlice>>([
|
||||
['2026-06-13', new Map([
|
||||
['A', slice(10, 1, {
|
||||
models: { 'shared-model': { calls: 1, cost: 10, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
})],
|
||||
// A subtraction entry for a provider the day does not have must be a
|
||||
// no-op (effective removal is zero) - it cannot eat day totals.
|
||||
['C', slice(999, 99)],
|
||||
])],
|
||||
])
|
||||
const merged = mergeDayEntries([], [baseline], true, subtract)
|
||||
const m = merged[0]!
|
||||
// Day totals equal the surviving slice (B): 7, not 2 (12 - raw 10).
|
||||
expect(m.cost).toBeCloseTo(7, 5)
|
||||
expect(m.calls).toBe(1)
|
||||
expect(m.providers['A']).toBeUndefined()
|
||||
expect(m.providers['C']).toBeUndefined()
|
||||
expect(m.providers['B']).toMatchObject({ cost: 7, calls: 1 })
|
||||
// The day-level model split lost only A's effective share, not B's.
|
||||
expect(m.models['shared-model']!.cost).toBeCloseTo(7, 5)
|
||||
expect(m.models['shared-model']!.calls).toBe(1)
|
||||
// Reconciliation: day totals equal the sum of the surviving slices.
|
||||
expect(m.cost).toBeCloseTo(m.providers['B']!.cost, 5)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
import { homedir } from 'os'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render } from 'ink'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { describe, it, expect, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { dailyActivityFooter, getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
|
||||
import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, getDailyActivityPageSize, getDailyActivityRows, getDashboardMaxWidth, getDashboardScanRange, getLayout, getRefreshIntervalMs, InteractiveDashboard, 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'
|
||||
|
|
@ -30,6 +35,7 @@ function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z
|
|||
firstTimestamp: timestamp,
|
||||
lastTimestamp: timestamp,
|
||||
totalCostUSD: cost,
|
||||
totalSavingsUSD: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
|
|
@ -42,6 +48,7 @@ function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z
|
|||
bashBreakdown: {},
|
||||
categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN },
|
||||
skillBreakdown: {},
|
||||
subagentBreakdown: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +165,14 @@ describe('shortProject - path shortening', () => {
|
|||
it('handles paths outside the home dir', () => {
|
||||
expect(shortProject('/opt/myproject')).toBe('opt/myproject')
|
||||
})
|
||||
|
||||
it('elides the parent folder and date year before the project title', () => {
|
||||
const path = `${home}/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex`
|
||||
expect(shortProject(path, 51)).toBe('Codex/2026-07-30/global-agents-md-config-toml-codex')
|
||||
expect(shortProject(path, 47)).toBe('…/2026-07-30/global-agents-md-config-toml-codex')
|
||||
expect(shortProject(path, 44)).toBe('…/…-07-30/global-agents-md-config-toml-codex')
|
||||
expect(shortProject(path, 34)).toBe('…/…-07-30/global-agents-md-config…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('avg/s in ProjectBreakdown', () => {
|
||||
|
|
@ -266,22 +281,455 @@ describe('dailyActivityFooter', () => {
|
|||
|
||||
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 })
|
||||
expect(getLayout(89)).toMatchObject({ dashWidth: 89, columnCount: 1, panelWidth: 89 })
|
||||
})
|
||||
|
||||
it('switches to two columns at 90 columns', () => {
|
||||
expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 })
|
||||
expect(getLayout(90)).toMatchObject({ dashWidth: 90, columnCount: 2, panelWidth: 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 through 134 columns', () => {
|
||||
expect(getLayout(134)).toMatchObject({ dashWidth: 134, columnCount: 2, panelWidth: 67 })
|
||||
})
|
||||
|
||||
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)
|
||||
it('switches to three columns at 135 columns', () => {
|
||||
expect(getLayout(135)).toMatchObject({ dashWidth: 135, columnCount: 3, panelWidth: 45 })
|
||||
})
|
||||
|
||||
it('continues growing three equal panels by one for every three columns', () => {
|
||||
expect(getLayout(160)).toMatchObject({ dashWidth: 160, columnCount: 3, panelWidth: 53 })
|
||||
expect(getLayout(161)).toMatchObject({ dashWidth: 161, columnCount: 3, panelWidth: 53 })
|
||||
expect(getLayout(162)).toMatchObject({ dashWidth: 162, columnCount: 3, panelWidth: 54 })
|
||||
expect(getLayout(165)).toMatchObject({ dashWidth: 165, columnCount: 3, panelWidth: 55 })
|
||||
})
|
||||
|
||||
it('stops at the lesser of 256 columns or the source-data width', () => {
|
||||
expect(getLayout(300)).toMatchObject({ dashWidth: 256, columnCount: 3, panelWidth: 85 })
|
||||
expect(getLayout(300, 213)).toMatchObject({ dashWidth: 213, columnCount: 3, panelWidth: 71 })
|
||||
})
|
||||
|
||||
it('derives the wide-layout ceiling from renderable source labels', () => {
|
||||
const short = makeProject('short', [makeSession('short', 1)])
|
||||
const long = makeProject('x'.repeat(200), [makeSession('long', 1)])
|
||||
|
||||
expect(getDashboardMaxWidth([long])).toBe(256)
|
||||
expect(getDashboardMaxWidth([short])).toBeLessThan(256)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Daily Activity viewport', () => {
|
||||
it('shows ten dates at a time', () => {
|
||||
expect(DAILY_ACTIVITY_PAGE_SIZE).toBe(10)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 1 as const, projectRows: 14, activityRows: 17, expected: 10 },
|
||||
{ columns: 2 as const, projectRows: 8, activityRows: 17, expected: 10 },
|
||||
{ columns: 2 as const, projectRows: 14, activityRows: 17, expected: 14 },
|
||||
{ columns: 3 as const, projectRows: 8, activityRows: 7, expected: 10 },
|
||||
{ columns: 3 as const, projectRows: 14, activityRows: 17, expected: 17 },
|
||||
])('uses $expected rows for a $columns-column row with $projectRows project and $activityRows activity rows', ({ columns, projectRows, activityRows, expected }) => {
|
||||
expect(getDailyActivityPageSize(columns, projectRows, activityRows)).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps day mode to one date', () => {
|
||||
expect(getDailyActivityPageSize(3, 14, 17, true)).toBe(1)
|
||||
})
|
||||
|
||||
it('matches fourteen visible project rows in the two-column layout', async () => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 100
|
||||
stdout.rows = 80
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
|
||||
const historySession = makeSession('history', 20)
|
||||
historySession.turns = Array.from({ length: 20 }, (_, index) =>
|
||||
makeTurn(`2026-07-${String(index + 1).padStart(2, '0')}T10:00:00Z`, [1]))
|
||||
const projects = [
|
||||
makeProject('project-01', [historySession]),
|
||||
...Array.from({ length: 13 }, (_, index) =>
|
||||
makeProject(`project-${String(index + 2).padStart(2, '0')}`, [makeSession(`s-${index}`, 1)])),
|
||||
]
|
||||
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: projects,
|
||||
initialPeriod: 'all',
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
windowColumns: 100,
|
||||
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => app.unmount())
|
||||
await app.waitUntilRenderFlush()
|
||||
|
||||
let frame = frames.filter(value => value.trim()).at(-1) ?? ''
|
||||
expect(frame.match(/2026-07-\d{2}/g)).toHaveLength(14)
|
||||
expect(frame).toContain('2026-07-20')
|
||||
|
||||
stdin.write(' ')
|
||||
await app.waitUntilRenderFlush()
|
||||
frame = frames.filter(value => value.trim()).at(-1) ?? ''
|
||||
expect(frame.match(/2026-07-\d{2}/g)).toHaveLength(14)
|
||||
expect(frame).toContain('2026-07-01')
|
||||
expect(frame).not.toContain('2026-07-20')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRefreshIntervalMs', () => {
|
||||
it('allows disabled refresh and clamps enabled refreshes to one minute', () => {
|
||||
expect(getRefreshIntervalMs(0)).toBe(0)
|
||||
expect(getRefreshIntervalMs(30)).toBe(60_000)
|
||||
expect(getRefreshIntervalMs(60)).toBe(60_000)
|
||||
expect(getRefreshIntervalMs(300)).toBe(300_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interactive terminal rendering', () => {
|
||||
it('isolates resize reflow from stale primary-screen frames', () => {
|
||||
expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true })
|
||||
})
|
||||
|
||||
it('leaves resize frame synchronization entirely to Ink', () => {
|
||||
const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8')
|
||||
expect(source).not.toContain('process.stdout.write(BSU)')
|
||||
expect(source).not.toContain("process.stdout.write('\\u001B[2J\\u001B[H')")
|
||||
expect(source).not.toContain('shouldResetScreenOnResize')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'today', period: 'today', expected: true },
|
||||
{ label: 'week', period: 'week', expected: true },
|
||||
{ label: 'a concrete day within a heavy period', period: 'all', initialDay: '2026-07-30', expected: true },
|
||||
{ label: '30days', period: '30days', expected: false },
|
||||
{ label: 'month', period: 'month', expected: false },
|
||||
{ label: 'all', period: 'all', expected: false },
|
||||
{ label: 'lifetime', period: 'lifetime', expected: false },
|
||||
] as const)(
|
||||
'schedules periodic dashboard refresh for $label: $expected',
|
||||
async ({ period, initialDay, expected }) => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 160
|
||||
stdout.rows = 50
|
||||
const setIntervalSpy = vi.spyOn(global, 'setInterval')
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: [makeProject('proj', [makeSession('s1', 1)])],
|
||||
initialPeriod: period,
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 60,
|
||||
windowColumns: 160,
|
||||
initialDay,
|
||||
}), { stdin, stdout, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => {
|
||||
app.unmount()
|
||||
setIntervalSpy.mockRestore()
|
||||
})
|
||||
|
||||
await app.waitUntilRenderFlush()
|
||||
|
||||
expect(setIntervalSpy.mock.calls.some(call => call[1] === 60_000)).toBe(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('accepts the next width before Ink paints each breakpoint transition', async () => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 135
|
||||
stdout.rows = 50
|
||||
const chunks: string[] = []
|
||||
stdout.on('data', chunk => chunks.push(stripAnsi(String(chunk))))
|
||||
const props = {
|
||||
initialProjects: [makeProject('proj', [makeSession('s1', 1)])],
|
||||
initialPeriod: 'today' as const,
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
}
|
||||
const app = render(React.createElement(InteractiveDashboard, { ...props, windowColumns: 135 }), {
|
||||
stdin, stdout, interactive: true, patchConsole: false,
|
||||
})
|
||||
onTestFinished(() => app.unmount())
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
chunks.length = 0
|
||||
app.rerender(React.createElement(InteractiveDashboard, { ...props, windowColumns: 134 }))
|
||||
await app.waitUntilRenderFlush()
|
||||
|
||||
let panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? ''
|
||||
expect(panelTitleLine).toContain('By Project')
|
||||
expect(panelTitleLine).not.toContain('By Activity')
|
||||
|
||||
chunks.length = 0
|
||||
app.rerender(React.createElement(InteractiveDashboard, { ...props, windowColumns: 89 }))
|
||||
await app.waitUntilRenderFlush()
|
||||
|
||||
panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? ''
|
||||
expect(panelTitleLine).not.toContain('By Project')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 80, rows: 12 },
|
||||
{ columns: 100, rows: 18 },
|
||||
{ columns: 160, rows: 24 },
|
||||
])('pins and scrolls the full $columns-column dashboard without losing position', async ({ columns, rows }) => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = columns
|
||||
stdout.rows = rows
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
const props = {
|
||||
initialProjects: [makeProject('proj', [makeSession('s1', 1)])],
|
||||
initialPeriod: 'today' as const,
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
windowColumns: columns,
|
||||
}
|
||||
const app = render(React.createElement(InteractiveDashboard, props), {
|
||||
stdin, stdout, debug: true, interactive: true, patchConsole: false,
|
||||
})
|
||||
onTestFinished(() => app.unmount())
|
||||
|
||||
await app.waitUntilRenderFlush()
|
||||
let frame = frames.filter(chunk => chunk.trim()).at(-1) ?? ''
|
||||
expect(frame.split('\n')).toHaveLength(rows - 1)
|
||||
expect(frame).toContain('[ Today ]')
|
||||
|
||||
stdin.write('\u001B[6~')
|
||||
await app.waitUntilRenderFlush()
|
||||
frame = frames.filter(chunk => chunk.trim()).at(-1) ?? ''
|
||||
expect(frame).not.toContain('[ Today ]')
|
||||
|
||||
app.rerender(React.createElement(InteractiveDashboard, {
|
||||
...props,
|
||||
windowColumns: columns + 1,
|
||||
}))
|
||||
await app.waitUntilRenderFlush()
|
||||
frame = frames.filter(chunk => chunk.trim()).at(-1) ?? ''
|
||||
expect(frame).not.toContain('[ Today ]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('InteractiveDashboard refresh', () => {
|
||||
it('keeps ten metric columns compact and visible before shortening project titles', async () => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 135
|
||||
stdout.rows = 100
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
|
||||
const session = makeSession('s1', 19.43)
|
||||
session.apiCalls = 2303
|
||||
session.categoryBreakdown.coding = { turns: 12, costUSD: 1, savingsUSD: 0, retries: 0, editTurns: 10, oneShotTurns: 5 }
|
||||
session.skillBreakdown.ponytail = { turns: 3, costUSD: 0.25, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 }
|
||||
session.modelBreakdown['gpt-5.6-sol'] = {
|
||||
calls: 2303,
|
||||
costUSD: 257.44,
|
||||
savingsUSD: 0,
|
||||
estimatedCostUSD: 257.44,
|
||||
activeDurationMs: 10_000,
|
||||
activeGeneratedTokens: 539,
|
||||
tokens: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 99,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
}
|
||||
session.modelBreakdown['gpt-5.6-terra'] = {
|
||||
calls: 22,
|
||||
costUSD: 0.63,
|
||||
savingsUSD: 0,
|
||||
tokens: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
}
|
||||
const project = makeProject('long-project', [session])
|
||||
project.projectPath = '/Users/jared/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex'
|
||||
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: [project],
|
||||
initialPeriod: 'today',
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
windowColumns: 135,
|
||||
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => app.unmount())
|
||||
|
||||
let frame = ''
|
||||
for (let i = 0; i < 100 && (!frame.includes('10.4K') || !frame.includes('By Model')); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
frame = frames.filter(value => value.trim()).at(-1) ?? ''
|
||||
}
|
||||
|
||||
for (const metric of ['cost', 'avg/s', 'session', 'overhead', 'cache', 'calls', '1-shot', 'Tok/s', 'turns', 'uses']) {
|
||||
expect(frame, `missing ${metric}`).toContain(metric)
|
||||
}
|
||||
for (const value of ['$19.43', '10.4K', '~$257.44', '99.0%', '2303', '53.9', '$1.00', '12', '50%', '$0.25']) {
|
||||
expect(frame, `missing ${value}`).toContain(value)
|
||||
}
|
||||
|
||||
const modelHeader = frame.split('\n').find(line => line.includes('cache') && line.includes('1-shot')) ?? ''
|
||||
const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? ''
|
||||
const projectCostIndex = projectHeader.lastIndexOf('cost', projectHeader.indexOf('avg/s'))
|
||||
expect(modelHeader.indexOf('Tok/s') + 'Tok/s'.length - modelHeader.indexOf('cost')).toBeLessThanOrEqual(33)
|
||||
expect(projectHeader.indexOf('overhead') + 'overhead'.length - projectCostIndex).toBeLessThanOrEqual(30)
|
||||
expect(frame).toContain('…/')
|
||||
})
|
||||
|
||||
it('keeps project metric headings readable before long project paths', async () => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 80
|
||||
stdout.rows = 100
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
const project = makeProject('long-project', [makeSession('s1', 19.43)])
|
||||
project.projectPath = '/Users/jared/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex'
|
||||
project.sessions[0]!.modelBreakdown['gpt-5.6-sol'] = {
|
||||
calls: 2303,
|
||||
costUSD: 257.44,
|
||||
savingsUSD: 0,
|
||||
estimatedCostUSD: 257.44,
|
||||
tokens: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 99,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: [project],
|
||||
initialPeriod: 'today',
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
windowColumns: 80,
|
||||
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => app.unmount())
|
||||
|
||||
let frame = ''
|
||||
for (let i = 0; i < 100 && !frame.includes('10.4K'); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
frame = frames.filter(value => value.trim()).at(-1) ?? ''
|
||||
}
|
||||
|
||||
expect(frame).toContain('10.4K')
|
||||
expect(frame).toContain('~$257.44')
|
||||
const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? ''
|
||||
expect(projectHeader).toMatch(/cost\s+avg\/s\s+session\s+overhead/)
|
||||
expect(projectHeader).not.toContain('sessover')
|
||||
})
|
||||
|
||||
it('keeps Optimize mounted without a loading frame when auto-refresh fires', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 160
|
||||
stdout.rows = 50
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
const session = makeSession('s1', 1)
|
||||
session.turns = Array.from({ length: 11 }, (_, index) => makeTurn(`2026-07-${String(index + 1).padStart(2, '0')}T10:00:00Z`, [1]))
|
||||
session.categoryBreakdown.coding = { turns: 12, costUSD: 1, retries: 0, editTurns: 10, oneShotTurns: 5 }
|
||||
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: [makeProject('proj', [session])],
|
||||
initialPeriod: 'today',
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 60,
|
||||
windowColumns: 160,
|
||||
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => {
|
||||
app.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
const dashboardFrame = frames.filter(frame => frame.trim()).at(-1) ?? ''
|
||||
const dashboardLines = dashboardFrame.split('\n')
|
||||
expect(dashboardLines.find(line => line.includes('Daily Activity'))).toContain('By Project')
|
||||
expect(dashboardLines.find(line => line.includes('Daily Activity'))).toContain('By Activity')
|
||||
expect(dashboardLines.find(line => line.includes('By Model'))).toContain('MCP Servers')
|
||||
expect(dashboardLines.find(line => line.includes('By Model'))).toContain('Core Tools')
|
||||
expect(dashboardLines.find(line => line.includes('Shell Commands'))).toContain('Skills & Agents')
|
||||
expect(dashboardFrame.match(/2026-07-/g)).toHaveLength(11)
|
||||
const dailyRow = dashboardLines.find(line => /2026-07-\d{2}/.test(line)) ?? ''
|
||||
const dailyBarIndex = ['█', '░'].map(char => dailyRow.indexOf(char)).filter(index => index >= 0).sort((a, b) => a - b)[0] ?? -1
|
||||
expect(dailyBarIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(dailyBarIndex).toBeLessThan(dailyRow.search(/2026-07-\d{2}/))
|
||||
const activityHeader = dashboardLines.find(line => line.includes('turns'))?.slice(106, 159) ?? ''
|
||||
const activityRow = dashboardLines.find(line => line.includes('Coding'))?.slice(106, 159) ?? ''
|
||||
expect(activityHeader.indexOf('cost') + 'cost'.length).toBe(activityRow.indexOf('$1.00') + '$1.00'.length)
|
||||
expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length)
|
||||
expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length)
|
||||
stdin.write('o')
|
||||
for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Token estimates are approximate.')); i++) {
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? ''
|
||||
expect(beforeRefresh).toContain('CodeBurn Optimize')
|
||||
expect(beforeRefresh).toContain('Token estimates are approximate.')
|
||||
|
||||
frames.length = 0
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
const frame = frames.filter(value => value.trim()).at(-1) ?? beforeRefresh
|
||||
expect(frame).toBe(beforeRefresh)
|
||||
expect(frame).toContain('CodeBurn Optimize')
|
||||
expect(frame).toContain('Token estimates are approximate.')
|
||||
expect(frame).toContain('b back')
|
||||
expect(frame).not.toContain('Loading Today')
|
||||
expect(frame).not.toContain('Scanning Today')
|
||||
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from 'os'
|
|||
|
||||
import { collectDoctorReport, renderDoctorTable, renderDoctorJson } from '../src/doctor.js'
|
||||
import { createCodexProvider } from '../src/providers/codex.js'
|
||||
import { createOpenCodeProvider } from '../src/providers/opencode.js'
|
||||
import { emptyCache, type SessionCache } from '../src/session-cache.js'
|
||||
import type { Provider, ProbeRoot, SessionSource } from '../src/providers/types.js'
|
||||
|
||||
|
|
@ -141,6 +142,118 @@ describe('collectDoctorReport - env override', () => {
|
|||
else process.env['CODEX_HOME'] = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('names a deliberate XDG_DATA_HOME override pointing at a missing dir, blaming the override not the install (opencode)', async () => {
|
||||
const prev = process.env['XDG_DATA_HOME']
|
||||
const bogus = join(tmpDir, 'xdg-missing')
|
||||
process.env['XDG_DATA_HOME'] = bogus
|
||||
try {
|
||||
// Construct after setting env so the provider resolves XDG_DATA_HOME
|
||||
// (src/providers/opencode.ts:38 reads it to resolve the data dir).
|
||||
const provider = createOpenCodeProvider()
|
||||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||||
const r = only(report, 'opencode')
|
||||
|
||||
expect(r.envOverrides).toContainEqual({ name: 'XDG_DATA_HOME', value: bogus })
|
||||
expect(r.status).toBe('empty')
|
||||
// Regression (Ruling 3 of lane 04): with XDG_DATA_HOME treated as an
|
||||
// ambient OS var, doctor skipped it and the verdict blamed the install
|
||||
// ("tool likely not installed") instead of the override the user set.
|
||||
expect(r.verdict).toContain('override XDG_DATA_HOME set')
|
||||
expect(r.verdict).toContain('does not exist')
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['XDG_DATA_HOME']
|
||||
else process.env['XDG_DATA_HOME'] = prev
|
||||
}
|
||||
})
|
||||
|
||||
// Windows sets APPDATA and LOCALAPPDATA for every process, so neither
|
||||
// carries user intent: both are fingerprinted (a change moves the discovery
|
||||
// root) but must never be named as a deliberate override (Ruling 3 of lane
|
||||
// 04). Table-driven over both so removing either from AMBIENT_ENV_VARS
|
||||
// fails a test instead of leaking it into the overrides list.
|
||||
for (const varName of ['APPDATA', 'LOCALAPPDATA']) {
|
||||
it(`does not name ${varName} as an override for a provider that declares it`, async () => {
|
||||
const prev = process.env[varName]
|
||||
process.env[varName] = join(tmpDir, varName.toLowerCase())
|
||||
try {
|
||||
const provider = fakeProvider({ name: 'claude', displayName: 'Claude' })
|
||||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||||
const r = only(report, 'claude')
|
||||
|
||||
expect(r.envOverrides.some(o => o.name === varName)).toBe(false)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[varName]
|
||||
else process.env[varName] = prev
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Every credential in SECRET_ENV_VARS must be redacted at collect time so
|
||||
// neither the text render nor the JSON report can leak it (Ruling 2 of lane
|
||||
// 04). Table-driven over both, so a credential added to the set without a
|
||||
// redaction test fails here instead of leaking into a bug report.
|
||||
for (const varName of ['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN']) {
|
||||
it(`redacts credential values (${varName}) from overrides, the table render, and the JSON report`, async () => {
|
||||
const secret = `sk-live-${varName}-value-12345`
|
||||
const prev = process.env[varName]
|
||||
const sibling = varName === 'AI_GATEWAY_API_KEY' ? 'VERCEL_OIDC_TOKEN' : 'AI_GATEWAY_API_KEY'
|
||||
const prevSibling = process.env[sibling]
|
||||
process.env[varName] = secret
|
||||
// Isolate the case under test: a stray ambient sibling must not change
|
||||
// what this case observes.
|
||||
delete process.env[sibling]
|
||||
try {
|
||||
const provider = fakeProvider({ name: 'vercel-gateway', displayName: 'Vercel AI Gateway', network: true })
|
||||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||||
const r = only(report, 'vercel-gateway')
|
||||
|
||||
// The "is this credential set?" diagnostic is useful; the value is a
|
||||
// live secret and must never leave doctor (Ruling 2 of lane 04).
|
||||
expect(r.envOverrides).toContainEqual({ name: varName, value: '<set>' })
|
||||
expect(r.envOverrides.some(o => o.value.includes(secret))).toBe(false)
|
||||
const table = renderDoctorTable(report, { color: false })
|
||||
expect(table).toContain(`${varName}=<set>`)
|
||||
expect(table).not.toContain(secret)
|
||||
expect(renderDoctorJson(report)).not.toContain(secret)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[varName]
|
||||
else process.env[varName] = prev
|
||||
if (prevSibling === undefined) delete process.env[sibling]
|
||||
else process.env[sibling] = prevSibling
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// CODEBURN_CURSOR_MAX_BUBBLES caps how many bubbles Cursor parses
|
||||
// (src/providers/cursor.ts:692) and KIMI_MODEL_NAME renames the model
|
||||
// attributed to Kimi sessions (src/providers/kimi.ts:155): both are
|
||||
// fingerprinted but cannot explain why nothing was discovered, so the
|
||||
// verdict must not name them — while Details still lists them, because they
|
||||
// ARE overrides in force. Each is asserted through the provider that
|
||||
// declares it.
|
||||
for (const [varName, providerName, displayName, value] of [
|
||||
['CODEBURN_CURSOR_MAX_BUBBLES', 'cursor', 'Cursor', '5000'],
|
||||
['KIMI_MODEL_NAME', 'kimi', 'Kimi', 'kimi-latest-920'],
|
||||
] as const) {
|
||||
it(`does not blame ${varName} for an empty ${displayName} (not a discovery path)`, async () => {
|
||||
const prev = process.env[varName]
|
||||
process.env[varName] = value
|
||||
try {
|
||||
const provider = fakeProvider({ name: providerName, displayName })
|
||||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||||
const r = only(report, providerName)
|
||||
|
||||
expect(r.envOverrides).toContainEqual({ name: varName, value })
|
||||
expect(r.verdict).not.toContain(varName)
|
||||
const table = renderDoctorTable(report, { color: false })
|
||||
expect(table).toContain(`${varName}=${value}`)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[varName]
|
||||
else process.env[varName] = prev
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Synthetic edge cases ───────────────────────────────────────────────────
|
||||
|
|
|
|||
42
tests/ink-win.test.ts
Normal file
42
tests/ink-win.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { BSU, ESU, stripSyncUpdateEscapes, patchStdoutForWindows } from '../src/ink-win.js'
|
||||
|
||||
describe('stripSyncUpdateEscapes', () => {
|
||||
it('strips an exact BSU chunk to empty', () => {
|
||||
expect(stripSyncUpdateEscapes(BSU)).toBe('')
|
||||
})
|
||||
|
||||
it('strips an exact ESU chunk to empty', () => {
|
||||
expect(stripSyncUpdateEscapes(ESU)).toBe('')
|
||||
})
|
||||
|
||||
it('strips a leading BSU from a concatenated clear write', () => {
|
||||
// #863 regression shape: the clear sequence glued to a BSU used to slip
|
||||
// through raw and hang Windows ConPTY.
|
||||
expect(stripSyncUpdateEscapes(BSU + '\x1b[2J\x1b[H')).toBe('\x1b[2J\x1b[H')
|
||||
})
|
||||
|
||||
it('strips a trailing ESU, and both ends at once', () => {
|
||||
expect(stripSyncUpdateEscapes('x' + ESU)).toBe('x')
|
||||
expect(stripSyncUpdateEscapes(BSU + 'x' + ESU)).toBe('x')
|
||||
})
|
||||
|
||||
it('removes every occurrence when escapes appear multiple times', () => {
|
||||
expect(stripSyncUpdateEscapes(BSU + 'a' + BSU + 'b' + ESU + 'c' + ESU)).toBe('abc')
|
||||
})
|
||||
|
||||
it('leaves a string without escapes untouched (same reference-equal content)', () => {
|
||||
const plain = 'status line \x1b[2J'
|
||||
expect(stripSyncUpdateEscapes(plain)).toBe(plain)
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchStdoutForWindows', () => {
|
||||
it('is a no-op off win32: process.stdout.write stays reference-identical', () => {
|
||||
// Skip on actual Windows runners, where the patch legitimately applies.
|
||||
if (process.platform === 'win32') return
|
||||
const before = process.stdout.write
|
||||
patchStdoutForWindows()
|
||||
expect(process.stdout.write).toBe(before)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
setLocalModelSavings,
|
||||
getLocalModelSavingsConfigHash,
|
||||
getPriceOverridesConfigHash,
|
||||
parseLiteLLMEntry,
|
||||
} from '../src/models.js'
|
||||
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
|
||||
|
|
@ -865,3 +866,18 @@ describe('findUnpricedModels', () => {
|
|||
expect(unpriced.map(u => u.model)).toEqual(['zz-big', 'zz-small'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLiteLLMEntry hardening', () => {
|
||||
it('returns null instead of throwing on a null or non-object entry', () => {
|
||||
// The live LiteLLM map is remote JSON; a null value for a model used to
|
||||
// throw on the field reads and abort the whole pricing load.
|
||||
expect(parseLiteLLMEntry(null as unknown as Parameters<typeof parseLiteLLMEntry>[0])).toBeNull()
|
||||
expect(parseLiteLLMEntry(undefined as unknown as Parameters<typeof parseLiteLLMEntry>[0])).toBeNull()
|
||||
expect(parseLiteLLMEntry(42 as unknown as Parameters<typeof parseLiteLLMEntry>[0])).toBeNull()
|
||||
})
|
||||
|
||||
it('still parses a valid entry', () => {
|
||||
const costs = parseLiteLLMEntry({ input_cost_per_token: 0.000003, output_cost_per_token: 0.000015 } as Parameters<typeof parseLiteLLMEntry>[0])
|
||||
expect(costs).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
detectLowWorthSessions,
|
||||
detectSessionOutliers,
|
||||
scanAndDetect,
|
||||
cacheKey,
|
||||
computeHealth,
|
||||
computeTrend,
|
||||
buildOptimizeJsonReport,
|
||||
|
|
@ -1041,6 +1042,34 @@ describe('detectSessionOutliers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('optimize cacheKey collision resistance', () => {
|
||||
it('does not collide two datasets that share project count and api-call sum', () => {
|
||||
// The old fingerprint was projectCount + sum(api calls) only, so any two
|
||||
// datasets agreeing on those two numbers shared one cached OptimizeResult -
|
||||
// the second scan got the first's findings. Same shape, different spend must
|
||||
// now key differently.
|
||||
const a = projectWithSessions([100, 1, 1, 1]) // 4 calls, cost 103
|
||||
const b = projectWithSessions([1, 1, 1, 1]) // 4 calls, cost 4
|
||||
const range = optimizeDateRange(4)
|
||||
expect(a.totalApiCalls).toBe(b.totalApiCalls)
|
||||
expect(cacheKey([a], range)).not.toBe(cacheKey([b], range))
|
||||
})
|
||||
|
||||
it('is stable for the identical dataset (still caches a genuine repeat)', () => {
|
||||
const a = projectWithSessions([5, 3, 2])
|
||||
const range = optimizeDateRange(3)
|
||||
expect(cacheKey([a], range)).toBe(cacheKey([projectWithSessions([5, 3, 2])], range))
|
||||
})
|
||||
|
||||
it('separates a re-price that leaves call count unchanged', () => {
|
||||
// A dataset re-priced (cost moves, calls do not) must not serve stale findings.
|
||||
const before = projectWithSessions([10, 10])
|
||||
const after = projectWithSessions([25, 10]) // same 2 calls, higher cost
|
||||
const range = optimizeDateRange(2)
|
||||
expect(cacheKey([before], range)).not.toBe(cacheKey([after], range))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeHealth', () => {
|
||||
it('returns A with 100 for no findings', () => {
|
||||
const { score, grade } = computeHealth([])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, appendFile, readFile, rm, stat, unlink } from 'fs/promises'
|
||||
import { mkdtemp, mkdir, writeFile, appendFile, readFile, rename, rm, stat, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
|
|
@ -286,14 +286,19 @@ describe('incremental append parsing', () => {
|
|||
await parseWith(warmCache)
|
||||
const inoBefore = (await stat(sessionPath)).ino
|
||||
|
||||
// Replace the file (new inode) with different, LARGER content.
|
||||
await unlink(sessionPath)
|
||||
// Replace the file (new inode) with different, LARGER content. The
|
||||
// replacement is created BESIDE the original and renamed over it: an
|
||||
// unlink-then-create lets ext4 hand the freed inode straight back, which
|
||||
// broke the new-inode premise on Linux CI. Two files alive at once are
|
||||
// guaranteed distinct inodes, and rename keeps the replacement's.
|
||||
const replaced = [
|
||||
...baseLines(),
|
||||
userLine('2026-05-01T12:00:00.000Z', 'brand new task'),
|
||||
asstLine('msg-z', '2026-05-01T12:00:02.000Z', { input_tokens: 500, output_tokens: 120 }, [readBlock('/z.ts')]),
|
||||
].join('\n') + '\n'
|
||||
await writeFile(sessionPath, replaced)
|
||||
const replacementPath = sessionPath + '.replacement'
|
||||
await writeFile(replacementPath, replaced)
|
||||
await rename(replacementPath, sessionPath)
|
||||
expect((await stat(sessionPath)).ino).not.toBe(inoBefore)
|
||||
|
||||
readLineCalls.length = 0
|
||||
|
|
|
|||
|
|
@ -40,9 +40,14 @@ describe('isProxiedPath: path matching rule', () => {
|
|||
expect(isProxiedPath('/Users/me/work/')).toBe(true)
|
||||
})
|
||||
|
||||
it('is case-insensitive (macOS/Windows default filesystems)', () => {
|
||||
it('folds case exactly where the default filesystem does (macOS/Windows yes, Linux no)', () => {
|
||||
// normalizeProxyPath lowercases only on darwin/win32, deliberately: ext4 is
|
||||
// case-sensitive and folding there could credit unrelated spend. Assert the
|
||||
// platform-correct behavior instead of hardcoding the macOS one, which made
|
||||
// this case fail on Linux CI by design.
|
||||
setProxyPaths(['/Users/Me/Work'])
|
||||
expect(isProxiedPath('/users/me/work/acme')).toBe(true)
|
||||
const foldsCase = process.platform === 'darwin' || process.platform === 'win32'
|
||||
expect(isProxiedPath('/users/me/work/acme')).toBe(foldsCase)
|
||||
})
|
||||
|
||||
it('matches a Windows-style config against a forward-slash cwd', () => {
|
||||
|
|
|
|||
|
|
@ -143,10 +143,16 @@ async function createJsonlSession(
|
|||
const dir = join(sessionStateDir, sessionId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'workspace.yaml'), `id: ${sessionId}\ncwd: /home/user/testproj\n`)
|
||||
// Relative timestamps: fixed calendar dates rot. The original '2026-05-01'
|
||||
// crossed copilot's durable 90-day age-out on 2026-07-30, at which point the
|
||||
// very first parse pruned the freshly-cached session and both durable tests
|
||||
// started failing everywhere with "expected +0 to be 200".
|
||||
const base = Date.now() - 5 * 24 * 60 * 60 * 1000
|
||||
const at = (offsetSec: number): string => new Date(base + offsetSec * 1000).toISOString()
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'session.model_change', timestamp: '2026-05-01T10:00:00Z', data: { newModel: 'gpt-4.1' } }),
|
||||
JSON.stringify({ type: 'user.message', timestamp: '2026-05-01T10:00:05Z', data: { content: 'hello', interactionId: 'int-1' } }),
|
||||
JSON.stringify({ type: 'assistant.message', timestamp: '2026-05-01T10:00:10Z', data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }),
|
||||
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'gpt-4.1' } }),
|
||||
JSON.stringify({ type: 'user.message', timestamp: at(5), data: { content: 'hello', interactionId: 'int-1' } }),
|
||||
JSON.stringify({ type: 'assistant.message', timestamp: at(10), data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }),
|
||||
]
|
||||
await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n')
|
||||
return join(dir, 'events.jsonl')
|
||||
|
|
@ -599,3 +605,77 @@ describe('(h) provider filter excludes claude from the orphan pass', () => {
|
|||
expect(totalCost(after)).toBeCloseTo(costBefore, 10)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (f) Growing resumed CLI session: durable merge appends only the new leg
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Resumed Copilot CLI sessions append one CUMULATIVE session.shutdown per leg
|
||||
// (#944). The parser emits per-leg deltas keyed by occurrence; this exercises
|
||||
// the PRODUCTION merge path — the durable union-by-dedup-key merge against the
|
||||
// on-disk cache when the file grows between parses — which the unit tests
|
||||
// (which pre-seed seenKeys) cannot reach.
|
||||
describe('(f) growing resumed CLI session durable merge', () => {
|
||||
it('totals equal the final cumulative rollup after the file grows a leg', async () => {
|
||||
const sessionStateDir = join(tmpHome, 'session-state')
|
||||
await mkdir(sessionStateDir, { recursive: true })
|
||||
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
|
||||
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
|
||||
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
|
||||
vi.stubEnv('CODEBURN_COPILOT_GLOBAL_STORAGE_DIR', join(tmpHome, 'no-global'))
|
||||
vi.stubEnv('CODEBURN_COPILOT_JETBRAINS_DIR', join(tmpHome, 'no-jb'))
|
||||
|
||||
const base = Date.now() - 5 * 24 * 60 * 60 * 1000
|
||||
const at = (offsetSec: number): string => new Date(base + offsetSec * 1000).toISOString()
|
||||
const dir = join(sessionStateDir, 'sess-grow')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'workspace.yaml'), 'id: sess-grow\ncwd: /home/user/testproj\n')
|
||||
const eventsPath = join(dir, 'events.jsonl')
|
||||
|
||||
// Cumulative rollups from a real resumed CLI 1.0.78 session.
|
||||
const shutdown = (ts: string, inputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, outputTokens: number) =>
|
||||
JSON.stringify({
|
||||
type: 'session.shutdown',
|
||||
timestamp: ts,
|
||||
data: {
|
||||
shutdownType: 'routine',
|
||||
modelMetrics: {
|
||||
'claude-sonnet-4-5': {
|
||||
requests: { count: 1, cost: 1 },
|
||||
usage: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const leg1 = [
|
||||
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'claude-sonnet-4-5' } }),
|
||||
JSON.stringify({ type: 'assistant.message', timestamp: at(10), data: { messageId: 'msg-1', outputTokens: 17, toolRequests: [] } }),
|
||||
shutdown(at(20), 24672, 0, 24670, 17),
|
||||
]
|
||||
await writeFile(eventsPath, leg1.join('\n') + '\n')
|
||||
|
||||
const sumUsage = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => {
|
||||
const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
|
||||
return {
|
||||
input: calls.reduce((s, c) => s + c.usage.inputTokens, 0),
|
||||
cacheRead: calls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0),
|
||||
cacheWrite: calls.reduce((s, c) => s + c.usage.cacheCreationInputTokens, 0),
|
||||
}
|
||||
}
|
||||
|
||||
const first = sumUsage(await parseAllSessions(undefined, 'copilot'))
|
||||
expect(first).toEqual({ input: 2, cacheRead: 0, cacheWrite: 24670 })
|
||||
|
||||
// The session resumes: leg 2 appends per-turn events plus a CUMULATIVE
|
||||
// rollup. The cached leg-1 delta must be kept once and only the leg-2
|
||||
// delta appended — totals equal the final cumulative rollup exactly.
|
||||
clearSessionCache()
|
||||
await writeFile(eventsPath, [
|
||||
...leg1,
|
||||
JSON.stringify({ type: 'assistant.message', timestamp: at(100), data: { messageId: 'msg-2', outputTokens: 132, toolRequests: [] } }),
|
||||
shutdown(at(120), 74463, 49489, 24968, 149),
|
||||
].join('\n') + '\n')
|
||||
|
||||
const second = sumUsage(await parseAllSessions(undefined, 'copilot'))
|
||||
expect(second).toEqual({ input: 74463 - 49489 - 24968, cacheRead: 49489, cacheWrite: 24968 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
254
tests/provider-env-declarations.test.ts
Normal file
254
tests/provider-env-declarations.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// Static guard for issue #920: every `process.env` read inside
|
||||
// src/providers/*.ts must be declared in PROVIDER_ENV_VARS for every provider
|
||||
// whose cache section that file's reads affect — or be allowlisted below with
|
||||
// a reason. An env var that changes what a provider discovers or how its
|
||||
// sessions parse but is not fingerprinted means the cache section survives
|
||||
// the change and serves silently stale numbers, exactly the defect class #920
|
||||
// reported (nine providers slipped through it).
|
||||
//
|
||||
// Scoping rule for the allowlist: an entry is keyed '<file>.ts:<VAR>' and
|
||||
// silences exactly one var in exactly one file. The same var read in any
|
||||
// other file is checked against the declarations like every other read, so an
|
||||
// entry can never mask a second file's undeclared read — the failure mode the
|
||||
// original global-keyed allowlist had (Ruling 4 of lane 04).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readdirSync, readFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { PROVIDER_ENV_VARS } from '../src/session-cache.js'
|
||||
import { getAllProviders } from '../src/providers/index.js'
|
||||
|
||||
// ── src/providers/<file> → provider registry name(s) ────────────────────
|
||||
// The provider(s) whose cache section the file's env reads affect. Derived
|
||||
// from the real code at the freeze sha (3600408); registry names come from
|
||||
// src/providers/index.ts. Do NOT infer this from the filename at runtime —
|
||||
// the two diverge (e.g. the shared sqlite-session-parser.ts serves two
|
||||
// providers). A file that contains env reads and is missing here fails the
|
||||
// guard: add it, with the provider(s) the reads serve.
|
||||
const FILE_PROVIDERS: Record<string, string[]> = {
|
||||
'claude.ts': ['claude'],
|
||||
'cline-cli.ts': ['cline-cli'],
|
||||
'codebuff.ts': ['codebuff'],
|
||||
'codewhale.ts': ['codewhale'],
|
||||
'codex.ts': ['codex'],
|
||||
'copilot.ts': ['copilot'],
|
||||
'droid.ts': ['droid'],
|
||||
'hermes.ts': ['hermes'],
|
||||
'lingtai-tui.ts': ['lingtai-tui'],
|
||||
// Its only literal read is CODEBURN_CURSOR_MAX_BUBBLES (cursor.ts:692).
|
||||
'cursor.ts': ['cursor'],
|
||||
// The ENV_DIR const (open-design.ts:10) resolves to CODEBURN_OPEN_DESIGN_DIR.
|
||||
'open-design.ts': ['open-design'],
|
||||
'openclaude.ts': ['openclaude'],
|
||||
'opencode.ts': ['opencode'],
|
||||
'goose.ts': ['goose'],
|
||||
'grok.ts': ['grok'],
|
||||
'crush.ts': ['crush'],
|
||||
'warp.ts': ['warp'],
|
||||
'antigravity.ts': ['antigravity'],
|
||||
'kilo-code.ts': ['kilo-code'],
|
||||
'kimi.ts': ['kimi'],
|
||||
'kiro.ts': ['kiro'],
|
||||
'mistral-vibe.ts': ['mistral-vibe'],
|
||||
'mux.ts': ['mux'],
|
||||
'qwen.ts': ['qwen'],
|
||||
'ibm-bob.ts': ['ibm-bob'],
|
||||
'quickdesk.ts': ['quickdesk'],
|
||||
'kimicode.ts': ['kimicode'],
|
||||
'zerostack.ts': ['zerostack'],
|
||||
// Shared sqlite parser; its only importers in src/ are kilo-code.ts and
|
||||
// opencode.ts. Its single read (CODEBURN_VERBOSE) is allowlisted, so this
|
||||
// entry is informational — but required, because the file has reads.
|
||||
'sqlite-session-parser.ts': ['kilo-code', 'opencode'],
|
||||
// Registered (lazy) network provider; its credential reads are declared in
|
||||
// PROVIDER_ENV_VARS (session-cache.ts) so a read-only refresh that serves
|
||||
// the cached report (parser.ts:2875/2888) cannot keep serving the previous
|
||||
// account's usage after a swap.
|
||||
'vercel-gateway.ts': ['vercel-gateway'],
|
||||
}
|
||||
|
||||
// ── Allowlisted reads ────────────────────────────────────────────────────
|
||||
// Reads that must NOT invalidate a cache section, one-line reason each.
|
||||
// Scoping rule: a key is '<file>.ts:<VAR>' — it silences exactly one var in
|
||||
// exactly one file, and a read of the same var anywhere else is still checked
|
||||
// against the declarations (see the header comment). If you add an entry here,
|
||||
// the guard goes silent for that var in that file — the reason must say
|
||||
// exactly why a change to it cannot make a cached section stale.
|
||||
// Reason shared by every copilot.ts entry (Ruling 1 of lane 04): copilot is
|
||||
// deliberately undeclared in PROVIDER_ENV_VARS. Declaring any of its reads
|
||||
// would change the copilot fingerprint, and on a fingerprint change
|
||||
// getOrCreateProviderSection (src/parser.ts:2650) keeps only cached entries
|
||||
// whose source path no longer exists — but OTel discovery returns one source
|
||||
// per DB file ({ path: dbPath }, copilot.ts:1935) and that DB keeps existing,
|
||||
// so the cached entry is dropped and re-parsed, destroying conversations
|
||||
// Copilot has since pruned from the DB that only the cache still holds.
|
||||
// Deferred until the durable carry-forward learns to merge instead of drop.
|
||||
const COPILOT_DEFERRED = 'deferred (Ruling 1): declaring it would force the durable re-parse that loses pruned OTel history'
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
'sqlite-session-parser.ts:CODEBURN_VERBOSE': 'sqlite-session-parser.ts:276 — logging verbosity only; changes no discovered path and no parsed value',
|
||||
'copilot.ts:CODEBURN_COPILOT_SESSION_STATE_DIR': COPILOT_DEFERRED,
|
||||
'copilot.ts:CODEBURN_COPILOT_OTEL_DB': COPILOT_DEFERRED,
|
||||
'copilot.ts:CODEBURN_COPILOT_JETBRAINS_DIR': COPILOT_DEFERRED,
|
||||
'copilot.ts:CODEBURN_COPILOT_WS_STORAGE_DIR': COPILOT_DEFERRED,
|
||||
'copilot.ts:CODEBURN_COPILOT_GLOBAL_STORAGE_DIR': COPILOT_DEFERRED,
|
||||
'copilot.ts:CODEBURN_COPILOT_DISABLE_OTEL': COPILOT_DEFERRED,
|
||||
'copilot.ts:APPDATA': COPILOT_DEFERRED,
|
||||
'copilot.ts:XDG_CONFIG_HOME': COPILOT_DEFERRED,
|
||||
'copilot.ts:LOCALAPPDATA': COPILOT_DEFERRED,
|
||||
}
|
||||
|
||||
// ── Static extraction ───────────────────────────────────────────────────
|
||||
|
||||
// Resolved relative to this test file, never the process cwd.
|
||||
const PROVIDERS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'providers')
|
||||
|
||||
type EnvRead = { varName: string; line: number }
|
||||
|
||||
// `const IDENT = 'NAME'` string declarations, used to resolve
|
||||
// `process.env[IDENT]` reads (open-design.ts does this with ENV_DIR).
|
||||
const STRING_CONST = /const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(['"])([^'"]*)\2/g
|
||||
|
||||
function extractEnvReads(source: string): { reads: EnvRead[]; unresolvable: Array<{ line: number; expr: string }> } {
|
||||
const consts = new Map<string, string>()
|
||||
for (const m of source.matchAll(STRING_CONST)) consts.set(m[1]!, m[3]!)
|
||||
|
||||
const reads: EnvRead[] = []
|
||||
const unresolvable: Array<{ line: number; expr: string }> = []
|
||||
const anyRead = /process\.env/g
|
||||
for (const m of source.matchAll(anyRead)) {
|
||||
const line = source.slice(0, m.index).split('\n').length
|
||||
const rest = source.slice(m.index + 'process.env'.length)
|
||||
// The expression as written, for failure messages.
|
||||
const expr = rest.trim().split(/[;\n]/)[0]!
|
||||
|
||||
if (rest.trimStart().startsWith('[')) {
|
||||
const bracket = rest.slice(rest.indexOf('['))
|
||||
const literal = /^\[\s*(['"])([A-Z0-9_]+)\1\s*\]/.exec(bracket)
|
||||
if (literal) {
|
||||
reads.push({ varName: literal[2]!, line })
|
||||
continue
|
||||
}
|
||||
const ident = /^\[\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\]/.exec(bracket)
|
||||
if (ident) {
|
||||
const resolved = consts.get(ident[1]!)
|
||||
if (resolved) {
|
||||
reads.push({ varName: resolved, line })
|
||||
continue
|
||||
}
|
||||
unresolvable.push({ line, expr: `process.env[${ident[1]}]` })
|
||||
continue
|
||||
}
|
||||
unresolvable.push({ line, expr: `process.env${expr}` })
|
||||
continue
|
||||
}
|
||||
|
||||
if (rest.trimStart().startsWith('.')) {
|
||||
const dot = /^\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/.exec(rest)
|
||||
if (dot) {
|
||||
reads.push({ varName: dot[1]!, line })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Bare `process.env` or any other form: cannot name a var — fail loudly,
|
||||
// an unresolvable read must never be silently skipped.
|
||||
unresolvable.push({ line, expr: `process.env${expr}` })
|
||||
}
|
||||
return { reads, unresolvable }
|
||||
}
|
||||
|
||||
function failWith(problems: string[]): void {
|
||||
if (problems.length > 0) throw new Error(`\n${problems.join('\n\n')}`)
|
||||
}
|
||||
|
||||
describe('provider env declarations (#920)', () => {
|
||||
it('every process.env read in src/providers is declared for the provider(s) it serves', () => {
|
||||
const problems: string[] = []
|
||||
|
||||
for (const entry of readdirSync(PROVIDERS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.ts')) continue
|
||||
|
||||
const source = readFileSync(join(PROVIDERS_DIR, entry.name), 'utf8')
|
||||
const { reads, unresolvable } = extractEnvReads(source)
|
||||
if (reads.length === 0 && unresolvable.length === 0) continue
|
||||
|
||||
const served = FILE_PROVIDERS[entry.name]
|
||||
if (!served) {
|
||||
problems.push(
|
||||
`src/providers/${entry.name} reads env vars (${reads.map(r => r.varName).join(', ')}) but is missing from FILE_PROVIDERS — add it with the provider(s) whose cache section these reads affect.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const { line, expr } of unresolvable) {
|
||||
problems.push(
|
||||
`src/providers/${entry.name}:${line}: unresolvable env read \`${expr}\` — resolve it to a literal name (e.g. \`const IDENT = 'NAME'\` in the same file) so the guard can verify it is declared; an unresolvable read must never be silently skipped.`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const { varName, line } of reads) {
|
||||
// File-scoped: an allowlist entry silences this var in this file only
|
||||
// (see the header comment); a read of the same var in another file
|
||||
// must be declared or allowlisted there.
|
||||
if (ALLOWLIST[`${entry.name}:${varName}`]) continue
|
||||
for (const provider of served) {
|
||||
if (!(PROVIDER_ENV_VARS[provider] ?? []).includes(varName)) {
|
||||
problems.push(
|
||||
`provider '${provider}' reads process.env['${varName}'] at src/providers/${entry.name}:${line} but it is not declared in PROVIDER_ENV_VARS['${provider}'] — declare it there (it changes what the provider discovers or how its sessions parse) or add '${entry.name}:${varName}' to ALLOWLIST with a reason.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failWith(problems)
|
||||
})
|
||||
|
||||
it('every PROVIDER_ENV_VARS key is a real provider name from the registry', async () => {
|
||||
const names = new Set((await getAllProviders()).map(p => p.name))
|
||||
const problems: string[] = []
|
||||
for (const key of Object.keys(PROVIDER_ENV_VARS)) {
|
||||
if (!names.has(key)) {
|
||||
// A typo'd key declares nothing and fails silently — the same defect
|
||||
// class #920 fixed. Do NOT delete the key or weaken the assertion;
|
||||
// surface it so the registry or the key gets corrected.
|
||||
problems.push(`PROVIDER_ENV_VARS key '${key}' is not a registered provider name — a typo'd key declares nothing and fails silently.`)
|
||||
}
|
||||
}
|
||||
failWith(problems)
|
||||
expect(problems).toEqual([])
|
||||
})
|
||||
|
||||
it('allowlist entries are file-scoped: every key is <file>.ts:<VAR> shaped, names a real file, and names a var that file actually reads', () => {
|
||||
const problems: string[] = []
|
||||
const providerFiles = new Set(
|
||||
readdirSync(PROVIDERS_DIR, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.endsWith('.ts'))
|
||||
.map(e => e.name),
|
||||
)
|
||||
|
||||
for (const key of Object.keys(ALLOWLIST)) {
|
||||
const match = /^([A-Za-z0-9._-]+\.ts):([A-Z0-9_]+)$/.exec(key)
|
||||
if (!match) {
|
||||
// A global-keyed entry would mask an undeclared read of the same var
|
||||
// in any other file (the pre-lane-04 failure mode). Reject it here so
|
||||
// the scoping rule is enforced, not just documented.
|
||||
problems.push(`ALLOWLIST key '${key}' is not '<file>.ts:<VAR>' shaped — an allowlist entry must silence exactly one var in exactly one file.`)
|
||||
continue
|
||||
}
|
||||
const [, fileName, varName] = match
|
||||
if (!providerFiles.has(fileName!)) {
|
||||
problems.push(`ALLOWLIST key '${key}' names '${fileName}', which is not a file in src/providers — the entry silences nothing and must be removed.`)
|
||||
continue
|
||||
}
|
||||
const { reads } = extractEnvReads(readFileSync(join(PROVIDERS_DIR, fileName!), 'utf8'))
|
||||
if (!reads.some(r => r.varName === varName)) {
|
||||
problems.push(`ALLOWLIST key '${key}' names var '${varName}' but src/providers/${fileName} never reads it — dead entry; remove it.`)
|
||||
}
|
||||
}
|
||||
|
||||
failWith(problems)
|
||||
expect(problems).toEqual([])
|
||||
})
|
||||
})
|
||||
110
tests/provider-probe-roots-tier2.test.ts
Normal file
110
tests/provider-probe-roots-tier2.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { isAbsolute, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { createClineProvider, getClineDataPath } from '../src/providers/cline.js'
|
||||
import { createRooCodeProvider } from '../src/providers/roo-code.js'
|
||||
import { createKiloCodeProvider } from '../src/providers/kilo-code.js'
|
||||
import { createGrokProvider } from '../src/providers/grok.js'
|
||||
import { createPiProvider, createOmpProvider } from '../src/providers/pi.js'
|
||||
import { createKimiProvider } from '../src/providers/kimi.js'
|
||||
import {
|
||||
clineTaskRoots,
|
||||
discoverClineTasks,
|
||||
getVSCodeGlobalStoragePaths,
|
||||
} from '../src/providers/vscode-cline-parser.js'
|
||||
|
||||
// #899 Tier 2, batch 1. probeRoots() must report the roots discovery actually
|
||||
// reads: a probe pointing somewhere discovery never looks is worse than none,
|
||||
// because it looks authoritative. Assertions pin exact root sets rather than
|
||||
// substrings, so a wrong-but-similar path cannot pass.
|
||||
//
|
||||
// This file is separate from the Tier 1 suite only because #903 introduces
|
||||
// that one and is still open; fold the two together once it lands.
|
||||
|
||||
const CLINE_EXTENSION = 'saoudrizwan.claude-dev'
|
||||
const ROO_EXTENSION = 'rooveterinaryinc.roo-cline'
|
||||
|
||||
describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
|
||||
it('cline reports exactly the roots discovery scans', async () => {
|
||||
// The provider whose silence motivated #874: four places to look, and until
|
||||
// now no way to see which of them CodeBurn actually read.
|
||||
const roots = await createClineProvider().probeRoots!()
|
||||
expect(roots).toEqual([
|
||||
...clineTaskRoots(CLINE_EXTENSION).map(path => ({ path, label: 'tasks' })),
|
||||
{ path: getClineDataPath(), label: 'tasks' },
|
||||
])
|
||||
expect(roots).toHaveLength(4)
|
||||
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
|
||||
})
|
||||
|
||||
it('cline reports the configured dirs verbatim when overridden', async () => {
|
||||
expect(await createClineProvider(['/tmp/cline-a', '/tmp/cline-b']).probeRoots!()).toEqual([
|
||||
{ path: '/tmp/cline-a', label: 'tasks' },
|
||||
{ path: '/tmp/cline-b', label: 'tasks' },
|
||||
])
|
||||
})
|
||||
|
||||
it('roo-code reports the override, or exactly the VS Code variant roots', async () => {
|
||||
expect(await createRooCodeProvider('/tmp/roo-a').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/roo-a', label: 'tasks' },
|
||||
])
|
||||
expect(await createRooCodeProvider().probeRoots!()).toEqual(
|
||||
getVSCodeGlobalStoragePaths(ROO_EXTENSION).map(path => ({ path, label: 'tasks' })),
|
||||
)
|
||||
})
|
||||
|
||||
// Regression: an earlier draft mirrored the resolution in a local helper that
|
||||
// detected "no override" with `=== undefined`, while discoverClineTasks uses
|
||||
// truthiness. An empty-string override made doctor report [""] while
|
||||
// discovery scanned the three default roots. Both now call one resolver.
|
||||
it('an empty-string override resolves the same for probeRoots and discovery', async () => {
|
||||
const probed = (await createRooCodeProvider('').probeRoots!()).map(r => r.path)
|
||||
expect(probed).toEqual(clineTaskRoots(ROO_EXTENSION, ''))
|
||||
expect(probed).toEqual(getVSCodeGlobalStoragePaths(ROO_EXTENSION))
|
||||
// discoverClineTasks resolves through the same function, so an empty
|
||||
// override cannot send discovery somewhere probeRoots did not report.
|
||||
expect(await discoverClineTasks(ROO_EXTENSION, 'roo-code', 'Roo Code', '')).toEqual([])
|
||||
})
|
||||
|
||||
it('kilo-code reports both halves of its discovery: tasks and the sqlite store', async () => {
|
||||
const roots = await createKiloCodeProvider('/tmp/kilo-a').probeRoots!()
|
||||
expect(roots[0]).toEqual({ path: '/tmp/kilo-a', label: 'tasks' })
|
||||
const sqlite = roots.filter(r => r.label === 'sqlite')
|
||||
expect(sqlite).toHaveLength(1)
|
||||
// The same dbDir discoverSqliteSessions reads, not a lookalike.
|
||||
expect(sqlite[0]!.path).toBe(
|
||||
join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'kilo'),
|
||||
)
|
||||
})
|
||||
|
||||
it('grok reports exactly its resolved sessions dir', async () => {
|
||||
expect(await createGrokProvider('/tmp/grok-a').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/grok-a', label: 'sessions' },
|
||||
])
|
||||
expect(await createGrokProvider().probeRoots!()).toEqual([
|
||||
{ path: join(homedir(), '.grok', 'sessions'), label: 'sessions' },
|
||||
])
|
||||
})
|
||||
|
||||
it('pi and omp each report their own sessions dir', async () => {
|
||||
expect(await createPiProvider('/tmp/pi-a').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/pi-a', label: 'sessions' },
|
||||
])
|
||||
expect(await createOmpProvider('/tmp/omp-a').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/omp-a', label: 'sessions' },
|
||||
])
|
||||
// Same module, two providers: the roots must not collide.
|
||||
const [piRoot] = await createPiProvider().probeRoots!()
|
||||
const [ompRoot] = await createOmpProvider().probeRoots!()
|
||||
expect(piRoot!.path).not.toBe(ompRoot!.path)
|
||||
})
|
||||
|
||||
it('kimi reports the sessions dir under its share root, not the share root itself', async () => {
|
||||
// Discovery walks <shareDir>/sessions; reporting shareDir would point doctor
|
||||
// at a directory that exists even when no sessions do.
|
||||
expect(await createKimiProvider('/tmp/kimi-a').probeRoots!()).toEqual([
|
||||
{ path: join('/tmp/kimi-a', 'sessions'), label: 'sessions' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -124,6 +124,18 @@ async function collectCalls(source: { path: string; project: string; provider: s
|
|||
return calls
|
||||
}
|
||||
|
||||
// Write a transcript inside the test's tmpDir sandbox, but at the production
|
||||
// directory shape — {ws}/{hash}/GitHub.copilot-chat/transcripts/<id>.jsonl —
|
||||
// because sessionId derivation reads the path structure (file basename for
|
||||
// transcripts). Never touches the real VS Code storage.
|
||||
async function createTranscriptFile(sessionId: string, lines: string[]) {
|
||||
const transcriptsDir = join(tmpDir, 'ws', 'hash1', 'GitHub.copilot-chat', 'transcripts')
|
||||
await mkdir(transcriptsDir, { recursive: true })
|
||||
const path = join(transcriptsDir, `${sessionId}.jsonl`)
|
||||
await writeFile(path, lines.join('\n') + '\n')
|
||||
return path
|
||||
}
|
||||
|
||||
describe('copilot provider - JSONL parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-'))
|
||||
|
|
@ -335,8 +347,122 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
expect(calls[0]!.model).toBe('gpt-4.1')
|
||||
})
|
||||
|
||||
it('attributes turns between subagent.started and subagent.completed to the subagent', async () => {
|
||||
// CLI ≥ ~1.0.7x writes subagent.started/completed (not subagent.selected);
|
||||
// event shapes from a real delegating 1.0.78 session. The label must cover
|
||||
// the subagent's turns and clear afterwards, not bleed onto the parent's.
|
||||
const eventsPath = await createSessionDir('sess-subagent-cli', [
|
||||
modelChange('claude-sonnet-5'),
|
||||
userMessage('delegate a search'),
|
||||
JSON.stringify({
|
||||
type: 'subagent.started',
|
||||
timestamp: '2026-08-07T10:00:11Z',
|
||||
data: { toolCallId: 'toolu_01SZnHjC', agentName: 'explore', agentDisplayName: 'Explore Agent' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-08-07T10:00:14Z',
|
||||
data: { messageId: 'msg-sub', model: 'claude-haiku-4.5', outputTokens: 197, toolRequests: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'subagent.completed',
|
||||
timestamp: '2026-08-07T10:00:19Z',
|
||||
data: { toolCallId: 'toolu_01SZnHjC', agentName: 'explore', model: 'claude-haiku-4.5', totalTokens: 26435 },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-08-07T10:00:22Z',
|
||||
data: { messageId: 'msg-parent', model: 'claude-sonnet-5', outputTokens: 51, toolRequests: [] },
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||||
const sub = calls.find(c => c.deduplicationKey.endsWith(':msg-sub'))!
|
||||
expect(sub.subagentTypes).toEqual(['explore'])
|
||||
expect(sub.model).toBe('claude-haiku-4.5')
|
||||
const parent = calls.find(c => c.deduplicationKey.endsWith(':msg-parent'))!
|
||||
expect(parent.subagentTypes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('completing a nested subagent restores the outer label, matched by toolCallId', async () => {
|
||||
const started = (id: string, name: string) =>
|
||||
JSON.stringify({ type: 'subagent.started', timestamp: '2026-08-07T10:00:11Z', data: { toolCallId: id, agentName: name } })
|
||||
const completed = (id: string) =>
|
||||
JSON.stringify({ type: 'subagent.completed', timestamp: '2026-08-07T10:00:19Z', data: { toolCallId: id, agentName: 'x' } })
|
||||
const msg = (messageId: string, outputTokens = 10) =>
|
||||
JSON.stringify({ type: 'assistant.message', timestamp: '2026-08-07T10:00:14Z', data: { messageId, model: 'claude-sonnet-5', outputTokens, toolRequests: [] } })
|
||||
|
||||
const eventsPath = await createSessionDir('sess-subagent-nested', [
|
||||
modelChange('claude-sonnet-5'),
|
||||
started('call-A', 'explore'),
|
||||
started('call-B', 'plan'),
|
||||
msg('msg-inner'), // while B runs → 'plan'
|
||||
completed('call-B'),
|
||||
msg('msg-outer'), // B done, A still active → 'explore', NOT unlabeled
|
||||
completed('call-A'),
|
||||
msg('msg-after'), // all done → no label
|
||||
])
|
||||
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||||
const byId = (id: string) => calls.find(c => c.deduplicationKey.endsWith(`:${id}`))!
|
||||
expect(byId('msg-inner').subagentTypes).toEqual(['plan'])
|
||||
expect(byId('msg-outer').subagentTypes).toEqual(['explore'])
|
||||
expect(byId('msg-after').subagentTypes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores a completed event whose non-empty toolCallId matches no active run', async () => {
|
||||
// A completion for a run we never saw start must not evict an unrelated
|
||||
// active run; only a genuinely ID-less completion may pop the stack.
|
||||
const eventsPath = await createSessionDir('sess-subagent-unmatched', [
|
||||
modelChange('claude-sonnet-5'),
|
||||
JSON.stringify({
|
||||
type: 'subagent.started',
|
||||
timestamp: '2026-08-07T10:00:11Z',
|
||||
data: { toolCallId: 'call-A', agentName: 'explore' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'subagent.completed',
|
||||
timestamp: '2026-08-07T10:00:12Z',
|
||||
data: { toolCallId: 'call-unknown', agentName: 'phantom' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-08-07T10:00:14Z',
|
||||
data: { messageId: 'msg-1', model: 'claude-sonnet-5', outputTokens: 10, toolRequests: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'subagent.completed',
|
||||
timestamp: '2026-08-07T10:00:15Z',
|
||||
data: { agentName: 'legacy-no-id' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-08-07T10:00:16Z',
|
||||
data: { messageId: 'msg-2', model: 'claude-sonnet-5', outputTokens: 12, toolRequests: [] },
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||||
// The unmatched completion left 'explore' active…
|
||||
expect(calls.find(c => c.deduplicationKey.endsWith(':msg-1'))!.subagentTypes).toEqual(['explore'])
|
||||
// …and the ID-less completion (legacy shape) ended it.
|
||||
expect(calls.find(c => c.deduplicationKey.endsWith(':msg-2'))!.subagentTypes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps subagent.selected sticky when no completed event ever arrives', async () => {
|
||||
// Older CLIs only write subagent.selected; nothing clears it.
|
||||
const eventsPath = await createSessionDir('sess-subagent-selected', [
|
||||
modelChange('claude-sonnet-5'),
|
||||
JSON.stringify({ type: 'subagent.selected', data: { agentName: 'refactor' } }),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 25 }),
|
||||
assistantMessage({ messageId: 'msg-2', outputTokens: 30, timestamp: '2026-04-15T10:01:00Z' }),
|
||||
])
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'jsonl' })
|
||||
expect(calls.map(c => c.subagentTypes)).toEqual([['refactor'], ['refactor']])
|
||||
})
|
||||
|
||||
it('infers OpenAI auto bucket for transcript toolCallId prefix call_', async () => {
|
||||
const eventsPath = await createSessionDir('sess-tr-call', [
|
||||
const eventsPath = await createTranscriptFile('sess-tr-call', [
|
||||
transcriptSessionStart('sess-tr-call'),
|
||||
transcriptUserMessage('check model inference'),
|
||||
transcriptAssistantMessage({
|
||||
|
|
@ -346,16 +472,20 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
}),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('copilot-openai-auto')
|
||||
// Each transcript is its own session, keyed by file basename — NOT the
|
||||
// shared parent dir name 'transcripts', which would collapse every
|
||||
// transcript into one session and one dedup namespace.
|
||||
expect(calls[0]!.sessionId).toBe('sess-tr-call')
|
||||
})
|
||||
|
||||
it('infers Anthropic auto bucket for transcript toolCallId prefixes tooluse_/toolu_vrtx_', async () => {
|
||||
const eventsPath = await createSessionDir('sess-tr-claude', [
|
||||
const eventsPath = await createTranscriptFile('sess-tr-claude', [
|
||||
transcriptSessionStart('sess-tr-claude'),
|
||||
transcriptUserMessage('check model inference'),
|
||||
transcriptAssistantMessage({
|
||||
|
|
@ -365,7 +495,7 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
}),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
|
|
@ -374,7 +504,7 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
})
|
||||
|
||||
it('chooses the dominant inferred transcript model when prefixes are mixed', async () => {
|
||||
const eventsPath = await createSessionDir('sess-tr-mixed', [
|
||||
const eventsPath = await createTranscriptFile('sess-tr-mixed', [
|
||||
transcriptSessionStart('sess-tr-mixed'),
|
||||
transcriptUserMessage('mixed'),
|
||||
transcriptAssistantMessage({
|
||||
|
|
@ -394,7 +524,7 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
}),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
|
|
@ -402,8 +532,35 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
expect(calls.every(c => c.model === 'copilot-openai-auto')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses a producerless transcript with explicit model info and no tool calls', async () => {
|
||||
// Prefix inference has nothing to work with here; the explicit
|
||||
// session.model_change must still establish the model, and the shutdown
|
||||
// rollup must stay ignored — provenance, not the producer field, gates it.
|
||||
const eventsPath = await createTranscriptFile('sess-tr-explicit', [
|
||||
JSON.stringify({ type: 'session.start', data: { sessionId: 'sess-tr-explicit' } }),
|
||||
modelChange('gpt-4.1'),
|
||||
transcriptUserMessage('hi'),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-04-15T10:00:15Z',
|
||||
data: { messageId: 'msg-1', outputTokens: 80, toolRequests: [] },
|
||||
}),
|
||||
shutdownEvent({
|
||||
modelMetrics: {
|
||||
'gpt-4.1': { inputTokens: 1000, outputTokens: 80, cacheReadTokens: 500, cacheWriteTokens: 200 },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('gpt-4.1')
|
||||
expect(calls[0]!.outputTokens).toBe(80)
|
||||
expect(calls.every(c => !c.deduplicationKey.includes(':shutdown:'))).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes Copilot MCP tool names from VS Code transcripts', async () => {
|
||||
const eventsPath = await createSessionDir('sess-tr-mcp-tools', [
|
||||
const eventsPath = await createTranscriptFile('sess-tr-mcp-tools', [
|
||||
transcriptSessionStart('sess-tr-mcp-tools'),
|
||||
transcriptUserMessage('use GitHub MCP'),
|
||||
transcriptAssistantMessage({
|
||||
|
|
@ -414,7 +571,7 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
}),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
|
|
@ -458,7 +615,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
|
|||
// One per-turn assistant.message call + one supplementary shutdown call.
|
||||
expect(calls).toHaveLength(2)
|
||||
|
||||
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5')
|
||||
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:1')
|
||||
expect(shutdown).toBeDefined()
|
||||
expect(shutdown!.model).toBe('claude-sonnet-4-5')
|
||||
expect(shutdown!.inputTokens).toBe(4) // 71282 - 35495 - 35783
|
||||
|
|
@ -546,6 +703,86 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
|
|||
expect(gpt.costUSD).toBeCloseTo(calculateCost('gpt-5', 50, 0, 0, 5000, 0), 12)
|
||||
})
|
||||
|
||||
it('emits per-leg deltas for a resumed session with cumulative shutdown rollups', async () => {
|
||||
// Numbers from a real resumed CLI 1.0.78 session (3 legs via --resume):
|
||||
// each leg appends a session.shutdown whose modelMetrics are CUMULATIVE.
|
||||
// Emitting deltas keyed by occurrence keeps a growing file append-only
|
||||
// under the durable union-by-key cache merge — re-parsing after each
|
||||
// resume adds only the new leg, never double-counting earlier ones.
|
||||
const legs = [
|
||||
{ inputTokens: 24672, outputTokens: 17, cacheReadTokens: 0, cacheWriteTokens: 24670 },
|
||||
{ inputTokens: 74463, outputTokens: 149, cacheReadTokens: 49489, cacheWriteTokens: 24968 },
|
||||
{ inputTokens: 124783, outputTokens: 243, cacheReadTokens: 99569, cacheWriteTokens: 25204 },
|
||||
]
|
||||
const lines = [modelChange('claude-sonnet-5'), assistantMessage({ messageId: 'msg-1', outputTokens: 17 })]
|
||||
for (const [i, leg] of legs.entries()) {
|
||||
lines.push(shutdownEvent({ modelMetrics: { 'claude-sonnet-5': leg }, timestamp: `2026-08-0${i + 1}T10:00:00Z` }))
|
||||
}
|
||||
const eventsPath = await createSessionDir('sess-resumed', lines)
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||||
|
||||
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
|
||||
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
|
||||
'copilot:sess-resumed:shutdown:claude-sonnet-5:1',
|
||||
'copilot:sess-resumed:shutdown:claude-sonnet-5:2',
|
||||
'copilot:sess-resumed:shutdown:claude-sonnet-5:3',
|
||||
])
|
||||
// Each leg lands on its own shutdown timestamp (a resumed session can
|
||||
// span days; whole-rollup emission would collapse them onto one).
|
||||
expect(shutdowns.map(c => c.timestamp)).toEqual([
|
||||
'2026-08-01T10:00:00Z', '2026-08-02T10:00:00Z', '2026-08-03T10:00:00Z',
|
||||
])
|
||||
// Per-leg deltas sum exactly to the final cumulative rollup.
|
||||
const sum = (k: 'inputTokens' | 'cacheReadInputTokens' | 'cacheCreationInputTokens') =>
|
||||
shutdowns.reduce((a, c) => a + c[k], 0)
|
||||
expect(sum('cacheReadInputTokens')).toBe(99569)
|
||||
expect(sum('cacheCreationInputTokens')).toBe(25204)
|
||||
expect(sum('inputTokens')).toBe(124783 - 99569 - 25204)
|
||||
|
||||
// A later re-parse of the grown file (prior legs already cached) emits
|
||||
// only what the seen-key set lacks.
|
||||
const seen = new Set(calls.map(c => c.deduplicationKey))
|
||||
const again = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
|
||||
expect(again).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('starts a fresh delta baseline when a cumulative rollup goes backwards (counter reset)', async () => {
|
||||
// Hypothetical but cheap to guard: if the CLI ever resets its counters
|
||||
// mid-session, the post-reset epoch must be billed from zero — a stale
|
||||
// high-water baseline would clamp it away (and the reset leg's real usage
|
||||
// with it).
|
||||
const eventsPath = await createSessionDir('sess-reset', [
|
||||
modelChange('claude-sonnet-5'),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 10 }),
|
||||
shutdownEvent({
|
||||
modelMetrics: { 'claude-sonnet-5': { inputTokens: 10000, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 5000 } },
|
||||
timestamp: '2026-08-01T10:00:00Z',
|
||||
}),
|
||||
// Reset: cumulative drops below the previous rollup → new epoch.
|
||||
shutdownEvent({
|
||||
modelMetrics: { 'claude-sonnet-5': { inputTokens: 2000, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 1000 } },
|
||||
timestamp: '2026-08-02T10:00:00Z',
|
||||
}),
|
||||
shutdownEvent({
|
||||
modelMetrics: { 'claude-sonnet-5': { inputTokens: 5000, outputTokens: 8, cacheReadTokens: 2000, cacheWriteTokens: 1500 } },
|
||||
timestamp: '2026-08-03T10:00:00Z',
|
||||
}),
|
||||
])
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||||
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
|
||||
expect(shutdowns).toHaveLength(3)
|
||||
// Leg 1: epoch-1 usage in full.
|
||||
expect(shutdowns[0]!.inputTokens).toBe(5000) // 10000 − 0 − 5000
|
||||
expect(shutdowns[0]!.cacheCreationInputTokens).toBe(5000)
|
||||
// Leg 2 (reset): billed from zero, not clamped away against the old baseline.
|
||||
expect(shutdowns[1]!.inputTokens).toBe(1000) // 2000 − 0 − 1000
|
||||
expect(shutdowns[1]!.cacheCreationInputTokens).toBe(1000)
|
||||
// Leg 3: normal delta within the new epoch.
|
||||
expect(shutdowns[2]!.inputTokens).toBe(500) // (5000−2000) − 2000 − 500
|
||||
expect(shutdowns[2]!.cacheReadInputTokens).toBe(2000)
|
||||
expect(shutdowns[2]!.cacheCreationInputTokens).toBe(500)
|
||||
})
|
||||
|
||||
it('keeps shutdown dedup keys stable across re-parses', async () => {
|
||||
const eventsPath = await createSessionDir('sess-reparse', [
|
||||
modelChange('claude-sonnet-4-5'),
|
||||
|
|
@ -594,7 +831,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
|
|||
})
|
||||
|
||||
it('ignores session.shutdown for VS Code transcript sessions', async () => {
|
||||
const eventsPath = await createSessionDir('sess-tr-shutdown', [
|
||||
const eventsPath = await createTranscriptFile('sess-tr-shutdown', [
|
||||
transcriptSessionStart('sess-tr-shutdown'),
|
||||
transcriptUserMessage('hi'),
|
||||
transcriptAssistantMessage({ messageId: 'msg-1', content: 'done', toolCallIds: ['call_abc'] }),
|
||||
|
|
@ -604,7 +841,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
|
|||
},
|
||||
}),
|
||||
])
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot', sourceType: 'transcript' }
|
||||
const calls = await collectCalls(source)
|
||||
|
||||
// Only the transcript assistant call; the shutdown rollup is CLI-only.
|
||||
|
|
@ -612,6 +849,165 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
|
|||
expect(calls.every(c => !c.deduplicationKey.includes(':shutdown:'))).toBe(true)
|
||||
expect(calls[0]!.model).toBe('copilot-openai-auto')
|
||||
})
|
||||
|
||||
// Regression test for #944: events are redacted copies of a real Copilot CLI
|
||||
// 1.0.78 session. The CLI writes the same producer ('copilot-agent') as VS
|
||||
// Code transcripts, so content sniffing skipped this session's shutdown
|
||||
// rollup — reporting 100 of its 49,573 tokens and zero input/cache.
|
||||
it('parses a CLI session whose session.start carries producer copilot-agent (issue #944)', async () => {
|
||||
const eventsPath = await createSessionDir('sess-cli-producer', [
|
||||
JSON.stringify({
|
||||
type: 'session.start',
|
||||
timestamp: '2026-08-07T17:56:35.573Z',
|
||||
data: {
|
||||
sessionId: 'sess-cli-producer',
|
||||
version: 1,
|
||||
producer: 'copilot-agent',
|
||||
copilotVersion: '1.0.78',
|
||||
startTime: '2026-08-07T17:56:35.554Z',
|
||||
context: { cwd: '/home/user/myproject' },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'session.model_change',
|
||||
timestamp: '2026-08-07T17:56:36.725Z',
|
||||
data: { newModel: 'claude-sonnet-5', reasoningEffort: null },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user.message',
|
||||
timestamp: '2026-08-07T17:56:36.732Z',
|
||||
data: { content: 'Run echo and summarize the output.' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-08-07T17:56:38.763Z',
|
||||
data: {
|
||||
messageId: 'a982a391-9ee3-4fbd-89a9-26d5af78c890',
|
||||
model: 'claude-sonnet-5',
|
||||
content: '',
|
||||
toolRequests: [{
|
||||
toolCallId: 'toolu_017eL3f5aeGiLoALignYMZEN',
|
||||
name: 'bash',
|
||||
arguments: { command: 'echo codeburn-repro-944', description: 'Echo test string' },
|
||||
type: 'function',
|
||||
}],
|
||||
turnId: '0',
|
||||
outputTokens: 81,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: '2026-08-07T17:56:40.417Z',
|
||||
data: {
|
||||
messageId: '8758ea51-797f-4285-972c-495911e2839f',
|
||||
model: 'claude-sonnet-5',
|
||||
content: 'The command printed the string "codeburn-repro-944".',
|
||||
toolRequests: [],
|
||||
turnId: '1',
|
||||
outputTokens: 19,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'session.shutdown',
|
||||
timestamp: '2026-08-07T17:56:40.591Z',
|
||||
data: {
|
||||
shutdownType: 'routine',
|
||||
sessionStartTime: 1786125395554,
|
||||
modelMetrics: {
|
||||
'claude-sonnet-5': {
|
||||
requests: { count: 2, cost: 1 },
|
||||
usage: { inputTokens: 49473, outputTokens: 100, cacheReadTokens: 24678, cacheWriteTokens: 24791, reasoningTokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Discovery tags session-state files 'jsonl'; provenance, not the shared
|
||||
// producer value, must classify this as a CLI session.
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
|
||||
|
||||
// Two per-turn output calls with the REAL model — not the
|
||||
// 'copilot-anthropic-auto' bucket transcript inference would pick from the
|
||||
// toolu_ toolCallId prefix.
|
||||
const perTurn = calls.filter(c => !c.deduplicationKey.includes(':shutdown:'))
|
||||
expect(perTurn.map(c => c.outputTokens)).toEqual([81, 19])
|
||||
expect(perTurn.every(c => c.model === 'claude-sonnet-5')).toBe(true)
|
||||
|
||||
// The shutdown rollup lands: the tokens the misclassification dropped.
|
||||
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:1')
|
||||
expect(shutdown).toBeDefined()
|
||||
expect(shutdown!.inputTokens).toBe(4) // 49473 − 24678 − 24791 (cache-inclusive)
|
||||
expect(shutdown!.cacheReadInputTokens).toBe(24678)
|
||||
expect(shutdown!.cacheCreationInputTokens).toBe(24791)
|
||||
expect(shutdown!.outputTokens).toBe(0) // owned by the per-turn events
|
||||
expect(shutdown!.costIsEstimated).toBe(false)
|
||||
expect(shutdown!.costUSD).toBeCloseTo(calculateCost('claude-sonnet-5', 4, 0, 24791, 24678, 0), 12)
|
||||
expect(shutdown!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('treats a bare (untagged) source as CLI format, not transcript', async () => {
|
||||
// Producer sniffing must not resurface for sources without a sourceType tag
|
||||
// (the pre-tagging shape): same events, same result as the tagged parse.
|
||||
const eventsPath = await createSessionDir('sess-cli-untagged', [
|
||||
JSON.stringify({
|
||||
type: 'session.start',
|
||||
timestamp: '2026-08-07T17:56:35.573Z',
|
||||
data: { sessionId: 'sess-cli-untagged', producer: 'copilot-agent', copilotVersion: '1.0.78' },
|
||||
}),
|
||||
modelChange('claude-sonnet-5'),
|
||||
userMessage('hello'),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 42 }),
|
||||
shutdownEvent({
|
||||
modelMetrics: {
|
||||
'claude-sonnet-5': { inputTokens: 1000, outputTokens: 42, cacheReadTokens: 600, cacheWriteTokens: 300 },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot' })
|
||||
expect(calls.some(c => c.deduplicationKey.includes(':shutdown:'))).toBe(true)
|
||||
expect(calls.find(c => c.deduplicationKey.includes(':shutdown:'))!.cacheReadInputTokens).toBe(600)
|
||||
})
|
||||
|
||||
it('wires discovery through parsing: a discovered CLI session keeps its shutdown rollup', async () => {
|
||||
// The full #944 pipeline: discoverSessions must tag the session-state file
|
||||
// so that the parser it hands off to keeps the shutdown tokens.
|
||||
await createSessionDir('sess-wire', [
|
||||
JSON.stringify({
|
||||
type: 'session.start',
|
||||
timestamp: '2026-08-07T17:56:35.573Z',
|
||||
data: { sessionId: 'sess-wire', producer: 'copilot-agent', copilotVersion: '1.0.78' },
|
||||
}),
|
||||
modelChange('claude-sonnet-5'),
|
||||
userMessage('hello'),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 42 }),
|
||||
shutdownEvent({
|
||||
modelMetrics: {
|
||||
'claude-sonnet-5': { inputTokens: 5000, outputTokens: 42, cacheReadTokens: 3000, cacheWriteTokens: 1500 },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Keep discovery hermetic: a real agent-traces.db on the host must not leak in.
|
||||
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
|
||||
try {
|
||||
const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode', '/nonexistent/global', '/nonexistent/jetbrains')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
|
||||
|
||||
const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:1')
|
||||
expect(shutdown).toBeDefined()
|
||||
expect(shutdown!.inputTokens).toBe(500) // 5000 − 3000 − 1500
|
||||
expect(shutdown!.cacheReadInputTokens).toBe(3000)
|
||||
expect(shutdown!.cacheCreationInputTokens).toBe(1500)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot provider - chatSessions parsing', () => {
|
||||
|
|
@ -811,6 +1207,9 @@ describe('copilot provider - discoverSessions', () => {
|
|||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.every(s => s.provider === 'copilot')).toBe(true)
|
||||
expect(sessions.every(s => s.path.endsWith('events.jsonl'))).toBe(true)
|
||||
// Session-state files are tagged as CLI sources — the tag (not the file's
|
||||
// producer value) decides transcript vs CLI parsing (#944).
|
||||
expect(sessions.every(s => (s as { sourceType?: string }).sourceType === 'jsonl')).toBe(true)
|
||||
})
|
||||
|
||||
it('reads project name from workspace.yaml cwd', async () => {
|
||||
|
|
@ -864,6 +1263,7 @@ describe('copilot provider - discoverSessions', () => {
|
|||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('myapp')
|
||||
expect(sessions[0]!.path).toContain('session-1.jsonl')
|
||||
expect((sessions[0] as { sourceType?: string }).sourceType).toBe('transcript')
|
||||
})
|
||||
|
||||
it('includes VSCodium workspaceStorage paths on all supported platforms', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, mkdtemp, rm } from 'fs/promises'
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
|
@ -432,6 +432,47 @@ skipUnlessSqlite('hermes provider', () => {
|
|||
expect(modelTokens.reduce((sum, tokens) => sum + tokens.reasoningTokens, 0)).toBe(22)
|
||||
})
|
||||
|
||||
// Regression for issue #913: Hermes writes state.db in WAL mode and keeps
|
||||
// the writer connection open for the life of the agent, so recent sessions
|
||||
// live in state.db-wal while the main file's mtime stays at the last
|
||||
// checkpoint. The date-range mtime pre-filter in parseProviderSources
|
||||
// then reads the source as "older than the range" and skips it, and every
|
||||
// session committed since the last checkpoint disappears from reports.
|
||||
// The fingerprint must fold the -wal sibling in so a stale main-file stat
|
||||
// cannot hide fresh sessions.
|
||||
it('still parses sessions committed to the WAL when the main db stat is checkpoint-stale', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'wal-session',
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
startedAt: 1779549200,
|
||||
title: 'WAL Session',
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('wal-session', 'user', 'Session committed after the last checkpoint', 1779549201)
|
||||
})
|
||||
|
||||
// Simulate the WAL-mode stat shape: the main file's mtime predates the
|
||||
// requested range (last checkpoint days ago), while a fresh -wal sibling
|
||||
// holds the recent commits. The db itself was written in rollback-journal
|
||||
// mode, so SQLite ignores the stray -wal on open; only its stat matters.
|
||||
const beforeRange = new Date('2026-05-20T00:00:00.000Z')
|
||||
await utimes(dbPath, beforeRange, beforeRange)
|
||||
await writeFile(`${dbPath}-wal`, 'wal-frames')
|
||||
|
||||
const { clearSessionCache, parseAllSessions } = await loadParserWithHermesHome(tmpDir, cacheDir)
|
||||
clearSessionCache()
|
||||
const projects = await parseAllSessions(dayRange(), 'hermes')
|
||||
const sessions = projects.flatMap(project => project.sessions)
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.totalInputTokens).toBe(100)
|
||||
})
|
||||
|
||||
it('treats sibling profile-like directories as default sessions', async () => {
|
||||
const profileLikeDir = join(dirname(tmpDir), `${basename(tmpDir)}-profiles_backup`, 'coder')
|
||||
await mkdir(profileLikeDir, { recursive: true })
|
||||
|
|
|
|||
|
|
@ -111,6 +111,26 @@ describe('kiro provider - chat file parsing', () => {
|
|||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('estimates input tokens from the full prompt, not a 500-char slice (money-path)', async () => {
|
||||
// Regression: parseChatFile estimated input tokens from pendingUserMessage
|
||||
// (the last human turn sliced to 500 chars) while output summed every bot
|
||||
// char, so a long prompt undercounted input tokens - and cost - severalfold.
|
||||
const wsHash = 'f'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'long.chat')
|
||||
const prompt = 'x'.repeat(2400) // 2400 chars / 4 = 600 tokens; a 500-slice would give 125
|
||||
await writeFile(chatPath, makeChatFile({ userPrompt: prompt, botResponses: ['ok'] }))
|
||||
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser({ path: chatPath, project: 'p', provider: 'kiro' }, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(600)
|
||||
// userMessage stays capped for display; only the token estimate uses the full length.
|
||||
expect(calls[0]!.userMessage.length).toBe(500)
|
||||
})
|
||||
|
||||
it('stores kiro-auto when model is auto', async () => {
|
||||
const wsHash = 'b'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
|
|
|
|||
|
|
@ -56,3 +56,29 @@ describe('VS Code Cline-family storage discovery', () => {
|
|||
].sort())
|
||||
})
|
||||
})
|
||||
|
||||
import { createClineParser } from '../../src/providers/vscode-cline-parser.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
describe('VS Code Cline-family parse hardening', () => {
|
||||
it('yields with an empty timestamp instead of throwing on a malformed ts', async () => {
|
||||
// entry.ts is only truthy-checked; a garbage value made new Date(ts)
|
||||
// .toISOString() throw RangeError and abort the whole session parse.
|
||||
const taskDir = join(tmpDir, 'tasks', 'bad-ts')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify([
|
||||
{ type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 100, tokensOut: 50 }), ts: 'not-a-real-timestamp' },
|
||||
]))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi\n<environment_details>\n</environment_details>' }] },
|
||||
]))
|
||||
|
||||
const source = { path: taskDir, project: 'p', provider: 'cline' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of createClineParser(source, new Set(), 'cline').parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.timestamp).toBe('')
|
||||
expect(calls[0]!.inputTokens).toBe(100)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { readFile, rm, writeFile, mkdir } from 'fs/promises'
|
||||
import { readFile, rm, utimes, writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { basename, join } from 'path'
|
||||
|
||||
import {
|
||||
CACHE_VERSION,
|
||||
PROVIDER_ENV_VARS,
|
||||
type CachedCall,
|
||||
type CachedFile,
|
||||
type CachedTurn,
|
||||
|
|
@ -281,6 +282,129 @@ describe('computeEnvFingerprint', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ── provider env overrides invalidate the fingerprint (#920) ─────────────
|
||||
|
||||
describe('provider env overrides invalidate the fingerprint (#920)', () => {
|
||||
// Nine providers honored an env var that relocates where discovery looks
|
||||
// without the var being declared in PROVIDER_ENV_VARS, so
|
||||
// computeEnvFingerprint did not hash it and the cache section survived the
|
||||
// change: sessions parsed from the old root kept being reported and the new
|
||||
// root was never read. Each pair below must change the fingerprint when the
|
||||
// var is set. codex/CODEX_HOME is the control — it already worked and must
|
||||
// keep working.
|
||||
const CASES: Array<[provider: string, varName: string]> = [
|
||||
['kiro', 'KIRO_HOME'],
|
||||
['grok', 'GROK_HOME'],
|
||||
['kimi', 'KIMI_SHARE_DIR'],
|
||||
['mux', 'MUX_ROOT'],
|
||||
['mistral-vibe', 'VIBE_HOME'],
|
||||
['zerostack', 'ZS_DATA_DIR'],
|
||||
['codebuff', 'CODEBUFF_DATA_DIR'],
|
||||
['goose', 'GOOSE_PATH_ROOT'],
|
||||
['crush', 'CRUSH_GLOBAL_DATA'],
|
||||
['codex', 'CODEX_HOME'],
|
||||
]
|
||||
const VARS = CASES.map(([, varName]) => varName)
|
||||
|
||||
// Save and restore every var we touch (beforeEach/afterEach), so a leaked
|
||||
// env var never breaks unrelated tests in the same worker — and an ambient
|
||||
// value never makes the "unset" case a lie.
|
||||
const saved = new Map<string, string | undefined>()
|
||||
|
||||
beforeEach(() => {
|
||||
for (const varName of VARS) {
|
||||
saved.set(varName, process.env[varName])
|
||||
delete process.env[varName]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const varName of VARS) {
|
||||
const original = saved.get(varName)
|
||||
if (original === undefined) delete process.env[varName]
|
||||
else process.env[varName] = original
|
||||
}
|
||||
})
|
||||
|
||||
for (const [provider, varName] of CASES) {
|
||||
it(`changes the ${provider} fingerprint when ${varName} is set`, () => {
|
||||
const unset = computeEnvFingerprint(provider)
|
||||
process.env[varName] = '/tmp/codeburn-920-override'
|
||||
const set = computeEnvFingerprint(provider)
|
||||
expect(set).not.toBe(unset)
|
||||
// Round trip: restoring the variable to its original state restores the
|
||||
// original fingerprint, so the hash is a pure function of the
|
||||
// environment.
|
||||
delete process.env[varName]
|
||||
expect(computeEnvFingerprint(provider)).toBe(unset)
|
||||
})
|
||||
}
|
||||
|
||||
it('changes the vercel-gateway fingerprint when AI_GATEWAY_API_KEY is set', () => {
|
||||
const prev = process.env['AI_GATEWAY_API_KEY']
|
||||
try {
|
||||
const unset = computeEnvFingerprint('vercel-gateway')
|
||||
process.env['AI_GATEWAY_API_KEY'] = 'sk-live-secret-abc'
|
||||
const set = computeEnvFingerprint('vercel-gateway')
|
||||
expect(set).not.toBe(unset)
|
||||
delete process.env['AI_GATEWAY_API_KEY']
|
||||
expect(computeEnvFingerprint('vercel-gateway')).toBe(unset)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['AI_GATEWAY_API_KEY']
|
||||
else process.env['AI_GATEWAY_API_KEY'] = prev
|
||||
}
|
||||
})
|
||||
|
||||
// Copilot is deliberately NOT declared in PROVIDER_ENV_VARS (Ruling 1 of
|
||||
// lane 04): its OTel discovery returns one source per DB file
|
||||
// ({ path: dbPath }, src/providers/copilot.ts:1935), and the durable
|
||||
// carry-forward in getOrCreateProviderSection (src/parser.ts:2650) drops
|
||||
// every cached entry whose source still exists on a fingerprint change — so
|
||||
// declaring any CODEBURN_COPILOT_* var would force a re-parse that destroys
|
||||
// conversations Copilot has since pruned from the DB, which only the cache
|
||||
// still holds. The fingerprint must therefore NOT move when one is set.
|
||||
// This reads as intent, not as an oversight — and the assertions below pin
|
||||
// the WHOLE invariant (no entry at all, plus every one of the nine deferred
|
||||
// reads), so a future "completing" edit fails a test instead of silently
|
||||
// re-opening the durable history-loss path.
|
||||
describe('copilot is deliberately undeclared in PROVIDER_ENV_VARS', () => {
|
||||
it('has no PROVIDER_ENV_VARS entry at all', () => {
|
||||
expect(PROVIDER_ENV_VARS['copilot']).toBeUndefined()
|
||||
})
|
||||
|
||||
// The nine reads copilot.ts performs whose declaration is deferred (each
|
||||
// is allowlisted in tests/provider-env-declarations.test.ts): setting any
|
||||
// of them must leave the copilot fingerprint untouched.
|
||||
const DEFERRED_COPILOT_VARS = [
|
||||
'CODEBURN_COPILOT_SESSION_STATE_DIR',
|
||||
'CODEBURN_COPILOT_OTEL_DB',
|
||||
'CODEBURN_COPILOT_JETBRAINS_DIR',
|
||||
'CODEBURN_COPILOT_WS_STORAGE_DIR',
|
||||
'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR',
|
||||
'CODEBURN_COPILOT_DISABLE_OTEL',
|
||||
'APPDATA',
|
||||
'LOCALAPPDATA',
|
||||
'XDG_CONFIG_HOME',
|
||||
]
|
||||
|
||||
for (const varName of DEFERRED_COPILOT_VARS) {
|
||||
it(`does not move the copilot fingerprint when ${varName} is set (deliberately undeclared)`, () => {
|
||||
const prev = process.env[varName]
|
||||
try {
|
||||
const before = computeEnvFingerprint('copilot')
|
||||
process.env[varName] = `/tmp/codeburn-copilot-920/${varName}`
|
||||
expect(computeEnvFingerprint('copilot')).toBe(before)
|
||||
delete process.env[varName]
|
||||
expect(computeEnvFingerprint('copilot')).toBe(before)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[varName]
|
||||
else process.env[varName] = prev
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── fingerprintFile ────────────────────────────────────────────────────
|
||||
|
||||
describe('fingerprintFile', () => {
|
||||
|
|
@ -337,6 +461,78 @@ describe('fingerprintFile', () => {
|
|||
expect(fp).not.toBeNull()
|
||||
expect(fp!.sizeBytes).toBe(9)
|
||||
})
|
||||
|
||||
// SQLite WAL mode parks committed writes in `<db>-wal`; the main file's
|
||||
// stat only moves on checkpoint, which a long-lived writer defers for
|
||||
// hours. A fingerprint from the main file alone reports data older than
|
||||
// what is really committed (issue #913). The WAL sibling must be folded in.
|
||||
it('folds -wal sibling into a # compound fingerprint (Hermes session)', async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true })
|
||||
const dbPath = join(TMP_DIR, 'state.db')
|
||||
await writeFile(dbPath, 'main-db')
|
||||
const past = new Date(Date.now() - 48 * 3600 * 1000)
|
||||
await utimes(dbPath, past, past)
|
||||
await writeFile(`${dbPath}-wal`, 'wal-frames')
|
||||
|
||||
const fp = await fingerprintFile(`${dbPath}#hermes-session=abc`)
|
||||
expect(fp).not.toBeNull()
|
||||
// mtime: the fresh WAL wins over the checkpoint-stale main file.
|
||||
expect(fp!.mtimeMs).toBeGreaterThan(past.getTime() + 3600 * 1000)
|
||||
// size: main + wal, so WAL growth alone changes the fingerprint.
|
||||
expect(fp!.sizeBytes).toBe('main-db'.length + 'wal-frames'.length)
|
||||
})
|
||||
|
||||
it('folds -wal sibling into a : compound fingerprint (OpenCode session)', async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true })
|
||||
const dbPath = join(TMP_DIR, 'opencode.db')
|
||||
await writeFile(dbPath, 'oc-db')
|
||||
const past = new Date(Date.now() - 48 * 3600 * 1000)
|
||||
await utimes(dbPath, past, past)
|
||||
await writeFile(`${dbPath}-wal`, 'oc-wal')
|
||||
|
||||
const fp = await fingerprintFile(`${dbPath}:ses_abc123`)
|
||||
expect(fp).not.toBeNull()
|
||||
expect(fp!.mtimeMs).toBeGreaterThan(past.getTime() + 3600 * 1000)
|
||||
expect(fp!.sizeBytes).toBe('oc-db'.length + 'oc-wal'.length)
|
||||
})
|
||||
|
||||
it('folds -wal sibling into a bare SQLite path (copilot agent-traces.db)', async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true })
|
||||
const dbPath = join(TMP_DIR, 'agent-traces.db')
|
||||
await writeFile(dbPath, 'traces')
|
||||
const past = new Date(Date.now() - 48 * 3600 * 1000)
|
||||
await utimes(dbPath, past, past)
|
||||
await writeFile(`${dbPath}-wal`, 'traces-wal')
|
||||
|
||||
const fp = await fingerprintFile(dbPath)
|
||||
expect(fp).not.toBeNull()
|
||||
expect(fp!.mtimeMs).toBeGreaterThan(past.getTime() + 3600 * 1000)
|
||||
expect(fp!.sizeBytes).toBe('traces'.length + 'traces-wal'.length)
|
||||
})
|
||||
|
||||
it('keeps compound fingerprints working when no -wal sibling exists', async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true })
|
||||
const dbPath = join(TMP_DIR, 'state.db')
|
||||
await writeFile(dbPath, 'main-only')
|
||||
|
||||
const fp = await fingerprintFile(`${dbPath}#hermes-session=abc`)
|
||||
expect(fp).not.toBeNull()
|
||||
expect(fp!.sizeBytes).toBe('main-only'.length)
|
||||
})
|
||||
|
||||
it('does not fold sibling files into non-SQLite fingerprints', async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true })
|
||||
const filePath = join(TMP_DIR, 'session.jsonl')
|
||||
await writeFile(filePath, 'jsonl-data')
|
||||
// A stray neighbor that happens to match the -wal naming must not leak
|
||||
// into a transcript fingerprint (offset-based append detection relies on
|
||||
// sizeBytes being the transcript's real byte length).
|
||||
await writeFile(`${filePath}-wal`, 'stray')
|
||||
|
||||
const fp = await fingerprintFile(filePath)
|
||||
expect(fp).not.toBeNull()
|
||||
expect(fp!.sizeBytes).toBe('jsonl-data'.length)
|
||||
})
|
||||
})
|
||||
|
||||
// ── reconcileFile ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
58
tests/sharing/malformed-request.test.ts
Normal file
58
tests/sharing/malformed-request.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { connect as tlsConnect } from 'tls'
|
||||
|
||||
import { generateIdentity, type Identity } from '../../src/sharing/identity.js'
|
||||
import { PeerStore } from '../../src/sharing/pairing.js'
|
||||
import { ShareServer } from '../../src/sharing/share-server.js'
|
||||
|
||||
// The share server listens on the LAN for device pairing and is dispatched via
|
||||
// `void this.handle(...)`, so a throw inside handle() is an UNHANDLED rejection.
|
||||
// A request target the HTTP parser accepts but the WHATWG URL parser rejects
|
||||
// (an unterminated IPv6 host) used to throw at `new URL(...)` before the
|
||||
// try/catch, which could crash the host process. The server must instead answer
|
||||
// and stay alive.
|
||||
describe('share server: malformed request URL does not crash the process', () => {
|
||||
let server: ShareServer
|
||||
let serverId: Identity
|
||||
let clientId: Identity
|
||||
let port: number
|
||||
|
||||
beforeAll(async () => {
|
||||
serverId = await generateIdentity('Server')
|
||||
clientId = await generateIdentity('Client')
|
||||
server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => ({ current: { cost: 1 } }) })
|
||||
port = await server.listen(0, '127.0.0.1')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
// Send one raw HTTP request line over mTLS and resolve with the response head.
|
||||
function rawRequest(line: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tlsConnect(
|
||||
{ host: '127.0.0.1', port, key: clientId.key, cert: clientId.cert, rejectUnauthorized: false },
|
||||
() => socket.write(`${line}\r\nHost: localhost\r\nConnection: close\r\n\r\n`),
|
||||
)
|
||||
let buf = ''
|
||||
socket.setTimeout(4000, () => { socket.destroy(); reject(new Error('timed out (server hung)')) })
|
||||
socket.on('data', (d) => { buf += d.toString() })
|
||||
socket.on('end', () => resolve(buf))
|
||||
socket.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
it('answers an unterminated-IPv6 target instead of hanging or crashing', async () => {
|
||||
// `new URL('//[::1', 'https://localhost')` throws TypeError; llhttp accepts
|
||||
// the target, so this exercises the exact pre-try throw path.
|
||||
const res = await rawRequest('GET //[::1 HTTP/1.1')
|
||||
expect(res).toMatch(/^HTTP\/1\.1 400/)
|
||||
})
|
||||
|
||||
it('is still alive for a valid request afterward', async () => {
|
||||
const res = await rawRequest('GET /api/peer/hello HTTP/1.1')
|
||||
expect(res).toMatch(/^HTTP\/1\.1 200/)
|
||||
expect(res).toContain(serverId.fingerprint)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue