mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Merge feat/cache-durability: durable daily cache with migration
This commit is contained in:
commit
465294ca1e
5 changed files with 143 additions and 50 deletions
|
|
@ -246,8 +246,8 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
var providerKeys: [String] {
|
||||
switch self {
|
||||
case .cursor: ["cursor", "cursor agent"]
|
||||
case .rooCode: ["roo-code"]
|
||||
case .kiloCode: ["kilo-code"]
|
||||
case .rooCode: ["roo-code", "roo code"]
|
||||
case .kiloCode: ["kilo-code", "kilocode"]
|
||||
case .openclaw: ["openclaw"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
|
|
|
|||
59
src/cli.ts
59
src/cli.ts
|
|
@ -7,7 +7,7 @@ import { convertCost } from './currency.js'
|
|||
import { renderStatusBar } from './format.js'
|
||||
import { type PeriodData, type ProviderCost } from './menubar-json.js'
|
||||
import { buildMenubarPayload } from './menubar-json.js'
|
||||
import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, MS_PER_DAY, BACKFILL_DAYS, toDateString } from './daily-cache.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
|
|
@ -24,11 +24,15 @@ const require = createRequire(import.meta.url)
|
|||
const { version } = require('../package.json')
|
||||
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
const BACKFILL_DAYS = 365
|
||||
|
||||
function toDateString(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
async function hydrateCache() {
|
||||
try {
|
||||
return await ensureCacheHydrated(
|
||||
(range) => parseAllSessions(range, 'all'),
|
||||
aggregateProjectsIntoDays,
|
||||
)
|
||||
} catch {
|
||||
return emptyCache()
|
||||
}
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { range: DateRange; label: string } {
|
||||
|
|
@ -304,6 +308,7 @@ program
|
|||
const period = toPeriod(opts.period)
|
||||
if (opts.format === 'json') {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
if (customRange) {
|
||||
const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
|
||||
const projects = filterProjectsByName(
|
||||
|
|
@ -317,6 +322,7 @@ program
|
|||
}
|
||||
return
|
||||
}
|
||||
await hydrateCache()
|
||||
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
|
||||
})
|
||||
|
||||
|
|
@ -377,41 +383,10 @@ program
|
|||
const periodInfo = getDateRange(opts.period)
|
||||
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(todayStart.getTime() - MS_PER_DAY))
|
||||
const isAllProviders = pf === 'all'
|
||||
|
||||
// The daily cache is provider-agnostic: always backfill it from .all so subsequent
|
||||
// provider-filtered reads can derive per-provider cost+calls from DailyEntry.providers.
|
||||
// Yesterday is always recomputed: it may have been cached mid-day with partial data.
|
||||
const cache = await withDailyCacheLock(async () => {
|
||||
let c = await loadDailyCache()
|
||||
|
||||
// Evict yesterday (and any stale future entries) so the gap fill recomputes them.
|
||||
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(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
|
||||
|
||||
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
|
||||
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
|
||||
const gapDays = aggregateProjectsIntoDays(gapProjects)
|
||||
c = addNewDays(c, gapDays, yesterdayStr)
|
||||
await saveDailyCache(c)
|
||||
}
|
||||
return c
|
||||
})
|
||||
const cache = await hydrateCache()
|
||||
|
||||
// CURRENT PERIOD DATA
|
||||
// - .all provider: assemble from cache + today (fast)
|
||||
|
|
@ -529,6 +504,7 @@ program
|
|||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
await hydrateCache()
|
||||
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
|
||||
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
|
||||
const { code, rate } = getCurrency()
|
||||
|
|
@ -550,6 +526,7 @@ program
|
|||
return
|
||||
}
|
||||
|
||||
await hydrateCache()
|
||||
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
|
||||
console.log(renderStatusBar(monthProjects))
|
||||
})
|
||||
|
|
@ -567,6 +544,7 @@ program
|
|||
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await hydrateCache()
|
||||
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
|
||||
})
|
||||
|
||||
|
|
@ -583,6 +561,7 @@ program
|
|||
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await hydrateCache()
|
||||
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
|
||||
})
|
||||
|
||||
|
|
@ -596,6 +575,7 @@ program
|
|||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
const periods: PeriodExport[] = [
|
||||
|
|
@ -873,6 +853,7 @@ program
|
|||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
const projects = await parseAllSessions(range, opts.provider)
|
||||
await runOptimize(projects, label, range)
|
||||
|
|
@ -885,6 +866,7 @@ program
|
|||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const { range } = getDateRange(opts.period)
|
||||
await renderCompare(range, opts.provider)
|
||||
})
|
||||
|
|
@ -896,6 +878,7 @@ program
|
|||
.action(async (opts) => {
|
||||
const { computeYield, formatYieldSummary } = await import('./yield.js')
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
console.log(`\n Analyzing yield for ${label}...\n`)
|
||||
const summary = await computeYield(range, process.cwd())
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ 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'
|
||||
|
||||
export const DAILY_CACHE_VERSION = 4
|
||||
const MIN_SUPPORTED_VERSION = 2
|
||||
const DAILY_CACHE_FILENAME = 'daily-cache.json'
|
||||
|
||||
export type DailyEntry = {
|
||||
|
|
@ -44,16 +46,39 @@ function getCachePath(): string {
|
|||
return join(getCacheDir(), DAILY_CACHE_FILENAME)
|
||||
}
|
||||
|
||||
function emptyCache(): DailyCache {
|
||||
export function emptyCache(): DailyCache {
|
||||
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
|
||||
}
|
||||
|
||||
function isValidCache(parsed: unknown): parsed is DailyCache {
|
||||
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 (c.version !== DAILY_CACHE_VERSION) return false
|
||||
if (typeof c.version !== 'number') return false
|
||||
if (!Array.isArray(c.days)) return false
|
||||
return true
|
||||
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> {
|
||||
|
|
@ -62,8 +87,20 @@ export async function loadDailyCache(): Promise<DailyCache> {
|
|||
try {
|
||||
const raw = await readFile(path, 'utf-8')
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isValidCache(parsed)) return emptyCache()
|
||||
return parsed
|
||||
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()
|
||||
}
|
||||
|
|
@ -113,3 +150,48 @@ export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
|
|||
lockChain = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
export const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
export const BACKFILL_DAYS = 365
|
||||
|
||||
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(todayStart.getTime() - MS_PER_DAY))
|
||||
|
||||
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(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,8 @@ const autoModelNames: Record<string, string> = {
|
|||
'cursor-auto': 'Cursor (auto)',
|
||||
'cursor-agent-auto': 'Cursor (auto)',
|
||||
'copilot-auto': 'Copilot (auto)',
|
||||
'copilot-openai-auto': 'Copilot (OpenAI)',
|
||||
'copilot-anthropic-auto': 'Copilot (Anthropic)',
|
||||
'kiro-auto': 'Kiro (auto)',
|
||||
'cline-auto': 'Cline (auto)',
|
||||
'openclaw-auto': 'OpenClaw (auto)',
|
||||
|
|
|
|||
|
|
@ -62,11 +62,11 @@ describe('loadDailyCache', () => {
|
|||
expect(cache.days).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty cache when the version does not match', async () => {
|
||||
const saved: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION - 999,
|
||||
it('returns an empty cache and backs up when version is too old to migrate', async () => {
|
||||
const saved = {
|
||||
version: 1,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [emptyDay('2026-04-10', 10)],
|
||||
days: [{ date: '2026-04-10', cost: 10, calls: 5 }],
|
||||
}
|
||||
const { writeFile, mkdir } = await import('fs/promises')
|
||||
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
||||
|
|
@ -74,6 +74,32 @@ describe('loadDailyCache', () => {
|
|||
const cache = await loadDailyCache()
|
||||
expect(cache.days).toEqual([])
|
||||
expect(cache.lastComputedDate).toBeNull()
|
||||
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true)
|
||||
})
|
||||
|
||||
it('migrates an older supported version by filling missing fields', async () => {
|
||||
const saved = {
|
||||
version: 2,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [{
|
||||
date: '2026-04-10', cost: 10, calls: 5, sessions: 2,
|
||||
inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100,
|
||||
models: { 'claude-opus-4-6': { calls: 5, cost: 10, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } },
|
||||
}],
|
||||
}
|
||||
const { writeFile, mkdir } = await import('fs/promises')
|
||||
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.version).toBe(DAILY_CACHE_VERSION)
|
||||
expect(cache.days).toHaveLength(1)
|
||||
expect(cache.days[0].date).toBe('2026-04-10')
|
||||
expect(cache.days[0].cost).toBe(10)
|
||||
expect(cache.days[0].editTurns).toBe(0)
|
||||
expect(cache.days[0].oneShotTurns).toBe(0)
|
||||
expect(cache.days[0].categories).toEqual({})
|
||||
expect(cache.days[0].providers).toEqual({})
|
||||
expect(cache.days[0].models['claude-opus-4-6'].calls).toBe(5)
|
||||
})
|
||||
|
||||
it('round-trips a valid cache through save and load', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue