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:
Resham Joshi 2026-10-08 00:39:18 +02:00 • committed by GitHub
parent 1284290743
commit 01705cf8f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 86 additions and 17 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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', () => {

View file

@ -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)
})