mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
fix: key the daily project split per real path, not per label (#1679)
A project label can span several paths: every Claude session started from the home folder shares -Users-<name>, while its cwd puts it in a different project. The day rollup keyed by label kept the first path it saw, so a rooted --project headline took the whole label bucket on some days and missed it on others. Key per (label, path), read old label-keyed days unchanged, bump the daily cache to v67.
This commit is contained in:
parent
1284290743
commit
01705cf8f2
5 changed files with 86 additions and 17 deletions
|
|
@ -323,7 +323,12 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// v66: Codex fork replay bursts drop only records found in the parent rollout;
|
||||
// burst records the parent kept only inside a running total now count. Calls
|
||||
// only rise, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
export const DAILY_CACHE_VERSION = 66
|
||||
// v67: a day's project split is keyed per (label, path) instead of per label,
|
||||
// which kept only the first path a label showed that day and handed the whole
|
||||
// label (e.g. every home-folder Claude session) to whichever project owned it.
|
||||
// Day and provider totals are unchanged, only the split inside them moves, so
|
||||
// no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
export const DAILY_CACHE_VERSION = 67
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
@ -403,6 +408,22 @@ export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number
|
|||
/// mapping are gone.
|
||||
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string }
|
||||
|
||||
/// One project label can span several real paths (every Claude session started
|
||||
/// from the home folder shares `-Users-<name>`), so since v67 a day's project
|
||||
/// split is keyed per (label, path). Days written earlier key by label alone
|
||||
/// and keep one path for the whole label; they stay readable through
|
||||
/// projectDayIdentity.
|
||||
const PROJECT_KEY_SEP = '\u0000'
|
||||
|
||||
export function projectDayKey(project: string, path?: string): string {
|
||||
return path ? `${project}${PROJECT_KEY_SEP}${path}` : project
|
||||
}
|
||||
|
||||
export function projectDayIdentity(key: string, stats: ProjectDayStats): ProjectFilterTarget & { projectPath: string } {
|
||||
const sep = key.indexOf(PROJECT_KEY_SEP)
|
||||
return { project: sep === -1 ? key : key.slice(0, sep), projectPath: stats.path ?? '' }
|
||||
}
|
||||
|
||||
export type ProviderDaySlice = {
|
||||
calls: number
|
||||
cost: number
|
||||
|
|
@ -1779,8 +1800,8 @@ export function cachedProjectIdentities(cache: DailyCache, startStr: string, end
|
|||
const identities: ProjectFilterTarget[] = []
|
||||
for (const day of cache.days) {
|
||||
if (day.date < startStr || day.date > endStr || !day.projects) continue
|
||||
for (const [name, stats] of Object.entries(day.projects)) {
|
||||
identities.push({ project: name, projectPath: stats.path ?? '' })
|
||||
for (const [key, stats] of Object.entries(day.projects)) {
|
||||
identities.push(projectDayIdentity(key, stats))
|
||||
}
|
||||
}
|
||||
return identities
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DailyEntry, ProjectDayStats, ProviderDaySlice } from './daily-cache.js'
|
||||
import { projectDayKey, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import type { PeriodData } from './menubar-json.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
|
||||
|
|
@ -67,14 +67,14 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn:
|
|||
}
|
||||
const ensureProject = (holder: { projects?: Record<string, ProjectDayStats> }, project: string, path?: string): ProjectDayStats => {
|
||||
const projects = (holder.projects ??= {})
|
||||
const key = projectDayKey(project, path)
|
||||
// defineProperty so a project directory named "__proto__" becomes an own
|
||||
// key instead of mutating the prototype link.
|
||||
let p = Object.hasOwn(projects, project) ? projects[project] : undefined
|
||||
let p = Object.hasOwn(projects, key) ? projects[key] : undefined
|
||||
if (!p) {
|
||||
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 }
|
||||
Object.defineProperty(projects, project, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, ...(path ? { path } : {}) }
|
||||
Object.defineProperty(projects, key, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
if (!p.path && path) p.path = path
|
||||
return p
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { scanAndDetect } from './optimize.js'
|
|||
import { callBillableOutputTokens, sessionBillableOutput, sessionBillableOutputTokens, inferSessionProvider } from './session-output.js'
|
||||
import { getDateRange } from './cli-date.js'
|
||||
import { activityStreak } from './streak.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, projectDayIdentity, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
import { spendProjectIdentity } from './spend-flow.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, excludeAggregateOnlyProjects, excludesAggregateOnlyProviders } from './parser.js'
|
||||
|
|
@ -602,7 +602,7 @@ function sumMatchingProjects(
|
|||
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
|
||||
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, matched: 0 }
|
||||
for (const [name, p] of Object.entries(projects)) {
|
||||
if (!matches({ project: name, projectPath: p.path ?? '' })) continue
|
||||
if (!matches(projectDayIdentity(name, p))) continue
|
||||
out.cost += p.cost
|
||||
out.calls += p.calls
|
||||
out.savingsUSD += p.savingsUSD ?? 0
|
||||
|
|
@ -1419,7 +1419,8 @@ export function buildPayloadProjects(
|
|||
}
|
||||
if (cacheDays) {
|
||||
for (const d of cacheDays) {
|
||||
for (const [name, p] of Object.entries(d.projects ?? {})) {
|
||||
for (const [key, p] of Object.entries(d.projects ?? {})) {
|
||||
const name = projectDayIdentity(key, p).project
|
||||
if (p.path) {
|
||||
const id = spendProjectIdentity({ project: name, projectPath: p.path }).id
|
||||
let byId = knownBySlug.get(name)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js'
|
||||
import { isTurnResidueOnly } from '../src/daily-cache.js'
|
||||
import { cachedProjectIdentities, isTurnResidueOnly, projectDayIdentity, projectDayKey, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { makeProjectFilter } from '../src/parser.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
|
||||
|
|
@ -369,9 +370,55 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 })
|
||||
// Per-project rollup at day level and inside each provider slice; path is
|
||||
// stored so display layers can derive a friendly name once sessions expire.
|
||||
expect(day.projects!['p']).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' })
|
||||
expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 })
|
||||
expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 })
|
||||
const key = projectDayKey('p', '/p')
|
||||
expect(day.projects![key]).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' })
|
||||
expect(day.providers['claude']!.projects![key]).toMatchObject({ cost: 7, calls: 1 })
|
||||
expect(day.providers['codex']!.projects![key]).toMatchObject({ cost: 3, calls: 1 })
|
||||
})
|
||||
|
||||
it('splits one project label across the real paths its sessions ran in', () => {
|
||||
// Every Claude session started from the home folder shares one label; the
|
||||
// canonical cwd groups them into different projects. Keying the day split
|
||||
// by label alone kept the first path and billed the whole label to it.
|
||||
const timestamp = '2026-04-10T10:00:00'
|
||||
const home = '-home-u'
|
||||
const withPath = (projectPath: string, cost: number, sessionId: string): ProjectSummary => {
|
||||
const p = makeSingleTurnProject([makeCall(timestamp, cost)])
|
||||
p.project = home
|
||||
p.projectPath = projectPath
|
||||
p.sessions[0]!.project = home
|
||||
p.sessions[0]!.sessionId = sessionId
|
||||
p.sessions[0]!.turns[0]!.assistantCalls[0]!.deduplicationKey = `dk-${sessionId}`
|
||||
return p
|
||||
}
|
||||
const days = aggregateProjectsIntoDays([withPath('/home/u', 2, 'a'), withPath('/home/u/app', 5, 'b'), withPath('/home/u/web', 11, 'c')])
|
||||
const day = days[0]!
|
||||
const unscoped = aggregateProjectsIntoDays([withPath('/home/u', 2, 'a'), withPath('/home/u/app', 5, 'b'), withPath('/home/u/web', 11, 'c')].map(p => ({ ...p, projectPath: '/home/u' })))[0]!
|
||||
expect(day.cost).toBe(18)
|
||||
expect(day.cost).toBe(unscoped.cost)
|
||||
expect(day.calls).toBe(unscoped.calls)
|
||||
expect(day.sessions).toBe(unscoped.sessions)
|
||||
|
||||
const totalFor = (holder: { projects?: DailyEntry['projects'] }, pattern: string) => {
|
||||
const matches = makeProjectFilter([pattern])
|
||||
return Object.entries(holder.projects ?? {})
|
||||
.filter(([key, stats]) => matches(projectDayIdentity(key, stats)))
|
||||
.reduce((sum, [, stats]) => sum + stats.cost, 0)
|
||||
}
|
||||
expect(totalFor(day, '/home/u/app')).toBe(5)
|
||||
expect(totalFor(day, '/home/u/web')).toBe(11)
|
||||
expect(totalFor(day.providers['claude']!, '/home/u/app')).toBe(5)
|
||||
expect(Object.values(day.projects!).reduce((s, p) => s + p.cost, 0)).toBe(day.cost)
|
||||
expect(Object.values(day.projects!).reduce((s, p) => s + p.sessions, 0)).toBe(day.sessions)
|
||||
expect(cachedProjectIdentities({ version: 0, savingsConfigHash: '', lastComputedDate: null, days }, day.date, day.date))
|
||||
.toEqual(expect.arrayContaining([{ project: home, projectPath: '/home/u/app' }, { project: home, projectPath: '/home/u/web' }]))
|
||||
})
|
||||
|
||||
it('still reads a day split written before v67 (label key, one path)', () => {
|
||||
const legacy = { cost: 4, calls: 1, savingsUSD: 0, sessions: 1, path: '/home/u/app' }
|
||||
expect(projectDayIdentity('-home-u', legacy)).toEqual({ project: '-home-u', projectPath: '/home/u/app' })
|
||||
expect(makeProjectFilter(['/home/u/app'])(projectDayIdentity('-home-u', legacy))).toBe(true)
|
||||
expect(projectDayIdentity('pathless', { cost: 1, calls: 1, savingsUSD: 0, sessions: 0 })).toEqual({ project: 'pathless', projectPath: '' })
|
||||
})
|
||||
|
||||
it('attributes a multi-provider turn to the majority provider exactly once', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
|
|||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { setIncludeGatewayInTotals } from '../src/config.js'
|
||||
import { emptyCache, type DailyCache } from '../src/daily-cache.js'
|
||||
import { emptyCache, projectDayKey, type DailyCache } from '../src/daily-cache.js'
|
||||
import { renderOverview } from '../src/overview.js'
|
||||
import { aggregateSessions } from '../src/sessions-report.js'
|
||||
import { aggregateModels } from '../src/models-report.js'
|
||||
|
|
@ -417,7 +417,7 @@ describe('vercel-gateway: daily aggregates are shown but not totalled', () => {
|
|||
expect(left.providers['vercel-gateway']!.cost).toBeCloseTo(GATEWAY_COST, 10)
|
||||
expect(left.providers['vercel-gateway']!.calls).toBe(3)
|
||||
// The gateway's own synthetic project leaves the day's project split.
|
||||
expect(Object.keys(left.projects ?? {})).toEqual(['local-repo'])
|
||||
expect(Object.keys(left.projects ?? {})).toEqual([projectDayKey('local-repo', 'local-repo')])
|
||||
// A day with no slice for the provider is returned untouched.
|
||||
expect(excludeProviderFromDay(day!, 'nope')).toBe(day)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue