mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-09 08:34:37 +00:00
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.
This commit is contained in:
parent
50611c3dad
commit
a3a4c97ec5
15 changed files with 788 additions and 87 deletions
|
|
@ -224,7 +224,11 @@ export type MenubarPayload = {
|
|||
// 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).
|
||||
// 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
|
||||
|
|
@ -236,11 +240,15 @@ export type MenubarPayload = {
|
|||
firstStarted: string
|
||||
lastEnded: string
|
||||
approx?: boolean
|
||||
models?: string[]
|
||||
categories?: Array<{ name: string; cost: number }>
|
||||
}>
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
attributedCost: number
|
||||
unattributedCost: number
|
||||
attributedCost?: number
|
||||
unattributedCost?: number
|
||||
otherPrCount?: number
|
||||
otherPrCost?: number
|
||||
}
|
||||
}
|
||||
optimize: {
|
||||
|
|
|
|||
|
|
@ -46,13 +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: 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', () => {
|
||||
|
|
@ -75,13 +84,61 @@ 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('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('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 () => {
|
||||
|
|
@ -114,6 +171,64 @@ describe('PullRequests', () => {
|
|||
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('$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 () => {
|
||||
getOverview.mockResolvedValue(makePayload())
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { MouseEvent } from 'react'
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import { 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 }) {
|
||||
|
|
@ -59,32 +81,70 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
|
|||
}
|
||||
|
||||
function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
|
||||
const { rows, attributedCost, unattributedCost, distinctSessions } = pullRequests
|
||||
const sessionWord = distinctSessions === 1 ? 'session' : 'sessions'
|
||||
const { rows, distinctCost, distinctSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests
|
||||
const [expandedUrl, setExpandedUrl] = useState<string | null>(null)
|
||||
|
||||
// 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)}
|
||||
/>
|
||||
))}
|
||||
{otherCount > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="pr-footnote">
|
||||
{formatUsd(attributedCost)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord}.
|
||||
{' '}Each turn's cost goes to the PR it was working on, so the rows are summable.
|
||||
</p>
|
||||
{unattributedCost > 0 && (
|
||||
<p className="pr-unattributed">Not tied to a specific PR: {formatUsd(unattributedCost)}</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
@ -92,18 +152,56 @@ function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
|
|||
|
||||
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 }: { pr: PrRow }) {
|
||||
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" {...(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>
|
||||
</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,9 +580,28 @@ 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 td.pr-other-label { color: var(--mut2); font-weight: var(--fw-medium); 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; }
|
||||
|
|
|
|||
11
src/main.ts
11
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 { attributedCost, unattributedCost, 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,6 +2048,7 @@ program
|
|||
{ header: 'Saved', right: true },
|
||||
{ header: 'Sessions', right: true },
|
||||
{ header: 'Calls', right: true },
|
||||
{ header: 'Models' },
|
||||
{ header: 'First' },
|
||||
{ header: 'Last' },
|
||||
],
|
||||
|
|
@ -2055,14 +2058,18 @@ program
|
|||
`$${r.savingsUSD.toFixed(2)}`,
|
||||
String(r.sessions),
|
||||
String(r.calls),
|
||||
modelsCell(r.models),
|
||||
r.firstStarted.slice(0, 10),
|
||||
r.lastEnded.slice(0, 10),
|
||||
]),
|
||||
)
|
||||
// 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 $${attributedCost.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`)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -61,11 +61,17 @@ export type PullRequestsPayload = {
|
|||
/// `attributedCost + unattributedCost`; kept for backward compatibility.
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
/// Sum of the per-PR rows' attributed cost (the rows ARE summable now).
|
||||
/// 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 = {
|
||||
|
|
|
|||
|
|
@ -1857,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
|
||||
|
|
@ -2028,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2051,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
|
||||
|
|
@ -2080,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) {
|
||||
|
|
|
|||
|
|
@ -364,21 +364,77 @@ 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'
|
||||
|
||||
// v5-shaped validation: identical to validateCache but pinned to version 5. The
|
||||
// per-turn schema is a superset (prRefs is optional), so a v5 file passes the
|
||||
// section/file/turn validators unchanged.
|
||||
function validateV5Cache(raw: unknown): raw is SessionCache {
|
||||
if (!raw || typeof raw !== 'object') return false
|
||||
const o = raw as Record<string, unknown>
|
||||
if (o['version'] !== 5) return false
|
||||
if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false
|
||||
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
|
||||
}
|
||||
|
||||
// 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 carried section takes the CURRENT envFingerprint so
|
||||
// the scan reuses it and appends the freshly-parsed present sources. The daily
|
||||
// cache, which owns 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 (!validateV5Cache(parsed)) return null
|
||||
const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false }
|
||||
for (const [provider, section] of Object.entries(parsed.providers)) {
|
||||
const files: Record<string, CachedFile> = {}
|
||||
for (const [path, file] of Object.entries(section.files)) {
|
||||
if (!existsSync(path) && file.prLinks?.length) files[path] = file
|
||||
}
|
||||
migrated.providers[provider] = {
|
||||
envFingerprint: computeEnvFingerprint(provider),
|
||||
files,
|
||||
...(section.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
|
||||
|
|
@ -99,6 +101,13 @@ export type PrRow = {
|
|||
/// (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+)/
|
||||
|
|
@ -108,8 +117,13 @@ export function shortenPrUrl(url: string): string {
|
|||
return m ? `${m[1]}/${m[2]}#${m[3]}` : url
|
||||
}
|
||||
|
||||
/// One PR's slice of a session's spend.
|
||||
export type PrContribution = { cost: number; calls: number; savingsUSD: number; approx: boolean }
|
||||
/// 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
|
||||
|
|
@ -122,54 +136,87 @@ export type SessionPrAttribution = {
|
|||
// 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[]; assistantCalls: Array<{ costUSD: number; savingsUSD?: number }> }>
|
||||
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 addContribution(
|
||||
map: Map<string, PrContribution>,
|
||||
url: string, cost: number, calls: number, savingsUSD: number, approx: boolean,
|
||||
): void {
|
||||
const e = map.get(url) ?? { cost: 0, calls: 0, savingsUSD: 0, approx: false }
|
||||
e.cost += cost
|
||||
e.calls += calls
|
||||
e.savingsUSD += savingsUSD
|
||||
if (approx) e.approx = true
|
||||
map.set(url, e)
|
||||
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. Each turn's cost/calls/savings are attributed to
|
||||
/// `current`, split evenly across a multi-PR set (a merge-sweep turn touching
|
||||
/// several PRs at once). Turns before the first reference land in `unattributed`
|
||||
/// (genuine session overhead: exploration, unrelated work).
|
||||
/// 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 and mark
|
||||
/// every portion `approx` so surfaces can flag it honestly.
|
||||
/// 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)
|
||||
const hasTurnRefs = session.turns.some(t => t.prRefs?.length) || !!session.prRefsAtRangeStart?.length
|
||||
if (!hasTurnRefs) {
|
||||
const links = session.prLinks
|
||||
if (links?.length) {
|
||||
const share = 1 / links.length
|
||||
for (const url of links) {
|
||||
addContribution(perUrl, url, session.totalCostUSD * share, session.apiCalls * share, session.totalSavingsUSD * share, true)
|
||||
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 = null
|
||||
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)
|
||||
|
|
@ -182,8 +229,20 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr
|
|||
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
|
||||
for (const url of current) addContribution(perUrl, url, cost * share, calls * share, savings * share, false)
|
||||
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 }
|
||||
}
|
||||
|
|
@ -191,27 +250,35 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr
|
|||
/// 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. Sorted by cost, desc.
|
||||
/// `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, {
|
||||
cost: number; savingsUSD: number; calls: number; approx: boolean
|
||||
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
|
||||
// Key on project + sessionId: a transcript basename (sessionId) can repeat
|
||||
// across projects, so sessionId alone would undercount distinct sessions.
|
||||
const sessionKey = `${session.project} | ||||