Skip to content

Commit a7447fb

Browse files
committed
feat: optimize parse caching across providers
1 parent 8d0affe commit a7447fb

9 files changed

Lines changed: 1242 additions & 267 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: 143 additions & 13 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'
@@ -18,6 +18,15 @@ import { join } from 'path'
1818
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
1919
type View = 'dashboard' | 'optimize' | 'compare'
2020

21+
type CachedWindow = {
22+
period: Period
23+
range: {
24+
start: Date
25+
end: Date
26+
}
27+
projects: ProjectSummary[]
28+
}
29+
2130
const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
2231
const PERIOD_LABELS: Record<Period, string> = {
2332
today: 'Today',
@@ -105,6 +114,10 @@ function getDateRange(period: Period): { start: Date; end: Date } {
105114
}
106115
}
107116

117+
function rangeCovers(outer: { start: Date; end: Date }, inner: { start: Date; end: Date }): boolean {
118+
return outer.start <= inner.start && outer.end >= inner.end
119+
}
120+
108121
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
109122

110123
function getLayout(columns?: number): Layout {
@@ -587,8 +600,137 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
587600
).size
588601
const compareAvailable = modelCount >= 2
589602
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
603+
const cacheByProviderRef = useRef(new Map<string, CachedWindow[]>())
604+
const reloadSeqRef = useRef(0)
605+
const preloadingRef = useRef(new Map<string, Promise<ProjectSummary[]>>())
590606
const findingCount = optimizeResult?.findings.length ?? 0
591607

