codeburn/src/daily-cache.ts
iamtoruk b0131f698c Store credential cache in file instead of keychain, use cache for per-provider menubar
Credential cache: switched from keychain to file-based storage under
Application Support. Ad-hoc signed builds invalidate keychain ACLs on
every rebuild, causing repeated macOS password prompts. Existing
keychain entries are migrated to file on first read, then deleted.

Per-provider menubar: the Codex/Claude/etc tabs previously re-parsed
all sessions from scratch (22s). Now parses only today with the
provider filter and uses the daily cache for historical days, matching
the fast path the All tab already uses.

Daily cache bumped to v7 to force a clean rebuild after pricing and
provider changes since v6.
2026-05-17 07:18:58 -07:00

218 lines
7.9 KiB
TypeScript

import { randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 7: new providers (Codebuff, Mistral Vibe, Kimi, Cline) and
// the per-provider menubar path now reads historical cost from the cache.
// Stale entries computed by older binaries may carry incorrect totals.
export const DAILY_CACHE_VERSION = 7
const MIN_SUPPORTED_VERSION = 7
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {
date: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
editTurns: number
oneShotTurns: number
models: Record<string, {
calls: number
cost: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}>
categories: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
providers: Record<string, { calls: number; cost: number }>
}
export type DailyCache = {
version: number
lastComputedDate: string | null
days: DailyEntry[]
}
function getCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}
function getCachePath(): string {
return join(getCacheDir(), DAILY_CACHE_FILENAME)
}
export function emptyCache(): DailyCache {
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
}
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; days: Record<string, unknown>[] } {
if (!parsed || typeof parsed !== 'object') return false
const c = parsed as Partial<DailyCache>
if (typeof c.version !== 'number') return false
if (!Array.isArray(c.days)) return false
return c.version >= MIN_SUPPORTED_VERSION && c.version <= DAILY_CACHE_VERSION
}
function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
return days.map(d => ({
date: d.date as string,
cost: (d.cost as number) ?? 0,
calls: (d.calls as number) ?? 0,
sessions: (d.sessions as number) ?? 0,
inputTokens: (d.inputTokens as number) ?? 0,
outputTokens: (d.outputTokens as number) ?? 0,
cacheReadTokens: (d.cacheReadTokens as number) ?? 0,
cacheWriteTokens: (d.cacheWriteTokens as number) ?? 0,
editTurns: (d.editTurns as number) ?? 0,
oneShotTurns: (d.oneShotTurns as number) ?? 0,
models: (d.models as DailyEntry['models']) ?? {},
categories: (d.categories as DailyEntry['categories']) ?? {},
providers: (d.providers as DailyEntry['providers']) ?? {},
}))
}
async function backupOldCache(path: string, version: number): Promise<void> {
const backupPath = `${path}.v${version}.bak`
try { await rename(path, backupPath) } catch { /* best-effort */ }
}
export async function loadDailyCache(): Promise<DailyCache> {
const path = getCachePath()
if (!existsSync(path)) return emptyCache()
try {
const raw = await readFile(path, 'utf-8')
const parsed: unknown = JSON.parse(raw)
if (isMigratableCache(parsed)) {
const migrated: DailyCache = {
version: DAILY_CACHE_VERSION,
lastComputedDate: parsed.lastComputedDate,
days: migrateDays(parsed.days),
}
if (parsed.version < DAILY_CACHE_VERSION) {
await saveDailyCache(migrated).catch(() => {})
}
return migrated
}
const oldVersion = (parsed as { version?: number })?.version
if (typeof oldVersion === 'number') await backupOldCache(path, oldVersion)
return emptyCache()
} catch {
return emptyCache()
}
}
export async function saveDailyCache(cache: DailyCache): Promise<void> {
const dir = getCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
const payload = JSON.stringify(cache)
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(payload, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(tempPath, finalPath)
} catch (err) {
try { await unlink(tempPath) } catch { /* ignore */ }
throw err
}
}
export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
const byDate = new Map(cache.days.map(d => [d.date, d]))
for (const day of incoming) {
byDate.set(day.date, day)
}
const merged = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date))
// Prune entries older than the BACKFILL window so the cache file does not
// grow unbounded over years of daily use. The "all time" / 6-month period
// and the BACKFILL_DAYS bootstrap both fit comfortably inside this cap.
// Anchor the cap on the newestDate boundary so a stale or stuck clock
// can't accidentally evict everything. Skip the prune entirely if
// newestDate is malformed — an invalid Date would produce a NaN cutoff
// and `d.date >= "Invalid Date"` would silently drop every entry.
const cutoffDate = new Date(`${newestDate}T00:00:00Z`)
let pruned = merged
if (!isNaN(cutoffDate.getTime())) {
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - DAILY_CACHE_RETENTION_DAYS)
const cutoff = toDateString(cutoffDate)
pruned = merged.filter(d => d.date >= cutoff)
}
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
? cache.lastComputedDate
: newestDate
return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: pruned }
}
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
return cache.days.filter(d => d.date >= start && d.date <= end)
}
let lockChain: Promise<unknown> = Promise.resolve()
export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
const next = lockChain.then(() => fn())
lockChain = next.catch(() => undefined)
return next
}
export const MS_PER_DAY = 24 * 60 * 60 * 1000
export const BACKFILL_DAYS = 365
// Keep 2 years of history so the longest UI-exposed period (6 months
// today, with headroom for future longer windows) always reads from
// cache while old entries get pruned.
export const DAILY_CACHE_RETENTION_DAYS = 730
export function toDateString(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
export async function ensureCacheHydrated(
parseSessions: (range: DateRange) => Promise<ProjectSummary[]>,
aggregateDays: (projects: ProjectSummary[]) => DailyEntry[],
): Promise<DailyCache> {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayEnd = new Date(todayStart.getTime() - 1)
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
return withDailyCacheLock(async () => {
let c = await loadDailyCache()
const hadYesterday = c.days.some(d => d.date >= yesterdayStr)
if (hadYesterday) {
const freshDays = c.days.filter(d => d.date < yesterdayStr)
const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
}
const gapStart = c.lastComputedDate
? new Date(
parseInt(c.lastComputedDate.slice(0, 4)),
parseInt(c.lastComputedDate.slice(5, 7)) - 1,
parseInt(c.lastComputedDate.slice(8, 10)) + 1
)
: new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = await parseSessions(gapRange)
const gapDays = aggregateDays(gapProjects)
c = addNewDays(c, gapDays, yesterdayStr)
await saveDailyCache(c)
}
return c
})
}