mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-26 17:03:31 +00:00
perf: collapse the dashboard's repeated parses and hoist the PR-correlation loops
renderDashboard issued three parseAllSessions calls (scan range, plan window,
durable headline). They often share a START and differ only in where they end —
end-of-day vs each caller's own `new Date()` — which the exact-key memo cannot
match, so a warm render paid for the discovery sweep, the cache read and the
parse two or three times over.
withSinglePassParse lets a caller declare the widest range it will ask for. A
later request that is a PURE NARROWING of it is served by slicing that parse
instead of running the pipeline again. Pure narrowing is deliberately strict:
same start, an end that is inside, and the same month shard scope. Both extra
conditions are load-bearing, because a parse's file set is a function of its
range:
- a changed file with `mtimeMs < range.start` is skipped without being parsed,
so an earlier start reads files a later start never sees;
- loadCache reads only the shards monthScopeForRange selects;
- either way the extra files seed seenKeys/seenMsgIds BEFORE the range slice,
and a seeded key suppresses the matching in-range turn in a provider parsed
later — usage the narrower parse would have counted.
Holding start and month scope equal makes both parses see an identical file set,
leaving the range slice as the only difference — the same trade burstReuse
already makes for the mirror-image case (same start, later end).
Independently, correlateCrossProviderPrSessions ran two O(n*m) scans: a
`sessions.filter` per subagent child (now a one-shot agentId index) and a full
`launches.filter` per candidate (now a sorted array plus a windowed scan). Both
preserve the exact match set; launch order was never observable because the
matches collapse into a Map keyed by the sorted ref list.
Warm, 21k-file corpus, isolated cache, median of 5 interleaved:
today 5.12s -> 3.81s (1.34x) 3 parses -> 2
month 5.46s -> 3.41s (1.60x) 2 parses -> 1
today --format json 4.11s -> 3.49s (1.18x, correlation hoist only)
vitest.config gains an explicit exclude so a .claude worktree's stale tests/
copy stops being swept into the run.
Refs #1106
This commit is contained in:
parent
d5e485f415
commit
a8b14a7916
4 changed files with 274 additions and 17 deletions
|
|
@ -6,7 +6,7 @@ import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, typ
|
|||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js'
|
||||
import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI, withSinglePassParse } from './parser.js'
|
||||
import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.js'
|
||||
import { aggregateModelTotals } from './model-breakdown.js'
|
||||
import { buildDurablePeriod } from './usage-aggregator.js'
|
||||
|
|
@ -307,6 +307,10 @@ export type DurableOverview = {
|
|||
carriedCostUSD: number
|
||||
}
|
||||
|
||||
function getDurableRange(period: Period, customRange: DateRange | null | undefined, day: string | null): DateRange {
|
||||
return day ? getDayRange(day) : customRange ?? getPeriodRange(period)
|
||||
}
|
||||
|
||||
async function computeDurableOverview(
|
||||
period: Period,
|
||||
provider: string,
|
||||
|
|
@ -315,7 +319,7 @@ async function computeDurableOverview(
|
|||
customRange: DateRange | null | undefined,
|
||||
day: string | null,
|
||||
): Promise<DurableOverview> {
|
||||
const range = day ? getDayRange(day) : customRange ?? getPeriodRange(period)
|
||||
const range = getDurableRange(period, customRange, day)
|
||||
const { data, carriedCostUSD } = await buildDurablePeriod(
|
||||
{ range, label: PERIOD_LABELS[period] },
|
||||
{ provider, project: projectFilter ?? [], exclude: excludeFilter ?? [] },
|
||||
|
|
@ -1867,6 +1871,42 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label,
|
|||
)
|
||||
}
|
||||
|
||||
/// The initial paint's data, assembled under one declared parse scope.
|
||||
///
|
||||
/// The scan parse, the plan window and the durable headline each ran the whole
|
||||
/// pipeline: three ranges that often share a start and differ only in where
|
||||
/// they end (end-of-day vs each caller's own `new Date()`), which the exact-key
|
||||
/// memo cannot match. Declaring the widest of them lets `withSinglePassParse`
|
||||
/// serve the rest by slicing it — but only the ones that are a pure narrowing,
|
||||
/// so a range that genuinely needs its own file set (a plan window starting
|
||||
/// before the scan range, a past `--day`) still parses on its own.
|
||||
async function assembleDashboardData(
|
||||
period: Period,
|
||||
provider: string,
|
||||
projectFilter: string[] | undefined,
|
||||
excludeFilter: string[] | undefined,
|
||||
customRange: DateRange | null | undefined,
|
||||
initialDay: string | null,
|
||||
scrollableDailyHistory: boolean,
|
||||
): Promise<{ scannedProjects: ProjectSummary[]; filteredProjects: ProjectSummary[]; planUsages: PlanUsage[]; initialDurable: DurableOverview }> {
|
||||
const range = getDashboardScanRange(period, customRange, initialDay, scrollableDailyHistory)
|
||||
const durableRange = getDurableRange(period, customRange, initialDay)
|
||||
const superset: DateRange = {
|
||||
start: new Date(Math.min(range.start.getTime(), durableRange.start.getTime())),
|
||||
end: new Date(Math.max(range.end.getTime(), durableRange.end.getTime())),
|
||||
}
|
||||
return withSinglePassParse(superset, async () => {
|
||||
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
|
||||
const planUsages = await getPlanUsages()
|
||||
// Durable headline totals for the initial paint (carry-forward cache + today),
|
||||
// matching the menubar/report. The interactive tree recomputes this on every
|
||||
// period/provider/refresh change; the static one-shot render uses just this.
|
||||
const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay)
|
||||
return { scannedProjects, filteredProjects, planUsages, initialDurable }
|
||||
})
|
||||
}
|
||||
|
||||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise<void> {
|
||||
// Interactive Ink UI: it renders to the same terminal and has its own in-frame
|
||||
// loading state, so the CLI scan-progress line must stay silent for its whole
|
||||
|
|
@ -1878,13 +1918,8 @@ export async function renderDashboard(period: Period = 'week', provider: string
|
|||
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
||||
const scrollableDailyHistory = isTTY && dayRange == null && customRange == null
|
||||
const range = getDashboardScanRange(period, customRange, initialDay ?? null, scrollableDailyHistory)
|
||||
const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory)
|
||||
const planUsages = await getPlanUsages()
|
||||
// Durable headline totals for the initial paint (carry-forward cache + today),
|
||||
// matching the menubar/report. The interactive tree recomputes this on every
|
||||
// period/provider/refresh change; the static one-shot render uses just this.
|
||||
const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null)
|
||||
const { scannedProjects, filteredProjects, planUsages, initialDurable } =
|
||||
await assembleDashboardData(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null, scrollableDailyHistory)
|
||||
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
|
||||
patchStdoutForWindows()
|
||||
if (isTTY) {
|
||||
|
|
|
|||
102
src/parser.ts
102
src/parser.ts
|
|
@ -4213,6 +4213,7 @@ function cacheKey(dateRange: DateRange | undefined, providerFilter: string | und
|
|||
export function clearSessionCache(): void {
|
||||
sessionCache.clear()
|
||||
canonicalPathCache.clear()
|
||||
singlePassScope?.parses.clear()
|
||||
}
|
||||
|
||||
function cachePut(key: string, data: ProjectSummary[], parseStartedAt: number) {
|
||||
|
|
@ -4499,14 +4500,24 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
|
|||
// mutating the child. This lets a Codex/Gemini/etc. review launched inside a
|
||||
// Claude subagent inherit the parent turn's PR while the subagent itself still
|
||||
// folds exactly once under the existing accounting model.
|
||||
//
|
||||
// Indexed once rather than re-filtered per child: the loop below only writes
|
||||
// to `evidence`, so the unlinked set is fixed for its whole duration.
|
||||
const unlinkedByAgentId = new Map<string | undefined, SessionSummary[]>()
|
||||
for (const s of sessions) {
|
||||
if (s.prLinks?.length) continue
|
||||
const bucket = unlinkedByAgentId.get(s.agentId)
|
||||
if (bucket) bucket.push(s)
|
||||
else unlinkedByAgentId.set(s.agentId, [s])
|
||||
}
|
||||
for (const resolved of resolveSubagentAttribution(projects).values()) {
|
||||
for (const child of resolved) {
|
||||
// A multi-PR spawn set is valid for folding the child's own cost, but is
|
||||
// too broad to identify which PR an independently saved nested review was
|
||||
// about. Require one PR for cross-provider propagation.
|
||||
if (child.unlinked || child.prSet?.length !== 1) continue
|
||||
const matches = sessions.filter(s => !s.prLinks?.length && s.agentId === child.fold.agentId)
|
||||
if (matches.length === 1) evidence.set(matches[0]!, child.prSet)
|
||||
const matches = unlinkedByAgentId.get(child.fold.agentId)
|
||||
if (matches?.length === 1) evidence.set(matches[0]!, child.prSet)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4536,6 +4547,21 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
|
|||
const PROMPT_PREFIX = 160
|
||||
const PROMPT_MIN = 80
|
||||
const LAUNCH_WINDOW_MS = 15 * 60 * 1000
|
||||
// Sorted once so each candidate scans only the launches inside its own
|
||||
// window instead of the whole array. Launch order is not observable: the
|
||||
// match set is collapsed into a Map keyed by the sorted ref list, and
|
||||
// assignCorrelatedPrs re-sorts what it is handed.
|
||||
launches.sort((a, b) => a.atMs - b.atMs)
|
||||
const firstLaunchAtOrAfter = (atMs: number): number => {
|
||||
let lo = 0
|
||||
let hi = launches.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (launches[mid]!.atMs < atMs) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
for (const session of candidates) {
|
||||
const provider = summaryProvider(session)
|
||||
const prompt = session.turns
|
||||
|
|
@ -4545,12 +4571,14 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
|
|||
const prefix = prompt.slice(0, PROMPT_PREFIX)
|
||||
const startedMs = Date.parse(session.firstTimestamp)
|
||||
if (!Number.isFinite(startedMs)) continue
|
||||
const matches = launches.filter(launch =>
|
||||
launch.provider !== provider
|
||||
&& Math.abs(launch.atMs - startedMs) <= LAUNCH_WINDOW_MS
|
||||
&& launch.commands.some(command => command.includes(prefix))
|
||||
)
|
||||
const refSets = new Map(matches.map(m => [m.refs.slice().sort().join('\0'), m.refs]))
|
||||
const refSets = new Map<string, string[]>()
|
||||
for (let i = firstLaunchAtOrAfter(startedMs - LAUNCH_WINDOW_MS); i < launches.length; i++) {
|
||||
const launch = launches[i]!
|
||||
if (launch.atMs - startedMs > LAUNCH_WINDOW_MS) break
|
||||
if (launch.provider === provider) continue
|
||||
if (!launch.commands.some(command => command.includes(prefix))) continue
|
||||
refSets.set(launch.refs.slice().sort().join('\0'), launch.refs)
|
||||
}
|
||||
if (refSets.size === 1) {
|
||||
assignCorrelatedPrs(session, [...refSets.values()][0]!, 'launcher-prompt')
|
||||
if (session.prLinks?.length) evidence.set(session, session.prLinks)
|
||||
|
|
@ -4677,7 +4705,65 @@ let readOnlyServedStale = false
|
|||
// new data, so the run must not report hydration complete even in write mode.
|
||||
let deferredRetryableSource = false
|
||||
|
||||
// One command invocation that renders a dashboard asks for several ranges that
|
||||
// differ only in where they END — the scan range runs to end-of-day, the
|
||||
// durable headline re-anchors on its own `new Date()`. The exact-key memo needs
|
||||
// both endpoints equal, so it never hits and each ran the whole pipeline again.
|
||||
// Inside this scope the declared range is parsed ONCE per provider filter and a
|
||||
// request that is a pure NARROWING of it is served by slicing that result.
|
||||
// Anything else parses normally.
|
||||
//
|
||||
// Pure narrowing means: same start, an end that is inside, and the same month
|
||||
// shard scope. All three are load-bearing, because a parse's file set is a
|
||||
// function of its range, not just its output:
|
||||
// - a CHANGED file with `mtimeMs < range.start` is skipped without being
|
||||
// parsed, so an earlier start pulls in files a later start never reads;
|
||||
// - `loadCache` reads only the shards `monthScopeForRange` selects, so a wider
|
||||
// month span hands the query loop cached files a narrower span never sees;
|
||||
// - either way those extra files seed `seenKeys` / `seenMsgIds` BEFORE the
|
||||
// range slice runs, and a seeded key SUPPRESSES the matching in-range turn
|
||||
// in a provider parsed later — usage the narrower parse would have counted.
|
||||
// Holding start and month scope equal makes both parses see an identical file
|
||||
// set in an identical order, which leaves the range slice as the only
|
||||
// difference — applied after the parse instead of during it, as burstReuse
|
||||
// already does for the mirror-image case (same start, LATER end).
|
||||
type SinglePassScope = { range: DateRange; parses: Map<string, Promise<ProjectSummary[]>> }
|
||||
let singlePassScope: SinglePassScope | null = null
|
||||
|
||||
export async function withSinglePassParse<T>(range: DateRange, fn: () => Promise<T>): Promise<T> {
|
||||
const outer = singlePassScope
|
||||
singlePassScope = { range, parses: new Map() }
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
singlePassScope = outer
|
||||
}
|
||||
}
|
||||
|
||||
function singlePassParse(dateRange: DateRange | undefined, providerFilter: string | undefined): Promise<ProjectSummary[]> | null {
|
||||
const scope = singlePassScope
|
||||
if (!scope || !dateRange) return null
|
||||
if (dateRange.start.getTime() !== scope.range.start.getTime()) return null
|
||||
if (dateRange.end.getTime() > scope.range.end.getTime()) return null
|
||||
const wide = monthScopeForRange(scope.range.start, scope.range.end)
|
||||
const narrow = monthScopeForRange(dateRange.start, dateRange.end)
|
||||
if (wide.fromMonth !== narrow.fromMonth || wide.toMonth !== narrow.toMonth) return null
|
||||
const key = providerFilter ?? 'all'
|
||||
let parsed = scope.parses.get(key)
|
||||
if (!parsed) {
|
||||
const codexCacheDir = getCodeburnCacheDir()
|
||||
parsed = withCodexCacheDirectory(codexCacheDir, () => parseAllSessionsInCacheScope(scope.range, providerFilter))
|
||||
scope.parses.set(key, parsed)
|
||||
}
|
||||
// The declared range itself is served verbatim, exactly as an unscoped run
|
||||
// would have produced it; only a strictly narrower end pays for a slice.
|
||||
if (dateRange.end.getTime() === scope.range.end.getTime()) return parsed
|
||||
return parsed.then(projects => filterProjectsByDateRange(projects, dateRange))
|
||||
}
|
||||
|
||||
export function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
|
||||
const scoped = singlePassParse(dateRange, providerFilter)
|
||||
if (scoped) return scoped
|
||||
// Capture synchronously, before the first await. AsyncLocalStorage keeps all
|
||||
// Codex cache reads, dirty writes, and the final flush on this call-time
|
||||
// directory even if an embedding host changes the process env mid-parse.
|
||||
|
|
|
|||
133
tests/single-pass-parse.test.ts
Normal file
133
tests/single-pass-parse.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
// Counts how many times the parse pipeline actually runs. A scope hit must
|
||||
// serve from an earlier run rather than starting another one.
|
||||
let discoveries = 0
|
||||
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/providers/index.js')>()
|
||||
return {
|
||||
...actual,
|
||||
discoverAllSessions: (...args: Parameters<typeof actual.discoverAllSessions>) => {
|
||||
discoveries++
|
||||
return actual.discoverAllSessions(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { parseAllSessions, clearSessionCache, withSinglePassParse } from '../src/parser.js'
|
||||
import type { DateRange, ProjectSummary } from '../src/types.js'
|
||||
|
||||
const CWD = '/tmp/single-pass-proj'
|
||||
let tmpDir: string
|
||||
|
||||
const userLine = (ts: string, text: string) => JSON.stringify({
|
||||
type: 'user', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
|
||||
message: { role: 'user', content: text },
|
||||
})
|
||||
const asstLine = (id: string, ts: string, outputTokens: number) => JSON.stringify({
|
||||
type: 'assistant', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
|
||||
message: { id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: outputTokens } },
|
||||
})
|
||||
|
||||
// Three turns on the same day: 09:00, 12:00 and 21:00 UTC. A range that ends at
|
||||
// midday must keep the first two and drop the third, whether it was applied
|
||||
// during the parse or as a slice afterwards.
|
||||
const DAY = '2026-08-20'
|
||||
const range = (startIso: string, endIso: string): DateRange => ({ start: new Date(startIso), end: new Date(endIso) })
|
||||
const DAY_START = `${DAY}T00:00:00.000Z`
|
||||
const DAY_END = `${DAY}T23:59:59.999Z`
|
||||
const MIDDAY = `${DAY}T12:30:00.000Z`
|
||||
|
||||
const shape = (projects: ProjectSummary[]) => projects.map(p => ({
|
||||
project: p.project,
|
||||
cost: p.totalCostUSD,
|
||||
calls: p.totalApiCalls,
|
||||
turns: p.sessions.flatMap(s => s.turns.map(t => t.timestamp)).sort(),
|
||||
}))
|
||||
|
||||
beforeEach(async () => {
|
||||
clearSessionCache()
|
||||
discoveries = 0
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'single-pass-'))
|
||||
const projectDir = join(tmpDir, 'projects', 'single-pass-proj')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(join(projectDir, 'sess-1.jsonl'), [
|
||||
userLine(`${DAY}T09:00:00.000Z`, 'morning task'),
|
||||
asstLine('msg-a', `${DAY}T09:00:01.000Z`, 20),
|
||||
userLine(`${DAY}T12:00:00.000Z`, 'noon task'),
|
||||
asstLine('msg-b', `${DAY}T12:00:01.000Z`, 30),
|
||||
userLine(`${DAY}T21:00:00.000Z`, 'evening task'),
|
||||
asstLine('msg-c', `${DAY}T21:00:01.000Z`, 40),
|
||||
].join('\n') + '\n')
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
|
||||
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearSessionCache()
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CODEBURN_CACHE_DIR']
|
||||
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('withSinglePassParse', () => {
|
||||
it('serves a narrower end from the declared parse, matching an unscoped parse of that range', async () => {
|
||||
const unscoped = shape(await parseAllSessions(range(DAY_START, MIDDAY)))
|
||||
clearSessionCache()
|
||||
discoveries = 0
|
||||
|
||||
const { wide, narrow } = await withSinglePassParse(range(DAY_START, DAY_END), async () => ({
|
||||
wide: shape(await parseAllSessions(range(DAY_START, DAY_END))),
|
||||
narrow: shape(await parseAllSessions(range(DAY_START, MIDDAY))),
|
||||
}))
|
||||
|
||||
expect(discoveries).toBe(1)
|
||||
expect(narrow).toEqual(unscoped)
|
||||
// Non-vacuous: the narrow view really is a strict subset of the wide one.
|
||||
expect(narrow[0]!.turns).toEqual([`${DAY}T09:00:00.000Z`, `${DAY}T12:00:00.000Z`])
|
||||
expect(wide[0]!.turns).toHaveLength(3)
|
||||
expect(narrow[0]!.cost).toBeLessThan(wide[0]!.cost)
|
||||
})
|
||||
|
||||
it('re-parses when the start differs, because a start also decides which files are read', async () => {
|
||||
await withSinglePassParse(range(DAY_START, DAY_END), async () => {
|
||||
await parseAllSessions(range(DAY_START, DAY_END))
|
||||
await parseAllSessions(range(`${DAY}T10:00:00.000Z`, MIDDAY))
|
||||
})
|
||||
expect(discoveries).toBe(2)
|
||||
})
|
||||
|
||||
it('re-parses when the declared end lands in a later month than the request', async () => {
|
||||
// Local end-of-day on the last of a month is next month in UTC, which is
|
||||
// what monthScopeForRange keys on: the two loads would read different shards.
|
||||
const start = '2026-08-31T00:00:00.000Z'
|
||||
await withSinglePassParse(range(start, '2026-09-01T00:30:00.000Z'), async () => {
|
||||
await parseAllSessions(range(start, '2026-09-01T00:30:00.000Z'))
|
||||
await parseAllSessions(range(start, '2026-08-31T23:00:00.000Z'))
|
||||
})
|
||||
expect(discoveries).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves a request that reaches past the declared end alone', async () => {
|
||||
await withSinglePassParse(range(DAY_START, MIDDAY), async () => {
|
||||
await parseAllSessions(range(DAY_START, MIDDAY))
|
||||
await parseAllSessions(range(DAY_START, DAY_END))
|
||||
})
|
||||
expect(discoveries).toBe(2)
|
||||
})
|
||||
|
||||
it('scopes nothing outside the callback', async () => {
|
||||
await withSinglePassParse(range(DAY_START, DAY_END), async () => {
|
||||
await parseAllSessions(range(DAY_START, DAY_END))
|
||||
})
|
||||
clearSessionCache()
|
||||
discoveries = 0
|
||||
await parseAllSessions(range(DAY_START, MIDDAY))
|
||||
expect(discoveries).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// A worktree under .claude/ carries its own tests/ copy. They are stale by
|
||||
// definition (another branch's checkout) and are not this run's subject.
|
||||
exclude: ['**/node_modules/**', '**/dist/**', '**/.claude/worktrees/**'],
|
||||
// Runs once per worker before any test. Scrubs the developer's shell so
|
||||
// session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every
|
||||
// provider-specific *_HOME) don't bleed real local data into fixtures.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue