From 01705cf8f22fbb6cb8095459df3544744b698392 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:39:18 +0200 Subject: [PATCH] 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-, 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. --- src/daily-cache.ts | 27 ++++++++-- src/day-aggregator.ts | 10 ++-- src/usage-aggregator.ts | 7 +-- tests/day-aggregator.test.ts | 55 ++++++++++++++++++-- tests/vercel-gateway-excluded-totals.test.ts | 4 +- 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index d2aad408..b6ac661b 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -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-`), 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 diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 46742907..de5b7878 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -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 }, 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 } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 0f0d3e86..d14b0ff3 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -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; matched: number } { const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record, 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) diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index fb9c743f..d399d542 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -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 & { 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', () => { diff --git a/tests/vercel-gateway-excluded-totals.test.ts b/tests/vercel-gateway-excluded-totals.test.ts index ad018fba..d58fafff 100644 --- a/tests/vercel-gateway-excluded-totals.test.ts +++ b/tests/vercel-gateway-excluded-totals.test.ts @@ -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) })