mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-06 14:55:17 +00:00
* feat(app): transient project selector in the desktop top bar Scopes every report to one project path for the current session through the same narrowing the VS Code workspace scope uses. The saved project filter is never written; a restart is back on all projects. * fix(app): let the name pickers ellipsize before the top bar overflows * fix: key the daily project split per real path, not per label A project label can span several paths: every Claude session started from the home folder shares -Users-<name>, while its cwd puts it in a different project. The day rollup keyed by label kept the first path it saw, so a rooted --project headline took the whole label bucket on some days and missed it on others. Key per (label, path), read old label-keyed days unchanged, bump the daily cache to v68. * fix(app): list only selectable, non-zero projects in the top bar picker Rows with no session cwd (the Cursor and Grok Bot imports, provider label fallbacks) report their label as the path, so the picker offered names the bridge refuses and dash-to-slash guesses like eywa/lab. Keep only absolute paths, and drop rows whose lifetime cost rounds to $0.00. The old test that rooted a slashless Codex path is replaced: Codex now records the absolute cwd, and a slashless path is always the label fallback. * fix(app): show a toast when a project pick is refused The pick was dropped silently. Also pin that an encoded -Users-... label never reaches the picker list. * feat: group projects by git repository Clones and worktrees that share an origin remote are one project in every project list (report JSON, By Project, the top bar picker, the terminal dashboard), with the checkouts listed under the row. A --project path inside a checkout scopes to the whole repository; --exact-project keeps the old single-folder match. The origin is learned while the folder exists, kept in git-origins.json and on each day's project entry (daily cache v69), so a deleted scratch clone stays in its repository. The report JSON project list now comes from the durable day set, so a row costs what selecting it reports. * fix: a picked project row selects that row exactly The top bar pick sends =<path>: a checkout in a repository still selects the whole repository, and a plain folder selects itself without the folders under it, which are rows of their own. So the picker row and the headline it opens are the same set. Typed --project paths keep their prefix match. Days adopted from an older cache learn the origin of every checkout that still exists, and lookups outside a parse are saved on exit. * fix: key the status snapshot on --exact-project * feat: one Temporary folders row for scratch folders outside a repository Every project path under a temp root (os.tmpdir(), /tmp, /var/folders and their /private spellings) that is not a checkout of a known repository folds into one row, in the report JSON, By Project, the terminal dashboard, the top bar picker and the Settings Projects pane. Its pattern @temp selects or hides all of them. The Projects pane leaves out rows that round to $0.00 unless a saved pattern names them, and a saved pattern naming any checkout of a repository row now names the whole row in the renderer too. * fix(claude): attribute each call to the project of its own cwd * test: fold the temp roots the tests set, compare checkout ids as identities (Windows) * fix(claude): keep subfolder cwds in their project, count and show a split session once * fix: an exclude path hides that checkout only, repository rows count a session once * fix(claude): a deleted subfolder stays its own project
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { PassThrough } from 'node:stream'
|
|
|
|
import React from 'react'
|
|
import { render } from 'ink'
|
|
import stripAnsi from 'strip-ansi'
|
|
import { afterEach, beforeEach, describe, expect, it, onTestFinished } from 'vitest'
|
|
import { appendFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
import {
|
|
assembleDashboardFirstPaint,
|
|
buildDashboardHistoryIndex,
|
|
DASHBOARD_COLD_INDEX_PHASES,
|
|
dashboardIndexSupportsPeriod,
|
|
InteractiveDashboard,
|
|
selectDashboardHistoryIndex,
|
|
shouldAutoFallbackToWeek,
|
|
} from '../src/dashboard.js'
|
|
import { getDateRange, type Period } from '../src/cli-date.js'
|
|
import { clearSessionCache, filesParsedFromSourceCount, isCompleteSessionSnapshotAvailable, parseAllSessions, sessionMemoPublicationCount } from '../src/parser.js'
|
|
import { clearLoadCacheMemo, fingerprintFileCount, isColdCacheOnDisk } from '../src/session-cache.js'
|
|
import { buildDurablePeriod } from '../src/usage-aggregator.js'
|
|
import type { ProjectSummary } from '../src/types.js'
|
|
import { setHome } from './setup/home.js'
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
|
let tmpDir: string
|
|
const originalHome = process.env['HOME']
|
|
|
|
beforeEach(async () => {
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'dashboard-progressive-'))
|
|
setHome(tmpDir)
|
|
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()
|
|
clearLoadCacheMemo()
|
|
delete process.env['CLAUDE_CONFIG_DIR']
|
|
delete process.env['CODEBURN_CACHE_DIR']
|
|
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
|
|
if (originalHome == null) setHome(undefined)
|
|
else setHome(originalHome)
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function writeSession(name: string, ageDays: number, outputTokens = 50): Promise<void> {
|
|
const dir = join(tmpDir, 'projects', 'proj')
|
|
await mkdir(dir, { recursive: true })
|
|
const at = new Date(Date.now() - ageDays * DAY_MS)
|
|
const path = join(dir, `${name}.jsonl`)
|
|
await writeFile(path, `${JSON.stringify({
|
|
type: 'assistant',
|
|
sessionId: name,
|
|
timestamp: at.toISOString(),
|
|
cwd: '/work/proj',
|
|
message: {
|
|
id: `msg-${name}`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-5',
|
|
content: [],
|
|
usage: { input_tokens: 100, output_tokens: outputTokens },
|
|
},
|
|
})}\n`)
|
|
await utimes(path, at, at)
|
|
}
|
|
|
|
async function appendSessionCall(name: string, outputTokens: number): Promise<void> {
|
|
const timestamp = new Date().toISOString()
|
|
await appendFile(join(tmpDir, 'projects', 'proj', `${name}.jsonl`), `${JSON.stringify({
|
|
type: 'assistant',
|
|
sessionId: name,
|
|
timestamp,
|
|
cwd: '/work/proj',
|
|
message: {
|
|
id: `msg-${name}-appended`,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-5',
|
|
content: [],
|
|
usage: { input_tokens: 100, output_tokens: outputTokens },
|
|
},
|
|
})}\n`)
|
|
}
|
|
|
|
async function renderAutoPeriodDashboard(initialProjects: ProjectSummary[], initialDurable: Awaited<ReturnType<typeof assembleDashboardFirstPaint>>['result']['initialDurable']) {
|
|
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
|
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
|
stdin.isTTY = true
|
|
stdin.setRawMode = () => stdin
|
|
stdin.ref = () => stdin
|
|
stdin.unref = () => stdin
|
|
stdout.isTTY = true
|
|
stdout.columns = 120
|
|
stdout.rows = 50
|
|
const frames: string[] = []
|
|
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
|
let indexed!: () => void
|
|
const settled = new Promise<void>(resolve => { indexed = resolve })
|
|
const app = render(React.createElement(InteractiveDashboard, {
|
|
initialProjects,
|
|
initialPeriod: 'today',
|
|
initialProvider: 'all',
|
|
initialDurable,
|
|
refreshSeconds: 0,
|
|
windowColumns: 120,
|
|
initialHistoryIndexing: true,
|
|
initialCacheWasCold: true,
|
|
autoFallbackFromEmptyToday: true,
|
|
onHistoryIndexed: indexed,
|
|
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
|
onTestFinished(() => app.unmount())
|
|
const settledFrame = async (): Promise<string> => {
|
|
await settled
|
|
await app.waitUntilRenderFlush()
|
|
return frames.filter(value => value.trim()).at(-1) ?? ''
|
|
}
|
|
return { app, frames, settledFrame }
|
|
}
|
|
|
|
describe('interactive dashboard progressive startup', () => {
|
|
it('selects and loads 7 Days in the rendered TUI when Today has exactly zero usage', async () => {
|
|
await writeSession('older', 3)
|
|
const paint = await assembleDashboardFirstPaint(
|
|
'today', 'all', undefined, undefined, null, null, false,
|
|
)
|
|
expect(paint.result.filteredProjects).toEqual([])
|
|
|
|
const { app, frames, settledFrame } = await renderAutoPeriodDashboard(
|
|
paint.result.filteredProjects,
|
|
paint.result.initialDurable,
|
|
)
|
|
await app.waitUntilRenderFlush()
|
|
expect(frames.filter(value => value.trim()).at(-1)).toContain('[ Today ]')
|
|
const frame = await settledFrame()
|
|
|
|
expect(frame).toContain('[ 7 Days ]')
|
|
expect(frame).toContain('proj')
|
|
})
|
|
|
|
it('preserves Today in the rendered TUI when Today has any usage', async () => {
|
|
await writeSession('today', 0)
|
|
await writeSession('older', 3)
|
|
const paint = await assembleDashboardFirstPaint(
|
|
'today', 'all', undefined, undefined, null, null, false,
|
|
)
|
|
expect(paint.result.filteredProjects).toHaveLength(1)
|
|
|
|
const { app, frames, settledFrame } = await renderAutoPeriodDashboard(
|
|
paint.result.filteredProjects,
|
|
paint.result.initialDurable,
|
|
)
|
|
await app.waitUntilRenderFlush()
|
|
expect(frames.filter(value => value.trim()).at(-1)).toContain('[ Today ]')
|
|
const frame = await settledFrame()
|
|
|
|
expect(frame).toContain('[ Today ]')
|
|
expect(frame).not.toContain('[ 7 Days ]')
|
|
})
|
|
|
|
it('falls back from an all-zero Today on cold and warm indexes, but keeps real usage on Today', () => {
|
|
const cache = { version: 29, savingsConfigHash: '', lastComputedDate: null, days: [], complete: true } as const
|
|
const coldWeek = { provider: 'all', normalizedProjects: [], cache, planUsages: [], readyThrough: 'week' as const }
|
|
const warmLifetime = { ...coldWeek, readyThrough: 'lifetime' as const }
|
|
const zeroSessionProject = {
|
|
project: 'zero', projectPath: '/tmp/zero', sessions: [{
|
|
sessionId: 'zero', project: 'zero', firstTimestamp: '', lastTimestamp: '', totalCostUSD: 0,
|
|
totalSavingsUSD: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0, apiCalls: 0, turns: [], modelBreakdown: {}, toolBreakdown: {},
|
|
mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {},
|
|
}], totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0,
|
|
} as ProjectSummary
|
|
const realUsage = { ...zeroSessionProject, totalApiCalls: 1 }
|
|
|
|
expect(shouldAutoFallbackToWeek(true, false, 'today', coldWeek, [])).toBe(true)
|
|
expect(shouldAutoFallbackToWeek(true, false, 'today', warmLifetime, [zeroSessionProject])).toBe(true)
|
|
expect(shouldAutoFallbackToWeek(true, false, 'today', coldWeek, [realUsage])).toBe(false)
|
|
expect(shouldAutoFallbackToWeek(true, false, 'today', warmLifetime, [realUsage])).toBe(false)
|
|
})
|
|
|
|
it('does not treat an empty cache as a usable complete snapshot', async () => {
|
|
expect(await isCompleteSessionSnapshotAvailable(getDateRange('today').range, 'all')).toBe(false)
|
|
await parseAllSessions(getDateRange('today').range, 'all')
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
expect(await isCompleteSessionSnapshotAvailable(getDateRange('today').range, 'all')).toBe(false)
|
|
await writeSession('arrived-after-empty-cache', 0)
|
|
const paint = await assembleDashboardFirstPaint(
|
|
'today', 'all', undefined, undefined, null, null, false,
|
|
)
|
|
expect(paint.result.filteredProjects.flatMap(project => project.sessions).map(session => session.sessionId))
|
|
.toEqual(['arrived-after-empty-cache'])
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
expect(await isCompleteSessionSnapshotAvailable(getDateRange('today').range, 'codex')).toBe(false)
|
|
})
|
|
|
|
it('discovers current source files when the normalized cache is cold', async () => {
|
|
await writeSession('today', 0)
|
|
|
|
const paint = await assembleDashboardFirstPaint(
|
|
'today', 'all', undefined, undefined, null, null, false,
|
|
)
|
|
|
|
expect(paint.result.filteredProjects.flatMap(project => project.sessions).map(session => session.sessionId))
|
|
.toEqual(['today'])
|
|
expect(filesParsedFromSourceCount()).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('paints Today first even when the normalized session cache is already complete', async () => {
|
|
await writeSession('today', 0)
|
|
await writeSession('old', 90)
|
|
const configDir = join(tmpDir, '.config', 'codeburn')
|
|
await mkdir(configDir, { recursive: true })
|
|
await writeFile(join(configDir, 'config.json'), JSON.stringify({
|
|
plans: { claude: { id: 'claude-max', monthlyUsd: 200, resetDay: 1 } },
|
|
}))
|
|
|
|
await parseAllSessions(getDateRange('lifetime').range, 'all')
|
|
await buildDurablePeriod(getDateRange('lifetime'), { provider: 'all' })
|
|
expect(await isColdCacheOnDisk()).toBe(false)
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
|
|
const parsedBefore = filesParsedFromSourceCount()
|
|
const fingerprintsBefore = fingerprintFileCount()
|
|
const paint = await assembleDashboardFirstPaint(
|
|
'today', 'all', undefined, undefined, null, null, false,
|
|
)
|
|
|
|
expect(paint.result.period).toBe('today')
|
|
expect(paint.result.filteredProjects.flatMap(project => project.sessions).map(session => session.sessionId)).toEqual(['today'])
|
|
expect(paint.result.planUsages).toEqual([])
|
|
expect(paint.deferredFiles).toBe(0)
|
|
expect(filesParsedFromSourceCount() - parsedBefore).toBe(0)
|
|
expect(fingerprintFileCount() - fingerprintsBefore).toBe(0)
|
|
})
|
|
|
|
it('projects every period from one normalized lifetime index without returning to source files', async () => {
|
|
const periods: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime']
|
|
await Promise.all([
|
|
writeSession('today', 0),
|
|
writeSession('week', 5),
|
|
writeSession('month', 20),
|
|
writeSession('older', 45),
|
|
writeSession('six-months', 120),
|
|
writeSession('lifetime', 500),
|
|
])
|
|
|
|
const baseline = new Map<Period, Awaited<ReturnType<typeof buildDurablePeriod>>>()
|
|
for (const period of periods) {
|
|
baseline.set(period, await buildDurablePeriod(getDateRange(period), { provider: 'all' }))
|
|
}
|
|
|
|
const index = await buildDashboardHistoryIndex('all', undefined, undefined)
|
|
const parsedAfterIndex = filesParsedFromSourceCount()
|
|
await rm(join(tmpDir, 'projects'), { recursive: true, force: true })
|
|
|
|
for (const period of periods) {
|
|
const selected = selectDashboardHistoryIndex(index, period)
|
|
const expected = baseline.get(period)!
|
|
expect(selected.durable).toEqual({
|
|
cost: expected.data.cost,
|
|
savingsUSD: expected.data.savingsUSD,
|
|
calls: expected.data.calls,
|
|
sessions: expected.data.sessions,
|
|
sessionCountBasis: expected.data.sessionCountBasis,
|
|
inputTokens: expected.data.inputTokens,
|
|
outputTokens: expected.data.outputTokens,
|
|
cacheReadTokens: expected.data.cacheReadTokens,
|
|
cacheWriteTokens: expected.data.cacheWriteTokens,
|
|
carriedCostUSD: expected.carriedCostUSD,
|
|
excludedGateway: expected.excludedGateway,
|
|
})
|
|
expect(selected.projects.flatMap(project => project.sessions).map(session => session.sessionId).sort())
|
|
.toEqual(expected.liveProjects.flatMap(project => project.sessions).map(session => session.sessionId).sort())
|
|
}
|
|
expect(filesParsedFromSourceCount()).toBe(parsedAfterIndex)
|
|
})
|
|
|
|
it('does not let the fast cached snapshot suppress source reconciliation', async () => {
|
|
await writeSession('today', 0)
|
|
await writeSession('old', 90)
|
|
await parseAllSessions(getDateRange('lifetime').range, 'all')
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
// The source changes before launch, with no resident watcher available to
|
|
// invalidate an in-process memo. Cached first paint may show the old value;
|
|
// its immediately-following source reconciliation must replace it.
|
|
await appendSessionCall('today', 450)
|
|
|
|
const beforeSnapshot = fingerprintFileCount()
|
|
const memoPublicationsBefore = sessionMemoPublicationCount()
|
|
const snapshot = await buildDashboardHistoryIndex('all', undefined, undefined, {
|
|
readyThrough: 'lifetime',
|
|
preferCompleteSnapshot: true,
|
|
})
|
|
const afterSnapshot = fingerprintFileCount()
|
|
expect(afterSnapshot).toBe(beforeSnapshot)
|
|
expect(sessionMemoPublicationCount()).toBe(memoPublicationsBefore)
|
|
expect(snapshot.normalizedProjects.flatMap(project => project.sessions)
|
|
.find(session => session.sessionId === 'today')?.totalOutputTokens).toBe(50)
|
|
|
|
const refreshed = await buildDashboardHistoryIndex('all', undefined, undefined)
|
|
expect(fingerprintFileCount()).toBeGreaterThan(afterSnapshot)
|
|
expect(refreshed.normalizedProjects.flatMap(project => project.sessions)
|
|
.find(session => session.sessionId === 'today')?.totalOutputTokens).toBe(500)
|
|
})
|
|
|
|
it('widens a cold index in readiness order without parsing a source twice', async () => {
|
|
const periods: Period[] = ['today', ...DASHBOARD_COLD_INDEX_PHASES]
|
|
await Promise.all([
|
|
writeSession('today', 0),
|
|
writeSession('week', 5),
|
|
writeSession('month', 20),
|
|
writeSession('older', 45),
|
|
writeSession('six-months', 120),
|
|
writeSession('lifetime', 500),
|
|
])
|
|
|
|
const parsedBefore = filesParsedFromSourceCount()
|
|
let previousParsed = parsedBefore
|
|
for (const [position, readyThrough] of DASHBOARD_COLD_INDEX_PHASES.entries()) {
|
|
const index = await buildDashboardHistoryIndex('all', undefined, undefined, {
|
|
readyThrough,
|
|
progressiveSource: true,
|
|
})
|
|
for (const [periodPosition, period] of periods.entries()) {
|
|
expect(dashboardIndexSupportsPeriod(index, period)).toBe(periodPosition <= position + 1)
|
|
}
|
|
const parsed = filesParsedFromSourceCount()
|
|
expect(parsed).toBeGreaterThanOrEqual(previousParsed)
|
|
previousParsed = parsed
|
|
}
|
|
|
|
expect(filesParsedFromSourceCount() - parsedBefore).toBe(6)
|
|
})
|
|
})
|