608+
const providerCacheKey = useCallback((provider: string) => `${provider}:${noCache ? 'nocache' : 'cache'}`, [noCache])
609+
const getRangeWidth = useCallback((range: { start: Date; end: Date }) => range.end.getTime() - range.start.getTime(), [])
610+
const makeCacheToken = useCallback((provider: string, period: Period) => `${providerCacheKey(provider)}:${period}`, [providerCacheKey])
611+
612+
const storeCachedWindow = useCallback((provider: string, period: Period, range: { start: Date; end: Date }, projects: ProjectSummary[]) => {
613+
if (noCache) return
614+
const key = providerCacheKey(provider)
615+
const windows = cacheByProviderRef.current.get(key) ?? []
616+
const normalizedRange = { start: new Date(range.start), end: new Date(range.end) }
617+
const existing = windows.findIndex(
618+
existing => existing.period === period && existing.range.start.getTime() === normalizedRange.start.getTime() && existing.range.end.getTime() === normalizedRange.end.getTime(),
619+
)
620+
if (existing >= 0) windows.splice(existing, 1)
621+
windows.push({ period, range: normalizedRange, projects })
622+
windows.sort((a, b) => a.range.start.getTime() - b.range.start.getTime())
623+
cacheByProviderRef.current.set(key, windows)
624+
}, [noCache, providerCacheKey])
625+
626+
const findCachedWindow = useCallback((provider: string, range: { start: Date; end: Date }) => {
627+
const candidates = cacheByProviderRef.current.get(providerCacheKey(provider)) ?? []
628+
let best: CachedWindow | undefined
629+
for (const candidate of candidates) {
630+
if (!rangeCovers(candidate.range, range)) continue
631+
if (!best) { best = candidate; continue }
632+
if (getRangeWidth(candidate.range) < getRangeWidth(best.range)) {
633+
best = candidate
634+
} else if (candidate.period !== best.period && getRangeWidth(candidate.range) === getRangeWidth(best.range) && candidate.range.start > best.range.start) {
635+
best = candidate
636+
}
637+
}
638+
return best
639+
}, [getRangeWidth, providerCacheKey])
640+
641+
const preloadWindow = useCallback(async (periodToLoad: Period, provider: string) => {
642+
if (noCache) return
643+
const preloadKey = makeCacheToken(provider, periodToLoad)
644+
const range = getDateRange(periodToLoad)
645+
const cached = findCachedWindow(provider, range)
646+
if (cached) return
647+
const inFlight = preloadingRef.current.get(preloadKey)
648+
if (inFlight) return
649+
650+
const promise = (async () => {
651+
const projects = await parseAllSessions(range, provider, { noCache, progress: null })
652+
if (!noCache) {
653+
storeCachedWindow(provider, periodToLoad, range, projects)
654+
}
655+
return projects
656+
})()
657+
658+
preloadingRef.current.set(preloadKey, promise)
659+
try {
660+
await promise
661+
} finally {
662+
preloadingRef.current.delete(preloadKey)
663+
}
664+
}, [findCachedWindow, makeCacheToken, noCache, storeCachedWindow])
665+
666+
const reloadData = useCallback(async (p: Period, prov: string, options?: { silent?: boolean }) => {
667+
const range = getDateRange(p)
668+
const request = ++reloadSeqRef.current
669+
const token = makeCacheToken(prov, p)
670+
const cachedWindow = findCachedWindow(prov, range)
671+
if (cachedWindow) {
672+
const projectsFromCache = filterProjectsByName(
673+
filterProjectsByDateRange(cachedWindow.projects, range),
674+
projectFilter,
675+
excludeFilter,
676+
)
677+
if (!options?.silent && request === reloadSeqRef.current) {
678+
setProjects(projectsFromCache)
679+
}
680+
return
681+
}
682+
683+
const inFlight = preloadingRef.current.get(token)
684+
if (inFlight) {
685+
if (!options?.silent) setLoading(true)
686+
try {
687+
const projects = await inFlight
688+
if (!noCache) {
689+
storeCachedWindow(prov, p, range, projects)
690+
}
691+
if (request !== reloadSeqRef.current) return
692+
const filtered = filterProjectsByName(projects, projectFilter, excludeFilter)
693+
if (!options?.silent) {
694+
setProjects(filtered)
695+
}
696+
} finally {
697+
if (!options?.silent && request === reloadSeqRef.current) setLoading(false)
698+
}
699+
return
700+
}
701+
702+
if (!options?.silent) setLoading(true)
703+
try {
704+
const projects = await parseAllSessions(range, prov, { noCache, progress: null })
705+
if (!noCache) {
706+
storeCachedWindow(prov, p, range, projects)
707+
}
708+
if (request !== reloadSeqRef.current) return
709+
const filtered = filterProjectsByName(projects, projectFilter, excludeFilter)
710+
if (!options?.silent) {
711+
setProjects(filtered)
712+
}
713+
} finally {
714+
if (!options?.silent && request === reloadSeqRef.current) setLoading(false)
715+
}
716+
}, [findCachedWindow, noCache, projectFilter, excludeFilter, storeCachedWindow])
717+
718+
useEffect(() => {
719+
if (noCache) return
720+
const initialRange = getDateRange(initialPeriod)
721+
const initialKey = providerCacheKey(initialProvider)
722+
const existing = cacheByProviderRef.current.get(initialKey) ?? []
723+
const alreadyCached = existing.some(entry => rangeCovers(entry.range, initialRange))
724+
if (!alreadyCached) {
725+
storeCachedWindow(initialProvider, initialPeriod, initialRange, initialProjects)
726+
}
727+
}, [initialPeriod, initialProvider, initialProjects, noCache, providerCacheKey, storeCachedWindow])
728+
729+
useEffect(() => {
730+
if (noCache || period === '30days') return
731+
void preloadWindow('30days', activeProvider)
732+
}, [noCache, period, activeProvider, preloadWindow])
733+
592734
useEffect(() => {
593735
let cancelled = false
594736
async function detect() {
@@ -629,18 +771,6 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
629771
return () => { cancelled = true }
630772
}, [projects, period, optimizeAvailable])
631773

632-
const reloadData = useCallback(async (p: Period, prov: string) => {
633-
setLoading(true)
634-
const range = getDateRange(p)
635-
const data = filterProjectsByName(
636-
await parseAllSessions(range, prov, { noCache, progress: null }),
637-
projectFilter,
638-
excludeFilter,
639-
)
640-
setProjects(data)
641-
setLoading(false)
642-
}, [excludeFilter, noCache, projectFilter])
643-
644774
useEffect(() => {
645775
if (!refreshSeconds || refreshSeconds <= 0) return
646776
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)