mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 03:15:30 +00:00
Merge pull request #711 from getagentseal/fix/audit-batch
Audit batch: prefetch storm, day-bucketing unification, warm-path efficiency
This commit is contained in:
commit
db289fab65
17 changed files with 499 additions and 72 deletions
|
|
@ -1,8 +1,9 @@
|
|||
// @vitest-environment jsdom
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { App } from './App'
|
||||
import { App, overviewMemoKey } from './App'
|
||||
import { __resetPolledMemo, hasPolledMemo } from './hooks/usePolled'
|
||||
import type { DateRange, MenubarPayload, OptimizeJsonReport, SpendFlow } from './lib/types'
|
||||
|
||||
const stored = new Map<string, string>()
|
||||
|
|
@ -429,3 +430,74 @@ describe('App shortcuts', () => {
|
|||
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider prefetch storm', () => {
|
||||
const PROVIDERS = [
|
||||
'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
|
||||
'hermes', 'zcode', 'cursor', 'kiro', 'codewhale', 'openrouter',
|
||||
]
|
||||
|
||||
function manyProviderPayload(): MenubarPayload {
|
||||
const base = overviewPayload()
|
||||
const providers: Record<string, number> = {}
|
||||
PROVIDERS.forEach((id, i) => { providers[id] = PROVIDERS.length - i })
|
||||
return { ...base, current: { ...base.current, providers } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset()
|
||||
mocks.getOverview.mockResolvedValue(manyProviderPayload())
|
||||
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
|
||||
mocks.getYield.mockResolvedValue({
|
||||
period: { label: 'Last 30 days', start: '', end: '' },
|
||||
summary: {
|
||||
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
|
||||
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
|
||||
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
|
||||
total: { costUSD: 0, sessions: 0 },
|
||||
productiveToRevertedCostRatio: null,
|
||||
},
|
||||
details: [],
|
||||
})
|
||||
localStorage.clear()
|
||||
__resetPolledMemo()
|
||||
})
|
||||
|
||||
// With MEMO_MAX too small (< providers + base keys) the base overview key
|
||||
// LRU-evicts between polls, blanking the overview and re-arming the prefetch
|
||||
// every 30s cycle: 12 redundant full-history parses forever. This asserts the
|
||||
// fix — each provider is prefetched EXACTLY ONCE total across three cycles.
|
||||
it('prefetches each detected provider exactly once across 3 poll cycles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
render(<App />)
|
||||
// Let the mount overview resolve so `ready` flips and the prefetch arms.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
// Three full 30s poll cycles plus the prefetch start delay and staggered
|
||||
// per-provider warms (12 × 400ms). A re-arming storm would re-spawn some
|
||||
// providers on cycles 2 and 3; the once-per-key guard must prevent it.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3 + 12_000) })
|
||||
|
||||
for (const id of PROVIDERS) {
|
||||
const spawns = mocks.getOverview.mock.calls.filter(
|
||||
c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined,
|
||||
)
|
||||
expect(spawns.length, `prefetch spawns for ${id}`).toBe(1)
|
||||
}
|
||||
|
||||
// Sanity: the active 'all' view was polled every cycle (not prefetch-gated).
|
||||
const allPolls = mocks.getOverview.mock.calls.filter(c => c[1] === 'all')
|
||||
expect(allPolls.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// The memo must be sized to hold every warmed provider so none LRU-evict
|
||||
// between polls — the eviction that (in the real app) blanked the base
|
||||
// overview key and re-armed the prefetch. Under the old fixed cap of 8,
|
||||
// 7 of these 12 keys would have been evicted by soak's end.
|
||||
for (const id of PROVIDERS) {
|
||||
expect(hasPolledMemo(overviewMemoKey(id, '30days', null, null)), `warm key ${id}`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { EmptyNote } from './components/EmptyState'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
|
@ -10,7 +10,7 @@ import { Splash } from './components/Splash'
|
|||
import { ToastHost } from './components/ToastHost'
|
||||
import { rangeLabel, TopBar } from './components/TopBar'
|
||||
import { Window } from './components/Window'
|
||||
import { hasPolledMemo, primePolledMemo, usePolled } from './hooks/usePolled'
|
||||
import { hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled'
|
||||
import { readDailyBudget } from './lib/budget'
|
||||
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
|
||||
import { motionClass } from './lib/motion'
|
||||
|
|
@ -78,8 +78,9 @@ const PERIOD_LABELS: Record<Period, string> = {
|
|||
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
||||
|
||||
// Instant-switch memo key for an overview result. Shared by the overview poll
|
||||
// and the provider prefetcher so the two never drift out of sync.
|
||||
function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
|
||||
// and the provider prefetcher so the two never drift out of sync. Exported so
|
||||
// the prefetch-storm test can assert warmed keys survive between polls.
|
||||
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
|
||||
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +89,13 @@ function overviewMemoKey(provider: string, period: Period, range: DateRange | nu
|
|||
// the interaction the user is actually having.
|
||||
const PREFETCH_START_DELAY_MS = 1500
|
||||
const PREFETCH_STAGGER_MS = 400
|
||||
// Base instant-switch memo keys live during overview polling besides the per-
|
||||
// provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`,
|
||||
// plus one slot of headroom for section navigation. The memo cap is sized to
|
||||
// (detected providers + this) so warmed entries — and the base overview key —
|
||||
// never LRU-evict between polls (which would blank the overview and re-arm the
|
||||
// prefetch every cycle).
|
||||
const BASE_MEMO_KEYS = 4
|
||||
|
||||
function isPeriod(value: string): value is Period {
|
||||
return (STANDARD_PERIODS as string[]).includes(value)
|
||||
|
|
@ -252,6 +260,12 @@ function AppMain() {
|
|||
setCurrencyTick(tick => tick + 1)
|
||||
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol])
|
||||
|
||||
// Size the instant-switch memo to hold every prefetched provider overview plus
|
||||
// the base keys, so warmed entries survive between polls instead of evicting.
|
||||
useEffect(() => {
|
||||
setPolledMemoMax(detectedProviders.length + BASE_MEMO_KEYS)
|
||||
}, [detectedProviders.length])
|
||||
|
||||
// Prefetch for millisecond switches: once the first overview has resolved,
|
||||
// quietly warm the instant-switch memo for every OTHER detected provider at the
|
||||
// current period, so a picker switch to one paints from memory instead of
|
||||
|
|
@ -260,6 +274,14 @@ function AppMain() {
|
|||
// actually toggles between. The CLI's own read-cache + in-flight coalescing keep
|
||||
// this from double-spawning against a live user fetch; hasPolledMemo skips any
|
||||
// provider already warm (including one warmed by a real visit).
|
||||
//
|
||||
// `warmedKeys` is a session-lifetime once-per-key guard: each (provider,period)
|
||||
// memo key is marked BEFORE its spawn, so an effect re-run — e.g. an overview
|
||||
// poll that momentarily blanked `overview.data` — can never re-spawn a provider
|
||||
// already warmed. New keys (a new provider id, or a period switch) still warm
|
||||
// exactly once. Without this the prefetch re-fired every poll: 12 redundant
|
||||
// full-history CLI parses every 30s, forever.
|
||||
const warmedKeys = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource) return
|
||||
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
|
||||
|
|
@ -269,7 +291,8 @@ function AppMain() {
|
|||
for (const id of targets) {
|
||||
if (cancelled) return
|
||||
const key = overviewMemoKey(id, period, null, null)
|
||||
if (hasPolledMemo(key)) continue
|
||||
if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue
|
||||
warmedKeys.current.add(key)
|
||||
try {
|
||||
const value = await codeburn.getOverview(period, id)
|
||||
if (!cancelled) primePolledMemo(key, value)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ export function Onboarding({ defaultEnabled, onDone }: { defaultEnabled: boolean
|
|||
<button type="button" className="onboard-link" onClick={() => { void codeburn.openExternal?.(COLLECT_URL) }}>
|
||||
What data we collect
|
||||
</button>
|
||||
<p className="onboard-hint">
|
||||
Tip: if a provider looks empty, grant Full Disk Access in System Settings › Privacy & Security.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ function reduceProgress(state: Progress, event: ScanProgressEvent): Progress {
|
|||
}
|
||||
case 'provider': {
|
||||
const order = state.order.includes(event.provider) ? state.order : [...state.order, event.provider]
|
||||
const next: ProvStatus = event.state === 'done' ? 'done' : 'active'
|
||||
// 'skipped' (a permission-locked provider) is terminal like 'done' so it
|
||||
// settles the strip instead of showing as perpetually active.
|
||||
const next: ProvStatus = event.state === 'done' || event.state === 'skipped' ? 'done' : 'active'
|
||||
return { ...state, order, status: { ...state.status, [event.provider]: next } }
|
||||
}
|
||||
case 'tick':
|
||||
|
|
|
|||
|
|
@ -20,11 +20,32 @@ export type Polled<T> = {
|
|||
// Module-level LRU of last-good results per memoKey. A section that switches deps
|
||||
// to a previously-seen key (e.g. a provider switch, or a switch-back) paints the
|
||||
// cached result in the same frame while a fresh fetch runs behind it — no blank,
|
||||
// no stale-freeze. ~8 entries is plenty for the handful of live (provider ×
|
||||
// period × range) combinations a user cycles through.
|
||||
const MEMO_MAX = 8
|
||||
// no stale-freeze.
|
||||
//
|
||||
// The cap must comfortably hold every key live at once: the base overview/act/
|
||||
// yield polls PLUS one prefetched overview per detected provider. Sized too small
|
||||
// it LRU-evicts the base `overview|all` key between polls, which blanks the
|
||||
// overview and re-triggers the provider prefetch every cycle (the prefetch
|
||||
// storm). The App raises it via setPolledMemoMax to (detected providers + base
|
||||
// keys); DEFAULT_MEMO_MAX is the floor for isolated hook/component tests.
|
||||
const DEFAULT_MEMO_MAX = 8
|
||||
const MEMO_MAX_CAP = 24
|
||||
let memoMax = DEFAULT_MEMO_MAX
|
||||
const memoStore = new Map<string, unknown>()
|
||||
|
||||
/** Raise (or lower) the instant-switch memo cap so warmed entries survive between
|
||||
* polls. Clamped to [DEFAULT_MEMO_MAX, MEMO_MAX_CAP]; trims immediately if the
|
||||
* new cap is smaller than the current contents. Called by the App as the set of
|
||||
* detected providers grows. */
|
||||
export function setPolledMemoMax(n: number): void {
|
||||
memoMax = Math.max(DEFAULT_MEMO_MAX, Math.min(MEMO_MAX_CAP, Math.floor(n)))
|
||||
while (memoStore.size > memoMax) {
|
||||
const oldest = memoStore.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
memoStore.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function memoGet<T>(key: string): T | undefined {
|
||||
if (!memoStore.has(key)) return undefined
|
||||
const value = memoStore.get(key) as T
|
||||
|
|
@ -37,7 +58,7 @@ function memoGet<T>(key: string): T | undefined {
|
|||
function memoSet(key: string, value: unknown): void {
|
||||
if (memoStore.has(key)) memoStore.delete(key)
|
||||
memoStore.set(key, value)
|
||||
while (memoStore.size > MEMO_MAX) {
|
||||
while (memoStore.size > memoMax) {
|
||||
const oldest = memoStore.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
memoStore.delete(oldest)
|
||||
|
|
@ -48,6 +69,7 @@ function memoSet(key: string, value: unknown): void {
|
|||
* one test never bleed into the next. */
|
||||
export function __resetPolledMemo(): void {
|
||||
memoStore.clear()
|
||||
memoMax = DEFAULT_MEMO_MAX
|
||||
}
|
||||
|
||||
/** Seed the instant-switch memo out of band. The prefetcher (App.tsx) warms the
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ export type TelemetryStatus = {
|
|||
* a warm launch's incremental re-parse emits the same events without it. */
|
||||
export type ScanProgressEvent =
|
||||
| { kind: 'providers'; providers: string[]; cold?: boolean }
|
||||
| { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number }
|
||||
| { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number }
|
||||
| { kind: 'tick'; provider: string; done: number; total: number }
|
||||
| { kind: 'done' }
|
||||
|
||||
|
|
|
|||
|
|
@ -258,6 +258,33 @@ describe('Plans', () => {
|
|||
await waitFor(() => expect(getQuota).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('renders the honest rate-limited note on a 429 backoff, per provider owner', async () => {
|
||||
getPlans.mockResolvedValue(baseStatus)
|
||||
getQuota.mockResolvedValue([
|
||||
{ provider: 'claude', connection: 'transientFailure', rateLimited: true, primary: null, details: [], planLabel: null, footerLines: [] },
|
||||
{ provider: 'codex', connection: 'stale', rateLimited: true, primary: null, details: [], planLabel: null, footerLines: [] },
|
||||
])
|
||||
|
||||
render(<Plans period="30days" />)
|
||||
|
||||
expect(await screen.findByText('Anthropic rate limited the quota endpoint, retrying in a few minutes')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenAI rate limited the quota endpoint, retrying in a few minutes')).toBeInTheDocument()
|
||||
// The rate-limited note replaces the generic waiting copy.
|
||||
expect(screen.queryByText('waiting on the CLI…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the generic waiting note when a transient failure is not rate limited', async () => {
|
||||
getPlans.mockResolvedValue(baseStatus)
|
||||
getQuota.mockResolvedValue([
|
||||
{ provider: 'claude', connection: 'transientFailure', rateLimited: false, primary: null, details: [], planLabel: null, footerLines: [] },
|
||||
])
|
||||
|
||||
render(<Plans period="30days" />)
|
||||
|
||||
expect(await screen.findByText('waiting on the CLI…')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/rate limited the quota endpoint/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the keychain access-denied state with recovery copy and a locked indicator', async () => {
|
||||
getPlans.mockResolvedValue(statusWithPlans)
|
||||
getQuota.mockResolvedValue([
|
||||
|
|
|
|||
|
|
@ -965,6 +965,7 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
|
|||
font: inherit; font-size: var(--fs-label); color: #b3a595; text-decoration: underline; text-underline-offset: 3px;
|
||||
}
|
||||
.onboard-link:hover, .onboard-link:focus-visible { color: #f4f1ec; outline: none; }
|
||||
.onboard-hint { margin: 2px 0 0; max-width: 320px; font-size: var(--fs-label); line-height: 1.5; color: #8a7d6f; }
|
||||
.onboard-controls {
|
||||
margin-top: 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ import { homedir } from 'os'
|
|||
import { join } from 'path'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
// Bumped to 12: CodeWhale support adds historical usage that earlier rollups
|
||||
// Bumped to 13: day bucketing is now TURN-anchored (a turn's whole cost/calls
|
||||
// land on the day of its user-message timestamp) to match the live headline/
|
||||
// report rollup. v12 bucketed each call by its own timestamp, so a midnight-
|
||||
// straddling turn split across two days and history.daily / the provider
|
||||
// breakdown never reconciled to current.cost. Raising MIN_SUPPORTED_VERSION
|
||||
// forces the one-time re-hydration that rebuilds history under turn bucketing.
|
||||
//
|
||||
// v12: CodeWhale support adds historical usage that earlier rollups
|
||||
// did not contain. Both the CodeWhale branch and the kiro credit-pricing
|
||||
// change (below) claimed v11 independently, so v12 is the first version that
|
||||
// contains both; raising MIN_SUPPORTED_VERSION forces the one-time
|
||||
|
|
@ -26,8 +33,8 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// that older binaries skipped. v8 added local-model savings to the daily
|
||||
// rollup; the `savingsConfigHash` field is invalidated separately when the
|
||||
// user changes their `localModelSavings` mapping.
|
||||
export const DAILY_CACHE_VERSION = 12
|
||||
const MIN_SUPPORTED_VERSION = 12
|
||||
export const DAILY_CACHE_VERSION = 13
|
||||
const MIN_SUPPORTED_VERSION = 13
|
||||
// Version-suffixed so different binaries each own a distinct file and never
|
||||
// clobber an incompatible schema. Bumping the version mints a fresh filename.
|
||||
const DAILY_CACHE_FILENAME = `daily-cache.v${DAILY_CACHE_VERSION}.json`
|
||||
|
|
@ -68,6 +75,12 @@ export type DailyCache = {
|
|||
/// hash mismatches and `ensureCacheHydrated` discards the cached days
|
||||
/// so historical savings are recomputed against the current mapping.
|
||||
savingsConfigHash: string
|
||||
/// IANA local timezone the days were bucketed under (day boundaries are
|
||||
/// local-time). If the machine's timezone changes, previously-cached days are
|
||||
/// bucketed against the wrong midnight, so a mismatch forces a full re-hydrate
|
||||
/// (same self-heal as `savingsConfigHash`). Absent on caches written before
|
||||
/// this field existed → not treated as a mismatch (no gratuitous rebuild).
|
||||
tzKey?: string
|
||||
lastComputedDate: string | null
|
||||
days: DailyEntry[]
|
||||
/// True only once the full backfill window has been hydrated from a COMPLETE
|
||||
|
|
@ -82,6 +95,12 @@ function getCacheDir(): string {
|
|||
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
|
||||
}
|
||||
|
||||
/** IANA name of the current local timezone (respects the TZ env var). Days are
|
||||
* bucketed by local midnight, so this tags the cache for TZ-change invalidation. */
|
||||
export function currentTzKey(): string {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || '' } catch { return '' }
|
||||
}
|
||||
|
||||
function getCachePath(): string {
|
||||
return join(getCacheDir(), DAILY_CACHE_FILENAME)
|
||||
}
|
||||
|
|
@ -96,10 +115,10 @@ export function dailyCachePath(): string {
|
|||
}
|
||||
|
||||
export function emptyCache(savingsConfigHash = ''): DailyCache {
|
||||
return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [], complete: false }
|
||||
return { version: DAILY_CACHE_VERSION, savingsConfigHash, tzKey: currentTzKey(), lastComputedDate: null, days: [], complete: false }
|
||||
}
|
||||
|
||||
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[]; complete?: boolean } {
|
||||
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean } {
|
||||
if (!parsed || typeof parsed !== 'object') return false
|
||||
const c = parsed as Partial<DailyCache>
|
||||
if (typeof c.version !== 'number') return false
|
||||
|
|
@ -126,10 +145,11 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
|
|||
}))
|
||||
}
|
||||
|
||||
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
|
||||
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
|
||||
return {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: parsed.savingsConfigHash ?? '',
|
||||
tzKey: parsed.tzKey,
|
||||
lastComputedDate: parsed.lastComputedDate,
|
||||
days: migrateDays(parsed.days),
|
||||
// Only a cache explicitly marked complete stays trusted; one written before
|
||||
|
|
@ -222,6 +242,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
|
|||
return {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: cache.savingsConfigHash,
|
||||
tzKey: cache.tzKey,
|
||||
lastComputedDate: nextLast,
|
||||
days: pruned,
|
||||
complete: cache.complete,
|
||||
|
|
@ -274,7 +295,7 @@ export async function ensureCacheHydrated(
|
|||
return withDailyCacheLock(async () => {
|
||||
let c = await loadDailyCache()
|
||||
|
||||
// Two reasons to drop the cached days and re-hydrate the whole retention
|
||||
// Three reasons to drop the cached days and re-hydrate the whole retention
|
||||
// window:
|
||||
// 1. Savings config changed — the cached `savingsUSD` totals are stale, and
|
||||
// we can't cheaply recompute them per historical day without re-parsing.
|
||||
|
|
@ -282,14 +303,25 @@ export async function ensureCacheHydrated(
|
|||
// pre-marker cache, or one frozen from a partial/interrupted hydration).
|
||||
// Its older days may be empty or partial; trusting `lastComputedDate`
|
||||
// would leave that gap forever (the "first ~20 days missing" bug).
|
||||
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true) {
|
||||
// 3. The local timezone changed — days are bucketed by local midnight, so a
|
||||
// TZ change mis-buckets every cached day. Only invalidate when a tzKey is
|
||||
// present and differs (a cache written before this field, or a test
|
||||
// fixture, has none → left alone rather than force a spurious rebuild).
|
||||
const tzKey = currentTzKey()
|
||||
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
|
||||
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
|
||||
c = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash,
|
||||
tzKey,
|
||||
lastComputedDate: null,
|
||||
days: [],
|
||||
complete: false,
|
||||
}
|
||||
} else if (c.tzKey === undefined) {
|
||||
// First write under the tzKey scheme: tag the cache so a later TZ change is
|
||||
// detectable, without discarding the (still-valid, same-TZ) cached days.
|
||||
c = { ...c, tzKey }
|
||||
}
|
||||
|
||||
// Drop any cached entry dated today or later. The cache only ever stores
|
||||
|
|
|
|||
|
|
@ -41,7 +41,15 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
|
||||
for (const turn of session.turns) {
|
||||
if (turn.assistantCalls.length === 0) continue
|
||||
const turnDate = dateKey(turn.assistantCalls[0]!.timestamp)
|
||||
// Turn-anchored bucketing: attribute the WHOLE turn — every one of its
|
||||
// calls — to the day of the turn's user-message timestamp, matching the
|
||||
// live headline/report rollup (main.ts daily). Falls back to the first
|
||||
// assistant-call timestamp when the user line is missing (continuation
|
||||
// sessions that begin mid-conversation). Previously the calls were
|
||||
// bucketed per-call by each call's own timestamp, so a midnight-
|
||||
// straddling turn split across two days and history.daily / the provider
|
||||
// breakdown never reconciled to current.cost (a constant offset).
|
||||
const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp)
|
||||
const turnDay = ensure(turnDate)
|
||||
|
||||
const editTurns = turn.hasEdits ? 1 : 0
|
||||
|
|
@ -61,19 +69,17 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
turnDay.categories[turn.category] = cat
|
||||
|
||||
for (const call of turn.assistantCalls) {
|
||||
const callDate = dateKey(call.timestamp)
|
||||
const callDay = ensure(callDate)
|
||||
const callSavings = call.savingsUSD ?? 0
|
||||
|
||||
callDay.cost += call.costUSD
|
||||
callDay.savingsUSD += callSavings
|
||||
callDay.calls += 1
|
||||
callDay.inputTokens += call.usage.inputTokens
|
||||
callDay.outputTokens += call.usage.outputTokens
|
||||
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
|
||||
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
|
||||
turnDay.cost += call.costUSD
|
||||
turnDay.savingsUSD += callSavings
|
||||
turnDay.calls += 1
|
||||
turnDay.inputTokens += call.usage.inputTokens
|
||||
turnDay.outputTokens += call.usage.outputTokens
|
||||
turnDay.cacheReadTokens += call.usage.cacheReadInputTokens
|
||||
turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
|
||||
|
||||
const model = callDay.models[call.model] ?? {
|
||||
const model = turnDay.models[call.model] ?? {
|
||||
calls: 0, cost: 0, savingsUSD: 0,
|
||||
inputTokens: 0, outputTokens: 0,
|
||||
cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
|
|
@ -85,13 +91,13 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
|
|||
model.outputTokens += call.usage.outputTokens
|
||||
model.cacheReadTokens += call.usage.cacheReadInputTokens
|
||||
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
|
||||
callDay.models[call.model] = model
|
||||
turnDay.models[call.model] = model
|
||||
|
||||
const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 }
|
||||
const provider = turnDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 }
|
||||
provider.calls += 1
|
||||
provider.cost += call.costUSD
|
||||
provider.savingsUSD += callSavings
|
||||
callDay.providers[call.provider] = provider
|
||||
turnDay.providers[call.provider] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1204,7 +1204,10 @@ program
|
|||
]
|
||||
}
|
||||
|
||||
if (periods.every(p => p.projects.length === 0)) {
|
||||
if (periods.every(p => p.projects.length === 0) && opts.format !== 'json') {
|
||||
// Human-readable prose for CSV / interactive use. JSON falls through and
|
||||
// writes a valid, schema-matching file with empty arrays so programmatic
|
||||
// consumers always get parseable output, never prose.
|
||||
console.log('\n No usage data found.\n')
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2057,6 +2057,16 @@ function warnProviderParseFailure(providerName: string, sourcePath: string, err:
|
|||
)
|
||||
}
|
||||
|
||||
// A permission error (EPERM/EACCES) on a provider's data — e.g. a directory or
|
||||
// SQLite DB the OS won't let us read without Full Disk Access. Per-file and
|
||||
// discovery errors are already isolated; this catches a provider-level throw so
|
||||
// one locked provider skips-and-continues instead of aborting the whole
|
||||
// hydration (which would empty the cache/daily backfill for every provider).
|
||||
function isPermissionError(err: unknown): boolean {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code
|
||||
return code === 'EPERM' || code === 'EACCES'
|
||||
}
|
||||
|
||||
// A cold-cache scan over a large ~/.claude/projects tree (hundreds of project
|
||||
// dirs, e.g. a git-worktree-per-task workflow) can run long enough that it
|
||||
// looks hung, and is CPU-heavy enough on a single thread to visibly compete
|
||||
|
|
@ -2103,7 +2113,7 @@ export type ScanProgressEvent =
|
|||
// still emits `providers`/`tick`, so consumers must gate any "indexing" UI on
|
||||
// this flag, not on the mere presence of tick work.
|
||||
| { kind: 'providers'; providers: string[]; cold?: boolean }
|
||||
| { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number }
|
||||
| { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number }
|
||||
| { kind: 'tick'; provider: string; done: number; total: number }
|
||||
|
||||
export function emitScanProgress(event: ScanProgressEvent): void {
|
||||
|
|
@ -2639,15 +2649,30 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa
|
|||
: undefined,
|
||||
}))
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' })
|
||||
const claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress)
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
|
||||
let claudeProjects: ProjectSummary[] = []
|
||||
try {
|
||||
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress)
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
|
||||
} catch (err) {
|
||||
if (!isPermissionError(err)) throw err
|
||||
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
|
||||
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
|
||||
}
|
||||
|
||||
const otherProjects: ProjectSummary[] = []
|
||||
for (const [providerName, sources] of providerGroups) {
|
||||
emitScanProgress({ kind: 'provider', provider: providerName, state: 'start' })
|
||||
const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange)
|
||||
emitScanProgress({ kind: 'provider', provider: providerName, state: 'done', files: sources.length })
|
||||
otherProjects.push(...projects)
|
||||
try {
|
||||
const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange)
|
||||
emitScanProgress({ kind: 'provider', provider: providerName, state: 'done', files: sources.length })
|
||||
otherProjects.push(...projects)
|
||||
} catch (err) {
|
||||
// A permission-locked provider skips-and-continues; any other error is a
|
||||
// real bug and still aborts (per-file/DB-lock cases are handled deeper).
|
||||
if (!isPermissionError(err)) throw err
|
||||
process.stderr.write(`codeburn: skipped ${providerName} data (permission denied; grant Full Disk Access to include it)\n`)
|
||||
emitScanProgress({ kind: 'provider', provider: providerName, state: 'skipped' })
|
||||
}
|
||||
await saveProgress()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
|
@ -546,6 +546,31 @@ async function removeOurLock(): Promise<void> {
|
|||
} catch { /* best-effort; a leaked lock is reclaimed as stale next cold start */ }
|
||||
}
|
||||
|
||||
// Synchronous variant for the signal path: a handler can't await, so read + unlink
|
||||
// synchronously. Only unlinks a lock we actually own.
|
||||
function removeOurLockSync(): void {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(lockPath(), 'utf-8')) as Partial<LockRecord>
|
||||
if (parsed?.pid === process.pid) unlinkSync(lockPath())
|
||||
} catch { /* best-effort; nothing to clean or already gone */ }
|
||||
}
|
||||
|
||||
// Arm once, only while we hold the lock: on a catchable termination (Ctrl-C, or a
|
||||
// SIGTERM from a parent) clean our lock before dying so a killed cold parse leaves
|
||||
// no leftover. SIGKILL can't be caught, so that path still relies on the next cold
|
||||
// start's stale-lock takeover. process.once + re-raise preserves the default exit.
|
||||
let signalCleanupArmed = false
|
||||
function armSignalCleanup(): void {
|
||||
if (signalCleanupArmed) return
|
||||
signalCleanupArmed = true
|
||||
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(sig, () => {
|
||||
removeOurLockSync()
|
||||
process.kill(process.pid, sig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const releaseHandle: HydrationHandle = { waited: false, release: removeOurLock }
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
|
@ -567,19 +592,28 @@ function sleep(ms: number): Promise<void> {
|
|||
export async function beginColdHydration(isCold: boolean): Promise<HydrationHandle> {
|
||||
if (!isCold) return NOOP_HANDLE
|
||||
try {
|
||||
if (await writeOurLock()) return releaseHandle
|
||||
if (await writeOurLock()) { armSignalCleanup(); return releaseHandle }
|
||||
const existing = await readLockRecord()
|
||||
const fresh = existing !== null && Date.now() - existing.at < LOCK_FRESH_MS
|
||||
if (existing && fresh && pidLooksAlive(existing.pid)) {
|
||||
// Another live process owns a fresh lock: wait for it to release, go stale,
|
||||
// or die, then let the caller reload the warm cache.
|
||||
// or die. A CLEAN release means the cache is warm — reload it. Going stale or
|
||||
// dying (e.g. a SIGKILLed cold scan) means the holder left partial data AND a
|
||||
// leftover lock file: take over — clean the stale lock and re-acquire — so we
|
||||
// re-parse under our own lock and remove the leftover on release, instead of
|
||||
// leaving it for the next cold start to reclaim.
|
||||
const deadline = Date.now() + LOCK_WAIT_MAX_MS
|
||||
let takeover = false
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(LOCK_POLL_MS)
|
||||
const cur = await readLockRecord()
|
||||
if (!cur) break
|
||||
if (Date.now() - cur.at >= LOCK_FRESH_MS) break
|
||||
if (!pidLooksAlive(cur.pid)) break
|
||||
if (Date.now() - cur.at >= LOCK_FRESH_MS) { takeover = true; break }
|
||||
if (!pidLooksAlive(cur.pid)) { takeover = true; break }
|
||||
}
|
||||
if (takeover) {
|
||||
try { await unlink(lockPath()) } catch { /* another process may have; fine */ }
|
||||
if (await writeOurLock()) { armSignalCleanup(); return releaseHandle }
|
||||
}
|
||||
return { waited: true, release: async () => {} }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,7 +240,31 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
if (!effectivelyScoped) {
|
||||
if (isAllProviders) {
|
||||
cache = await hydrateCache()
|
||||
const todayProjects = await getTodayAllProjects()
|
||||
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
|
||||
// Single all-provider scan for the requested window. Previously the overview
|
||||
// parsed every provider TWICE — once over todayRange and once over the full
|
||||
// period — even though the period scan already contains today's turns. Scan
|
||||
// once here and derive the "today" slice from it below: aggregateProjects-
|
||||
// IntoDays buckets by each turn's own timestamp, so a period scan's today
|
||||
// slice is identical to a dedicated today scan's. A purely historical window
|
||||
// (ends before today) never reaches today, so its separate today scan — the
|
||||
// one the always-on daily-history strip needs — is left to run.
|
||||
let rawScan: ProjectSummary[]
|
||||
if (isTodayOnly) {
|
||||
rawScan = fp(await parseAllSessions(todayRange, 'all'))
|
||||
scanProjects = rawScan
|
||||
scanRange = todayRange
|
||||
} else {
|
||||
rawScan = fp(await parseAllSessions(periodInfo.range, 'all'))
|
||||
scanProjects = daysSelection ? filterProjectsByDays(rawScan, daysSelection.days) : rawScan
|
||||
scanRange = periodInfo.range
|
||||
}
|
||||
// Seed the today memo from the un-daysSelection rawScan so the always-on
|
||||
// daily-history strip still shows today even when the heatmap day filter
|
||||
// excludes it (matching the old dedicated today scan, which ignored it).
|
||||
if (rangeEndStr >= todayStr) {
|
||||
todayAllDays = aggregateProjectsIntoDays(rawScan).filter(d => d.date === todayStr)
|
||||
}
|
||||
const todayDays = await getTodayAllDays()
|
||||
const historicalDays = rangeStartStr <= historicalRangeEndStr
|
||||
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
|
||||
|
|
@ -249,15 +273,6 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
||||
const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays
|
||||
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
|
||||
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
|
||||
if (isTodayOnly) {
|
||||
scanProjects = todayProjects
|
||||
scanRange = todayRange
|
||||
} else {
|
||||
const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
|
||||
scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects
|
||||
scanRange = periodInfo.range
|
||||
}
|
||||
} else {
|
||||
cache = await loadDailyCache()
|
||||
const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { ProjectSummary } from '../src/types.js'
|
|||
|
||||
import {
|
||||
addNewDays,
|
||||
currentTzKey,
|
||||
dailyCachePath,
|
||||
DAILY_CACHE_VERSION,
|
||||
type DailyCache,
|
||||
|
|
@ -309,6 +310,7 @@ describe('ensureCacheHydrated', () => {
|
|||
const saved: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: '',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: '2026-06-11',
|
||||
days: [emptyDay('2026-06-11', 5, 10)],
|
||||
complete: true,
|
||||
|
|
@ -414,3 +416,43 @@ describe('ensureCacheHydrated: savings config invalidation', () => {
|
|||
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureCacheHydrated: timezone invalidation', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
|
||||
const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}`
|
||||
const parseSessions = async (): Promise<ProjectSummary[]> => []
|
||||
const aggregateDays = (): DailyEntry[] => []
|
||||
|
||||
it('re-hydrates when the cached tzKey differs from the current timezone', async () => {
|
||||
// Days are bucketed by local midnight, so a cache tagged under a different
|
||||
// timezone mis-buckets every day and must be discarded (like a savings-hash
|
||||
// mismatch). 'Test/OtherZone' can never equal a real IANA zone.
|
||||
const seeded: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: '',
|
||||
tzKey: 'Test/OtherZone',
|
||||
lastComputedDate: twoDaysAgoStr,
|
||||
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
|
||||
complete: true,
|
||||
}
|
||||
await saveDailyCache(seeded)
|
||||
const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, '')
|
||||
expect(rehydrated.tzKey).toBe(currentTzKey())
|
||||
expect(rehydrated.days).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps cached days when the tzKey matches the current timezone', async () => {
|
||||
const seeded: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: '',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: twoDaysAgoStr,
|
||||
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
|
||||
complete: true,
|
||||
}
|
||||
await saveDailyCache(seeded)
|
||||
const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, '')
|
||||
expect(preserved.days).toHaveLength(1)
|
||||
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provid
|
|||
}
|
||||
|
||||
describe('aggregateProjectsIntoDays', () => {
|
||||
it('buckets api calls by calendar date derived from timestamp', () => {
|
||||
it('buckets a whole turn (all its calls) on the turn user-message date', () => {
|
||||
// Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly
|
||||
// on the day of its user-message timestamp — matching the live headline/
|
||||
// report rollup — instead of splitting per-call across two days.
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
|
|
@ -79,11 +82,9 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
]
|
||||
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
|
||||
expect(days[0]!.cost).toBe(4)
|
||||
expect(days[0]!.calls).toBe(1)
|
||||
expect(days[1]!.cost).toBe(6)
|
||||
expect(days[1]!.calls).toBe(1)
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-09'])
|
||||
expect(days[0]!.cost).toBe(10)
|
||||
expect(days[0]!.calls).toBe(2)
|
||||
})
|
||||
|
||||
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
|
||||
|
|
@ -263,17 +264,17 @@ describe('buildPeriodDataFromDays', () => {
|
|||
expect(pd.models).toEqual([])
|
||||
})
|
||||
|
||||
it('attributes a midnight-straddling turn to the first assistant call date, not the user message date', () => {
|
||||
// Regression for the bug that shipped in 0.8.2-0.8.4: when a user message
|
||||
// sat on one side of midnight and the assistant response landed on the other,
|
||||
// day-aggregator.ts bucketed by assistant time but renderStatusBar bucketed
|
||||
// by user time, so the menubar and `codeburn status` disagreed on Today.
|
||||
// The invariant for both surfaces: a turn is counted on the day its first
|
||||
// assistant call actually ran.
|
||||
it('attributes a midnight-straddling turn to the user-message date, matching the live report', () => {
|
||||
// A turn whose user message sits on one side of midnight and whose assistant
|
||||
// response lands on the other must bucket by the USER-MESSAGE timestamp, so
|
||||
// the daily cache (history.daily + provider breakdown) reconciles exactly to
|
||||
// the live headline/report rollup (main.ts daily), which anchors on the same
|
||||
// turn timestamp. The prior per-call bucketing split such turns and left a
|
||||
// constant offset between the trend bars and current.cost.
|
||||
const userTs = '2026-04-20T23:58:00Z'
|
||||
const assistantTs = '2026-04-21T00:30:00Z'
|
||||
const assistantLocal = new Date(assistantTs)
|
||||
const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}`
|
||||
const userLocal = new Date(userTs)
|
||||
const expectedDate = `${userLocal.getFullYear()}-${String(userLocal.getMonth() + 1).padStart(2, '0')}-${String(userLocal.getDate()).padStart(2, '0')}`
|
||||
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
|
|
@ -308,3 +309,98 @@ describe('buildPeriodDataFromDays', () => {
|
|||
expect(costDay!.calls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('daily-cache ↔ report daily-bucket parity', () => {
|
||||
// The daily cache (history.daily + provider breakdown) and the live report /
|
||||
// headline (main.ts daily rollup) must bucket days by the SAME rule, or their
|
||||
// per-day totals drift and their period sums diverge from current.cost at
|
||||
// window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both
|
||||
// are now TURN-anchored: this asserts per-day equality against a reference
|
||||
// that mirrors main.ts:486-499 (turn.timestamp anchor), plus the invariant
|
||||
// history.daily Σ == report.daily Σ == total call cost.
|
||||
|
||||
// Mirrors the live report/headline daily rollup in src/main.ts (bucket the
|
||||
// whole turn — all its calls — on the turn's user-message date).
|
||||
function reportDailyByDate(projects: ProjectSummary[]): Record<string, number> {
|
||||
const byDate: Record<string, number> = {}
|
||||
for (const p of projects) {
|
||||
for (const sess of p.sessions) {
|
||||
for (const turn of sess.turns) {
|
||||
if (turn.assistantCalls.length === 0) continue
|
||||
const ts = turn.timestamp || turn.assistantCalls[0]!.timestamp
|
||||
const day = dateKey(ts)
|
||||
for (const call of turn.assistantCalls) byDate[day] = (byDate[day] ?? 0) + call.costUSD
|
||||
}
|
||||
}
|
||||
}
|
||||
return byDate
|
||||
}
|
||||
|
||||
it('buckets each day identically to the report and reconciles the period total', () => {
|
||||
// Construct in LOCAL time so the turn genuinely straddles local midnight on
|
||||
// any machine TZ (UTC-literal timestamps would straddle in some zones only).
|
||||
const iso = (y: number, mo: number, d: number, h: number, mi: number) =>
|
||||
new Date(y, mo, d, h, mi, 0).toISOString()
|
||||
const turnTs = iso(2026, 3, 16, 23, 50) // day A, late evening
|
||||
const call1Ts = iso(2026, 3, 16, 23, 55) // day A
|
||||
const call2Ts = iso(2026, 3, 17, 0, 10) // day A+1 (turn straddles midnight)
|
||||
const turn2Ts = iso(2026, 3, 17, 9, 0) // day A+1
|
||||
const dayA = dateKey(turnTs)
|
||||
const dayB = dateKey(call2Ts)
|
||||
expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight
|
||||
|
||||
// A midnight-straddling turn (calls on both days) plus a same-day turn, so
|
||||
// per-CALL bucketing would produce DIFFERENT per-day totals than the turn-
|
||||
// anchored report — the case the old code got wrong.
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: turnTs,
|
||||
lastTimestamp: turn2Ts,
|
||||
totalCostUSD: 12,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: 3,
|
||||
turns: [
|
||||
{
|
||||
userMessage: 'straddles into next day', timestamp: turnTs, sessionId: 's1',
|
||||
category: 'coding', retries: 0, hasEdits: false,
|
||||
assistantCalls: [makeCall(call1Ts, 2), makeCall(call2Ts, 3)],
|
||||
},
|
||||
{
|
||||
userMessage: 'later same day', timestamp: turn2Ts, sessionId: 's1',
|
||||
category: 'coding', retries: 0, hasEdits: false,
|
||||
assistantCalls: [makeCall(turn2Ts, 7)],
|
||||
},
|
||||
],
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown: {} as never,
|
||||
skillBreakdown: {} as never,
|
||||
}],
|
||||
}),
|
||||
]
|
||||
|
||||
const historyDaily = aggregateProjectsIntoDays(projects)
|
||||
const historyByDate = Object.fromEntries(historyDaily.map(d => [d.date, d.cost]))
|
||||
const reportByDate = reportDailyByDate(projects)
|
||||
|
||||
// history.daily (cache path) buckets each day EXACTLY as the report does.
|
||||
expect(historyByDate).toEqual(reportByDate)
|
||||
// And the provider breakdown, summed per day, matches too (same bug root).
|
||||
for (const d of historyDaily) {
|
||||
const providerSum = Object.values(d.providers).reduce((s, pr) => s + pr.cost, 0)
|
||||
expect(providerSum).toBeCloseTo(d.cost, 10)
|
||||
}
|
||||
|
||||
// history.daily Σ == report.daily Σ == current.cost (total of all call costs).
|
||||
const historySum = historyDaily.reduce((s, d) => s + d.cost, 0)
|
||||
const reportSum = Object.values(reportByDate).reduce((s, c) => s + c, 0)
|
||||
const totalCallCost = 2 + 3 + 7
|
||||
expect(historySum).toBeCloseTo(totalCallCost, 10)
|
||||
expect(reportSum).toBeCloseTo(totalCallCost, 10)
|
||||
// Day A owns the WHOLE straddling turn (2+3=5), not just its first call (2).
|
||||
expect(historyByDate[dayA]).toBe(5)
|
||||
expect(historyByDate[dayB]).toBe(7)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -135,4 +135,28 @@ describe('parseAllSessions hydration lock', () => {
|
|||
expect(totalOutput(result)).toBe(50)
|
||||
expect(existsSync(lockPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('takes over a lock whose live holder dies mid-wait, then cleans it up', async () => {
|
||||
await writeClaudeSession(50)
|
||||
await mkdir(cacheDir, { recursive: true })
|
||||
|
||||
// A fresh lock held by a live process (pid 1): the cold parse starts waiting.
|
||||
await writeFile(lockPath(), JSON.stringify({ pid: 1, at: Date.now() }))
|
||||
let resolved = false
|
||||
const promise = parseAllSessions(undefined, 'claude').then(r => { resolved = true; return r })
|
||||
await delay(60)
|
||||
expect(resolved).toBe(false) // still blocked on the live foreign lock
|
||||
|
||||
// The holder is SIGKILLed mid-scan: its pid goes dead and its lock is left
|
||||
// behind (no clean release). The waiter must detect the dead pid, take over
|
||||
// (re-parse under its own lock), and remove the leftover on release.
|
||||
await writeFile(lockPath(), JSON.stringify({ pid: 2_147_483_646, at: Date.now() }))
|
||||
|
||||
const result = await promise
|
||||
expect(resolved).toBe(true)
|
||||
// Took over and re-parsed the source (50) rather than trusting a partial cache.
|
||||
expect(totalOutput(result)).toBe(50)
|
||||
// No leftover lock: the takeover acquired it and released it in the finally.
|
||||
expect(existsSync(lockPath())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue