mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 23:54:45 +00:00
perf(desktop): warm reports and cache compare scans
This commit is contained in:
parent
6e3c57a9ff
commit
5c7ff78a89
21 changed files with 418 additions and 84 deletions
|
|
@ -32,7 +32,7 @@ This follows the menubar pattern:
|
|||
|
||||
Current bridge calls:
|
||||
|
||||
- Overview: `codeburn status --format menubar-json --period <period> [--provider <provider>]`
|
||||
- Overview: `codeburn status --format menubar-json --period <period> --no-timeline --no-optimize [--provider <provider>]`
|
||||
- Plans: `codeburn status --format json --period <period>`
|
||||
- Models: `codeburn models --format json --period <period> [--provider <provider>] [--by-task]`
|
||||
- Optimize: `codeburn yield --format json --period <period>`
|
||||
|
|
|
|||
|
|
@ -456,9 +456,9 @@ describe('spawnCli concurrency scheduler', () => {
|
|||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
schedulerBin(startedFile, releaseDir)
|
||||
|
||||
// Fill both slots so the next two calls must queue.
|
||||
const p1 = spawnCli(['fill1'], { priority: 'background' })
|
||||
const p2 = spawnCli(['fill2'], { priority: 'background' })
|
||||
// Fill both slots with real interactive work so the next two calls queue.
|
||||
const p1 = spawnCli(['fill1'], { priority: 'interactive' })
|
||||
const p2 = spawnCli(['fill2'], { priority: 'interactive' })
|
||||
await waitUntil(() => startedList(startedFile).length === 2)
|
||||
|
||||
// Queue a background first, then an interactive.
|
||||
|
|
@ -478,6 +478,27 @@ describe('spawnCli concurrency scheduler', () => {
|
|||
await Promise.all([p1, p2, pbg, pint])
|
||||
})
|
||||
|
||||
it('reserves one CLI lane while background prefetch is running', async () => {
|
||||
const startedFile = join(dir, 'started')
|
||||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
schedulerBin(startedFile, releaseDir)
|
||||
|
||||
const bg1 = spawnCli(['bg1'], { priority: 'background' })
|
||||
const bg2 = spawnCli(['bg2'], { priority: 'background' })
|
||||
await waitUntil(() => startedList(startedFile).includes('bg1'))
|
||||
await delay(50)
|
||||
expect(startedList(startedFile)).toEqual(['bg1'])
|
||||
|
||||
const interactive = spawnCli(['clicked'], { priority: 'interactive' })
|
||||
await waitUntil(() => startedList(startedFile).includes('clicked'))
|
||||
expect(startedList(startedFile)).not.toContain('bg2')
|
||||
|
||||
release(releaseDir, 'bg1'); release(releaseDir, 'clicked')
|
||||
await waitUntil(() => startedList(startedFile).includes('bg2'))
|
||||
release(releaseDir, 'bg2')
|
||||
await Promise.all([bg1, bg2, interactive])
|
||||
})
|
||||
|
||||
it('does not spend a slot on a coalesced (same-argv) call', async () => {
|
||||
const startedFile = join(dir, 'started')
|
||||
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
|
||||
|
|
|
|||
|
|
@ -77,10 +77,13 @@ let running = 0
|
|||
const interactiveQueue: SlotWaiter[] = []
|
||||
const backgroundQueue: SlotWaiter[] = []
|
||||
|
||||
/** Grant free slots to queued waiters, interactive first, up to the cap. */
|
||||
/** Grant free slots to queued waiters, interactive first, up to the cap.
|
||||
* Background warmups intentionally use at most MAX-1 slots so speculative tab
|
||||
* prefetch can never occupy every CLI lane; a click always has one lane ready. */
|
||||
function pumpSlots(): void {
|
||||
while (running < MAX_CONCURRENT_CLI) {
|
||||
const waiter = interactiveQueue.shift() ?? backgroundQueue.shift()
|
||||
const interactive = interactiveQueue.shift()
|
||||
const waiter = interactive ?? (running < MAX_CONCURRENT_CLI - 1 ? backgroundQueue.shift() : undefined)
|
||||
if (!waiter) return
|
||||
running += 1
|
||||
waiter.resolve()
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ const CHANNELS = [
|
|||
] as const
|
||||
|
||||
const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--provider', 'claude'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize'] },
|
||||
{ channel: 'codeburn:getPlans', args: ['week'], argv: ['status', '--format', 'json', '--period', 'week'] },
|
||||
{ channel: 'codeburn:getActReport', args: [], argv: ['act', 'report', '--json'] },
|
||||
{ channel: 'codeburn:getModels', args: ['week', 'claude', true], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task'] },
|
||||
|
|
@ -86,9 +86,9 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
|
|||
{ channel: 'codeburn:getYield', args: ['today', 'claude'], argv: ['yield', '--format', 'json', '--period', 'today', '--provider', 'claude'] },
|
||||
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
|
||||
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--no-optimize', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
|
||||
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
|
|
@ -163,7 +163,7 @@ describe('createBridgeHandlers (IPC wiring)', () => {
|
|||
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
|
||||
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
|
||||
const res = await handlers['codeburn:getOverview']!('30days', 'all')
|
||||
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'])
|
||||
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize'])
|
||||
expect(res).toEqual({ ok: true, value: { current: { cost: 12.34 } } })
|
||||
})
|
||||
|
||||
|
|
@ -443,6 +443,21 @@ describe('createBridgeHandlers (cold-start warmup)', () => {
|
|||
expect(opts[2]?.priority).toBe('background')
|
||||
})
|
||||
|
||||
it('runs section prefetch reads at background priority without changing argv', async () => {
|
||||
const opts: Array<Record<string, unknown> | undefined> = []
|
||||
const spawnCli = vi.fn(async (_args: string[], o?: Record<string, unknown>) => { opts.push(o); return [] })
|
||||
const handlers = createBridgeHandlers(base({ spawnCli }))
|
||||
|
||||
await handlers['codeburn:getSessions']!('30days', 'all', undefined, true)
|
||||
await handlers['codeburn:getModels']!('30days', 'all', false, undefined, true)
|
||||
await handlers['codeburn:getCompareModels']!('30days', 'all', true)
|
||||
|
||||
expect(spawnCli.mock.calls[0]?.[0]).toEqual(['sessions', '--format', 'json', '--period', '30days'])
|
||||
expect(spawnCli.mock.calls[1]?.[0]).toEqual(['models', '--format', 'json', '--period', '30days'])
|
||||
expect(spawnCli.mock.calls[2]?.[0]).toEqual(['compare', '--format', 'json', '--period', '30days'])
|
||||
expect(opts.map(o => o?.priority)).toEqual(['background', 'background', 'background'])
|
||||
})
|
||||
|
||||
it('re-arms the long timeout when the first overview fails (cache is still cold)', async () => {
|
||||
const opts: Array<{ timeoutMs?: number } | undefined> = []
|
||||
let n = 0
|
||||
|
|
|
|||
|
|
@ -253,12 +253,15 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
telemetry?.track('cold_start', { ms: Date.now() - (coldStartBegan ?? Date.now()), timedOut })
|
||||
}
|
||||
|
||||
const run = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
|
||||
const run = (build: (...args: any[]) => string[], backgroundArg?: number): Handler => async (...args: any[]) => {
|
||||
let cmd: string | undefined
|
||||
try {
|
||||
const argv = build(...args)
|
||||
cmd = argv[0]
|
||||
return { ok: true, value: await deps.spawnCli(argv) }
|
||||
const priority: SpawnPriority | undefined = backgroundArg !== undefined && args[backgroundArg] === true
|
||||
? 'background'
|
||||
: undefined
|
||||
return { ok: true, value: await deps.spawnCli(argv, priority ? { priority } : undefined) }
|
||||
} catch (err) {
|
||||
const error = toEnvelopeError(err)
|
||||
telemetry?.track('cli_error', cliErrorProps(err, cmd))
|
||||
|
|
@ -270,7 +273,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
|
||||
// omits the flag and keeps the timeline unchanged.
|
||||
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
|
||||
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
|
||||
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', '--no-optimize',
|
||||
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
|
||||
]
|
||||
|
||||
|
|
@ -319,13 +322,13 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
'codeburn:getActReport': run(() => ['act', 'report', '--json']),
|
||||
'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [
|
||||
'models', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...(byTask ? ['--by-task'] : []), ...rangeArgs(vRange(range)),
|
||||
]),
|
||||
], 4),
|
||||
'codeburn:getSessions': run((period: string, provider: string, range?: DateRange) => [
|
||||
'sessions', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
|
||||
]),
|
||||
], 3),
|
||||
'codeburn:getCompareModels': run((period: string, provider: string) => [
|
||||
'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)),
|
||||
]),
|
||||
], 2),
|
||||
'codeburn:getCompare': run((period: string, provider: string, modelA: string, modelB: string) => [
|
||||
'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), '--model-a', vToken(modelA), '--model-b', vToken(modelB),
|
||||
]),
|
||||
|
|
@ -334,10 +337,10 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
]),
|
||||
'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [
|
||||
'spend', '--format', 'flow-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
|
||||
]),
|
||||
], 3),
|
||||
'codeburn:getOptimizeReport': run((period: string, provider: string, range?: DateRange) => [
|
||||
'optimize', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
|
||||
]),
|
||||
], 3),
|
||||
'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', vPeriod(period)]),
|
||||
'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']),
|
||||
'codeburn:getShareStatus': run(() => ['share', 'status', '--format', 'json']),
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ const bridge = {
|
|||
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
|
||||
getPlans: (period: string) => invoke('codeburn:getPlans', period),
|
||||
getActReport: () => invoke('codeburn:getActReport'),
|
||||
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
|
||||
getSessions: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSessions', period, provider, range),
|
||||
getCompareModels: (period: string, provider: string) => invoke('codeburn:getCompareModels', period, provider),
|
||||
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange, background?: boolean) => invoke('codeburn:getModels', period, provider, byTask, range, background),
|
||||
getSessions: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getSessions', period, provider, range, background),
|
||||
getCompareModels: (period: string, provider: string, background?: boolean) => invoke('codeburn:getCompareModels', period, provider, background),
|
||||
getCompare: (period: string, provider: string, modelA: string, modelB: string) => invoke('codeburn:getCompare', period, provider, modelA, modelB),
|
||||
getYield: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getYield', period, provider, range),
|
||||
getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range),
|
||||
getOptimizeReport: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOptimizeReport', period, provider, range),
|
||||
getSpendFlow: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getSpendFlow', period, provider, range, background),
|
||||
getOptimizeReport: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getOptimizeReport', period, provider, range, background),
|
||||
getDevices: (period: string) => invoke('codeburn:getDevices', period),
|
||||
getDevicesScan: () => invoke('codeburn:getDevicesScan'),
|
||||
getShareStatus: () => invoke('codeburn:getShareStatus'),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
|
|||
import { motionClass } from './lib/motion'
|
||||
import { codeburn } from './lib/ipc'
|
||||
import { localDateKey } from './lib/period'
|
||||
import { compareModelsReportKey, modelsReportKey, optimizeReportKey, sessionsReportKey, spendFlowReportKey } from './lib/reportKeys'
|
||||
import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence'
|
||||
import { OverviewContent } from './sections/Overview'
|
||||
import { OptimizeContent } from './sections/Optimize'
|
||||
|
|
@ -141,13 +142,15 @@ const PREFETCH_START_DELAY_MS = 1500
|
|||
// A warm spawn takes seconds, so a 400ms stagger let the loop fire the whole set
|
||||
// almost at once; pace it wide enough that each warm genuinely trails the last.
|
||||
const PREFETCH_STAGGER_MS = 2000
|
||||
const SECTION_PREFETCH_START_DELAY_MS = 500
|
||||
const SECTION_PREFETCH_STAGGER_MS = 100
|
||||
// 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
|
||||
// provider prefetch entries plus the active provider's sessions, models, spend,
|
||||
// optimize, yield, and compare-picker reports. 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
|
||||
const BASE_MEMO_KEYS = 12
|
||||
|
||||
function isPeriod(value: string): value is Period {
|
||||
return (STANDARD_PERIODS as string[]).includes(value)
|
||||
|
|
@ -281,7 +284,9 @@ function AppMain() {
|
|||
void (async () => {
|
||||
let modelCategories: Map<string, string> | undefined
|
||||
try {
|
||||
modelCategories = topCategoryByModel(await codeburn.getModels(period, 'all', false))
|
||||
const rows = await codeburn.getModels(period, 'all', false)
|
||||
primePolledMemo(modelsReportKey(period, 'all', false), rows)
|
||||
modelCategories = topCategoryByModel(rows)
|
||||
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
|
||||
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
|
||||
})()
|
||||
|
|
@ -393,6 +398,58 @@ function AppMain() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
|
||||
|
||||
// Warm the first view of each heavyweight section after the overview paints.
|
||||
// These run one at a time at background priority; the main-process scheduler
|
||||
// reserves the second CLI lane for a real click. Results seed the same memo
|
||||
// keys usePolled reads, so the first navigation paints immediately instead of
|
||||
// showing a skeleton while a fresh process starts.
|
||||
const warmedSectionKeys = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource) return
|
||||
const tasks: Array<{ key: string; fetch: () => Promise<unknown> }> = [
|
||||
{
|
||||
key: sessionsReportKey(period, provider),
|
||||
fetch: () => codeburn.getSessions(period, provider, undefined, true),
|
||||
},
|
||||
{
|
||||
key: modelsReportKey(period, provider, false),
|
||||
fetch: () => codeburn.getModels(period, provider, false, undefined, true),
|
||||
},
|
||||
{
|
||||
key: spendFlowReportKey(period, provider),
|
||||
fetch: () => codeburn.getSpendFlow(period, provider, undefined, true),
|
||||
},
|
||||
{
|
||||
key: optimizeReportKey(period, provider),
|
||||
fetch: () => codeburn.getOptimizeReport(period, provider, undefined, true),
|
||||
},
|
||||
{
|
||||
key: compareModelsReportKey(period, provider),
|
||||
fetch: () => codeburn.getCompareModels(period, provider, true),
|
||||
},
|
||||
]
|
||||
|
||||
let cancelled = false
|
||||
const warm = async () => {
|
||||
for (const task of tasks) {
|
||||
if (cancelled) return
|
||||
if (warmedSectionKeys.current.has(task.key) || hasPolledMemo(task.key)) continue
|
||||
warmedSectionKeys.current.add(task.key)
|
||||
try {
|
||||
const value = await task.fetch()
|
||||
if (!cancelled) primePolledMemo(task.key, value)
|
||||
} catch {
|
||||
// Best effort. Remove the guard so a later dependency change can retry;
|
||||
// a real navigation still fetches and surfaces the structured error.
|
||||
warmedSectionKeys.current.delete(task.key)
|
||||
}
|
||||
if (!cancelled) await new Promise(resolve => setTimeout(resolve, SECTION_PREFETCH_STAGGER_MS))
|
||||
}
|
||||
}
|
||||
const start = setTimeout(() => { void warm() }, SECTION_PREFETCH_START_DELAY_MS)
|
||||
return () => { cancelled = true; clearTimeout(start) }
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, overview.data == null])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(id)
|
||||
|
|
@ -411,6 +468,8 @@ function AppMain() {
|
|||
// couple seconds (quick like the menubar) instead of at the next poll.
|
||||
const onConfigMutated = useCallback(() => {
|
||||
clearPolledMemo()
|
||||
warmedKeys.current.clear()
|
||||
warmedSectionKeys.current.clear()
|
||||
refreshVisible()
|
||||
}, [refreshVisible])
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ describe('usePolled', () => {
|
|||
|
||||
// Mount on key kA, resolve to A0 → memoized under kA.
|
||||
const { result, rerender } = renderHook(
|
||||
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }),
|
||||
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k, intervalMs: null }),
|
||||
{ initialProps: { k: 'kA' } },
|
||||
)
|
||||
await act(async () => { resolvers[0]!('A0') })
|
||||
|
|
@ -126,6 +126,21 @@ describe('usePolled', () => {
|
|||
expect(result.current.switching).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch a fresh memoized result on remount, but manual refresh bypasses freshness', async () => {
|
||||
primePolledMemo('fresh-tab', 'cached')
|
||||
const fetcher = vi.fn().mockResolvedValue('fresh')
|
||||
const { result } = renderHook(() => usePolled(fetcher, [], { memoKey: 'fresh-tab', intervalMs: 60_000 }))
|
||||
|
||||
await act(async () => {})
|
||||
expect(result.current.data).toBe('cached')
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => { result.current.refresh() })
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.data).toBe('fresh')
|
||||
})
|
||||
|
||||
it('clears stale data on a switch to an unmemoized key (skeleton, never the prior filter)', async () => {
|
||||
const resolvers: Array<(v: string) => void> = []
|
||||
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ export type Polled<T> = {
|
|||
// 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
|
||||
const MEMO_MAX_CAP = 64
|
||||
let memoMax = DEFAULT_MEMO_MAX
|
||||
const memoStore = new Map<string, unknown>()
|
||||
type MemoEntry = { value: unknown; at: number }
|
||||
const memoStore = new Map<string, MemoEntry>()
|
||||
|
||||
/** 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
|
||||
|
|
@ -46,18 +47,18 @@ export function setPolledMemoMax(n: number): void {
|
|||
}
|
||||
}
|
||||
|
||||
function memoGet<T>(key: string): T | undefined {
|
||||
function memoGet<T>(key: string): { value: T; at: number } | undefined {
|
||||
if (!memoStore.has(key)) return undefined
|
||||
const value = memoStore.get(key) as T
|
||||
const entry = memoStore.get(key) as MemoEntry
|
||||
// Touch recency.
|
||||
memoStore.delete(key)
|
||||
memoStore.set(key, value)
|
||||
return value
|
||||
memoStore.set(key, entry)
|
||||
return { value: entry.value as T, at: entry.at }
|
||||
}
|
||||
|
||||
function memoSet(key: string, value: unknown): void {
|
||||
function memoSet(key: string, value: unknown, at = Date.now()): void {
|
||||
if (memoStore.has(key)) memoStore.delete(key)
|
||||
memoStore.set(key, value)
|
||||
memoStore.set(key, { value, at })
|
||||
while (memoStore.size > memoMax) {
|
||||
const oldest = memoStore.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
|
|
@ -120,11 +121,15 @@ export function usePolled<T>(
|
|||
const intervalMs = opts.intervalMs !== undefined ? opts.intervalMs : cadence.intervalMs
|
||||
const enabled = opts.enabled ?? true
|
||||
const memoKey = opts.memoKey
|
||||
const [data, setData] = useState<T | null>(() => (memoKey ? memoGet<T>(memoKey) ?? null : null))
|
||||
const [data, setData] = useState<T | null>(() => (memoKey ? memoGet<T>(memoKey)?.value ?? null : null))
|
||||
const [error, setError] = useState<CliError | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [lastSuccessAt, setLastSuccessAt] = useState<number | null>(null)
|
||||
// Fresh memo data suppresses only the first load after a component remount.
|
||||
// Later dependency changes (provider/period/config/refreshToken) must still
|
||||
// revalidate, even when they switch to a recently-prefetched key.
|
||||
const initialLoadRef = useRef(true)
|
||||
// Generation counter: every load() (mount, deps change, interval, refresh)
|
||||
// claims the next epoch; a fetch applies its result only while its epoch is
|
||||
// still current. This is what keeps a slow fetch from an older deps/period
|
||||
|
|
@ -134,8 +139,10 @@ export function usePolled<T>(
|
|||
// visibilitychange catch-up can read it without re-subscribing on every poll.
|
||||
const lastSuccessRef = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
const load = useCallback((force = false) => {
|
||||
if (!enabled) return
|
||||
const initialLoad = initialLoadRef.current
|
||||
initialLoadRef.current = false
|
||||
const epoch = ++epochRef.current
|
||||
// Instant paint: on a deps/key change, if a last-good result for the new key
|
||||
// is cached, show it immediately and flag `switching` while the fresh fetch
|
||||
|
|
@ -144,11 +151,30 @@ export function usePolled<T>(
|
|||
// numbers. (An interval re-poll keeps the same key, whose last result is
|
||||
// always cached, so a background refresh never blanks.)
|
||||
let servedCached = false
|
||||
let cachedAt: number | null = null
|
||||
if (memoKey) {
|
||||
const cached = memoGet<T>(memoKey)
|
||||
if (cached !== undefined) { setData(cached); servedCached = true }
|
||||
if (cached !== undefined) {
|
||||
setData(cached.value)
|
||||
servedCached = true
|
||||
cachedAt = cached.at
|
||||
}
|
||||
else setData(null)
|
||||
}
|
||||
// A tab remount or switch-back should not immediately launch an identical
|
||||
// CLI process when its last-good result is still inside the configured
|
||||
// refresh cadence. Manual cadence keeps memoized data until Refresh is
|
||||
// explicitly clicked. Stale entries continue through the normal
|
||||
// stale-while-revalidate path below.
|
||||
const cachedIsFresh = cachedAt !== null && (intervalMs === null || Date.now() - cachedAt < intervalMs)
|
||||
if (servedCached && initialLoad && !force && cachedIsFresh) {
|
||||
setLoading(false)
|
||||
setSwitching(false)
|
||||
setError(null)
|
||||
setLastSuccessAt(cachedAt)
|
||||
lastSuccessRef.current = cachedAt
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setSwitching(servedCached)
|
||||
// Clear any prior error at the start of each attempt so a fresh poll never
|
||||
|
|
@ -177,7 +203,7 @@ export function usePolled<T>(
|
|||
// `memoKey` are prepended so flipping the gate / key re-creates load and
|
||||
// fires immediately.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, memoKey, ...deps])
|
||||
}, [enabled, memoKey, intervalMs, ...deps])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
|
|
@ -212,7 +238,7 @@ export function usePolled<T>(
|
|||
}, [load, intervalMs])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
load()
|
||||
load(true)
|
||||
}, [load])
|
||||
|
||||
return { data, error, loading, switching, lastSuccessAt, refresh }
|
||||
|
|
|
|||
29
app/renderer/lib/reportKeys.ts
Normal file
29
app/renderer/lib/reportKeys.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { DateRange, Period } from './types'
|
||||
|
||||
function rangeKey(range: DateRange | null | undefined): string {
|
||||
return `${range?.from ?? ''}-${range?.to ?? ''}`
|
||||
}
|
||||
|
||||
export function sessionsReportKey(period: Period, provider: string, range?: DateRange | null): string {
|
||||
return `sessions|${period}|${provider}|${rangeKey(range)}`
|
||||
}
|
||||
|
||||
export function modelsReportKey(period: Period, provider: string, byTask: boolean, range?: DateRange | null): string {
|
||||
return `models|${period}|${provider}|${byTask}|${rangeKey(range)}`
|
||||
}
|
||||
|
||||
export function spendFlowReportKey(period: Period, provider: string, range?: DateRange | null): string {
|
||||
return `spendflow|${period}|${provider}|${rangeKey(range)}`
|
||||
}
|
||||
|
||||
export function optimizeReportKey(period: Period, provider: string, range?: DateRange | null): string {
|
||||
return `optimize|${period}|${provider}|${rangeKey(range)}`
|
||||
}
|
||||
|
||||
export function yieldReportKey(period: Period, provider: string, range?: DateRange | null): string {
|
||||
return `yield|${period}|${provider}|${rangeKey(range)}`
|
||||
}
|
||||
|
||||
export function compareModelsReportKey(period: Period, provider: string): string {
|
||||
return `comparemodels|${period}|${provider}`
|
||||
}
|
||||
|
|
@ -644,13 +644,13 @@ export interface CodeburnBridge {
|
|||
/** Node process.arch of the host ('arm64', 'x64', ...). Absent on preloads
|
||||
* that predate the direct-download update link. */
|
||||
readonly arch?: string
|
||||
getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise<ModelReportRow[]>
|
||||
getSessions(period: Period, provider: string, range?: DateRange): Promise<SessionRow[]>
|
||||
getCompareModels(period: Period, provider: string): Promise<ModelStats[]>
|
||||
getModels(period: Period, provider: string, byTask: boolean, range?: DateRange, background?: boolean): Promise<ModelReportRow[]>
|
||||
getSessions(period: Period, provider: string, range?: DateRange, background?: boolean): Promise<SessionRow[]>
|
||||
getCompareModels(period: Period, provider: string, background?: boolean): Promise<ModelStats[]>
|
||||
getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise<CompareJsonReport>
|
||||
getYield(period: Period, provider: string, range?: DateRange): Promise<YieldJsonReport>
|
||||
getSpendFlow(period: Period, provider: string, range?: DateRange): Promise<SpendFlow>
|
||||
getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise<OptimizeJsonReport>
|
||||
getSpendFlow(period: Period, provider: string, range?: DateRange, background?: boolean): Promise<SpendFlow>
|
||||
getOptimizeReport(period: Period, provider: string, range?: DateRange, background?: boolean): Promise<OptimizeJsonReport>
|
||||
getDevices(period: Period): Promise<CombinedUsage>
|
||||
getDevicesScan(): Promise<DeviceScanResult>
|
||||
getShareStatus(): Promise<ShareStatus>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SectionSkeleton } from '../components/Skeleton'
|
|||
import { usePolled } from '../hooks/usePolled'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { compareModelsReportKey } from '../lib/reportKeys'
|
||||
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
|
||||
|
||||
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
|
||||
|
|
@ -44,7 +45,7 @@ export function Compare({
|
|||
const models = usePolled<ModelStats[]>(
|
||||
() => codeburn.getCompareModels(period, provider),
|
||||
[period, provider, refreshToken],
|
||||
{ enabled: ready, memoKey: `comparemodels|${period}|${provider}` },
|
||||
{ enabled: ready, memoKey: compareModelsReportKey(period, provider) },
|
||||
)
|
||||
const [modelA, setModelA] = useState<string | null>(null)
|
||||
const [modelB, setModelB] = useState<string | null>(null)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { Section } from '../components/Sidebar'
|
|||
import { usePolled } from '../hooks/usePolled'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { modelsReportKey } from '../lib/reportKeys'
|
||||
import type { AuditRow, DateRange, ModelReportRow, Period } from '../lib/types'
|
||||
import type { SettingsPane } from './Settings'
|
||||
|
||||
|
|
@ -95,7 +96,7 @@ function ModelsUsage({
|
|||
const report = usePolled<ModelReportRow[]>(
|
||||
() => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask),
|
||||
[period, provider, byTask, range?.from, range?.to, refreshToken],
|
||||
{ enabled: ready, memoKey: `models|${period}|${provider}|${byTask}|${range?.from ?? ''}-${range?.to ?? ''}` },
|
||||
{ enabled: ready, memoKey: modelsReportKey(period, provider, byTask, range) },
|
||||
)
|
||||
|
||||
if (!report.data) {
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ describe('Optimize', () => {
|
|||
expect(screen.getByText('No abandoned sessions in this range yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels the Fixes tab with the rendered list length, not the menubar-wide findingCount', async () => {
|
||||
it('labels the Fixes tab from the full optimize report', async () => {
|
||||
const payload = makePayload()
|
||||
// The menubar counts 25 findings, but the Fixes tab only renders topFindings.
|
||||
payload.optimize = {
|
||||
|
|
@ -223,7 +223,7 @@ describe('Optimize', () => {
|
|||
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByRole('tab', { name: 'Fixes 2' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('tab', { name: 'Fixes 3' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: 'Fixes 25' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { StaleBanner } from '../components/StaleBanner'
|
|||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { optimizeReportKey, yieldReportKey } from '../lib/reportKeys'
|
||||
import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
|
||||
|
||||
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
|
||||
|
|
@ -41,12 +42,12 @@ export function OptimizeContent({
|
|||
const optimizeReport = usePolled<OptimizeJsonReport>(
|
||||
() => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider),
|
||||
[period, provider, range?.from, range?.to, refreshToken],
|
||||
{ enabled: ready, memoKey: `optimize|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
|
||||
{ enabled: ready, memoKey: optimizeReportKey(period, provider, range) },
|
||||
)
|
||||
const yieldReport = usePolled<YieldJsonReport>(
|
||||
() => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider),
|
||||
[period, provider, range?.from, range?.to, refreshToken],
|
||||
{ enabled: ready, memoKey: `optyield|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
|
||||
{ enabled: ready, memoKey: yieldReportKey(period, provider, range) },
|
||||
)
|
||||
const [tab, setTab] = useState<OptimizeTab>('waste')
|
||||
|
||||
|
|
@ -59,11 +60,11 @@ export function OptimizeContent({
|
|||
const revertedTotal = yieldData ? formatUsd(yieldData.summary.reverted.costUSD) : '—'
|
||||
const abandonedTotal = yieldData ? formatUsd(yieldData.summary.abandoned.costUSD) : '—'
|
||||
const options = [
|
||||
{ value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` },
|
||||
{ value: 'waste', label: `Waste ${formatUsd(optimizeReport.data?.summary.potentialSavingsCostUSD ?? 0)}` },
|
||||
{ value: 'reverts', label: `Reverts ${revertedTotal}` },
|
||||
{ value: 'abandoned', label: `Abandoned ${abandonedTotal}` },
|
||||
// The Fixes tab renders topFindings (capped list), so label the count that shows.
|
||||
{ value: 'fixes', label: `Fixes ${overview.data.optimize.topFindings.length.toLocaleString('en-US')}` },
|
||||
// The Fixes tab renders the complete optimize report, so use its full count.
|
||||
{ value: 'fixes', label: `Fixes ${(optimizeReport.data?.summary.findingCount ?? 0).toLocaleString('en-US')}` },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -83,7 +84,7 @@ export function OptimizeContent({
|
|||
) : tab === 'abandoned' ? (
|
||||
<YieldRows report={yieldReport} category="abandoned" empty="No abandoned sessions in this range yet." />
|
||||
) : (
|
||||
<FixesRows data={overview.data} />
|
||||
<FixesRows report={optimizeReport} />
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
|
|
@ -234,6 +235,15 @@ function YieldRows({
|
|||
)
|
||||
}
|
||||
|
||||
function FixesRows({ data }: { data: MenubarPayload }) {
|
||||
return <FindingRows findings={data.optimize.topFindings} empty="No fixes in this range yet." />
|
||||
function FixesRows({ report }: { report: Polled<OptimizeJsonReport> }) {
|
||||
if (!report.data) {
|
||||
if (report.error) return <CliErrorPanel error={report.error} subject="optimize findings" />
|
||||
return <EmptyNote>Scanning optimize findings…</EmptyNote>
|
||||
}
|
||||
const findings: Finding[] = report.data.findings.map(finding => ({
|
||||
title: finding.title,
|
||||
impact: finding.severity,
|
||||
savingsUSD: finding.estimatedSavingsUSD,
|
||||
}))
|
||||
return <FindingRows findings={findings} empty="No fixes in this range yet." />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import { motionEnabled, useBarGrowIn } from '../lib/motion'
|
|||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { optimizeReportKey, yieldReportKey } from '../lib/reportKeys'
|
||||
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
|
||||
import type {
|
||||
ActReportJson,
|
||||
DailyHistoryEntry,
|
||||
DateRange,
|
||||
MenubarPayload,
|
||||
OptimizeJsonReport,
|
||||
Period,
|
||||
YieldJsonReport,
|
||||
} from '../lib/types'
|
||||
|
|
@ -669,8 +671,40 @@ export function OverviewContent({
|
|||
// so the cold hydration runs once (via overview) rather than 3 parses at once
|
||||
// on boot. Defaults true so standalone renders/tests poll normally.
|
||||
const actReport = usePolled<ActReportJson>(() => codeburn.getActReport(), [], { enabled: ready, memoKey: 'overview-act' })
|
||||
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready, memoKey: `overview-yield|${period}|${provider}` })
|
||||
const { data, error } = overview
|
||||
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready, memoKey: yieldReportKey(period, provider) })
|
||||
const optimizeReport = usePolled<OptimizeJsonReport | null>(
|
||||
async () => {
|
||||
if (typeof codeburn.getOptimizeReport !== 'function') return null
|
||||
const report = range
|
||||
? await codeburn.getOptimizeReport(period, provider, range)
|
||||
: await codeburn.getOptimizeReport(period, provider)
|
||||
// Some older preload/test bridges expose the method but return no value.
|
||||
// Treat that as unavailable enrichment; the lightweight overview remains
|
||||
// fully usable with the optimize fields already present in its payload.
|
||||
return report ?? null
|
||||
},
|
||||
[period, provider, range?.from, range?.to],
|
||||
{ enabled: ready, memoKey: optimizeReportKey(period, provider, range) },
|
||||
)
|
||||
const error = overview.error
|
||||
// The headline status call intentionally skips optimize work so the first
|
||||
// frame arrives quickly. Enrich that payload when the independently-cached
|
||||
// optimize report resolves; until then the rest of Overview remains usable.
|
||||
const data = useMemo<MenubarPayload | null>(() => {
|
||||
if (!overview.data || !optimizeReport.data) return overview.data
|
||||
return {
|
||||
...overview.data,
|
||||
optimize: {
|
||||
findingCount: optimizeReport.data.summary.findingCount,
|
||||
savingsUSD: optimizeReport.data.summary.potentialSavingsCostUSD,
|
||||
topFindings: optimizeReport.data.findings.slice(0, 5).map(finding => ({
|
||||
title: finding.title,
|
||||
impact: finding.severity,
|
||||
savingsUSD: finding.estimatedSavingsUSD,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}, [overview.data, optimizeReport.data])
|
||||
const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map<string, string>(), [data])
|
||||
|
||||
if (!data) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Stat } from '../components/Stat'
|
|||
import { usePolled } from '../hooks/usePolled'
|
||||
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { sessionsReportKey } from '../lib/reportKeys'
|
||||
import type { DateRange, Period, SessionRow } from '../lib/types'
|
||||
|
||||
export const INITIAL_VISIBLE = 120
|
||||
|
|
@ -120,7 +121,7 @@ export function Sessions({
|
|||
const report = usePolled<SessionRow[]>(
|
||||
() => range ? codeburn.getSessions(period, provider, range) : codeburn.getSessions(period, provider),
|
||||
[period, provider, range?.from, range?.to, refreshToken],
|
||||
{ enabled: ready, memoKey: `sessions|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
|
||||
{ enabled: ready, memoKey: sessionsReportKey(period, provider, range) },
|
||||
)
|
||||
const rows = report.data ?? []
|
||||
const q = query.trim().toLowerCase()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { StaleBanner } from '../components/StaleBanner'
|
|||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { spendFlowReportKey } from '../lib/reportKeys'
|
||||
import { contiguousDailyWindow, dataStartKey, localDateKey } from '../lib/period'
|
||||
import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ export function SpendContent({
|
|||
const flow = usePolled<SpendFlow>(
|
||||
() => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider),
|
||||
[period, provider, range?.from, range?.to, refreshToken],
|
||||
{ enabled: ready, memoKey: `spendflow|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
|
||||
{ enabled: ready, memoKey: spendFlowReportKey(period, provider, range) },
|
||||
)
|
||||
|
||||
if (!overview.data) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { readdir, readFile } from 'fs/promises'
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import type { ProjectSummary } from './types.js'
|
||||
|
|
@ -342,6 +343,64 @@ function isCompactFile(name: string): boolean {
|
|||
return name.includes('compact')
|
||||
}
|
||||
|
||||
const SELF_CORRECTION_CACHE_VERSION = 1
|
||||
const SELF_CORRECTION_CACHE_FILE = `compare-self-corrections.v${SELF_CORRECTION_CACHE_VERSION}.json`
|
||||
|
||||
type CorrectionEvent = { key: string; model: string; correction: boolean }
|
||||
type CorrectionFileCache = {
|
||||
mtimeMs: number
|
||||
sizeBytes: number
|
||||
events: CorrectionEvent[]
|
||||
}
|
||||
type CorrectionCache = {
|
||||
version: number
|
||||
files: Record<string, CorrectionFileCache>
|
||||
}
|
||||
|
||||
function correctionCacheDir(): string {
|
||||
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
|
||||
}
|
||||
|
||||
function correctionCachePath(): string {
|
||||
return join(correctionCacheDir(), SELF_CORRECTION_CACHE_FILE)
|
||||
}
|
||||
|
||||
async function loadCorrectionCache(): Promise<CorrectionCache> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(correctionCachePath(), 'utf8')) as Partial<CorrectionCache>
|
||||
if (parsed.version !== SELF_CORRECTION_CACHE_VERSION || !parsed.files || typeof parsed.files !== 'object') {
|
||||
return { version: SELF_CORRECTION_CACHE_VERSION, files: {} }
|
||||
}
|
||||
return { version: SELF_CORRECTION_CACHE_VERSION, files: parsed.files }
|
||||
} catch {
|
||||
return { version: SELF_CORRECTION_CACHE_VERSION, files: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function isCorrectionFileCache(value: unknown): value is CorrectionFileCache {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as Partial<CorrectionFileCache>
|
||||
return typeof entry.mtimeMs === 'number'
|
||||
&& typeof entry.sizeBytes === 'number'
|
||||
&& Array.isArray(entry.events)
|
||||
&& entry.events.every(event => !!event
|
||||
&& typeof event === 'object'
|
||||
&& typeof event.key === 'string'
|
||||
&& typeof event.model === 'string'
|
||||
&& typeof event.correction === 'boolean')
|
||||
}
|
||||
|
||||
async function saveCorrectionCache(cache: CorrectionCache): Promise<void> {
|
||||
try {
|
||||
await mkdir(correctionCacheDir(), { recursive: true })
|
||||
// This is a performance-only cache. A partial write is harmless: the next
|
||||
// run fails validation/JSON parsing and rebuilds it from the transcripts.
|
||||
await writeFile(correctionCachePath(), JSON.stringify(cache), { encoding: 'utf8', mode: 0o600 })
|
||||
} catch {
|
||||
// Read-only homes and transient antivirus locks must never break compare.
|
||||
}
|
||||
}
|
||||
|
||||
async function collectJsonlFiles(sessionDir: string): Promise<string[]> {
|
||||
const entries = await readdir(sessionDir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
|
@ -363,6 +422,10 @@ async function collectJsonlFiles(sessionDir: string): Promise<string[]> {
|
|||
export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<string, number>> {
|
||||
const counts = new Map<string, number>()
|
||||
const seen = new Set<string>()
|
||||
const cache = await loadCorrectionCache()
|
||||
let cacheChanged = false
|
||||
const allFiles: string[] = []
|
||||
const discovered = new Set<string>()
|
||||
|
||||
for (const dir of projectDirs) {
|
||||
let entries
|
||||
|
|
@ -372,22 +435,37 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<st
|
|||
continue
|
||||
}
|
||||
|
||||
const allFiles: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.jsonl') && !isCompactFile(entry.name)) {
|
||||
allFiles.push(join(dir, entry.name))
|
||||
const file = join(dir, entry.name)
|
||||
if (!discovered.has(file)) { discovered.add(file); allFiles.push(file) }
|
||||
} else if (entry.isDirectory()) {
|
||||
try {
|
||||
const sessionFiles = await collectJsonlFiles(join(dir, entry.name))
|
||||
allFiles.push(...sessionFiles)
|
||||
for (const file of sessionFiles) {
|
||||
if (!discovered.has(file)) { discovered.add(file); allFiles.push(file) }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of allFiles) {
|
||||
for (const file of allFiles) {
|
||||
let events: CorrectionEvent[]
|
||||
let fingerprint: { mtimeMs: number; sizeBytes: number }
|
||||
try {
|
||||
const info = await stat(file)
|
||||
fingerprint = { mtimeMs: info.mtimeMs, sizeBytes: info.size }
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const cached = cache.files[file]
|
||||
if (isCorrectionFileCache(cached) && cached.mtimeMs === fingerprint.mtimeMs && cached.sizeBytes === fingerprint.sizeBytes) {
|
||||
events = cached.events
|
||||
} else {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(file, 'utf8')
|
||||
|
|
@ -395,6 +473,8 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<st
|
|||
continue
|
||||
}
|
||||
|
||||
events = []
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
|
@ -418,16 +498,24 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<st
|
|||
if (typeof model !== 'string' || model === '<synthetic>') continue
|
||||
|
||||
const dedupeKey = `${model}:${ts}`
|
||||
if (seen.has(dedupeKey)) continue
|
||||
seen.add(dedupeKey)
|
||||
|
||||
const text = extractText(msgRec['content'])
|
||||
if (SELF_CORRECTION_PATTERNS.some(p => p.test(text))) {
|
||||
counts.set(model, (counts.get(model) ?? 0) + 1)
|
||||
}
|
||||
events.push({ key: dedupeKey, model, correction: SELF_CORRECTION_PATTERNS.some(p => p.test(text)) })
|
||||
}
|
||||
|
||||
cache.files[file] = { ...fingerprint, events }
|
||||
cacheChanged = true
|
||||
}
|
||||
|
||||
// Keep the original global model+timestamp deduplication semantics even
|
||||
// when events came from different cached files.
|
||||
for (const event of events) {
|
||||
if (seen.has(event.key)) continue
|
||||
seen.add(event.key)
|
||||
if (event.correction) counts.set(event.model, (counts.get(event.model) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheChanged) await saveCorrectionCache(cache)
|
||||
|
||||
return counts
|
||||
}
|
||||
|
|
|
|||
22
src/main.ts
22
src/main.ts
|
|
@ -1861,6 +1861,20 @@ program
|
|||
const projects = await parseAllSessions(range, opts.provider)
|
||||
const models = aggregateModelStats(projects)
|
||||
|
||||
// The desktop uses the no-pair form only to populate its model pickers.
|
||||
// Self-correction counts require a separate raw JSONL scan and are not
|
||||
// displayed in that picker, so return the lightweight aggregate before
|
||||
// doing that work. The selected pair path below still enriches both
|
||||
// models with the full metric.
|
||||
if (!opts.modelA && !opts.modelB) {
|
||||
process.stdout.write(JSON.stringify(models, null, 2) + '\n')
|
||||
return
|
||||
}
|
||||
if (!opts.modelA || !opts.modelB) {
|
||||
process.stderr.write('codeburn compare: --model-a and --model-b must be provided together.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const providers = await getAllProviders()
|
||||
const dirs: string[] = []
|
||||
for (const provider of providers) {
|
||||
|
|
@ -1872,14 +1886,6 @@ program
|
|||
model.selfCorrections = corrections.get(model.model) ?? 0
|
||||
}
|
||||
|
||||
if (!opts.modelA && !opts.modelB) {
|
||||
process.stdout.write(JSON.stringify(models, null, 2) + '\n')
|
||||
return
|
||||
}
|
||||
if (!opts.modelA || !opts.modelB) {
|
||||
process.stderr.write('codeburn compare: --model-a and --model-b must be provided together.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
const modelA = models.find(model => model.model === opts.modelA)
|
||||
const modelB = models.find(model => model.model === opts.modelB)
|
||||
if (!modelA) {
|
||||
|
|
|
|||
|
|
@ -278,12 +278,17 @@ function jsonlLine(type: string, model: string, text: string, timestamp = '2026-
|
|||
|
||||
describe('scanSelfCorrections', () => {
|
||||
let tmpDir: string
|
||||
let previousCacheDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
|
||||
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||||
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
|
@ -406,6 +411,22 @@ describe('scanSelfCorrections', () => {
|
|||
await rm(dir2, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates only a changed file fingerprint and includes appended corrections', async () => {
|
||||
const sessionDir = join(tmpDir, 'session-growing')
|
||||
await mkdir(sessionDir)
|
||||
const file = join(sessionDir, 'session.jsonl')
|
||||
await writeFile(file, jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z') + '\n')
|
||||
|
||||
expect((await scanSelfCorrections([tmpDir])).get('opus-4-6')).toBe(1)
|
||||
|
||||
await writeFile(file, [
|
||||
jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z'),
|
||||
jsonlLine('assistant', 'opus-4-6', 'My mistake.', '2026-04-15T10:01:00Z'),
|
||||
].join('\n') + '\n')
|
||||
|
||||
expect((await scanSelfCorrections([tmpDir])).get('opus-4-6')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeCategoryComparison', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue