Skip to content

Commit 7594fa0

Browse files
committed
feat: optimize parse caching across providers
1 parent 563f9c4 commit 7594fa0

9 files changed

Lines changed: 1261 additions & 282 deletions

File tree

bin/codeburn

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env node
2+
3+
import { homedir } from "node:os";
4+
5+
try {
6+
process.cwd();
7+
} catch (error) {
8+
if (
9+
error &&
10+
typeof error === "object" &&
11+
"code" in error &&
12+
(error).code === "ENOENT"
13+
) {
14+
process.chdir(homedir());
15+
} else {
16+
throw error;
17+
}
18+
}
19+
20+
await import("../dist/cli.js");

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
"type": "module",
66
"main": "./dist/cli.js",
77
"bin": {
8-
"codeburn": "dist/cli.js"
8+
"codeburn": "bin/codeburn"
99
},
1010
"files": [
11-
"dist"
11+
"dist",
12+
"bin"
1213
],
1314
"scripts": {
1415
"build": "tsup",

src/dashboard.tsx

Lines changed: 162 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'
44
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
55
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
66
import { formatCost, formatTokens } from './format.js'
7-
import { parseAllSessions, filterProjectsByName } from './parser.js'
7+
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName } from './parser.js'
88
import { loadPricing } from './models.js'
99
import { getAllProviders } from './providers/index.js'
1010
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
@@ -20,6 +20,15 @@ import { join } from 'path'
2020
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
2121
type View = 'dashboard' | 'optimize' | 'compare'
2222

23+
type CachedWindow = {
24+
period: Period
25+
range: {
26+
start: Date
27+
end: Date
28+
}
29+
projects: ProjectSummary[]
30+
}
31+
2332
const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
2433
const PERIOD_LABELS: Record<Period, string> = {
2534
today: 'Today',
@@ -108,6 +117,10 @@ function getDateRange(period: Period): { start: Date; end: Date } {
108117
}
109118
}
110119

120+
function rangeCovers(outer: { start: Date; end: Date }, inner: { start: Date; end: Date }): boolean {
121+
return outer.start <= inner.start && outer.end >= inner.end
122+
}
123+
111124
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
112125

113126
function getLayout(columns?: number): Layout {
@@ -630,9 +643,156 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
630643
).size
631644
const compareAvailable = modelCount >= 2
632645
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
633-
const reloadGenerationRef = useRef(0)
646+
const cacheByProviderRef = useRef(new Map<string, CachedWindow[]>())
647+
const reloadSeqRef = useRef(0)
648+
const preloadingRef = useRef(new Map<string, Promise<ProjectSummary[]>>())
634649
const findingCount = optimizeResult?.findings.length ?? 0
635650

651+
const providerCacheKey = useCallback((provider: string) => `${provider}:${noCache ? 'nocache' : 'cache'}`, [noCache])
652+
const getRangeWidth = useCallback((range: { start: Date; end: Date }) => range.end.getTime() - range.start.getTime(), [])
653+
const makeCacheToken = useCallback((provider: string, period: Period) => `${providerCacheKey(provider)}:${period}`, [providerCacheKey])
654+
655+
const storeCachedWindow = useCallback((provider: string, period: Period, range: { start: Date; end: Date }, projects: ProjectSummary[]) => {
656+
if (noCache) return
657+
const key = providerCacheKey(provider)
658+
const windows = cacheByProviderRef.current.get(key) ?? []
659+
const normalizedRange = { start: new Date(range.start), end: new Date(range.end) }
660+
const existing = windows.findIndex(
661+
existing => existing.period === period && existing.range.start.getTime() === normalizedRange.start.getTime() && existing.range.end.getTime() === normalizedRange.end.getTime(),
662+
)
663+
if (existing >= 0) windows.splice(existing, 1)
664+
windows.push({ period, range: normalizedRange, projects })
665+
windows.sort((a, b) => a.range.start.getTime() - b.range.start.getTime())
666+
cacheByProviderRef.current.set(key, windows)
667+
}, [noCache, providerCacheKey])
668+
669+
const findCachedWindow = useCallback((provider: string, range: { start: Date; end: Date }) => {
670+
const candidates = cacheByProviderRef.current.get(providerCacheKey(provider)) ?? []
671+
let best: CachedWindow | undefined
672+
for (const candidate of candidates) {
673+
if (!rangeCovers(candidate.range, range)) continue
674+
if (!best) { best = candidate; continue }
675+
if (getRangeWidth(candidate.range) < getRangeWidth(best.range)) {
676+
best = candidate
677+
} else if (candidate.period !== best.period && getRangeWidth(candidate.range) === getRangeWidth(best.range) && candidate.range.start > best.range.start) {
678+
best = candidate
679+
}
680+
}
681+
return best
682+
}, [getRangeWidth, providerCacheKey])
683+
684+
const preloadWindow = useCallback(async (periodToLoad: Period, provider: string) => {
685+
if (noCache) return
686+
const preloadKey = makeCacheToken(provider, periodToLoad)
687+
const range = getDateRange(periodToLoad)
688+
const cached = findCachedWindow(provider, range)
689+
if (cached) return
690+
const inFlight = preloadingRef.current.get(preloadKey)
691+
if (inFlight) return
692+
693+
const promise = (async () => {
694+
const projects = await parseAllSessions(range, provider, { noCache, progress: null })
695+
if (!noCache) {
696+
storeCachedWindow(provider, periodToLoad, range, projects)
697+
}
698+
return projects
699+
})()
700+
701+
preloadingRef.current.set(preloadKey, promise)
702+
try {
703+
await promise
704+
} finally {
705+
preloadingRef.current.delete(preloadKey)
706+
}
707+
}, [findCachedWindow, makeCacheToken, noCache, storeCachedWindow])
708+
709+
const reloadData = useCallback(async (p: Period, prov: string, options?: { silent?: boolean }) => {
710+
const range = getDateRange(p)
711+
const request = ++reloadSeqRef.current
712+
const token = makeCacheToken(prov, p)
713+
const cachedWindow = findCachedWindow(prov, range)
714+
if (!options?.silent) {
715+
setOptimizeResult(null)
716+
}
717+
718+
if (cachedWindow) {
719+
const projectsFromCache = filterProjectsByName(
720+
filterProjectsByDateRange(cachedWindow.projects, range),
721+
projectFilter,
722+
excludeFilter,
723+
)
724+
if (!options?.silent && request === reloadSeqRef.current) {
725+
setProjects(projectsFromCache)
726+
}
727+
if (!options?.silent) {
728+
const usage = await getPlanUsageOrNull()
729+
if (request !== reloadSeqRef.current) return
730+
setPlanUsage(usage ?? undefined)
731+
}
732+
return
733+
}
734+
735+
const inFlight = preloadingRef.current.get(token)
736+
if (inFlight) {
737+
if (!options?.silent) setLoading(true)
738+
try {
739+
const projects = await inFlight
740+
if (!noCache) {
741+
storeCachedWindow(prov, p, range, projects)
742+
}
743+
if (request !== reloadSeqRef.current) return
744+
const filtered = filterProjectsByName(projects, projectFilter, excludeFilter)
745+
if (!options?.silent) {
746+
setProjects(filtered)
747+
}
748+
} finally {
749+
if (!options?.silent && request === reloadSeqRef.current) setLoading(false)
750+
}
751+
if (!options?.silent) {
752+
const usage = await getPlanUsageOrNull()
753+
if (request !== reloadSeqRef.current) return
754+
setPlanUsage(usage ?? undefined)
755+
}
756+
return
757+
}
758+
759+
if (!options?.silent) setLoading(true)
760+
try {
761+
const projects = await parseAllSessions(range, prov, { noCache, progress: null })
762+
if (!noCache) {
763+
storeCachedWindow(prov, p, range, projects)
764+
}
765+
if (request !== reloadSeqRef.current) return
766+
const filtered = filterProjectsByName(projects, projectFilter, excludeFilter)
767+
if (!options?.silent) {
768+
setProjects(filtered)
769+
}
770+
} finally {
771+
if (!options?.silent && request === reloadSeqRef.current) setLoading(false)
772+
}
773+
if (!options?.silent) {
774+
const usage = await getPlanUsageOrNull()
775+
if (request !== reloadSeqRef.current) return
776+
setPlanUsage(usage ?? undefined)
777+
}
778+
}, [excludeFilter, findCachedWindow, getPlanUsageOrNull, noCache, projectFilter, storeCachedWindow])
779+
780+
useEffect(() => {
781+
if (noCache) return
782+
const initialRange = getDateRange(initialPeriod)
783+
const initialKey = providerCacheKey(initialProvider)
784+
const existing = cacheByProviderRef.current.get(initialKey) ?? []
785+
const alreadyCached = existing.some(entry => rangeCovers(entry.range, initialRange))
786+
if (!alreadyCached) {
787+
storeCachedWindow(initialProvider, initialPeriod, initialRange, initialProjects)
788+
}
789+
}, [initialPeriod, initialProvider, initialProjects, noCache, providerCacheKey, storeCachedWindow])
790+
791+
useEffect(() => {
792+
if (noCache || period === '30days') return
793+
void preloadWindow('30days', activeProvider)
794+
}, [noCache, period, activeProvider, preloadWindow])
795+
636796
useEffect(() => {
637797
let cancelled = false
638798
async function detect() {
@@ -673,32 +833,6 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
673833
return () => { cancelled = true }
674834
}, [projects, period, optimizeAvailable])
675835

676-
const reloadData = useCallback(async (p: Period, prov: string) => {
677-
const generation = ++reloadGenerationRef.current
678-
setLoading(true)
679-
setOptimizeResult(null)
680-
try {
681-
const range = getDateRange(p)
682-
const data = filterProjectsByName(
683-
await parseAllSessions(range, prov, { noCache: noCache ?? false, progress: null }),
684-
projectFilter,
685-
excludeFilter,
686-
)
687-
if (reloadGenerationRef.current !== generation) return
688-
689-
setProjects(data)
690-
const usage = await getPlanUsageOrNull()
691-
if (reloadGenerationRef.current !== generation) return
692-
setPlanUsage(usage ?? undefined)
693-
} catch (error) {
694-
console.error(error)
695-
} finally {
696-
if (reloadGenerationRef.current === generation) {
697-
setLoading(false)
698-
}
699-
}
700-
}, [excludeFilter, noCache, projectFilter])
701-
702836
useEffect(() => {
703837
if (!refreshSeconds || refreshSeconds <= 0) return
704838
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000)

src/discovery-cache.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@ import type { SessionSource } from './providers/types.js'
88

99
const DISCOVERY_CACHE_VERSION = 1
1010

11+
const DISCOVERY_DIRECTORY_MARKER_PREFIX = '__dir__:'
12+
13+
function traceDiscoveryCacheRead(op: string, filePath: string, note?: string): void {
14+
if (process.env['CODEBURN_FILE_TRACE'] !== '1') return
15+
const suffix = note ? ` ${note}` : ''
16+
process.stderr.write(`codeburn-trace discovery ${op} ${filePath}${suffix}\n`)
17+
}
18+
1119
export type DiscoverySnapshotEntry = {
1220
path: string
1321
mtimeMs: number
22+
dirSignature?: string
1423
}
1524

16-
type DiscoveryCacheEntry = {
25+
export type DiscoveryCacheEntry = {
1726
version: number
1827
provider: string
1928
scope: string
@@ -78,10 +87,46 @@ function snapshotsMatch(left: DiscoverySnapshotEntry[], right: DiscoverySnapshot
7887
if (left.length !== right.length) return false
7988
return left.every((entry, index) => {
8089
const other = right[index]
81-
return !!other && entry.path === other.path && entry.mtimeMs === other.mtimeMs
90+
return !!other
91+
&& entry.path === other.path
92+
&& entry.mtimeMs === other.mtimeMs
93+
&& entry.dirSignature === other.dirSignature
8294
})
8395
}
8496

97+
function makeDirectoryMarker(path: string, dirSignature?: string): DiscoverySnapshotEntry {
98+
return {
99+
path: `${DISCOVERY_DIRECTORY_MARKER_PREFIX}${path}`,
100+
mtimeMs: 0,
101+
dirSignature,
102+
}
103+
}
104+
105+
export function isDiscoveryDirectoryMarker(path: string): boolean {
106+
return path.startsWith(DISCOVERY_DIRECTORY_MARKER_PREFIX)
107+
}
108+
109+
export function directoryPathFromMarker(markerPath: string): string | null {
110+
return markerPath.startsWith(DISCOVERY_DIRECTORY_MARKER_PREFIX)
111+
? markerPath.slice(DISCOVERY_DIRECTORY_MARKER_PREFIX.length)
112+
: null
113+
}
114+
115+
async function loadDiscoveryCacheEntry(provider: string, scope: string): Promise<DiscoveryCacheEntry | null> {
116+
const path = cachePath(provider, scope)
117+
if (!existsSync(path)) return null
118+
traceDiscoveryCacheRead('entry:read', path, `provider=${provider} scope=${scope}`)
119+
120+
try {
121+
const raw = await readFile(path, 'utf-8')
122+
const parsed: unknown = JSON.parse(raw)
123+
if (!isDiscoveryCacheEntry(parsed) || parsed.provider !== provider || parsed.scope !== scope) return null
124+
return parsed
125+
} catch {
126+
return null
127+
}
128+
}
129+
85130
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
86131
await mkdir(dirname(path), { recursive: true })
87132
const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`
@@ -129,6 +174,13 @@ export async function loadDiscoveryCache(
129174
}
130175
}
131176

177+
export async function loadDiscoveryCacheEntryUnchecked(
178+
provider: string,
179+
scope: string,
180+
): Promise<DiscoveryCacheEntry | null> {
181+
return loadDiscoveryCacheEntry(provider, scope)
182+
}
183+
132184
export async function saveDiscoveryCache(
133185
provider: string,
134186
scope: string,
@@ -144,3 +196,7 @@ export async function saveDiscoveryCache(
144196
sources,
145197
} satisfies DiscoveryCacheEntry)
146198
}
199+
200+
export function discoveryDirectoryMarker(prefixPath: string, dirSignature?: string): DiscoverySnapshotEntry {
201+
return makeDirectoryMarker(prefixPath, dirSignature)
202+
}

0 commit comments

Comments
 (0)