mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 15:14:37 +00:00
perf(desktop): hydrate history progressively on first launch
This commit is contained in:
parent
148d6116c1
commit
ca28767f56
18 changed files with 455 additions and 49 deletions
|
|
@ -357,6 +357,14 @@ describe('spawnCli coalescing (read-only)', () => {
|
|||
expect(readFileSync(countFile, 'utf8')).toBe('x') // exactly one spawn
|
||||
})
|
||||
|
||||
it('does not reuse a result when the command-specific environment changes', async () => {
|
||||
const countFile = join(dir, 'env-spawns')
|
||||
fakeBin('env-counter.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({fast:process.env.CODEBURN_FAST_START==='1'}))`)
|
||||
await expect(spawnCli(['status'], { extraEnv: { CODEBURN_FAST_START: '1' } })).resolves.toEqual({ fast: true })
|
||||
await expect(spawnCli(['status'])).resolves.toEqual({ fast: false })
|
||||
expect(readFileSync(countFile, 'utf8')).toBe('xx')
|
||||
})
|
||||
|
||||
it('spawns again once the 5s result cache has expired', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -400,7 +400,12 @@ export function spawnCli(
|
|||
const spec = spawnSpecFor(target, args)
|
||||
if (opts.extraEnv) spec.env = { ...spec.env, ...opts.extraEnv }
|
||||
|
||||
const key = JSON.stringify([spec.bin, ...spec.args])
|
||||
// Environment can change command semantics even when argv is identical.
|
||||
// In particular, the desktop's cold first paint sets CODEBURN_FAST_START,
|
||||
// then immediately launches the same argv without it to finish full history.
|
||||
// Treat those as distinct flights/cache entries or the background request
|
||||
// would incorrectly reuse the partial first-paint payload and never run.
|
||||
const key = JSON.stringify([spec.bin, ...spec.args, opts.extraEnv ?? null])
|
||||
const cached = readCache.get(key)
|
||||
if (cached && Date.now() - cached.at < COALESCE_TTL_MS) return Promise.resolve(cached.value)
|
||||
const existing = readInflight.get(key)
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ describe('createBridgeHandlers (cold-start warmup)', () => {
|
|||
|
||||
await handlers['codeburn:getOverview']!('30days', 'all')
|
||||
expect(opts[0]?.timeoutMs).toBe(10 * 60_000)
|
||||
expect((opts[0]?.extraEnv as Record<string, string> | undefined)?.CODEBURN_PROGRESS).toBe('1')
|
||||
expect(opts[0]?.extraEnv).toEqual({ CODEBURN_PROGRESS: '1', CODEBURN_FAST_START: '1' })
|
||||
expect(typeof opts[0]?.onStderr).toBe('function')
|
||||
expect(emitProgress).toHaveBeenCalledWith({ kind: 'done' })
|
||||
|
||||
|
|
@ -429,6 +429,58 @@ describe('createBridgeHandlers (cold-start warmup)', () => {
|
|||
expect(opts[1]?.extraEnv).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a cold payload before progressively hydrating and unlocking larger periods', async () => {
|
||||
const calls: Array<{ args: string[]; opts?: Record<string, unknown> }> = []
|
||||
const spawnCli = vi.fn(async (args: string[], opts?: Record<string, unknown>) => {
|
||||
calls.push({ args, opts })
|
||||
return calls.length === 1
|
||||
? { indexing: true, current: { cost: 1 } }
|
||||
: { current: { cost: 1 } }
|
||||
})
|
||||
const emitProgress = vi.fn()
|
||||
const handlers = createBridgeHandlers(base({ spawnCli, emitProgress }))
|
||||
|
||||
const result = await handlers['codeburn:getOverview']!('today', 'all')
|
||||
expect(result).toMatchObject({ ok: true, value: { indexing: true } })
|
||||
await vi.waitFor(() => expect(spawnCli).toHaveBeenCalledTimes(9))
|
||||
expect(calls[1]?.args).toEqual(expect.arrayContaining(['--from', '--to']))
|
||||
expect(calls[1]?.opts).toMatchObject({
|
||||
timeoutMs: 10 * 60_000,
|
||||
priority: 'background',
|
||||
extraEnv: { CODEBURN_FAST_START: '1' },
|
||||
})
|
||||
expect(calls[3]?.args).toContain('week')
|
||||
expect(calls[7]?.args).toContain('all')
|
||||
expect(calls[8]?.args).toContain('lifetime')
|
||||
expect(calls[8]?.opts?.extraEnv).toBeUndefined()
|
||||
expect(emitProgress).toHaveBeenCalledWith({ kind: 'period-ready', period: 'today' })
|
||||
expect(emitProgress).toHaveBeenCalledWith({ kind: 'period-ready', period: 'week' })
|
||||
expect(emitProgress).toHaveBeenCalledWith({ kind: 'period-ready', period: '30days' })
|
||||
await vi.waitFor(() => expect(emitProgress).toHaveBeenCalledWith({ kind: 'history-ready' }))
|
||||
})
|
||||
|
||||
it('keeps overview polls range-limited while progressive history is running', async () => {
|
||||
let releaseStage!: () => void
|
||||
const stageGate = new Promise<void>(resolve => { releaseStage = resolve })
|
||||
let call = 0
|
||||
const opts: Array<Record<string, unknown> | undefined> = []
|
||||
const spawnCli = vi.fn(async (_args: string[], options?: Record<string, unknown>) => {
|
||||
opts.push(options)
|
||||
call++
|
||||
if (call === 1) return { indexing: true, current: { cost: 1 } }
|
||||
if (call === 2) await stageGate
|
||||
return { indexing: true, current: { cost: 1 } }
|
||||
})
|
||||
const handlers = createBridgeHandlers(base({ spawnCli, emitProgress: vi.fn() }))
|
||||
|
||||
await handlers['codeburn:getOverview']!('today', 'all')
|
||||
await vi.waitFor(() => expect(spawnCli).toHaveBeenCalledTimes(2))
|
||||
await handlers['codeburn:getOverview']!('today', 'all')
|
||||
expect(opts[2]?.extraEnv).toEqual({ CODEBURN_FAST_START: '1' })
|
||||
releaseStage()
|
||||
await vi.waitFor(() => expect(spawnCli.mock.calls.length).toBeGreaterThanOrEqual(10))
|
||||
})
|
||||
|
||||
it('drops a warmed overview to background priority only when the prefetch flag is set', async () => {
|
||||
const opts: Array<Record<string, unknown> | undefined> = []
|
||||
const spawnCli = vi.fn(async (_args: string[], o?: Record<string, unknown>) => { opts.push(o); return { current: { cost: 1 } } })
|
||||
|
|
|
|||
|
|
@ -71,12 +71,12 @@ export function createBeforeQuitHandler(deps: BeforeQuitDeps): (event: BeforeQui
|
|||
// `kind` survives contextBridge serialization. preload.ts unwraps it.
|
||||
export type Envelope<T = unknown> = { ok: true; value: T } | { ok: false; error: { kind: string; message: string } }
|
||||
|
||||
// The first overview fetch after boot hydrates a cold cache from scratch (a full
|
||||
// history parse). That can far exceed the 45s read timeout, and killing it means
|
||||
// the cache never persists, so every later poll restarts the scan — perpetual
|
||||
// slowness. Give the first (cold) overview a long window; revert to the default
|
||||
// once it succeeds. Sections gate their own first poll on this one resolving so
|
||||
// the cold hydration runs ONCE, not once per section in parallel.
|
||||
// The first overview fetch asks the CLI for a range-limited fast start. On a
|
||||
// cold install it returns accurate data for the visible range, marked
|
||||
// `indexing`, then we expand the durable cache in progressive background stages.
|
||||
// Warm caches ignore the fast-start hint and take the normal path. Keep a long
|
||||
// timeout so unusually large individual stages can finish instead of being
|
||||
// killed and restarted.
|
||||
const WARMUP_TIMEOUT_MS = 10 * 60_000
|
||||
// Wire marker for CLI scan-progress lines (src/parser.ts: PROGRESS_LINE_PREFIX).
|
||||
const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS '
|
||||
|
|
@ -126,6 +126,43 @@ function rangeArgs(range: DateRange | undefined): string[] {
|
|||
return range ? ['--from', range.from, '--to', range.to] : []
|
||||
}
|
||||
|
||||
function localDateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function trailingRange(days: number): DateRange {
|
||||
const end = new Date()
|
||||
end.setHours(12, 0, 0, 0)
|
||||
const start = new Date(end)
|
||||
start.setDate(start.getDate() - Math.max(0, days - 1))
|
||||
return { from: localDateKey(start), to: localDateKey(end) }
|
||||
}
|
||||
|
||||
type HydrationStage = {
|
||||
id: string
|
||||
label: string
|
||||
period: string
|
||||
range?: DateRange
|
||||
unlock?: string
|
||||
final?: boolean
|
||||
}
|
||||
|
||||
/** Small-to-large cold-cache expansion. The first two hidden day stages make
|
||||
* the later 7-day scan incremental; only periods represented by visible tabs
|
||||
* emit `period-ready` and become interactive in the renderer. */
|
||||
export function progressiveHydrationStages(): HydrationStage[] {
|
||||
return [
|
||||
{ id: 'yesterday', label: 'Today + yesterday', period: 'today', range: trailingRange(2) },
|
||||
{ id: 'day-before', label: 'Last 3 days', period: 'today', range: trailingRange(3) },
|
||||
{ id: 'week', label: 'Last 7 days', period: 'week', unlock: 'week' },
|
||||
{ id: '14days', label: 'Last 14 days', period: 'today', range: trailingRange(14) },
|
||||
{ id: '30days', label: 'Last 30 days', period: '30days', unlock: '30days' },
|
||||
{ id: 'month', label: 'This month', period: 'month', unlock: 'month' },
|
||||
{ id: 'all', label: 'Last 6 months', period: 'all', unlock: 'all' },
|
||||
{ id: 'lifetime', label: 'Lifetime', period: 'lifetime', unlock: 'lifetime', final: true },
|
||||
]
|
||||
}
|
||||
|
||||
function configSourceArgs(source: string | null): string[] {
|
||||
return source ? ['--claude-config-source', source] : []
|
||||
}
|
||||
|
|
@ -240,6 +277,11 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
// overview fetch runs cold (long timeout + progress streaming); the shared
|
||||
// spawnCli coalescing means concurrent same-arg re-polls join one child.
|
||||
let overviewWarmed = false
|
||||
// While a cold install expands from Today to Lifetime, ordinary overview
|
||||
// polls must stay range-limited too. Otherwise the first 30-second poll would
|
||||
// launch the old monolithic full-history scan alongside the staged worker.
|
||||
let historyHydrating = false
|
||||
let historyHydrationJob: Promise<void> | null = null
|
||||
// cold_start is a once-per-launch metric. Because coalesced re-polls each
|
||||
// re-enter the cold branch (and overviewWarmed only flips on success, so it
|
||||
// never guards a still-failing warmup), emitting inline would record one row
|
||||
|
|
@ -285,16 +327,58 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
const priority: SpawnPriority | undefined = background ? 'background' : undefined
|
||||
try {
|
||||
const args = buildOverviewArgs(period, provider, range, configSource)
|
||||
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
|
||||
if (overviewWarmed) {
|
||||
const warmOpts = historyHydrating
|
||||
? { extraEnv: { CODEBURN_FAST_START: '1' }, ...(priority ? { priority } : {}) }
|
||||
: (priority ? { priority } : undefined)
|
||||
return { ok: true, value: await deps.spawnCli(args, warmOpts) }
|
||||
}
|
||||
const value = await deps.spawnCli(args, {
|
||||
timeoutMs: WARMUP_TIMEOUT_MS,
|
||||
extraEnv: { CODEBURN_PROGRESS: '1' },
|
||||
extraEnv: { CODEBURN_PROGRESS: '1', CODEBURN_FAST_START: '1' },
|
||||
onStderr: makeProgressReader(emitProgress),
|
||||
...(priority ? { priority } : {}),
|
||||
})
|
||||
overviewWarmed = true
|
||||
emitProgress({ kind: 'done' })
|
||||
emitColdStart(false)
|
||||
// A cold fast-start deliberately leaves the session cache incomplete.
|
||||
// Grow it from small ranges to large ones so Today remains responsive and
|
||||
// each visible period can unlock as soon as its own stage is accurate.
|
||||
if ((value as { indexing?: boolean } | null)?.indexing === true) {
|
||||
emitProgress({ kind: 'period-ready', period })
|
||||
if (!historyHydrationJob) {
|
||||
historyHydrating = true
|
||||
historyHydrationJob = (async () => {
|
||||
let lifetimeReady = false
|
||||
try {
|
||||
for (const stage of progressiveHydrationStages()) {
|
||||
emitProgress({ kind: 'history-stage', id: stage.id, label: stage.label, state: 'start' })
|
||||
const stageArgs = buildOverviewArgs(stage.period, 'all', stage.range)
|
||||
try {
|
||||
await deps.spawnCli(stageArgs, {
|
||||
timeoutMs: WARMUP_TIMEOUT_MS,
|
||||
priority: 'background',
|
||||
// Every intermediate stage must leave the cache incomplete;
|
||||
// Lifetime is the sole full-hydration/finalization pass.
|
||||
...(stage.final ? {} : { extraEnv: { CODEBURN_FAST_START: '1' } }),
|
||||
})
|
||||
emitProgress({ kind: 'history-stage', id: stage.id, label: stage.label, state: 'ready' })
|
||||
if (stage.unlock) emitProgress({ kind: 'period-ready', period: stage.unlock })
|
||||
if (stage.final) lifetimeReady = true
|
||||
} catch (err) {
|
||||
emitProgress({ kind: 'history-stage', id: stage.id, label: stage.label, state: 'error' })
|
||||
telemetry?.track('cli_error', cliErrorProps(err, 'status'))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
historyHydrating = false
|
||||
historyHydrationJob = null
|
||||
if (lifetimeReady) emitProgress({ kind: 'history-ready' })
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
return { ok: true, value }
|
||||
} catch (err) {
|
||||
const error = toEnvelopeError(err)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ vi.stubGlobal('localStorage', {
|
|||
clear: () => stored.clear(),
|
||||
})
|
||||
|
||||
const progress = vi.hoisted(() => ({ listeners: new Set<(event: any) => void>() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onProgress: vi.fn(),
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
|
||||
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
|
||||
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
|
||||
|
|
@ -119,6 +121,11 @@ function withConfigs(payload: MenubarPayload): MenubarPayload {
|
|||
|
||||
function installDefaultMocks() {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset()
|
||||
progress.listeners.clear()
|
||||
mocks.onProgress.mockImplementation((cb: (event: any) => void) => {
|
||||
progress.listeners.add(cb)
|
||||
return () => { progress.listeners.delete(cb) }
|
||||
})
|
||||
mocks.getOverview.mockResolvedValue(overviewPayload())
|
||||
mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
|
||||
mocks.getOptimizeReport.mockResolvedValue({
|
||||
|
|
@ -173,6 +180,10 @@ function installDefaultMocks() {
|
|||
mocks.resetCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
|
||||
}
|
||||
|
||||
function emitProgress(event: any) {
|
||||
for (const listener of progress.listeners) listener(event)
|
||||
}
|
||||
|
||||
describe('App shortcuts', () => {
|
||||
beforeEach(() => {
|
||||
installDefaultMocks()
|
||||
|
|
@ -202,6 +213,28 @@ describe('App shortcuts', () => {
|
|||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
|
||||
})
|
||||
|
||||
it('disables unfinished periods and unlocks each tab when its history stage is ready', async () => {
|
||||
localStorage.setItem('codeburn.defaultPeriod', 'today')
|
||||
mocks.getOverview.mockResolvedValue({ ...overviewPayload(), indexing: true })
|
||||
render(<App />)
|
||||
|
||||
const today = await screen.findByRole('tab', { name: 'Today' })
|
||||
const week = screen.getByRole('tab', { name: '7D' })
|
||||
const lifetime = screen.getByRole('tab', { name: 'Life' })
|
||||
await waitFor(() => expect(today).not.toHaveAttribute('aria-disabled'))
|
||||
expect(week).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(lifetime).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(mocks.getActReport).not.toHaveBeenCalled()
|
||||
|
||||
act(() => emitProgress({ kind: 'period-ready', period: 'week' }))
|
||||
await waitFor(() => expect(week).not.toHaveAttribute('aria-disabled'))
|
||||
fireEvent.click(week)
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all'))
|
||||
|
||||
act(() => emitProgress({ kind: 'history-ready' }))
|
||||
await waitFor(() => expect(lifetime).not.toHaveAttribute('aria-disabled'))
|
||||
})
|
||||
|
||||
it('switches sections with command-number shortcuts', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ function AppMain() {
|
|||
const [refreshToken, setRefreshToken] = useState(0)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [, setCurrencyTick] = useState(0)
|
||||
const [readyPeriods, setReadyPeriods] = useState<Set<string>>(() => new Set())
|
||||
|
||||
// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
|
||||
// stays flag-free; only add --claude-config-source once a config is picked.
|
||||
|
|
@ -238,6 +239,40 @@ function AppMain() {
|
|||
)
|
||||
const refreshOverview = overview.refresh
|
||||
|
||||
// Cold history expands progressively. Unlock a range as soon as its stage is
|
||||
// ready; after Lifetime finalizes, refresh once to replace the partial payload
|
||||
// and remove the indexing marker without waiting for the polling cadence.
|
||||
useEffect(() => {
|
||||
if (!codeburn || typeof codeburn.onProgress !== 'function') return
|
||||
return codeburn.onProgress(event => {
|
||||
if (event.kind === 'period-ready') {
|
||||
setReadyPeriods(current => {
|
||||
if (current.has(event.period)) return current
|
||||
const next = new Set(current)
|
||||
next.add(event.period)
|
||||
return next
|
||||
})
|
||||
} else if (event.kind === 'history-ready') {
|
||||
setReadyPeriods(new Set(STANDARD_PERIODS))
|
||||
refreshOverview()
|
||||
}
|
||||
})
|
||||
}, [refreshOverview])
|
||||
|
||||
useEffect(() => {
|
||||
if (!overview.data) return
|
||||
if (!overview.data.indexing) {
|
||||
setReadyPeriods(new Set(STANDARD_PERIODS))
|
||||
return
|
||||
}
|
||||
setReadyPeriods(current => {
|
||||
if (current.has(period)) return current
|
||||
const next = new Set(current)
|
||||
next.add(period)
|
||||
return next
|
||||
})
|
||||
}, [overview.data, period])
|
||||
|
||||
// Boot readiness: the overview poll is the single cold-cache warmer (long
|
||||
// timeout + progress). Other sections gate their first CLI spawn on this so a
|
||||
// cold first run hydrates ONCE here instead of fanning out into a parallel
|
||||
|
|
@ -276,7 +311,7 @@ function AppMain() {
|
|||
// fails we still emit the snapshot, just without the model x category cross.
|
||||
const snapshotDayRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return
|
||||
if (!overview.data || overview.data.indexing || provider !== 'all' || customRange || claudeConfigSource) return
|
||||
const today = localDateKey(new Date())
|
||||
if (snapshotDayRef.current === today) return
|
||||
snapshotDayRef.current = today
|
||||
|
|
@ -365,7 +400,7 @@ function AppMain() {
|
|||
overviewBusyRef.current = overview.loading
|
||||
const warmedKeys = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource) return
|
||||
if (!ready || overview.data == null || overview.data.indexing || customRange || claudeConfigSource) return
|
||||
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
|
||||
if (targets.length === 0) return
|
||||
let cancelled = false
|
||||
|
|
@ -393,10 +428,10 @@ function AppMain() {
|
|||
}
|
||||
const start = setTimeout(() => { void warm() }, PREFETCH_START_DELAY_MS)
|
||||
return () => { cancelled = true; clearTimeout(start) }
|
||||
// `overview.data == null` (a boolean) gates on first-resolution without
|
||||
// re-running every poll; the data content itself is intentionally not a dep.
|
||||
// `overview.data == null` gates first-resolution; `indexing` re-arms exactly
|
||||
// once when the background lifetime scan replaces the partial payload.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null, overview.data?.indexing])
|
||||
|
||||
// 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
|
||||
|
|
@ -405,7 +440,7 @@ function AppMain() {
|
|||
// showing a skeleton while a fresh process starts.
|
||||
const warmedSectionKeys = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!ready || overview.data == null || customRange || claudeConfigSource) return
|
||||
if (!ready || overview.data == null || overview.data.indexing || customRange || claudeConfigSource) return
|
||||
const tasks: Array<{ key: string; fetch: () => Promise<unknown> }> = [
|
||||
{
|
||||
key: sessionsReportKey(period, provider),
|
||||
|
|
@ -448,7 +483,7 @@ function AppMain() {
|
|||
}
|
||||
const start = setTimeout(() => { void warm() }, SECTION_PREFETCH_START_DELAY_MS)
|
||||
return () => { cancelled = true; clearTimeout(start) }
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, overview.data == null])
|
||||
}, [ready, period, provider, customRange, claudeConfigSource, overview.data == null, overview.data?.indexing])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
|
@ -502,7 +537,7 @@ function AppMain() {
|
|||
}, [refreshVisible, navigate])
|
||||
|
||||
const onPeriodChange = (value: string) => {
|
||||
if (isPeriod(value)) {
|
||||
if (isPeriod(value) && (!overview.data?.indexing || readyPeriods.has(value))) {
|
||||
setCustomRange(null)
|
||||
setPeriod(value)
|
||||
}
|
||||
|
|
@ -570,10 +605,12 @@ function AppMain() {
|
|||
claudeConfigs={claudeConfigs}
|
||||
configSource={claudeConfigSource}
|
||||
onConfigSelect={onConfigSelect}
|
||||
readyPeriods={readyPeriods}
|
||||
indexingPeriods={overview.data?.indexing === true}
|
||||
/>
|
||||
<div className={motionClass('body', 'section-fade')}>
|
||||
{section === 'overview' ? (
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} refreshToken={refreshToken} />
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready && overview.data?.indexing !== true} refreshToken={refreshToken} />
|
||||
) : section === 'sessions' ? (
|
||||
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
|
||||
) : section === 'pullRequests' ? (
|
||||
|
|
@ -612,7 +649,7 @@ function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPay
|
|||
if (polled.data) {
|
||||
return (
|
||||
<>
|
||||
{polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
|
||||
{polled.data.indexing ? 'Indexing history' : polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type SegOption = { value: string; label: string }
|
||||
export type SegOption = { value: string; label: string; disabled?: boolean; loading?: boolean }
|
||||
|
||||
/** The `.seg` segmented control used for period and lens switching. */
|
||||
export function SegTabs({
|
||||
|
|
@ -17,18 +17,20 @@ export function SegTabs({
|
|||
{options.map(opt => (
|
||||
<span
|
||||
key={opt.value}
|
||||
className={opt.value === value ? 'on' : undefined}
|
||||
className={[opt.value === value ? 'on' : '', opt.disabled ? 'disabled' : ''].filter(Boolean).join(' ') || undefined}
|
||||
role="tab"
|
||||
aria-selected={opt.value === value}
|
||||
tabIndex={0}
|
||||
onClick={() => onChange(opt.value)}
|
||||
aria-disabled={opt.disabled || undefined}
|
||||
tabIndex={opt.disabled ? -1 : 0}
|
||||
onClick={() => { if (!opt.disabled) onChange(opt.value) }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (!opt.disabled && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onChange(opt.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{opt.loading && <i className="seg-spinner" aria-hidden="true" />}
|
||||
{opt.label}
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ function reduceProgress(state: Progress, event: ScanProgressEvent): Progress {
|
|||
for (const p of state.order) status[p] = 'done'
|
||||
return { ...state, status }
|
||||
}
|
||||
case 'history-stage':
|
||||
case 'period-ready':
|
||||
case 'history-ready':
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export function TopBar({
|
|||
claudeConfigs,
|
||||
configSource,
|
||||
onConfigSelect,
|
||||
readyPeriods,
|
||||
indexingPeriods = false,
|
||||
}: {
|
||||
title: ReactNode
|
||||
scope?: ReactNode
|
||||
|
|
@ -48,13 +50,21 @@ export function TopBar({
|
|||
claudeConfigs?: ClaudeConfigSelector
|
||||
configSource: string | null
|
||||
onConfigSelect: (id: string) => void
|
||||
readyPeriods?: ReadonlySet<string>
|
||||
indexingPeriods?: boolean
|
||||
}) {
|
||||
const periodOptions = indexingPeriods
|
||||
? PERIOD_OPTIONS.map(option => {
|
||||
const ready = readyPeriods?.has(option.value) === true
|
||||
return { ...option, disabled: !ready, loading: !ready }
|
||||
})
|
||||
: PERIOD_OPTIONS
|
||||
return (
|
||||
<div className="bar">
|
||||
<div className="t">{title}</div>
|
||||
{scope !== undefined && <span className="scope">{scope}</span>}
|
||||
<div className="sp" />
|
||||
<SegTabs options={PERIOD_OPTIONS} value={customRange ? '' : period} onChange={onPeriodChange} />
|
||||
<SegTabs options={periodOptions} value={customRange ? '' : period} onChange={onPeriodChange} />
|
||||
<CalendarPop value={customRange} onSelect={onRangeSelect} />
|
||||
<ProviderPop value={provider} label={providerLabel} options={providerOptions} onSelect={onProviderSelect} />
|
||||
{claudeConfigs && <ConfigPicker configs={claudeConfigs} value={configSource} onSelect={onConfigSelect} />}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export type ClaudeConfigSelector = {
|
|||
|
||||
export type MenubarPayload = {
|
||||
generated: string
|
||||
/** Cold first paint is ready while full history continues indexing. */
|
||||
indexing?: boolean
|
||||
current: {
|
||||
label: string
|
||||
cost: number
|
||||
|
|
@ -618,6 +620,9 @@ export type ScanProgressEvent =
|
|||
| { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number }
|
||||
| { kind: 'tick'; provider: string; done: number; total: number }
|
||||
| { kind: 'done' }
|
||||
| { kind: 'history-stage'; id: string; label: string; state: 'start' | 'ready' | 'error' }
|
||||
| { kind: 'period-ready'; period: Period }
|
||||
| { kind: 'history-ready' }
|
||||
|
||||
/** Update-availability status from the main process (app/electron/updates.ts). */
|
||||
export type UpdateStatus = {
|
||||
|
|
|
|||
|
|
@ -217,9 +217,12 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
|
|||
.ni.on { background: var(--hover); color: var(--ink); box-shadow: none; }
|
||||
.ni.on::before { background: var(--accent); }
|
||||
.seg { background: var(--bg); border: 1px solid var(--line); }
|
||||
.seg span { cursor: pointer; }
|
||||
.seg span { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.seg .on { background: var(--panel); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.seg span:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
|
||||
.seg span.disabled { cursor: default; opacity: .52; }
|
||||
.seg-spinner { width: 8px; height: 8px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: seg-spin .8s linear infinite; }
|
||||
@keyframes seg-spin { to { transform: rotate(360deg); } }
|
||||
.pop-wrap, .calendar-wrap { position: relative; }
|
||||
.pop { background: var(--panel); color: var(--ink); cursor: pointer; user-select: none; }
|
||||
.pop-menu {
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,7 @@ program
|
|||
optimize: opts.optimize !== false,
|
||||
timeline: opts.timeline !== false,
|
||||
claudeConfigSourceId: opts.claudeConfigSource,
|
||||
fastStart: process.env['CODEBURN_FAST_START'] === '1',
|
||||
})
|
||||
if (opts.scope === 'combined') {
|
||||
// Combined multi-device usage is best-effort enrichment on the menubar's
|
||||
|
|
|
|||
|
|
@ -177,6 +177,9 @@ export type ClaudeConfigSelector = {
|
|||
|
||||
export type MenubarPayload = {
|
||||
generated: string
|
||||
/** True while a cold desktop install is serving an accurate range-limited
|
||||
* first paint and lifetime history continues indexing in the background. */
|
||||
indexing?: boolean
|
||||
current: {
|
||||
label: string
|
||||
cost: number
|
||||
|
|
|
|||
|
|
@ -3097,7 +3097,7 @@ const CACHE_TTL_MS = 180_000
|
|||
const MAX_CACHE_ENTRIES = 10
|
||||
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()
|
||||
|
||||
function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
|
||||
function cacheKey(dateRange?: DateRange, providerFilter?: string, partialHydration = false): string {
|
||||
const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none'
|
||||
// Include the Claude config-dir env so a config change in a long-lived
|
||||
// process (menubar / GNOME extension / test workers) does not return
|
||||
|
|
@ -3105,7 +3105,7 @@ function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
|
|||
const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '')
|
||||
// Proxy attribution (totalProxiedCostUSD) is computed live from proxyPaths and
|
||||
// then cached, so the key must change when that config changes.
|
||||
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}`
|
||||
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}:${partialHydration ? 'partial' : 'full'}`
|
||||
}
|
||||
|
||||
export function clearSessionCache(): void {
|
||||
|
|
@ -3521,8 +3521,72 @@ export function isSessionHydrationComplete(): boolean {
|
|||
return sessionHydrationComplete
|
||||
}
|
||||
|
||||
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
|
||||
const key = cacheKey(dateRange, providerFilter)
|
||||
export type ParseSessionsOptions = {
|
||||
/**
|
||||
* Parse only sources that can contribute to `dateRange` and persist the work
|
||||
* as an INCOMPLETE cache. Used by the desktop's first paint so a cold install
|
||||
* does not wait for lifetime history. A later ordinary parse resumes from the
|
||||
* partial cache and is the only path allowed to mark it complete.
|
||||
*/
|
||||
partialHydration?: boolean
|
||||
}
|
||||
|
||||
function providerCoverageIncludes(cached: string, requested: string): boolean {
|
||||
return cached === 'all' || cached === requested
|
||||
}
|
||||
|
||||
/** Prepare an incomplete cache for a range-filtered parse without ever treating
|
||||
* a narrow file snapshot as lifetime-complete. Claude cache files are retained
|
||||
* because its scanner always captures the full transcript before slicing output;
|
||||
* durable providers are retained because their cache is the historical source
|
||||
* of truth. Other providers must reparse unchanged files when coverage expands. */
|
||||
function prepareIncompleteCache(
|
||||
cache: SessionCache,
|
||||
dateRange: DateRange | undefined,
|
||||
providerFilter: string | undefined,
|
||||
partialHydration: boolean,
|
||||
): void {
|
||||
const previous = cache.partialRange
|
||||
if (!partialHydration) {
|
||||
if (!previous) return
|
||||
for (const [provider, section] of Object.entries(cache.providers)) {
|
||||
if (provider === 'claude' || section.durable || DURABLE_PROVIDER_NAMES.has(provider)) continue
|
||||
delete cache.providers[provider]
|
||||
}
|
||||
delete cache.partialRange
|
||||
;(cache as { _dirty?: boolean })._dirty = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!dateRange) return
|
||||
const requested = {
|
||||
startMs: dateRange.start.getTime(),
|
||||
endMs: dateRange.end.getTime(),
|
||||
providerFilter: providerFilter ?? 'all',
|
||||
}
|
||||
const covered = previous
|
||||
&& previous.startMs <= requested.startMs
|
||||
&& previous.endMs >= requested.endMs
|
||||
&& providerCoverageIncludes(previous.providerFilter, requested.providerFilter)
|
||||
if (previous && !covered) {
|
||||
for (const [provider, section] of Object.entries(cache.providers)) {
|
||||
if (provider === 'claude' || section.durable || DURABLE_PROVIDER_NAMES.has(provider)) continue
|
||||
delete cache.providers[provider]
|
||||
}
|
||||
}
|
||||
if (!covered) {
|
||||
cache.partialRange = requested
|
||||
;(cache as { _dirty?: boolean })._dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseAllSessions(
|
||||
dateRange?: DateRange,
|
||||
providerFilter?: string,
|
||||
parseOptions: ParseSessionsOptions = {},
|
||||
): Promise<ProjectSummary[]> {
|
||||
const partialHydration = parseOptions.partialHydration === true
|
||||
const key = cacheKey(dateRange, providerFilter, partialHydration)
|
||||
const cached = sessionCache.get(key)
|
||||
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data
|
||||
|
||||
|
|
@ -3542,7 +3606,8 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
|
|||
if (hydration.waited) diskCache = await loadCache()
|
||||
const isCold = !isCacheComplete(diskCache)
|
||||
try {
|
||||
return await runParse(key, diskCache, dateRange, providerFilter, { isCold })
|
||||
prepareIncompleteCache(diskCache, dateRange, providerFilter, partialHydration)
|
||||
return await runParse(key, diskCache, dateRange, providerFilter, { isCold, partialHydration })
|
||||
} finally {
|
||||
await hydration.release()
|
||||
}
|
||||
|
|
@ -3580,6 +3645,7 @@ type RunParseOptions = {
|
|||
isCold?: boolean
|
||||
readOnly?: boolean
|
||||
refreshLock?: RefreshLockHandle
|
||||
partialHydration?: boolean
|
||||
}
|
||||
|
||||
async function runParse(
|
||||
|
|
@ -3589,7 +3655,7 @@ async function runParse(
|
|||
providerFilter?: string,
|
||||
options: RunParseOptions = {},
|
||||
): Promise<ProjectSummary[]> {
|
||||
const { isCold = false, readOnly = false, refreshLock } = options
|
||||
const { isCold = false, readOnly = false, refreshLock, partialHydration = false } = options
|
||||
const seenMsgIds = new Set<string>()
|
||||
const seenKeys = new Set<string>()
|
||||
const allSources = await discoverAllSessions(providerFilter)
|
||||
|
|
@ -3684,8 +3750,14 @@ async function runParse(
|
|||
// on is durable. A run killed before here never reaches this, so its throttled
|
||||
// partial saves keep `complete: false` and the next launch resumes cold.
|
||||
const wasComplete = isCacheComplete(diskCache)
|
||||
if (!readOnly && !wasComplete) diskCache.complete = true
|
||||
if (!readOnly && ((diskCache as { _dirty?: boolean })._dirty || !wasComplete)) {
|
||||
// A range-limited first-paint parse is intentionally not proof that older
|
||||
// sources were indexed. Persist its useful recent rows, but leave the cache
|
||||
// incomplete so the background full hydration resumes and finalizes it.
|
||||
if (!readOnly && !wasComplete && !partialHydration) {
|
||||
diskCache.complete = true
|
||||
if (diskCache.partialRange) delete diskCache.partialRange
|
||||
}
|
||||
if (!readOnly && ((diskCache as { _dirty?: boolean })._dirty || (!wasComplete && !partialHydration))) {
|
||||
try {
|
||||
const published = await saveCache(diskCache, refreshLock?.verifyStillOwner)
|
||||
if (!published) throw new RefreshFenceLostError()
|
||||
|
|
@ -3694,7 +3766,11 @@ async function runParse(
|
|||
if (refreshLock) throw new RefreshPublicationUnavailableError()
|
||||
}
|
||||
}
|
||||
sessionHydrationComplete = true
|
||||
// Reaching the end of an ordinary parse is the same completion signal used
|
||||
// before partial desktop hydration existed. Keep that behavior for normal
|
||||
// and read-only refreshes; only the explicitly range-limited first-paint
|
||||
// path must advertise that lifetime history is still incomplete.
|
||||
sessionHydrationComplete = !partialHydration
|
||||
|
||||
// Merge across providers by normalised project path so the same repository
|
||||
// is not double-counted when it was worked on with more than one tool
|
||||
|
|
|
|||
|
|
@ -137,6 +137,15 @@ export type SessionCache = {
|
|||
* can freeze a partial daily history. Absent on caches written before this
|
||||
* field existed → read as incomplete (one self-healing re-hydration). */
|
||||
complete?: boolean
|
||||
/** Coverage represented by range-filtered, non-durable provider entries while
|
||||
* `complete !== true`. A wider partial request must invalidate those entries;
|
||||
* otherwise an unchanged file would be reused as though its older turns had
|
||||
* been parsed. Removed by the final ordinary full-history pass. */
|
||||
partialRange?: {
|
||||
startMs: number
|
||||
endMs: number
|
||||
providerFilter: string
|
||||
}
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
|
@ -401,6 +410,12 @@ function validateCache(raw: unknown): raw is SessionCache {
|
|||
const o = raw as Record<string, unknown>
|
||||
if (o['version'] !== CACHE_VERSION) return false
|
||||
if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false
|
||||
if (o['partialRange'] !== undefined) {
|
||||
const range = o['partialRange']
|
||||
if (!range || typeof range !== 'object' || Array.isArray(range)) return false
|
||||
const r = range as Record<string, unknown>
|
||||
if (!isNum(r['startMs']) || !isNum(r['endMs']) || typeof r['providerFilter'] !== 'string') return false
|
||||
}
|
||||
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { homedir } from 'node:os'
|
|||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js'
|
||||
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, buildMenubarPayload } from './menubar-json.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js'
|
||||
import { isCacheComplete as isSessionCacheComplete, loadCache as loadSessionCache } from './session-cache.js'
|
||||
import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
|
||||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js'
|
||||
|
|
@ -121,6 +122,9 @@ export type AggregateOpts = {
|
|||
/// true. The desktop app never renders it, so it passes `--no-timeline` to
|
||||
/// skip the buildGranularHistory pass on every menubar poll.
|
||||
timeline?: boolean
|
||||
/** Desktop-only cold-start mode. When the session cache is incomplete, serve
|
||||
* the requested range first and let the app resume full indexing afterward. */
|
||||
fastStart?: boolean
|
||||
}
|
||||
|
||||
type ConfigOption = { id: string; label: string; path: string }
|
||||
|
|
@ -287,7 +291,10 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
|
||||
|
||||
const cache = await hydrateCache()
|
||||
const cache = opts.fastStart ? emptyCache(getDailyCacheConfigHash()) : await hydrateCache()
|
||||
const parse = opts.fastStart
|
||||
? (range: DateRange, provider: string) => parseAllSessions(range, provider, { partialHydration: true })
|
||||
: (range: DateRange, provider: string) => parseAllSessions(range, provider)
|
||||
|
||||
// Today's live data always comes from an all-provider parse so the union (and
|
||||
// any per-provider slice of it) sees every provider's today. `todayAllDays` is
|
||||
|
|
@ -298,25 +305,25 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
let scanRange: DateRange
|
||||
if (pf === 'all') {
|
||||
if (isTodayOnly) {
|
||||
const raw = fp(await parseAllSessions(todayRange, 'all'))
|
||||
const raw = fp(await parse(todayRange, 'all'))
|
||||
liveProjects = raw
|
||||
scanRange = todayRange
|
||||
todayAllDays = aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
|
||||
} else {
|
||||
const raw = fp(await parseAllSessions(periodInfo.range, 'all'))
|
||||
const raw = fp(await parse(periodInfo.range, 'all'))
|
||||
liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw
|
||||
scanRange = periodInfo.range
|
||||
// A period that reaches today contains today's turns already, so derive the
|
||||
// today slice from the same parse instead of scanning today again.
|
||||
todayAllDays = rangeEndStr >= todayStr
|
||||
? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
|
||||
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
: aggregateProjectsIntoDays(fp(await parse(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
}
|
||||
} else {
|
||||
// Provider-filtered: today's all-provider parse feeds the union (sliced
|
||||
// below); the provider-scoped parse feeds the detail/enrichment fields.
|
||||
todayAllDays = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
const rawProv = fp(await parseAllSessions(isTodayOnly ? todayRange : periodInfo.range, pf))
|
||||
todayAllDays = aggregateProjectsIntoDays(fp(await parse(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
const rawProv = fp(await parse(isTodayOnly ? todayRange : periodInfo.range, pf))
|
||||
liveProjects = daysSelection && !isTodayOnly ? filterProjectsByDays(rawProv, daysSelection.days) : rawProv
|
||||
scanRange = isTodayOnly ? todayRange : periodInfo.range
|
||||
}
|
||||
|
|
@ -370,13 +377,20 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
|
||||
const isAllProviders = pf === 'all'
|
||||
// The Electron app requests fast start only for its first overview. Honor it
|
||||
// solely for a genuinely incomplete session cache; warm launches keep the
|
||||
// durable history path and return the exact same payload as before.
|
||||
const fastStart = opts.fastStart === true && !isSessionCacheComplete(await loadSessionCache())
|
||||
const parse = fastStart
|
||||
? (range: DateRange, provider: string) => parseAllSessions(range, provider, { partialHydration: true })
|
||||
: (range: DateRange, provider: string) => parseAllSessions(range, provider)
|
||||
|
||||
let todayAllProjects: ProjectSummary[] | null = null
|
||||
let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null
|
||||
|
||||
const getTodayAllProjects = async (): Promise<ProjectSummary[]> => {
|
||||
if (!todayAllProjects) {
|
||||
todayAllProjects = fp(await parseAllSessions(todayRange, 'all'))
|
||||
todayAllProjects = fp(await parse(todayRange, 'all'))
|
||||
}
|
||||
return todayAllProjects
|
||||
}
|
||||
|
|
@ -408,7 +422,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// A config source scopes Claude usage only, so scan just Claude (main.ts
|
||||
// rejects a contradictory non-Claude --provider). This also avoids parsing
|
||||
// every other provider's corpus on each scoped refresh.
|
||||
const rawProjects = fp(await parseAllSessions(periodInfo.range, 'claude'))
|
||||
const rawProjects = fp(await parse(periodInfo.range, 'claude'))
|
||||
const fullProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects
|
||||
claudeConfigs = await claudeConfigSelector(fullProjects, requestedClaudeConfigSourceId)
|
||||
const selectedSourceId = claudeConfigs?.selectedId ?? null
|
||||
|
|
@ -434,6 +448,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
project: opts.project,
|
||||
exclude: opts.exclude,
|
||||
daysSelection,
|
||||
fastStart,
|
||||
})
|
||||
currentData = durable.data
|
||||
scanProjects = durable.liveProjects
|
||||
|
|
@ -510,7 +525,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
end: now,
|
||||
}
|
||||
const historyProjects = filterProjectsByClaudeConfigSource(
|
||||
fp(await parseAllSessions(historyRange, 'claude')),
|
||||
fp(await parse(historyRange, 'claude')),
|
||||
claudeConfigs.selectedId,
|
||||
)
|
||||
dailyHistory = dailyEntriesToHistory(aggregateProjectsIntoDays(historyProjects))
|
||||
|
|
@ -766,5 +781,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
const granularRange = opts.daysSelection?.range ?? scanRange
|
||||
const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange)
|
||||
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory)
|
||||
const payload = buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory)
|
||||
if (fastStart) payload.indexing = true
|
||||
return payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,13 @@ async function seedLiveTodaySession(): Promise<void> {
|
|||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString()
|
||||
const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString()
|
||||
// Keep the fixture inside both "today through now" and "all through end of
|
||||
// today" even when the suite runs before noon. Fixed midday timestamps made
|
||||
// the provider-parity assertion depend on the local time of the test run.
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const elapsed = Math.max(0, now.getTime() - todayStart)
|
||||
const ts = new Date(todayStart + Math.floor(elapsed / 3)).toISOString()
|
||||
const ts2 = new Date(todayStart + Math.floor(elapsed * 2 / 3)).toISOString()
|
||||
const line = (id: string, t: string): string => JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: t,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { mkdir, mkdtemp, rm, unlink, writeFile, readFile } from 'fs/promises'
|
|||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js'
|
||||
import { sessionCachePath } from '../src/session-cache.js'
|
||||
|
||||
let tmpHome: string
|
||||
|
|
@ -63,6 +63,52 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe('parseAllSessions hydration lock', () => {
|
||||
it('persists a range-limited first paint as partial, then an ordinary parse finalizes it', async () => {
|
||||
await writeClaudeSession(50)
|
||||
const range = { start: new Date('2026-05-15T00:00:00Z'), end: new Date('2026-05-15T23:59:59Z') }
|
||||
|
||||
expect(totalOutput(await parseAllSessions(range, 'claude', { partialHydration: true }))).toBe(50)
|
||||
const partial = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
|
||||
expect(partial.complete).not.toBe(true)
|
||||
expect(partial.partialRange).toEqual({
|
||||
startMs: range.start.getTime(),
|
||||
endMs: range.end.getTime(),
|
||||
providerFilter: 'claude',
|
||||
})
|
||||
expect(isSessionHydrationComplete()).toBe(false)
|
||||
|
||||
clearSessionCache()
|
||||
expect(totalOutput(await parseAllSessions(range, 'claude'))).toBe(50)
|
||||
const complete = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
|
||||
expect(complete.complete).toBe(true)
|
||||
expect(complete.partialRange).toBeUndefined()
|
||||
expect(isSessionHydrationComplete()).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates narrow non-durable provider snapshots when partial coverage expands', async () => {
|
||||
await writeClaudeSession(50)
|
||||
const narrow = { start: new Date('2026-05-15T00:00:00Z'), end: new Date('2026-05-15T23:59:59Z') }
|
||||
await parseAllSessions(narrow, 'claude', { partialHydration: true })
|
||||
|
||||
const seeded = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
|
||||
// A synthetic unchanged non-durable section represents the dangerous case:
|
||||
// its files were parsed for only the narrow range and must not be reused as
|
||||
// though they included older turns when the next stage grows backward.
|
||||
seeded.providers['fixture-provider'] = { envFingerprint: 'fixture', files: {} }
|
||||
await writeFile(sessionCachePath(), JSON.stringify(seeded))
|
||||
clearSessionCache()
|
||||
|
||||
const expanded = { start: new Date('2026-05-01T00:00:00Z'), end: narrow.end }
|
||||
await parseAllSessions(expanded, 'fixture-provider', { partialHydration: true })
|
||||
const cache = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
|
||||
expect(cache.providers['fixture-provider']).toBeUndefined()
|
||||
// Claude is intentionally retained: its scanner always caches the full
|
||||
// transcript before the result is range-sliced.
|
||||
expect(cache.providers.claude).toBeDefined()
|
||||
expect(cache.partialRange.startMs).toBe(expanded.start.getTime())
|
||||
expect(cache.complete).not.toBe(true)
|
||||
})
|
||||
|
||||
it('waits for a live foreign lock, then serves the warm cache instead of re-parsing', async () => {
|
||||
// Warm the cache once from a real on-disk session (output 50), capture the
|
||||
// exact cache structure, then tamper the cached output to a sentinel (999)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue