mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-14 11:04:44 +00:00
Merge pull request #864 from GodCC6/fix/project-filter-durable-headline
Some checks failed
CI / semgrep (push) Has been cancelled
Some checks failed
CI / semgrep (push) Has been cancelled
fix: --project/--exclude are ignored by the durable headline totals
This commit is contained in:
commit
2de4d100bf
6 changed files with 538 additions and 5 deletions
|
|
@ -9,6 +9,7 @@
|
|||
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
|
||||
|
||||
### Fixed (CLI)
|
||||
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
|
||||
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -265,7 +265,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
|
|||
if (!isRecord(raw)) return {}
|
||||
const out: NonNullable<DailyEntry['projects']> = {}
|
||||
for (const [name, p] of Object.entries(raw)) {
|
||||
if (name in Object.prototype || !isRecord(p)) continue
|
||||
// A project key is a directory basename, so it can legitimately be a
|
||||
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
|
||||
// as an own property via defineProperty, so keeping it is pollution-safe —
|
||||
// and dropping it would silently subtract that project's cost from a
|
||||
// --project/--exclude total (the day's split would no longer sum to its own
|
||||
// cost, which the filtered headline relies on).
|
||||
if (!isRecord(p)) continue
|
||||
setOwn(out, name, {
|
||||
cost: num(p.cost),
|
||||
calls: num(p.calls),
|
||||
|
|
|
|||
|
|
@ -992,6 +992,7 @@ program
|
|||
cacheWriteTokens: durable.data.cacheWriteTokens,
|
||||
days: durable.days,
|
||||
carriedCostUSD: durable.carriedCostUSD,
|
||||
unattributedCostUSD: durable.unattributedCostUSD,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ export type OverviewDurable = {
|
|||
cacheWriteTokens: number
|
||||
days: DailyEntry[]
|
||||
carriedCostUSD: number
|
||||
/// Cost a --project/--exclude filter could not attribute (cached days with no
|
||||
/// per-project split). Optional so callers that never filter can omit it.
|
||||
unattributedCostUSD?: number
|
||||
}
|
||||
|
||||
export function renderOverview(
|
||||
|
|
@ -350,5 +353,12 @@ export function renderOverview(
|
|||
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
|
||||
}
|
||||
|
||||
// A project filter cannot claim days the cache holds without a project split
|
||||
// (recorded before that split existed), so they sit outside this total. Say how
|
||||
// much rather than let the filtered figure look inexplicably short.
|
||||
if (durable && (durable.unattributedCostUSD ?? 0) > 0) {
|
||||
out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
|
||||
}
|
||||
|
||||
return out.join('\n') + '\n'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { aggregateModels } from './models-report.js'
|
|||
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
|
||||
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
|
||||
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
|
||||
|
|
@ -226,25 +226,130 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Does a cached day's project entry pass the active name filters? Mirrors
|
||||
/// parser.filterProjectsByName exactly — case-insensitive substring match
|
||||
/// against the project name OR its filesystem path, include first then exclude —
|
||||
/// so a filter selects the same projects whether it is resolved against a fresh
|
||||
/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is
|
||||
/// absent on entries whose sessions were gone before it could be recorded; the
|
||||
/// name is then all there is to match on, as it is for the display layers.
|
||||
function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean {
|
||||
const n = name.toLowerCase()
|
||||
const p = (path ?? '').toLowerCase()
|
||||
const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern))
|
||||
if (include.length > 0 && !include.some(hit)) return false
|
||||
if (exclude.length > 0 && exclude.some(hit)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/// Sum the per-project day stats that pass the filters. `defineProperty` so a
|
||||
/// project directory named "__proto__" stays an own key instead of mutating the
|
||||
/// prototype link (same reason day-aggregator does it when writing them).
|
||||
function sumMatchingProjects(
|
||||
projects: Record<string, ProjectDayStats>,
|
||||
include: string[],
|
||||
exclude: string[],
|
||||
): { 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 (!dayProjectMatches(name, p.path, include, exclude)) continue
|
||||
out.cost += p.cost
|
||||
out.calls += p.calls
|
||||
out.savingsUSD += p.savingsUSD ?? 0
|
||||
out.sessions += p.sessions ?? 0
|
||||
out.matched += 1
|
||||
Object.defineProperty(out.projects, name, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Collapse a day to the slice matching the active --project/--exclude filters,
|
||||
/// the project-level counterpart of sliceDayToProvider. Without this a
|
||||
/// project-filtered headline counted every historical day WHOLE — excluded
|
||||
/// projects included — while every detail panel (By Project / By Activity / By
|
||||
/// Model, all built from the name-filtered live parse) left them out, so the two
|
||||
/// could not be reconciled.
|
||||
///
|
||||
/// `DailyEntry.projects` (cache v15+) carries cost/calls/savingsUSD/sessions per
|
||||
/// project, so those four are recomputed EXACTLY. The day's tokens, models and
|
||||
/// categories have no per-project split to slice, so they are dropped here
|
||||
/// rather than reported as if they belonged to the surviving projects;
|
||||
/// buildDurablePeriod refills them from the project-filtered live parse, which
|
||||
/// is exact for every session that still exists.
|
||||
///
|
||||
/// A day recorded before v15 has no `projects` at all and nothing can
|
||||
/// reconstruct one once the sources are gone. Such a day cannot be attributed to
|
||||
/// any project, so it contributes nothing to a project-filtered total and its
|
||||
/// cost is surfaced as `unattributedCostUSD` instead of being silently folded in
|
||||
/// (understating with a stated figure beats overstating with excluded spend).
|
||||
function sliceDayToProject(day: DailyEntry, include: string[], exclude: string[]): DailyEntry {
|
||||
const zeroDay = (): DailyEntry => ({
|
||||
date: day.date, cost: 0, savingsUSD: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {},
|
||||
...(day.carried ? { carried: true as const } : {}),
|
||||
})
|
||||
if (!day.projects) return zeroDay()
|
||||
const totals = sumMatchingProjects(day.projects, include, exclude)
|
||||
if (totals.matched === 0) return zeroDay()
|
||||
|
||||
// Provider slices carry their own per-project split, so `--provider X` on top
|
||||
// of a project filter stays consistent with the day-level slice. A slice
|
||||
// adopted from a pre-v15 cache has no split and is dropped for the same
|
||||
// reason the day-level one is.
|
||||
const providers: Record<string, ProviderDaySlice> = {}
|
||||
for (const [name, slice] of Object.entries(day.providers)) {
|
||||
if (!slice.projects) continue
|
||||
const sliced = sumMatchingProjects(slice.projects, include, exclude)
|
||||
if (sliced.matched === 0) continue
|
||||
Object.defineProperty(providers, name, {
|
||||
value: { cost: sliced.cost, calls: sliced.calls, savingsUSD: sliced.savingsUSD, sessions: sliced.sessions, projects: sliced.projects },
|
||||
enumerable: true, writable: true, configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
date: day.date,
|
||||
cost: totals.cost,
|
||||
savingsUSD: totals.savingsUSD,
|
||||
calls: totals.calls,
|
||||
sessions: totals.sessions,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
|
||||
providers,
|
||||
projects: totals.projects,
|
||||
...(day.carried ? { carried: true as const } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The durable day set behind a period's headline: historical days from the
|
||||
/// carry-forward cache (up to yesterday, INCLUDING days whose session files have
|
||||
/// expired) unioned with today parsed live, then narrowed to the requested range
|
||||
/// and (when given) the heatmap day selection. Identical construction to the
|
||||
/// menubar's all-provider headline — this IS that construction, extracted.
|
||||
///
|
||||
/// `sliceHistorical` narrows the cache-sourced days only. Today's days come from
|
||||
/// a parse the caller already name-filtered, so re-slicing them would be a no-op
|
||||
/// at best and could only lose data the filter meant to keep.
|
||||
function unionDaysForPeriod(
|
||||
cache: DailyCache,
|
||||
todayAllDays: DailyEntry[],
|
||||
periodInfo: PeriodInfo,
|
||||
daysSelection: Set<string> | null,
|
||||
sliceHistorical?: (day: DailyEntry) => DailyEntry,
|
||||
): DailyEntry[] {
|
||||
const now = new Date()
|
||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
|
||||
const historicalDays = rangeStartStr <= historicalRangeEndStr
|
||||
const cacheDays = rangeStartStr <= historicalRangeEndStr
|
||||
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
|
||||
: []
|
||||
// Apply the day selection BEFORE slicing so a day the heatmap filtered out
|
||||
// never reaches the slicer (which tallies what it could not attribute).
|
||||
const selectedCacheDays = daysSelection ? cacheDays.filter(d => daysSelection.has(d.date)) : cacheDays
|
||||
const historicalDays = sliceHistorical ? selectedCacheDays.map(d => sliceHistorical(d)) : selectedCacheDays
|
||||
const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
||||
const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
||||
return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered
|
||||
|
|
@ -266,6 +371,12 @@ export type DurablePeriod = {
|
|||
days: DailyEntry[]
|
||||
/// Sum of `cost` on `carried` days included in the period (footnote source).
|
||||
carriedCostUSD: number
|
||||
/// Cost the active --project/--exclude filter had to set aside: cached days
|
||||
/// recorded before per-project day stats existed (v15) carry no project split,
|
||||
/// so they cannot be attributed to the filtered projects. Always 0 when no
|
||||
/// project filter is active. Reported so a filtered total that is short of the
|
||||
/// unfiltered one says so instead of just looking wrong.
|
||||
unattributedCostUSD: number
|
||||
/// Fresh per-period parse (provider + name filtered) for detail views that
|
||||
/// still need surviving session files.
|
||||
liveProjects: ProjectSummary[]
|
||||
|
|
@ -325,7 +436,33 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
scanRange = isTodayOnly ? todayRange : periodInfo.range
|
||||
}
|
||||
|
||||
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null)
|
||||
// Name filters must reach the cache-sourced days too. Today's parse is already
|
||||
// name-filtered above (`fp`), but the historical remainder comes straight out
|
||||
// of the day cache, so without this slice a --project/--exclude headline
|
||||
// counted every expired-source day whole while the detail panels did not.
|
||||
const projectInclude = (opts.project ?? []).map(s => s.toLowerCase())
|
||||
const projectExclude = (opts.exclude ?? []).map(s => s.toLowerCase())
|
||||
const hasProjectFilter = projectInclude.length > 0 || projectExclude.length > 0
|
||||
// What a filtered total cannot claim, and therefore has to leave out: a cached
|
||||
// day with no project split at all, or — with a provider filter also active,
|
||||
// since the headline then reads that provider's slice — a slice carried from a
|
||||
// cache generation that predates per-project splits. Both are stated back to
|
||||
// the caller (footnoted by the overview) instead of vanishing from the total.
|
||||
const unattributableCost = (day: DailyEntry): number => {
|
||||
if (pf === 'all') return day.projects ? 0 : day.cost
|
||||
const slice = Object.hasOwn(day.providers, pf) ? day.providers[pf] : undefined
|
||||
if (!slice) return 0
|
||||
return !day.projects || !slice.projects ? slice.cost : 0
|
||||
}
|
||||
let unattributedCostUSD = 0
|
||||
const sliceHistorical = hasProjectFilter
|
||||
? (day: DailyEntry): DailyEntry => {
|
||||
unattributedCostUSD += unattributableCost(day)
|
||||
return sliceDayToProject(day, projectInclude, projectExclude)
|
||||
}
|
||||
: undefined
|
||||
|
||||
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
|
||||
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
|
||||
const data = buildPeriodDataFromDays(days, periodInfo.label)
|
||||
|
||||
|
|
@ -342,6 +479,21 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
// Cache buckets a session on its START day, the scan on any ACTIVE day; both
|
||||
// are lower bounds of distinct sessions, so max is the tightest safe bound.
|
||||
data.sessions = Math.max(data.sessions, scanData.sessions)
|
||||
// Tokens/models/categories have no per-project split in the day cache, so
|
||||
// sliceDayToProject drops them (see there). Under a project filter they come
|
||||
// from the live parse instead: exact for the filtered projects, bounded by
|
||||
// source retention like every other scan-derived field above, and consistent
|
||||
// with the By Model / By Activity panels that read the same parse. Cost, calls,
|
||||
// sessions and savings stay durable — sliced out of the cache, expired days
|
||||
// included.
|
||||
if (hasProjectFilter) {
|
||||
data.inputTokens = scanData.inputTokens
|
||||
data.outputTokens = scanData.outputTokens
|
||||
data.cacheReadTokens = scanData.cacheReadTokens
|
||||
data.cacheWriteTokens = scanData.cacheWriteTokens
|
||||
data.models = scanData.models
|
||||
data.categories = scanData.categories
|
||||
}
|
||||
const estimatedByModel = new Map(
|
||||
scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]),
|
||||
)
|
||||
|
|
@ -352,7 +504,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
}
|
||||
|
||||
const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0)
|
||||
return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange }
|
||||
return { data, days, carriedCostUSD, unattributedCostUSD, liveProjects, cache, todayAllDays, scanRange }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
363
tests/project-filter-durable-totals.test.ts
Normal file
363
tests/project-filter-durable-totals.test.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
|
||||
import { renderOverview } from '../src/overview.js'
|
||||
import type { DateRange } from '../src/types.js'
|
||||
|
||||
// The durable headline (overview / report Overview panel / menubar current) is
|
||||
// built from the carry-forward daily cache unioned with today's live parse, so
|
||||
// days whose session files expired still count. Historical days were sliced to
|
||||
// the requested PROVIDER but never to the requested PROJECT, so
|
||||
// `--project` / `--exclude` were silently ignored for every day except today:
|
||||
// the Overview total counted excluded projects while every detail panel (By
|
||||
// Project / By Activity / By Model, all built from the name-filtered live
|
||||
// parse) left them out, and the two panels could not be reconciled.
|
||||
//
|
||||
// Per-project day stats exist in the cache since v15 (DailyEntry.projects), so
|
||||
// the historical days CAN be sliced — that is what these tests pin down.
|
||||
|
||||
const ROOT = join(tmpdir(), `codeburn-project-filter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
// One carried day, split across two projects. The day totals are the sum, so a
|
||||
// correct project slice returns strictly less than the whole day.
|
||||
const KEEP = { cost: 30, calls: 10, sessions: 1 }
|
||||
const DROP = { cost: 70, calls: 30, sessions: 2 }
|
||||
const DAY_COST = KEEP.cost + DROP.cost
|
||||
const DAY_CALLS = KEEP.calls + DROP.calls
|
||||
const DAY_SESSIONS = KEEP.sessions + DROP.sessions
|
||||
|
||||
function daysAgoStr(n: number): string {
|
||||
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** A day only the cache holds (sources aged out), split across two projects. */
|
||||
function carriedDayWithProjects(date: string): DailyEntry {
|
||||
const projects = {
|
||||
'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' },
|
||||
'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' },
|
||||
}
|
||||
return {
|
||||
date,
|
||||
cost: DAY_COST,
|
||||
savingsUSD: 0,
|
||||
calls: DAY_CALLS,
|
||||
sessions: DAY_SESSIONS,
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 4,
|
||||
oneShotTurns: 2,
|
||||
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
||||
providers: {
|
||||
claude: {
|
||||
calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, sessions: DAY_SESSIONS,
|
||||
inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 4, oneShotTurns: 2,
|
||||
models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
|
||||
projects,
|
||||
},
|
||||
},
|
||||
projects,
|
||||
carried: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** A carried day recorded before v15: totals, but no per-project split at all. */
|
||||
function carriedDayWithoutProjects(date: string): DailyEntry {
|
||||
const day = carriedDayWithProjects(date)
|
||||
delete day.projects
|
||||
delete day.providers['claude']!.projects
|
||||
return day
|
||||
}
|
||||
|
||||
async function seedCache(day: DailyEntry): Promise<void> {
|
||||
const cache: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: getDailyCacheConfigHash(),
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(1),
|
||||
days: [day],
|
||||
complete: true,
|
||||
}
|
||||
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
|
||||
}
|
||||
|
||||
/** A real, priced Claude session dated TODAY, under project dir `live-proj`. */
|
||||
async function seedLiveTodaySession(): Promise<void> {
|
||||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'live-proj')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
const at = (h: number, m: number): string => new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0).toISOString()
|
||||
const line = (id: string, t: string): string => JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: t,
|
||||
sessionId: 's-today',
|
||||
message: {
|
||||
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
|
||||
content: [],
|
||||
usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
|
||||
},
|
||||
})
|
||||
await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', at(12, 0)), line('m2', at(12, 30))].join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/** What the name-filtered live parse alone reports — the By Project panel's source. */
|
||||
async function liveOnly(range: DateRange, include: string[], exclude: string[]): Promise<{ cost: number; calls: number }> {
|
||||
clearSessionCache()
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, 'all'), include, exclude)
|
||||
const data = buildPeriodData('live', projects)
|
||||
return { cost: data.cost, calls: data.calls }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPricing()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
||||
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
|
||||
await mkdir(join(ROOT, 'cache'), { recursive: true })
|
||||
process.env['HOME'] = join(ROOT, 'home')
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
|
||||
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
|
||||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
delete process.env['CODEX_HOME']
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearSessionCache()
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = savedEnv[k]
|
||||
}
|
||||
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// A fixed 20-day window back from now: it always spans the carried day (10 days
|
||||
// ago) and today, so the period never depends on where "now" falls in the
|
||||
// calendar. A real `month` range drops the 10-days-ago day whenever today is
|
||||
// within 10 days of the 1st, which made these tests flake early in each month.
|
||||
const coveringRange = (): DateRange => ({
|
||||
start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
|
||||
end: new Date(),
|
||||
})
|
||||
|
||||
describe('durable headline honours --project / --exclude on carried days', () => {
|
||||
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
|
||||
// The headline counts the surviving sessions plus ONLY the kept project's
|
||||
// carried cost — not the whole carried day.
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
|
||||
// And it reconciles with the detail panels, which see the same live parse.
|
||||
const byProject = durable.liveProjects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
expect(durable.data.cost).toBeCloseTo(byProject + KEEP.cost, 6)
|
||||
})
|
||||
|
||||
it('keeps only the named project when --project is given', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, ['keep-me'], [])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', project: ['keep-me'] })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||
expect(durable.data.calls).toBe(live.calls + KEEP.calls)
|
||||
})
|
||||
|
||||
it('matches a filter against the project path as well as its name', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/Users/gone/drop-me'] })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
|
||||
})
|
||||
|
||||
it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
|
||||
// A project directory can legitimately be named "constructor"/"valueOf"/etc.
|
||||
// The day cache must carry it as an own key: if the load path drops it, the
|
||||
// day's per-project split no longer sums to the day cost, and the sliced
|
||||
// headline silently loses that project's spend.
|
||||
const day = carriedDayWithProjects(daysAgoStr(10))
|
||||
const protoName = 'constructor'
|
||||
Object.defineProperty(day.projects!, protoName, {
|
||||
value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
|
||||
enumerable: true, writable: true, configurable: true,
|
||||
})
|
||||
day.cost += 25
|
||||
day.calls += 5
|
||||
await seedCache(day)
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
// Keep everything (exclude a non-matching term): the constructor project's
|
||||
// $25 must be present alongside keep-me and drop-me.
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
|
||||
const live = await liveOnly(range, [], ['zzz-nomatch'])
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
|
||||
expect(durable.unattributedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('contributes nothing from a carried day whose every project is excluded', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['keep-me', 'drop-me'] })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
|
||||
expect(durable.carriedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the project filter underneath a provider filter', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
// Stated as the delta the filter must produce, so the assertion holds
|
||||
// whatever the provider-scoped live parse contributes.
|
||||
clearSessionCache()
|
||||
const unfiltered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude' })
|
||||
clearSessionCache()
|
||||
const filtered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
|
||||
|
||||
expect(unfiltered.data.cost - filtered.data.cost).toBeCloseTo(DROP.cost, 6)
|
||||
expect(unfiltered.data.calls - filtered.data.calls).toBe(DROP.calls)
|
||||
})
|
||||
|
||||
it('keeps the menubar payload in step with the report under a project filter', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
clearSessionCache()
|
||||
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
|
||||
// Both surfaces route the headline through the one shared builder, so a
|
||||
// project filter must land on them identically.
|
||||
expect(menubar.current.cost).toBe(durable.data.cost)
|
||||
expect(menubar.current.calls).toBe(durable.data.calls)
|
||||
expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
|
||||
})
|
||||
|
||||
it('leaves the unfiltered headline exactly as it was', async () => {
|
||||
await seedCache(carriedDayWithProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], [])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
|
||||
expect(durable.data.calls).toBe(live.calls + DAY_CALLS)
|
||||
expect(durable.carriedCostUSD).toBeCloseTo(DAY_COST, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carried days with no per-project split (pre-v15)', () => {
|
||||
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
|
||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], ['drop-me'])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
|
||||
// The day cannot be attributed to any project, so a project-filtered total
|
||||
// cannot claim it. It is reported separately rather than silently folded in.
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost, 6)
|
||||
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
|
||||
})
|
||||
|
||||
it('reports a provider slice with no project split as unattributed too', async () => {
|
||||
// A v14-era provider slice carried into a v15 day: the day knows its project
|
||||
// split, that provider's slice does not. Under --provider the headline reads
|
||||
// the slice, so the day drops out — say how much rather than lose it quietly.
|
||||
const day = carriedDayWithProjects(daysAgoStr(10))
|
||||
delete day.providers['claude']!.projects
|
||||
await seedCache(day)
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
|
||||
|
||||
expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
|
||||
expect(durable.carriedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('says so in the overview instead of just showing a short total', async () => {
|
||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
|
||||
expect(durable.unattributedCostUSD).toBeGreaterThan(0)
|
||||
|
||||
const rendered = renderOverview(durable.liveProjects, {
|
||||
label: 'This month',
|
||||
color: false,
|
||||
durable: {
|
||||
cost: durable.data.cost,
|
||||
savingsUSD: durable.data.savingsUSD,
|
||||
calls: durable.data.calls,
|
||||
sessions: durable.data.sessions,
|
||||
inputTokens: durable.data.inputTokens,
|
||||
outputTokens: durable.data.outputTokens,
|
||||
cacheReadTokens: durable.data.cacheReadTokens,
|
||||
cacheWriteTokens: durable.data.cacheWriteTokens,
|
||||
days: durable.days,
|
||||
carriedCostUSD: durable.carriedCostUSD,
|
||||
unattributedCostUSD: durable.unattributedCostUSD,
|
||||
},
|
||||
})
|
||||
expect(rendered).toContain('no per-project history')
|
||||
})
|
||||
|
||||
it('still counts the day in full when no project filter is active', async () => {
|
||||
await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
|
||||
await seedLiveTodaySession()
|
||||
const range = coveringRange()
|
||||
|
||||
const live = await liveOnly(range, [], [])
|
||||
clearSessionCache()
|
||||
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
|
||||
|
||||
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
|
||||
expect(durable.unattributedCostUSD).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue