mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-26 01:14:56 +00:00
sessions: attribute PR spend per turn instead of per session (#790)
* sessions: attribute PR spend per turn instead of per session The by-PR surfaces attributed a session's full cost to every PR it referenced, so one orchestration session that touched many PRs rendered identical full-session rows. Capture per-turn PR references during transcript parsing and attribute each turn's cost to the PR set active at that turn (split evenly across a multi-PR merge-sweep turn), carrying the most recent PR set forward across turns that reference nothing. Turns before the first reference form an unattributed bucket. A session whose transcript expired before per-turn capture keeps its session-level prLinks but has no per-turn refs; it falls back to an even whole-session split, and any row carrying such a portion is flagged approximate. Rows are now summable: the CLI and app footers report the attributed sum plus the unattributed remainder instead of a distinct-session total. Bumps the session cache version so surviving transcripts re-parse and populate the new per-turn field. Daily-cache versioning is untouched. * sessions: harden PR attribution and add models + category breakdown Addresses the review findings on the per-turn PR attribution and adds the model and task-category surfaces. Correctness: - Cache migration: the 5 -> 6 session-cache bump now adopts the prior v5 file's expired-source PR entries instead of abandoning them, and the claude scan preserves and surfaces PR-bearing orphans, so a session whose transcript was deleted still appears as a legacy even-split instead of vanishing. The daily cache is untouched. - Date-range carry: the parser captures the PR set active at the start of the in-range turn slice and seeds the state machine with it, so a PR referenced before the window still owns its later, in-range, ref-less turns instead of the session falling back to a whole-session approx split. - Calls are split across a multi-PR turn by largest-remainder, keeping per-PR counts whole (a 1-call, 2-PR turn no longer renders as 2 calls). - The CLI and app footers reconcile to the rounded row values actually shown. - Distinct sessions are keyed by project + sessionId, not sessionId alone. - The app tolerates an older by-reference payload (no attributedCost): it keeps the old non-summable footer and never renders NaN. Features: - Each PR contribution records the models of its calls and the turn's task category (split by the same share on a multi-PR turn); legacy even-split rows carry the model union but no category breakdown. - Payload rows gain models (short names, cost-desc) and categories (label + cost, cost-desc, omitted when empty); the payload gains otherPrCount/otherPrCost for the PRs beyond the sent top 20. - CLI --by-pr gains a Models column. The desktop table is now full-width with a Models column and click-to-expand rows showing a per-category cost breakdown with proportional bars, keyboard accessible, with an "Other (N more PRs)" row when capped. Tests: state-machine seed/models/categories/largest-remainder/dedup, the prRefs round-trip through the real incremental-append path (continuation + straddle), v5 adoption of an expired PR session, payload round-trip, and the desktop expansion/models/old-payload cases. * sessions: reconcile mixed PR rows, harden v5 adoption, bound models Round-2 review follow-ups. - Mixed legacy/live category breakdown: a PR row that combines an expired legacy contribution (even-split, no turn data) with a live per-turn contribution now emits an explicit "Legacy estimate (no per-turn detail)" category carrying the legacy share, so the expansion reconciles to the row cost instead of silently dropping it. A legacy-only row still shows no breakdown. - v5 adoption is now per cached file: one malformed entry is skipped instead of rejecting the whole v5 cache and dropping every valid expired PR session. - The desktop "Other (N more PRs)" line moved to the table footer as a muted, separated summary rather than a sorted row. - Row expansion resets when the PR set changes (period/provider/data), so a stale expansion cannot linger on a row that is gone. - Payload models per row are capped to the top 4 by attributed cost, with a name-ascending tie-break; categories get the same stable tie-break. Tests: mixed live+legacy reconciliation, legacy-only omission, model cap and tie-break, corrupt-plus-valid v5 adoption, and the desktop expansion-reset. * session-cache: validate optional agentType and failed during v5 adoption; app: reset PR expansion on period switch A v5 entry that was valid except for a malformed optional field (agentType, failed) passed per-file validation and flowed downstream as a non-string. The expansion reset keyed only on the row URL set, so a period switch that returned the same PRs kept a stale expansion open over changed numbers. --------- Co-authored-by: reviewer <review@local>
This commit is contained in:
parent
a9d4471d17
commit
45e93129f2
18 changed files with 1402 additions and 106 deletions
|
|
@ -220,11 +220,15 @@ export type MenubarPayload = {
|
|||
skills: Array<{ name: string; turns: number; cost: number }>
|
||||
subagents: Array<{ name: string; calls: number; cost: number }>
|
||||
mcpServers: Array<{ name: string; calls: number }>
|
||||
// Spend by referenced pull request (top 20 by cost) plus the multi-link-safe
|
||||
// distinct total. Optional: older CLIs omit it, and it is absent when no PR
|
||||
// links were observed. Rows are by-reference — a session referencing several
|
||||
// PRs counts toward each — so `rows[].cost` must never be summed; use
|
||||
// `distinctCost`/`distinctSessions` for a total.
|
||||
// Spend by referenced pull request (top 20 by cost), attributed at turn
|
||||
// granularity. Optional: older CLIs omit it, and it is absent when no PR links
|
||||
// were observed. Rows carry attributed cost/calls and ARE summable;
|
||||
// `attributedCost + unattributedCost === distinctCost`. `approx` marks a row
|
||||
// fed by the legacy whole-session even split (transcript expired). `models` is
|
||||
// the short model names that processed the PR (cost-desc); `categories` is the
|
||||
// per-task-category attributed cost (cost-desc), omitted for legacy rows.
|
||||
// `attributedCost`/`unattributedCost` are optional so a payload from an older
|
||||
// CLI (by-reference rows, not summable) still type-checks and can be detected.
|
||||
pullRequests?: {
|
||||
rows: Array<{
|
||||
url: string
|
||||
|
|
@ -235,9 +239,16 @@ export type MenubarPayload = {
|
|||
calls: number
|
||||
firstStarted: string
|
||||
lastEnded: string
|
||||
approx?: boolean
|
||||
models?: string[]
|
||||
categories?: Array<{ name: string; cost: number }>
|
||||
}>
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
attributedCost?: number
|
||||
unattributedCost?: number
|
||||
otherPrCount?: number
|
||||
otherPrCost?: number
|
||||
}
|
||||
}
|
||||
optimize: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// @vitest-environment jsdom
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
|
@ -46,11 +46,22 @@ function makePayload(pullRequests?: PrPayload): MenubarPayload {
|
|||
|
||||
const SAMPLE: PrPayload = {
|
||||
rows: [
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z' },
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z' },
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable', 'opus', 'haiku'], categories: [{ name: 'Feature work', cost: 180.25 }, { name: 'Debugging', cost: 60.25 }] },
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z', models: ['sonnet'], categories: [{ name: 'Refactoring', cost: 90.25 }] },
|
||||
],
|
||||
distinctCost: 300.75,
|
||||
distinctCost: 376.05,
|
||||
distinctSessions: 3,
|
||||
attributedCost: 330.75,
|
||||
unattributedCost: 45.3,
|
||||
otherPrCount: 0,
|
||||
otherPrCost: 0,
|
||||
}
|
||||
|
||||
// Get the button-role row wrapping a given PR link, for click/keyboard toggling.
|
||||
function rowForLink(link: HTMLElement): HTMLElement {
|
||||
const row = link.closest('[role="button"]')
|
||||
if (!row) throw new Error('expected a button-role row around the PR link')
|
||||
return row as HTMLElement
|
||||
}
|
||||
|
||||
describe('PullRequests', () => {
|
||||
|
|
@ -73,23 +84,182 @@ describe('PullRequests', () => {
|
|||
expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the PR URL externally instead of navigating', async () => {
|
||||
it('renders the Models column with a "+N" overflow tag', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
// Three models collapse to the first two plus a count of the rest.
|
||||
expect(await screen.findByText('fable, opus +1')).toBeInTheDocument()
|
||||
// A single model renders as-is, with no overflow tag.
|
||||
expect(screen.getByText('sonnet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the PR URL externally without navigating or toggling the row', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
await userEvent.click(link)
|
||||
expect(openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/pull/780')
|
||||
// Clicking the link must not expand its row.
|
||||
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Feature work')).toBeNull()
|
||||
})
|
||||
|
||||
it('states the distinct-total footer explaining by-reference attribution', async () => {
|
||||
it('expands a row to its category breakdown on click, then collapses', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
const row = rowForLink(link)
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await userEvent.click(row)
|
||||
expect(row).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Feature work')).toBeInTheDocument()
|
||||
expect(screen.getByText('$180.25')).toBeInTheDocument()
|
||||
expect(screen.getByText('Debugging')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(row)
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Feature work')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes an open expansion when the period changes the PR set', async () => {
|
||||
const changed: PrPayload = { ...SAMPLE, rows: [SAMPLE.rows[0]!] } // #781 dropped
|
||||
getOverview.mockImplementation((period: string) => Promise.resolve(makePayload(period === 'lifetime' ? SAMPLE : changed)))
|
||||
const { rerender } = render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
await userEvent.click(rowForLink(link))
|
||||
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
rerender(<PullRequests period="week" provider="all" />)
|
||||
// The new period drops #781, so the PR set changes and the stale expansion
|
||||
// resets once the new data lands (wait for the breakdown to disappear).
|
||||
await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull())
|
||||
const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('closes an open expansion on a period switch even when the PR set is identical', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
const { rerender } = render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
await userEvent.click(rowForLink(link))
|
||||
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
// Same rows come back for the new period; the expansion must still reset,
|
||||
// since the row's underlying numbers may differ across periods.
|
||||
rerender(<PullRequests period="week" provider="all" />)
|
||||
await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull())
|
||||
const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('toggles expansion from the keyboard with Enter', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
const row = rowForLink(link)
|
||||
row.focus()
|
||||
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(row).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Feature work')).toBeInTheDocument()
|
||||
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('states the attributed-total footer and the summable framing', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const note = await screen.findByText(/attributed to the rows above/)
|
||||
expect(note.textContent).toContain('$330.75')
|
||||
expect(note.textContent).toContain('3 PR-linked sessions')
|
||||
expect(note.textContent).toContain('summable')
|
||||
expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30')
|
||||
})
|
||||
|
||||
it('marks an approximate (legacy) row with a ~ prefix and a tooltip', async () => {
|
||||
const approxPayload: PrPayload = {
|
||||
rows: [
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true },
|
||||
],
|
||||
distinctCost: 12.5,
|
||||
distinctSessions: 1,
|
||||
attributedCost: 12.5,
|
||||
unattributedCost: 0,
|
||||
}
|
||||
getOverview.mockResolvedValue(makePayload(approxPayload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const cost = await screen.findByText('~$12.50')
|
||||
expect(cost).toHaveAttribute('title')
|
||||
// A zero unattributed remainder hides the muted line.
|
||||
expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a category-less (legacy) row to a muted note, not an empty box', async () => {
|
||||
const approxPayload: PrPayload = {
|
||||
rows: [
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true },
|
||||
],
|
||||
distinctCost: 12.5,
|
||||
distinctSessions: 1,
|
||||
attributedCost: 12.5,
|
||||
unattributedCost: 0,
|
||||
}
|
||||
getOverview.mockResolvedValue(makePayload(approxPayload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#900' })
|
||||
await userEvent.click(rowForLink(link))
|
||||
expect(screen.getByText(/No per-turn detail/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the old-CLI by-reference footer without NaN and never claims summable', async () => {
|
||||
const oldPayload: PrPayload = {
|
||||
rows: [
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/500', label: 'getagentseal/codeburn#500', cost: 120.4, savingsUSD: 0, sessions: 2, calls: 300, firstStarted: '2026-06-01T10:00:00Z', lastEnded: '2026-06-02T12:00:00Z' },
|
||||
],
|
||||
distinctCost: 120.4,
|
||||
distinctSessions: 2,
|
||||
}
|
||||
getOverview.mockResolvedValue(makePayload(oldPayload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const note = await screen.findByText(/produced pull requests/)
|
||||
expect(note.textContent).toContain('$300.75')
|
||||
expect(note.textContent).toContain('3 distinct sessions')
|
||||
expect(note.textContent).toContain('counts toward each')
|
||||
expect(note.textContent).toContain('$120.40')
|
||||
expect(note.textContent).toContain('by reference')
|
||||
expect(note.textContent).toContain('not summed')
|
||||
expect(note.textContent).not.toContain('summable')
|
||||
// No optional field renders as NaN and no unattributed line appears.
|
||||
expect(screen.queryByText(/NaN/)).toBeNull()
|
||||
expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an Other (N more PRs) reconciliation row when PRs are capped', async () => {
|
||||
const cappedPayload: PrPayload = {
|
||||
rows: [
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 200, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable'], categories: [{ name: 'Feature work', cost: 200 }] },
|
||||
],
|
||||
distinctCost: 288.4,
|
||||
distinctSessions: 4,
|
||||
attributedCost: 288.4,
|
||||
unattributedCost: 0,
|
||||
otherPrCount: 5,
|
||||
otherPrCost: 88.4,
|
||||
}
|
||||
getOverview.mockResolvedValue(makePayload(cappedPayload))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Other (5 more PRs)')).toBeInTheDocument()
|
||||
expect(screen.getByText('$88.40')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
|
||||
|
|
@ -101,7 +271,7 @@ describe('PullRequests', () => {
|
|||
})
|
||||
|
||||
it('shows the empty state when the PR array is present but empty', async () => {
|
||||
getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0 }))
|
||||
getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0, attributedCost: 0, unattributedCost: 0 }))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { MouseEvent } from 'react'
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { CliErrorPanel } from '../components/CliErrorPanel'
|
||||
import { EmptyNote } from '../components/EmptyState'
|
||||
|
|
@ -22,11 +23,32 @@ function spanLabel(firstStarted: string, lastEnded: string): string {
|
|||
return start === end ? start : `${start} - ${end}`
|
||||
}
|
||||
|
||||
function sessionWord(n: number): string {
|
||||
return n === 1 ? 'session' : 'sessions'
|
||||
}
|
||||
|
||||
// Up to two short model names, then a "+N" overflow tag; empty for no models.
|
||||
function modelsLabel(models: string[]): string {
|
||||
if (models.length <= 2) return models.join(', ')
|
||||
return `${models.slice(0, 2).join(', ')} +${models.length - 2}`
|
||||
}
|
||||
|
||||
function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void codeburn.openExternal(url)
|
||||
}
|
||||
|
||||
// Keyboard activation for the button-role row, guarded so Enter/Space fired on
|
||||
// the inner link (its own control) never doubles up as a row toggle.
|
||||
function rowKeyDown(event: KeyboardEvent<HTMLTableRowElement>, toggle: () => void): void {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Standalone entry: self-fetches the overview payload (used in tests). The App
|
||||
* passes its shared overview poll straight into PullRequestsContent instead. */
|
||||
export function PullRequests({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) {
|
||||
|
|
@ -34,7 +56,9 @@ export function PullRequests({ period, provider, range = null }: { period: Perio
|
|||
() => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider),
|
||||
[period, provider, range?.from, range?.to],
|
||||
)
|
||||
return <PullRequestsContent overview={overview} />
|
||||
// The key remounts the content on a period/provider/range switch so row state
|
||||
// (an open expansion) never survives onto the same PR rendered from new data.
|
||||
return <PullRequestsContent key={`${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`} overview={overview} />
|
||||
}
|
||||
|
||||
export function PullRequestsContent({ overview }: { overview: Polled<MenubarPayload> }) {
|
||||
|
|
@ -59,44 +83,136 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
|
|||
}
|
||||
|
||||
function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
|
||||
const { rows, distinctCost, distinctSessions } = pullRequests
|
||||
const sessionWord = distinctSessions === 1 ? 'session' : 'sessions'
|
||||
const { rows, distinctCost, distinctSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests
|
||||
const [expandedUrl, setExpandedUrl] = useState<string | null>(null)
|
||||
// Reset any open expansion when the PR set changes (a period/provider switch or
|
||||
// a refresh that alters the list): a stale expandedUrl would otherwise linger
|
||||
// pointing at a row that is no longer present.
|
||||
const rowKey = rows.map(row => row.url).join('|')
|
||||
useEffect(() => { setExpandedUrl(null) }, [rowKey])
|
||||
|
||||
// A new-attribution payload carries `attributedCost`; an older by-reference
|
||||
// payload omits it, so the rows are not summable and the footer must differ.
|
||||
const summable = attributedCost !== undefined
|
||||
const unattributed = unattributedCost ?? 0
|
||||
const otherCount = otherPrCount ?? 0
|
||||
const otherCost = otherPrCost ?? 0
|
||||
// Reconcile to the visible numbers: sum the rounded row costs (plus any
|
||||
// capped-away remainder) so the footer total equals what the eye adds up.
|
||||
const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0) + otherCost
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ov-model-scroll">
|
||||
<div className="pr-scroll">
|
||||
<table className="ov-models pr-table" aria-label="Spend by pull request">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pull request</th>
|
||||
<th className="pr-models">Models</th>
|
||||
<th className="num">Cost</th>
|
||||
<th className="num">Sessions</th>
|
||||
<th className="num">Calls</th>
|
||||
<th className="num">Active</th>
|
||||
<th className="pr-chevron-cell" aria-hidden="true"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(pr => <PrRowView key={pr.url} pr={pr} />)}
|
||||
{rows.map(pr => (
|
||||
<PrRowView
|
||||
key={pr.url}
|
||||
pr={pr}
|
||||
expanded={expandedUrl === pr.url}
|
||||
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
{otherCount > 0 && (
|
||||
// A muted summary line, kept out of the sorted rows: its cost is an
|
||||
// aggregate of the capped-away PRs and can exceed a visible row.
|
||||
<tfoot>
|
||||
<tr className="pr-other-row">
|
||||
<td className="pr-other-label">Other ({otherCount.toLocaleString('en-US')} more PRs)</td>
|
||||
<td className="pr-models"></td>
|
||||
<td className="num mono">{formatUsd(otherCost)}</td>
|
||||
<td className="num"></td>
|
||||
<td className="num"></td>
|
||||
<td className="num pr-span"></td>
|
||||
<td className="pr-chevron-cell"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
<p className="pr-footnote">
|
||||
{formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord} produced pull requests.
|
||||
{' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed.
|
||||
</p>
|
||||
{summable ? (
|
||||
<p className="pr-footnote">
|
||||
{formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)}.
|
||||
{' '}Each turn's cost goes to the PR it was working on, so the rows are summable.
|
||||
</p>
|
||||
) : (
|
||||
<p className="pr-footnote">
|
||||
{formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord(distinctSessions)} produced pull requests.
|
||||
{' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed.
|
||||
</p>
|
||||
)}
|
||||
{unattributed > 0 && (
|
||||
<p className="pr-unattributed">Not tied to a specific PR: {formatUsd(unattributed)}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PrRowView({ pr }: { pr: PrRow }) {
|
||||
const APPROX_TITLE = 'Approximate: the transcript expired before per-turn capture, so this PR’s share is an even split of the whole session.'
|
||||
|
||||
function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; onToggle: () => void }) {
|
||||
const models = pr.models ?? []
|
||||
const categories = pr.categories ?? []
|
||||
const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="ov-model-name">
|
||||
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
|
||||
</td>
|
||||
<td className="num mono">{formatUsd(pr.cost)}</td>
|
||||
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
|
||||
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
|
||||
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
|
||||
</tr>
|
||||
<>
|
||||
<tr
|
||||
className="pr-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
onKeyDown={event => rowKeyDown(event, onToggle)}
|
||||
>
|
||||
<td className="ov-model-name">
|
||||
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
|
||||
</td>
|
||||
<td className="pr-models">{models.length ? modelsLabel(models) : ''}</td>
|
||||
<td className="num mono" {...(pr.approx ? { title: APPROX_TITLE } : {})}>
|
||||
{pr.approx ? '~' : ''}{formatUsd(pr.cost)}
|
||||
</td>
|
||||
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
|
||||
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
|
||||
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
|
||||
<td className="pr-chevron-cell"><span className="pr-chevron" aria-hidden="true">›</span></td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="pr-detail-row">
|
||||
<td className="pr-detail-cell" colSpan={7}>
|
||||
{categories.length > 0 ? (
|
||||
<div className="pr-cats" role="region" aria-label={`${pr.label} cost breakdown`}>
|
||||
{categories.map(cat => (
|
||||
<div className="pr-cat" key={cat.name}>
|
||||
<div className="pr-cat-bar" aria-hidden="true">
|
||||
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
|
||||
</div>
|
||||
<div className="pr-cat-main">
|
||||
<span className="pr-cat-name">{cat.name}</span>
|
||||
<strong>{formatUsd(cat.cost)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="pr-cat-empty">No per-turn detail (estimated from a whole-session split).</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -580,10 +580,31 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
|
|||
.ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; }
|
||||
.ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; }
|
||||
.ov-models td.mono { font-family: var(--mono); color: var(--ink); }
|
||||
.pr-scroll { overflow-x: auto; }
|
||||
.pr-table { min-width: 640px; }
|
||||
.pr-link { color: var(--accent-text); font-weight: var(--fw-medium); text-decoration: none; cursor: pointer; }
|
||||
.pr-link:hover { text-decoration: underline; }
|
||||
.pr-table th.pr-models, .pr-table td.pr-models { overflow: hidden; max-width: 180px; color: var(--mut); text-align: left; text-overflow: ellipsis; }
|
||||
.pr-table td.pr-span { color: var(--mut2); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.pr-table th.pr-chevron-cell, .pr-table td.pr-chevron-cell { width: 26px; padding: 0 8px; text-align: center; }
|
||||
.pr-chevron { display: inline-block; color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease; }
|
||||
.pr-row { cursor: pointer; }
|
||||
.pr-row:hover { background: var(--hover); }
|
||||
.pr-row:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.pr-row[aria-expanded="true"] .pr-chevron { transform: rotate(90deg); }
|
||||
.pr-table td.pr-detail-cell { height: auto; padding: 11px 14px; background: color-mix(in srgb, var(--panel) 84%, var(--hover)); text-align: left; white-space: normal; }
|
||||
.pr-cats { display: flex; flex-direction: column; gap: 9px; max-width: 520px; }
|
||||
.pr-cat { display: flex; flex-direction: column; gap: 5px; }
|
||||
.pr-cat-bar { height: 3px; overflow: hidden; border-radius: 2px; background: var(--fill); }
|
||||
.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
|
||||
.pr-cat-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; }
|
||||
.pr-cat-name { overflow: hidden; color: var(--ink); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pr-cat-main strong { flex: 0 0 auto; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); }
|
||||
.pr-table tfoot .pr-other-row td { border-top: 1px solid var(--line); color: var(--mut2); font-style: italic; }
|
||||
.pr-table td.pr-other-label { color: var(--mut2); font-weight: var(--fw-medium); font-style: italic; text-align: left; }
|
||||
.pr-footnote { margin: 12px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; }
|
||||
.pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
|
||||
.opt-waste { min-width: 0; }
|
||||
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.opt-findings { display: grid; min-width: 0; }
|
||||
|
|
|
|||
16
src/main.ts
16
src/main.ts
|
|
@ -2037,8 +2037,10 @@ program
|
|||
process.stdout.write('No sessions with captured PR links in this period. Links are captured as sessions are parsed; older transcripts gain them on their next re-parse.\n')
|
||||
return
|
||||
}
|
||||
const { cost, sessions } = prLinkedTotals(projects)
|
||||
const { unattributedCost, sessions } = prLinkedTotals(projects)
|
||||
const { renderTable: renderTextTable } = await import('./text-table.js')
|
||||
const modelsCell = (models: string[]): string =>
|
||||
models.length === 0 ? '' : models.slice(0, 2).join(', ') + (models.length > 2 ? ` +${models.length - 2}` : '')
|
||||
const table = renderTextTable(
|
||||
[
|
||||
{ header: 'PR' },
|
||||
|
|
@ -2046,20 +2048,28 @@ program
|
|||
{ header: 'Saved', right: true },
|
||||
{ header: 'Sessions', right: true },
|
||||
{ header: 'Calls', right: true },
|
||||
{ header: 'Models' },
|
||||
{ header: 'First' },
|
||||
{ header: 'Last' },
|
||||
],
|
||||
prRows.map(r => [
|
||||
r.label,
|
||||
`$${r.cost.toFixed(2)}`,
|
||||
`${r.approx ? '~' : ''}$${r.cost.toFixed(2)}`,
|
||||
`$${r.savingsUSD.toFixed(2)}`,
|
||||
String(r.sessions),
|
||||
String(r.calls),
|
||||
modelsCell(r.models),
|
||||
r.firstStarted.slice(0, 10),
|
||||
r.lastEnded.slice(0, 10),
|
||||
]),
|
||||
)
|
||||
process.stdout.write(table + `\nDistinct PR-linked spend: $${cost.toFixed(2)} across ${sessions} session${sessions === 1 ? '' : 's'}. A session referencing several PRs counts toward each, so rows exceed this total when links overlap.\n`)
|
||||
// Footer reconciles to the ROUNDED row values actually printed (not the
|
||||
// exact float sum), so the visible column adds up to the stated total.
|
||||
const shownAttributed = prRows.reduce((sum, r) => sum + Number(r.cost.toFixed(2)), 0)
|
||||
const approxNote = prRows.some(r => r.approx)
|
||||
? ' ~ marks rows estimated from a whole-session even split (transcript expired before per-turn capture).'
|
||||
: ''
|
||||
process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`)
|
||||
return
|
||||
}
|
||||
const rows = aggregateSessions(projects)
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ export type PeriodData = {
|
|||
/// Share (0-1) of cost-bearing calls that resolved a price.
|
||||
pricingCoverage?: number
|
||||
/// Spend attributed by referenced pull request (from Claude session
|
||||
/// transcripts). Rows are by-reference — a session referencing several PRs
|
||||
/// counts toward EACH — so never sum them; `distinctCost`/`distinctSessions`
|
||||
/// are the multi-link-safe total. Absent when no PR links were observed.
|
||||
/// transcripts), at turn granularity. Rows carry attributed cost/calls and ARE
|
||||
/// summable; `attributedCost`/`unattributedCost` split the PR-linked spend.
|
||||
/// Absent when no PR links were observed.
|
||||
pullRequests?: PullRequestsPayload
|
||||
/// Per-branch spend, last-seen branch carried forward across each session's
|
||||
/// turns. A `null` branch is unbranched spend inside a branch-bearing session.
|
||||
|
|
@ -57,11 +57,21 @@ export type PeriodData = {
|
|||
export type PullRequestsPayload = {
|
||||
/// Per-PR rows, cost-descending, capped at the top 20 by the producer.
|
||||
rows: PrRow[]
|
||||
/// Distinct-session spend across every PR-linked session — the figure safe to
|
||||
/// present as a total, since the per-PR rows double-count sessions that
|
||||
/// reference more than one PR.
|
||||
/// Distinct-session spend across every PR-linked session. Equals
|
||||
/// `attributedCost + unattributedCost`; kept for backward compatibility.
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
/// Sum of EVERY PR's attributed cost (all rows, not just the sent top 20).
|
||||
attributedCost: number
|
||||
/// PR-linked spend not tied to any specific PR (pre-reference session
|
||||
/// overhead). `attributedCost + unattributedCost === distinctCost`.
|
||||
unattributedCost: number
|
||||
/// Count of PRs beyond the sent `rows` (0 when nothing was capped). The app
|
||||
/// renders an "Other (N more PRs)" summary row so the visible table still
|
||||
/// reconciles to `attributedCost`.
|
||||
otherPrCount: number
|
||||
/// Attributed cost of those capped-away PRs (0 when nothing was capped).
|
||||
otherPrCost: number
|
||||
}
|
||||
|
||||
export type ProviderCost = {
|
||||
|
|
|
|||
|
|
@ -912,6 +912,12 @@ export function compactEntry(raw: JournalEntry): JournalEntry {
|
|||
if (raw.cwd !== undefined) entry.cwd = raw.cwd
|
||||
// Preserved so groupIntoTurns can stamp each turn's git branch (rich capture).
|
||||
if (typeof raw.gitBranch === 'string' && raw.gitBranch) entry.gitBranch = raw.gitBranch
|
||||
// Preserved so groupIntoTurns can attribute each PR reference to its turn.
|
||||
// Only `pr-link` entries carry `prUrl`; every other field of theirs is dropped.
|
||||
if (raw.type === 'pr-link') {
|
||||
const prUrl = (raw as Record<string, unknown>)['prUrl']
|
||||
if (typeof prUrl === 'string' && prUrl) (entry as Record<string, unknown>)['prUrl'] = prUrl
|
||||
}
|
||||
|
||||
const att = (raw as Record<string, unknown>)['attachment']
|
||||
if (att && typeof att === 'object') {
|
||||
|
|
@ -1440,6 +1446,10 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
// from the user entry (gitBranch is on every user/assistant entry); a
|
||||
// continuation turn with no leading user text falls back to its first call.
|
||||
let currentBranch: string | undefined
|
||||
// GitHub PR URLs referenced within the turn currently being accumulated. A
|
||||
// `pr-link` entry is emitted after the assistant creates/references a PR, so it
|
||||
// lands inside the same turn (before the next user message) and attaches here.
|
||||
let currentPrRefs: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryBranch = typeof entry.gitBranch === 'string' && entry.gitBranch ? entry.gitBranch : undefined
|
||||
|
|
@ -1453,6 +1463,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
timestamp: currentTimestamp,
|
||||
sessionId: currentSessionId,
|
||||
...(currentBranch ? { gitBranch: currentBranch } : {}),
|
||||
...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}),
|
||||
})
|
||||
}
|
||||
currentUserMessage = text
|
||||
|
|
@ -1460,6 +1471,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
currentTimestamp = entry.timestamp ?? ''
|
||||
currentSessionId = entry.sessionId ?? ''
|
||||
currentBranch = entryBranch
|
||||
currentPrRefs = []
|
||||
}
|
||||
} else if (entry.type === 'assistant') {
|
||||
if (entryBranch && !currentBranch) currentBranch = entryBranch
|
||||
|
|
@ -1469,6 +1481,9 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
const call = parseApiCall(entry, toolResultMeta)
|
||||
if (call) currentCalls.push(call)
|
||||
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
|
||||
} else if (entry.type === 'pr-link') {
|
||||
const url = (entry as Record<string, unknown>)['prUrl']
|
||||
if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1479,6 +1494,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
timestamp: currentTimestamp,
|
||||
sessionId: currentSessionId,
|
||||
...(currentBranch ? { gitBranch: currentBranch } : {}),
|
||||
...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1841,14 +1857,19 @@ async function scanProjectDirs(
|
|||
}
|
||||
discoverProgress.finish()
|
||||
|
||||
if (readOnly) {
|
||||
for (const [filePath, cached] of Object.entries(section.files)) {
|
||||
if (allDiscoveredFiles.has(filePath)) continue
|
||||
const dirName = cached.canonicalProjectName
|
||||
?? cached.turns[0]?.calls[0]?.project
|
||||
?? basename(dirname(filePath))
|
||||
unchangedFiles.push({ filePath, dirName, cached })
|
||||
}
|
||||
// Orphans: cached sessions whose source file is no longer discovered. In
|
||||
// read-only mode surface them all (the snapshot is authoritative, nothing is
|
||||
// being pruned). In write mode surface only PR-bearing orphans: their transcript
|
||||
// is gone and can never re-parse, but they carry attributable PR spend the by-PR
|
||||
// report must keep (as a legacy even-split); the eviction below preserves the
|
||||
// same set so `section.files` still holds them when summaries are built.
|
||||
for (const [filePath, cached] of Object.entries(section.files)) {
|
||||
if (allDiscoveredFiles.has(filePath)) continue
|
||||
if (!readOnly && !cached.prLinks?.length) continue
|
||||
const dirName = cached.canonicalProjectName
|
||||
?? cached.turns[0]?.calls[0]?.project
|
||||
?? basename(dirname(filePath))
|
||||
unchangedFiles.push({ filePath, dirName, cached })
|
||||
}
|
||||
|
||||
// Pre-seed dedup set from cached (unchanged) files
|
||||
|
|
@ -1907,6 +1928,10 @@ async function scanProjectDirs(
|
|||
if (!newTurns[0]!.userMessage.trim() && mergedTurns.length > 0) {
|
||||
const last = mergedTurns[mergedTurns.length - 1]!
|
||||
last.calls = mergeBoundaryCalls(last.calls, newTurns[0]!.calls)
|
||||
// A PR referenced in the appended continuation belongs to this same
|
||||
// turn: union its refs in so the shortcut matches a full re-parse.
|
||||
const refs = Array.from(new Set([...(last.prRefs ?? []), ...(newTurns[0]!.prRefs ?? [])])).sort()
|
||||
if (refs.length > 0) last.prRefs = refs
|
||||
startIdx = 1
|
||||
}
|
||||
for (let i = startIdx; i < newTurns.length; i++) mergedTurns.push(newTurns[i]!)
|
||||
|
|
@ -2008,10 +2033,12 @@ async function scanProjectDirs(
|
|||
|
||||
if (!readOnly && dirs.length > 0) {
|
||||
for (const cachedPath of Object.keys(section.files)) {
|
||||
if (!allDiscoveredFiles.has(cachedPath)) {
|
||||
delete section.files[cachedPath]
|
||||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
}
|
||||
if (allDiscoveredFiles.has(cachedPath)) continue
|
||||
// Keep PR-bearing orphans: their transcript is gone and can never re-parse,
|
||||
// but they carry attributable PR spend (surfaced above as a legacy split).
|
||||
if (section.files[cachedPath]?.prLinks?.length) continue
|
||||
delete section.files[cachedPath]
|
||||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2031,8 +2058,23 @@ async function scanProjectDirs(
|
|||
// full ordered turn list) means a later date slice can drop the anchor turn
|
||||
// without the surviving turns losing their branch.
|
||||
let carriedBranch: string | undefined
|
||||
// The PR set active going into the report range: carried across the FULL turn
|
||||
// list, frozen the moment the first in-range turn is reached. Lets per-turn PR
|
||||
// attribution seed from a reference made before the window (see
|
||||
// attributeSessionPrSpend); the branch carry above solves the same problem.
|
||||
let carriedPrRefs: string[] | undefined
|
||||
let prRefsAtRangeStart: string[] | undefined
|
||||
let frozePrRefs = !dateRange
|
||||
let classifiedTurns = cachedFile.turns.map(turn => {
|
||||
if (turn.gitBranch) carriedBranch = turn.gitBranch
|
||||
if (dateRange && !frozePrRefs) {
|
||||
const firstTs = turn.calls[0]?.timestamp
|
||||
if (firstTs && new Date(firstTs) >= dateRange.start) {
|
||||
prRefsAtRangeStart = carriedPrRefs
|
||||
frozePrRefs = true
|
||||
}
|
||||
}
|
||||
if (turn.prRefs?.length) carriedPrRefs = turn.prRefs
|
||||
return cachedTurnToClassified(turn, carriedBranch)
|
||||
})
|
||||
// Captured from the FULL turn list, before the date slice below can drop the
|
||||
|
|
@ -2060,6 +2102,7 @@ async function scanProjectDirs(
|
|||
session.agentType = cachedFile.agentType
|
||||
if (everHadBranch) session.everHadBranch = true
|
||||
if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort()
|
||||
if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
|
||||
if (cachedFile.title) session.title = cachedFile.title
|
||||
|
||||
if (session.apiCalls > 0) {
|
||||
|
|
@ -2235,6 +2278,9 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
|
|||
sessionId: turn.sessionId,
|
||||
userMessage: turn.userMessage.slice(0, 2000),
|
||||
calls: turn.assistantCalls.map(apiCallToCachedCall),
|
||||
// Stored per-turn directly (already sorted/deduped in groupIntoTurns), unlike
|
||||
// gitBranch's change-detection dedup, so each turn's refs are self-contained.
|
||||
...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2344,6 +2390,7 @@ function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): Clas
|
|||
timestamp: turn.timestamp,
|
||||
sessionId: turn.sessionId,
|
||||
...(branch ? { gitBranch: branch } : {}),
|
||||
...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}),
|
||||
}
|
||||
return classifyTurn(parsed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ export type CachedTurn = {
|
|||
// previous turn's branch (a report carries the last stored value forward).
|
||||
// Rich-session-capture; optional, Claude only.
|
||||
gitBranch?: string
|
||||
// Claude: GitHub PR URLs referenced during this turn, sorted and deduplicated.
|
||||
// Stored per-turn directly (unlike gitBranch, no change-detection), so a turn's
|
||||
// own refs are self-contained. Drives turn-level PR spend attribution. Optional.
|
||||
prRefs?: string[]
|
||||
}
|
||||
|
||||
export type FileFingerprint = {
|
||||
|
|
@ -123,7 +127,10 @@ export type SessionCache = {
|
|||
// Cached kiro entries from v4 carry costUSD: undefined and would keep being
|
||||
// re-priced from estimated tokens forever, since historical session files
|
||||
// never change. Bump forces a one-time re-parse so metered credit costs land.
|
||||
export const CACHE_VERSION = 5
|
||||
// v6: per-turn `prRefs` capture for turn-level PR spend attribution. Existing
|
||||
// cache turns carry no prRefs; bumping forces a one-time re-parse so surviving
|
||||
// transcripts populate the field. (Daily-cache versioning is untouched.)
|
||||
export const CACHE_VERSION = 6
|
||||
|
||||
// The cache filename is version-suffixed so different binaries (e.g. an old
|
||||
// launchd menubar on a prior release and a newer desktop app) each own a
|
||||
|
|
@ -321,6 +328,7 @@ function validateTurn(t: unknown): t is CachedTurn {
|
|||
&& typeof o['sessionId'] === 'string'
|
||||
&& typeof o['userMessage'] === 'string'
|
||||
&& isOptionalString(o['gitBranch'])
|
||||
&& (o['prRefs'] === undefined || isStringArray(o['prRefs']))
|
||||
&& Array.isArray(o['calls'])
|
||||
&& (o['calls'] as unknown[]).every(validateCall)
|
||||
}
|
||||
|
|
@ -336,6 +344,8 @@ function validateCachedFile(f: unknown): f is CachedFile {
|
|||
&& isOptionalString(o['title'])
|
||||
&& (o['prLinks'] === undefined || isStringArray(o['prLinks']))
|
||||
&& isOptionalBool(o['isSidechain'])
|
||||
&& isOptionalString(o['agentType'])
|
||||
&& isOptionalBool(o['failed'])
|
||||
&& Array.isArray(o['turns'])
|
||||
&& (o['turns'] as unknown[]).every(validateTurn)
|
||||
}
|
||||
|
|
@ -356,21 +366,83 @@ function validateCache(raw: unknown): raw is SessionCache {
|
|||
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
|
||||
}
|
||||
|
||||
// The immediately-prior versioned file. On the 5 -> 6 bump we adopt its
|
||||
// still-relevant entries (see adoptV5Cache) rather than abandoning them; the
|
||||
// file itself is never written or deleted (old binaries still own it).
|
||||
const V5_CACHE_FILE = 'session-cache.v5.json'
|
||||
|
||||
// Lightweight top-level check: a version-5 cache with a providers object. The
|
||||
// individual files are validated per-entry in adoptV5Cache so one corrupt entry
|
||||
// cannot drop every valid expired-transcript PR session along with it.
|
||||
function isV5CacheEnvelope(raw: unknown): raw is { version: number; providers: Record<string, unknown> } {
|
||||
if (!raw || typeof raw !== 'object') return false
|
||||
const o = raw as Record<string, unknown>
|
||||
return o['version'] === 5
|
||||
&& !!o['providers'] && typeof o['providers'] === 'object' && !Array.isArray(o['providers'])
|
||||
}
|
||||
|
||||
// One-time migration for the 5 -> 6 bump (per-turn prRefs capture). A fresh v6
|
||||
// cache would abandon v5 wholesale, so any PR-linked session whose transcript was
|
||||
// since deleted would vanish instead of taking the by-PR legacy even-split path.
|
||||
// Carry forward exactly the v5 entries whose source no longer exists AND that
|
||||
// carry prLinks (they can never re-parse, but they hold attributable PR spend);
|
||||
// present sources are intentionally dropped so they re-parse fresh under v6 and
|
||||
// gain per-turn refs. Each file is validated individually, so a single corrupt
|
||||
// entry is skipped rather than discarding the whole cache. Each carried section
|
||||
// takes the CURRENT envFingerprint so the scan reuses it and appends the
|
||||
// freshly-parsed present sources. The daily cache (durable cost history) is not
|
||||
// touched.
|
||||
async function adoptV5Cache(): Promise<SessionCache | null> {
|
||||
try {
|
||||
const raw = await readFile(join(getCacheDir(), V5_CACHE_FILE), 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!isV5CacheEnvelope(parsed)) return null
|
||||
const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false }
|
||||
for (const [provider, section] of Object.entries(parsed.providers)) {
|
||||
if (!section || typeof section !== 'object') continue
|
||||
const rawFiles = (section as Record<string, unknown>)['files']
|
||||
const files: Record<string, CachedFile> = {}
|
||||
if (rawFiles && typeof rawFiles === 'object' && !Array.isArray(rawFiles)) {
|
||||
for (const [path, file] of Object.entries(rawFiles as Record<string, unknown>)) {
|
||||
if (!validateCachedFile(file)) continue
|
||||
if (!existsSync(path) && file.prLinks?.length) files[path] = file
|
||||
}
|
||||
}
|
||||
migrated.providers[provider] = {
|
||||
envFingerprint: computeEnvFingerprint(provider),
|
||||
files,
|
||||
...((section as Record<string, unknown>)['durable'] ? { durable: true } : {}),
|
||||
}
|
||||
}
|
||||
return migrated
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCache(): Promise<SessionCache> {
|
||||
try {
|
||||
const raw = await readFile(getCachePath(), 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!validateCache(parsed)) return emptyCache()
|
||||
if (!validateCache(parsed)) return afterMissingVersionedCache()
|
||||
return parsed
|
||||
} catch {
|
||||
// Versioned file absent/unreadable: try a one-time adoption of the legacy
|
||||
// unversioned file. validateCache requires version === CACHE_VERSION, so a
|
||||
// different-version legacy file is ignored (left intact). We copy it into the
|
||||
// versioned file once via saveCache; the legacy file is never modified.
|
||||
return adoptLegacyCache()
|
||||
return afterMissingVersionedCache()
|
||||
}
|
||||
}
|
||||
|
||||
// The versioned (v6) file is absent/unreadable. Prefer adopting the prior v5
|
||||
// file's expired-source PR orphans; failing that, fall back to the legacy
|
||||
// unversioned file. Either way the versioned file is minted on the next save.
|
||||
async function afterMissingVersionedCache(): Promise<SessionCache> {
|
||||
const v5 = await adoptV5Cache()
|
||||
if (v5) return v5
|
||||
// validateCache requires version === CACHE_VERSION, so a different-version
|
||||
// legacy file is ignored (left intact). We copy it into the versioned file once
|
||||
// via saveCache; the legacy file is never modified.
|
||||
return adoptLegacyCache()
|
||||
}
|
||||
|
||||
async function adoptLegacyCache(): Promise<SessionCache> {
|
||||
try {
|
||||
const raw = await readFile(getLegacyCachePath(), 'utf-8')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { ProjectSummary, SessionSummary } from './types.js'
|
||||
import { getShortModelName } from './models.js'
|
||||
import { CATEGORY_LABELS } from './types.js'
|
||||
import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js'
|
||||
|
||||
export type SessionRow = {
|
||||
sessionId: string
|
||||
|
|
@ -95,6 +97,17 @@ export type PrRow = {
|
|||
calls: number
|
||||
firstStarted: string
|
||||
lastEnded: string
|
||||
/// True when any contributing session used the legacy even-split fallback
|
||||
/// (session-level prLinks but no surviving per-turn refs), so this row's share
|
||||
/// is an approximation rather than genuine turn-level attribution.
|
||||
approx: boolean
|
||||
/// Short model names that processed this PR's attributed calls, ordered by
|
||||
/// attributed cost descending, deduplicated.
|
||||
models: string[]
|
||||
/// Attributed cost per task category (from the turns' classification), ordered
|
||||
/// by cost descending. Omitted for legacy approx rows: with no turn-level
|
||||
/// attribution there is no honest per-category split.
|
||||
categories?: Array<{ name: string; cost: number }>
|
||||
}
|
||||
|
||||
const GITHUB_PR_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
|
||||
|
|
@ -104,47 +117,231 @@ export function shortenPrUrl(url: string): string {
|
|||
return m ? `${m[1]}/${m[2]}#${m[3]}` : url
|
||||
}
|
||||
|
||||
/// Spend attributed to each pull request a session's transcript referenced.
|
||||
/// A session that mentions two PRs counts fully toward BOTH rows: attribution
|
||||
/// is by reference, not a split, so rows must never be summed into a grand
|
||||
/// total (the caller reports distinct-session spend separately).
|
||||
/// One PR's slice of a session's spend. `models`/`categories` map a key (raw
|
||||
/// model name / task category) to the attributed cost carried under it.
|
||||
export type PrContribution = {
|
||||
cost: number; calls: number; savingsUSD: number; approx: boolean
|
||||
models: Map<string, number>
|
||||
categories: Map<string, number>
|
||||
}
|
||||
|
||||
/// A single session's PR-attributed spend: `perUrl` is the turn-level split
|
||||
/// across the PRs it referenced; `unattributed` is the spend that belongs to no
|
||||
/// specific PR (turns before the session's first PR reference).
|
||||
export type SessionPrAttribution = {
|
||||
perUrl: Map<string, PrContribution>
|
||||
unattributed: { cost: number; calls: number; savingsUSD: number }
|
||||
}
|
||||
|
||||
// Minimal structural shape a SessionSummary satisfies, so the state machine is
|
||||
// unit-testable without constructing a full session fixture.
|
||||
type AttributableSession = {
|
||||
turns: Array<{ prRefs?: string[]; category?: string; assistantCalls: Array<{ costUSD: number; savingsUSD?: number; model?: string }> }>
|
||||
prLinks?: string[]
|
||||
totalCostUSD: number
|
||||
apiCalls: number
|
||||
totalSavingsUSD: number
|
||||
/// The PR set carried into the in-range turn slice: the refs of the last turn
|
||||
/// BEFORE the report's range start that referenced any PR. Seeds `current` so a
|
||||
/// PR referenced before the range still owns its later, in-range, ref-less turns
|
||||
/// (mirrors the branch carry-forward). Set by the parser; absent in unit tests.
|
||||
prRefsAtRangeStart?: string[]
|
||||
}
|
||||
|
||||
function addToMap(m: Map<string, number>, key: string, value: number): void {
|
||||
m.set(key, (m.get(key) ?? 0) + value)
|
||||
}
|
||||
|
||||
function ensureContribution(map: Map<string, PrContribution>, url: string): PrContribution {
|
||||
let e = map.get(url)
|
||||
if (!e) {
|
||||
e = { cost: 0, calls: 0, savingsUSD: 0, approx: false, models: new Map(), categories: new Map() }
|
||||
map.set(url, e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Split an integer `total` across `n` buckets as evenly as possible, giving the
|
||||
// first `total % n` buckets the extra unit (largest-remainder, deterministic by
|
||||
// bucket order). Keeps per-PR call counts integral so aggregated rows never
|
||||
// over- or under-count from independent per-row rounding (a 1-call, 2-PR turn
|
||||
// allocates [1, 0], not [0.5, 0.5] that would each round up to 1).
|
||||
export function allocateEven(total: number, n: number): number[] {
|
||||
const base = Math.floor(total / n)
|
||||
const extra = total - base * n
|
||||
return Array.from({ length: n }, (_, i) => base + (i < extra ? 1 : 0))
|
||||
}
|
||||
|
||||
/// Attribute a session's spend to the PRs it referenced, at TURN granularity.
|
||||
///
|
||||
/// Walk the turns in order carrying `current` = the PR set of the most recent
|
||||
/// turn that referenced any PR (seeded from `prRefsAtRangeStart` so a reference
|
||||
/// made before the report window still owns its in-range follow-up turns). Each
|
||||
/// turn's cost/savings are split evenly across a multi-PR set (a merge-sweep turn
|
||||
/// touching several PRs); calls are split by largest-remainder so they stay whole.
|
||||
/// Each contribution also records the models of its calls and the turn's task
|
||||
/// category, both weighted by the same split share. Turns before the first
|
||||
/// reference land in `unattributed` (genuine session overhead).
|
||||
///
|
||||
/// Legacy fallback: a session whose transcript already expired keeps its
|
||||
/// session-level `prLinks` but has NO per-turn `prRefs`. With no turn boundaries
|
||||
/// to attribute by, split the whole session evenly across its prLinks, mark every
|
||||
/// portion `approx`, and carry the session's model union (its calls still name
|
||||
/// their models) but NO category breakdown, since none can be honestly assigned.
|
||||
export function attributeSessionPrSpend(session: AttributableSession): SessionPrAttribution {
|
||||
const perUrl = new Map<string, PrContribution>()
|
||||
const unattributed = { cost: 0, calls: 0, savingsUSD: 0 }
|
||||
|
||||
const hasTurnRefs = session.turns.some(t => t.prRefs?.length) || !!session.prRefsAtRangeStart?.length
|
||||
if (!hasTurnRefs) {
|
||||
const links = session.prLinks
|
||||
if (links?.length) {
|
||||
const legacyModels = new Map<string, number>()
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
if (call.model) addToMap(legacyModels, call.model, call.costUSD)
|
||||
}
|
||||
}
|
||||
const share = 1 / links.length
|
||||
const callAlloc = allocateEven(session.apiCalls, links.length)
|
||||
links.forEach((url, i) => {
|
||||
const e = ensureContribution(perUrl, url)
|
||||
e.cost += session.totalCostUSD * share
|
||||
e.calls += callAlloc[i]!
|
||||
e.savingsUSD += session.totalSavingsUSD * share
|
||||
e.approx = true
|
||||
for (const [m, mc] of legacyModels) addToMap(e.models, m, mc * share)
|
||||
})
|
||||
}
|
||||
return { perUrl, unattributed }
|
||||
}
|
||||
|
||||
let current: string[] | null = session.prRefsAtRangeStart?.length ? session.prRefsAtRangeStart : null
|
||||
for (const turn of session.turns) {
|
||||
if (turn.prRefs?.length) current = turn.prRefs
|
||||
const cost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
|
||||
const calls = turn.assistantCalls.length
|
||||
const savings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0)
|
||||
if (cost === 0 && calls === 0 && savings === 0) continue
|
||||
if (current === null) {
|
||||
unattributed.cost += cost
|
||||
unattributed.calls += calls
|
||||
unattributed.savingsUSD += savings
|
||||
continue
|
||||
}
|
||||
const modelCostInTurn = new Map<string, number>()
|
||||
for (const call of turn.assistantCalls) {
|
||||
if (call.model) addToMap(modelCostInTurn, call.model, call.costUSD)
|
||||
}
|
||||
const share = 1 / current.length
|
||||
const callAlloc = allocateEven(calls, current.length)
|
||||
current.forEach((url, i) => {
|
||||
const e = ensureContribution(perUrl, url)
|
||||
e.cost += cost * share
|
||||
e.calls += callAlloc[i]!
|
||||
e.savingsUSD += savings * share
|
||||
if (turn.category) addToMap(e.categories, turn.category, cost * share)
|
||||
for (const [m, mc] of modelCostInTurn) addToMap(e.models, m, mc * share)
|
||||
})
|
||||
}
|
||||
return { perUrl, unattributed }
|
||||
}
|
||||
|
||||
/// Spend attributed to each pull request at turn granularity (see
|
||||
/// attributeSessionPrSpend). Rows carry ATTRIBUTED cost/calls and ARE summable;
|
||||
/// `sessions` counts the distinct sessions that contributed any spend to the PR;
|
||||
/// `approx` marks rows fed by the legacy even-split fallback; `models` and
|
||||
/// `categories` are the attributed model/category breakdowns. Sorted by cost, desc.
|
||||
export function aggregateByPr(projects: ProjectSummary[]): PrRow[] {
|
||||
const byUrl = new Map<string, PrRow>()
|
||||
const byUrl = new Map<string, {
|
||||
cost: number; savingsUSD: number; calls: number; approx: boolean
|
||||
legacyCost: number
|
||||
sessions: Set<string>; firstStarted: string; lastEnded: string
|
||||
models: Map<string, number>; categories: Map<string, number>
|
||||
}>()
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
if (!session.prLinks?.length) continue
|
||||
for (const url of session.prLinks) {
|
||||
// Key on project + sessionId: a transcript basename (sessionId) can repeat
|
||||
// across projects, so sessionId alone would undercount distinct sessions.
|
||||
const sessionKey = `${session.project} | ||||