mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
feat: group projects by git repository (#1691)
* 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
This commit is contained in:
parent
d49a6a4eba
commit
bc62059744
27 changed files with 736 additions and 101 deletions
|
|
@ -488,7 +488,8 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
|
|||
let transientProject: string | null = null
|
||||
const projectArgs = (): string[] => {
|
||||
const filter = savedFilter()
|
||||
return filterArgs(transientProject ? scopeFilter([transientProject])(filter) : filter)
|
||||
// "=": the picked row alone, not the folders under it that are rows of their own.
|
||||
return filterArgs(transientProject ? scopeFilter([`=${transientProject}`])(filter) : filter)
|
||||
}
|
||||
const telemetry = deps.telemetry ?? null
|
||||
// Flips true after the first overview fetch succeeds. Until then, every
|
||||
|
|
@ -803,10 +804,10 @@ export function createBridgeHandlers(deps: Deps): Record<string, Handler> {
|
|||
try { return { ok: true, value: readProjectFilter() } }
|
||||
catch (error) { return { ok: false, error: toEnvelopeError(error) } }
|
||||
},
|
||||
// An absolute path only: a bare name would be a substring pattern and
|
||||
// could take in every project sharing it.
|
||||
// An absolute path only, or the temporary-folders row: a bare name would be
|
||||
// a substring pattern and could take in every project sharing it.
|
||||
'codeburn:setTransientProject': async (projectPath?: unknown) => {
|
||||
if (projectPath !== null && (typeof projectPath !== 'string' || !path.isAbsolute(projectPath) || projectPath.includes('\0'))) {
|
||||
if (projectPath !== null && projectPath !== '@temp' && (typeof projectPath !== 'string' || !path.isAbsolute(projectPath) || projectPath.includes('\0'))) {
|
||||
return { ok: false, error: { kind: 'bad-args', message: 'invalid project path' } }
|
||||
}
|
||||
transientProject = projectPath
|
||||
|
|
|
|||
|
|
@ -944,10 +944,10 @@ describe('project filter', () => {
|
|||
expect(await handlers['codeburn:setTransientProject']!('/Users/me/work/-app')).toEqual({ ok: true, value: undefined })
|
||||
await handlers['codeburn:getSessions']!('week', 'all')
|
||||
// The pick replaces the saved includes; the saved excludes still apply.
|
||||
expect(calls[0]).toEqual(['sessions', '--format', 'json', '--period', 'week', '--project=/Users/me/work/-app', '--exclude=scratch'])
|
||||
expect(calls[0]).toEqual(['sessions', '--format', 'json', '--period', 'week', '--project==/Users/me/work/-app', '--exclude=scratch'])
|
||||
// A project pick is local data: combined is dropped like with any filter.
|
||||
await handlers['codeburn:getOverview']!('30days', 'all', undefined, undefined, undefined, 'combined')
|
||||
expect(calls[1]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--project=/Users/me/work/-app', '--exclude=scratch'])
|
||||
expect(calls[1]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--project==/Users/me/work/-app', '--exclude=scratch'])
|
||||
// Not project-scoped: plans, the Projects pane list, and exports.
|
||||
await handlers['codeburn:getPlans']!('week')
|
||||
expect(calls[2]).toEqual(['status', '--format', 'json', '--period', 'week'])
|
||||
|
|
@ -963,6 +963,14 @@ describe('project filter', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('accepts the temporary-folders row for the top bar pick', async () => {
|
||||
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
|
||||
const handlers = createBridgeHandlers(deps({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
|
||||
expect(await handlers['codeburn:setTransientProject']!('@temp')).toEqual({ ok: true, value: undefined })
|
||||
await handlers['codeburn:getSessions']!('week', 'all')
|
||||
expect(calls[0]).toEqual(['sessions', '--format', 'json', '--period', 'week', '--project==@temp'])
|
||||
})
|
||||
|
||||
it('accepts only an absolute project path for the top bar pick', async () => {
|
||||
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
|
||||
const handlers = createBridgeHandlers(deps({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ function AppMain({ transientProject, onTransientProject }: { transientProject: T
|
|||
// "Combined" in place of the (forced-'all') provider label.
|
||||
const scopeCaption = scope === 'combined'
|
||||
? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${t('shell.scope.combined')}`
|
||||
: `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${activeProviderLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}${transientProject ? ` · ${shortenProjectPath(transientProject.path, 2)}` : ''}`
|
||||
: `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${activeProviderLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}${transientProject ? ` · ${transientProject.label ?? shortenProjectPath(transientProject.path, 2)}` : ''}`
|
||||
const refreshing = usePolledInFlight() || overview.switching || (!!headlineSnapshot && overview.loading)
|
||||
const selectedReportKeys = selectedReportMemoKeys(section, period, provider, customRange, activeOverviewKey)
|
||||
const selectedReportTimestamps = selectedReportKeys.map(polledMemoTimestamp)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,19 @@ describe('projectScopeOptions', () => {
|
|||
expect(projectScopeOptions(projects, NO_FILTER).map(option => option.value)).toEqual(['/w/a', 'C:\\work\\b'])
|
||||
})
|
||||
|
||||
it('names a repository row after the repository', () => {
|
||||
const repo = { ...row('/w/codeburn', 9, 'codeburn'), checkouts: [{ path: '/w/codeburn', cost: 6 }, { path: '/tmp/clone-3', cost: 3 }] }
|
||||
expect(projectScopeOptions([repo, row('/w/site', 1)], NO_FILTER).map(option => option.label)).toEqual(['codeburn', 'w/site'])
|
||||
})
|
||||
|
||||
it('offers the temporary-folders row and hides a repository excluded whole by any checkout', () => {
|
||||
const repo = { ...row('/w/codeburn', 9, 'codeburn'), checkouts: [{ path: '/w/codeburn', cost: 6 }, { path: '/tmp/clone-3', cost: 3 }] }
|
||||
const temp = { ...row('@temp', 4, 'Temporary folders'), temporary: true }
|
||||
expect(projectScopeOptions([repo, temp], NO_FILTER).map(option => [option.value, option.label])).toEqual([['/w/codeburn', 'codeburn'], ['@temp', 'Temporary folders']])
|
||||
expect(projectScopeOptions([repo, temp], { project: [], exclude: ['=/tmp/clone-3'] }).map(option => option.value)).toEqual(['@temp'])
|
||||
expect(projectScopeOptions([repo, temp], { project: [], exclude: ['/tmp/clone-3'] }).map(option => option.value)).toEqual(['/w/codeburn', '@temp'])
|
||||
})
|
||||
|
||||
it('hides projects whose lifetime cost rounds to $0.00', () => {
|
||||
const projects = [row('/w/zero', 0), row('/w/tiny', 0.004), row('/w/half', 0.005), row('/w/cent', 0.01)]
|
||||
expect(projectScopeOptions(projects, NO_FILTER).map(option => option.value)).toEqual(['/w/cent', '/w/half'])
|
||||
|
|
|
|||
|
|
@ -7,19 +7,20 @@ import { isRooted, projectVisible } from '../lib/projectMatch'
|
|||
import type { ProjectFilter, ProjectRow } from '../lib/types'
|
||||
import { Dropdown, type DropdownOption } from './Dropdown'
|
||||
|
||||
export type TransientProject = { name: string; path: string }
|
||||
/** `path` is one checkout; the CLI widens it to its whole repository. */
|
||||
export type TransientProject = { name: string; path: string; label?: string }
|
||||
|
||||
const ALL = ''
|
||||
const MAX_SHOWN = 100
|
||||
|
||||
/** Projects the saved filter shows, costliest first. Only rows with an
|
||||
* absolute path: a bucket with no session cwd (an import, a provider's label
|
||||
* absolute path, and the temporary-folders row: a bucket with no session cwd (an import, a provider's label
|
||||
* fallback) reports its label as the path, and no rooted filter selects it.
|
||||
* Rows that round to $0.00 are left out too, they would show nothing. */
|
||||
export function projectScopeOptions(projects: ProjectRow[], filter: ProjectFilter): Array<DropdownOption & { name: string }> {
|
||||
const byPath = new Map<string, ProjectRow>()
|
||||
for (const project of projects) {
|
||||
if (!isRooted(project.path) || !projectVisible(project, filter)) continue
|
||||
if ((!isRooted(project.path) && !project.temporary) || !projectVisible(project, filter)) continue
|
||||
const path = project.path.trim()
|
||||
const held = byPath.get(path)
|
||||
if (!held || (held.cost ?? 0) < (project.cost ?? 0)) byPath.set(path, project)
|
||||
|
|
@ -32,7 +33,7 @@ export function projectScopeOptions(projects: ProjectRow[], filter: ProjectFilte
|
|||
}
|
||||
return rows.map(([path, project]) => {
|
||||
const label = shortenProjectPath(path, 2)
|
||||
return { value: path, label: labels.get(label)! > 1 ? path : label, name: project.name }
|
||||
return { value: path, label: project.temporary ? t('shell.project.temporary') : project.checkouts ? project.name : labels.get(label)! > 1 ? path : label, name: project.name }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ export function ProjectScopePicker({ value, onSelect }: { value: TransientProjec
|
|||
|
||||
const matches = matchProjectOptions(options ?? [], query)
|
||||
const shown = matches.slice(0, MAX_SHOWN)
|
||||
const selected = value ? { value: value.path, label: shortenProjectPath(value.path, 2), name: value.name } : null
|
||||
const selected = value ? { value: value.path, label: value.label ?? shortenProjectPath(value.path, 2), name: value.name } : null
|
||||
const list: DropdownOption[] = query.trim()
|
||||
? shown
|
||||
: [
|
||||
|
|
@ -91,7 +92,10 @@ export function ProjectScopePicker({ value, onSelect }: { value: TransientProjec
|
|||
onChange={path => {
|
||||
if (path === ALL) { onSelect(null); return }
|
||||
const option = options?.find(entry => entry.value === path) ?? selected
|
||||
if (option) onSelect({ name: option.name, path: option.value })
|
||||
if (!option) return
|
||||
// Only a repository row is named apart from its path.
|
||||
const named = option.label !== option.value && option.label !== shortenProjectPath(option.value, 2)
|
||||
onSelect({ name: option.name, path: option.value, ...(named ? { label: option.label } : {}) })
|
||||
}}
|
||||
footer={footer}
|
||||
search={{ value: query, onChange: setQuery, placeholder: t('shell.project.search'), ariaLabel: t('shell.project.search') }}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export const shell: SectionCatalog = {
|
|||
'shell.action.dismiss': 'Dismiss',
|
||||
'shell.provider.all': 'All providers',
|
||||
'shell.project.all': 'All projects',
|
||||
'shell.project.temporary': 'Temporary folders',
|
||||
'shell.project.ariaLabel': 'Project',
|
||||
'shell.project.search': 'Search projects',
|
||||
'shell.project.loading': 'Loading projects…',
|
||||
|
|
@ -152,6 +153,7 @@ export const shell: SectionCatalog = {
|
|||
'shell.action.dismiss': 'Fermer',
|
||||
'shell.provider.all': 'Tous les fournisseurs',
|
||||
'shell.project.all': 'Tous les projets',
|
||||
'shell.project.temporary': 'Dossiers temporaires',
|
||||
'shell.project.ariaLabel': 'Projet',
|
||||
'shell.project.search': 'Rechercher un projet',
|
||||
'shell.project.loading': 'Chargement des projets…',
|
||||
|
|
@ -247,6 +249,7 @@ export const shell: SectionCatalog = {
|
|||
'shell.action.dismiss': '閉じる',
|
||||
'shell.provider.all': 'すべてのプロバイダー',
|
||||
'shell.project.all': 'すべてのプロジェクト',
|
||||
'shell.project.temporary': '一時フォルダ',
|
||||
'shell.project.ariaLabel': 'プロジェクト',
|
||||
'shell.project.search': 'プロジェクトを検索',
|
||||
'shell.project.loading': 'プロジェクトを読み込み中…',
|
||||
|
|
@ -342,6 +345,7 @@ export const shell: SectionCatalog = {
|
|||
'shell.action.dismiss': '닫기',
|
||||
'shell.provider.all': '모든 제공자',
|
||||
'shell.project.all': '모든 프로젝트',
|
||||
'shell.project.temporary': '임시 폴더',
|
||||
'shell.project.ariaLabel': '프로젝트',
|
||||
'shell.project.search': '프로젝트 검색',
|
||||
'shell.project.loading': '프로젝트 불러오는 중…',
|
||||
|
|
@ -437,6 +441,7 @@ export const shell: SectionCatalog = {
|
|||
'shell.action.dismiss': '关闭',
|
||||
'shell.provider.all': '所有提供商',
|
||||
'shell.project.all': '所有项目',
|
||||
'shell.project.temporary': '临时文件夹',
|
||||
'shell.project.ariaLabel': '项目',
|
||||
'shell.project.search': '搜索项目',
|
||||
'shell.project.loading': '正在加载项目…',
|
||||
|
|
@ -532,6 +537,7 @@ export const shell: SectionCatalog = {
|
|||
'shell.action.dismiss': '關閉',
|
||||
'shell.provider.all': '所有供應商',
|
||||
'shell.project.all': '所有專案',
|
||||
'shell.project.temporary': '暫存資料夾',
|
||||
'shell.project.ariaLabel': '專案',
|
||||
'shell.project.search': '搜尋專案',
|
||||
'shell.project.loading': '正在載入專案…',
|
||||
|
|
|
|||
|
|
@ -108,8 +108,8 @@ export function dayFilters(date: string, ...more: string[]): InvestigationFilter
|
|||
export function providerFilters(provider: string): InvestigationFilters {
|
||||
return { ...EMPTY_FILTERS, providers: [provider] }
|
||||
}
|
||||
export function projectFilters(project: string): InvestigationFilters {
|
||||
return { ...EMPTY_FILTERS, projects: [project] }
|
||||
export function projectFilters(...projects: string[]): InvestigationFilters {
|
||||
return { ...EMPTY_FILTERS, projects }
|
||||
}
|
||||
export function modelFilters(models: string[]): InvestigationFilters {
|
||||
return { ...EMPTY_FILTERS, models }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
* CLI lies. It sits in the CLI suite because it needs both halves, and the
|
||||
* desktop CI job installs only app/ dependencies. */
|
||||
|
||||
type MatchTarget = { name: string; path?: string }
|
||||
type MatchTarget = { name: string; path?: string; checkouts?: Array<{ path: string }>; temporary?: boolean }
|
||||
|
||||
/** The CLI's pattern (and the path of its row) for every temp-root folder
|
||||
* outside a known repository. */
|
||||
export const TEMPORARY_PROJECTS = '@temp'
|
||||
|
||||
export function isRooted(pattern: string): boolean {
|
||||
const raw = pattern.trim().replace(/\\/g, '/')
|
||||
|
|
@ -22,6 +26,11 @@ export function absProjectPathKey(value: string): string | null {
|
|||
|
||||
export function projectMatches(project: MatchTarget, pattern: string): boolean {
|
||||
const projectPath = project.path ?? ''
|
||||
// "=/path": that row's folder alone (the CLI widens it to the repository).
|
||||
if (pattern.startsWith('=') && isRooted(pattern.slice(1))) {
|
||||
const anchor = absProjectPathKey(pattern.slice(1))
|
||||
return anchor !== null && anchor === absProjectPathKey(projectPath)
|
||||
}
|
||||
if (isRooted(pattern)) {
|
||||
const anchor = absProjectPathKey(pattern)
|
||||
const target = absProjectPathKey(projectPath)
|
||||
|
|
@ -42,8 +51,27 @@ export function projectPattern(project: MatchTarget): string {
|
|||
return rooted ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
/** Excludes win; an empty include list means every project. */
|
||||
export function projectVisible(project: MatchTarget, filter: { project: string[]; exclude: string[] }): boolean {
|
||||
if (filter.exclude.some(pattern => projectMatches(project, pattern))) return false
|
||||
return filter.project.length === 0 || filter.project.some(pattern => projectMatches(project, pattern))
|
||||
function checkoutsOf(project: MatchTarget): MatchTarget[] {
|
||||
return !project.temporary && project.checkouts?.length ? project.checkouts.map(c => ({ name: project.name, path: c.path })) : [project]
|
||||
}
|
||||
|
||||
/** A pattern naming any checkout of a repository row names the whole
|
||||
* repository, as in the CLI. */
|
||||
export function projectNamedBy(project: MatchTarget, pattern: string): boolean {
|
||||
return projectMatches(project, pattern) || checkoutsOf(project).some(c => projectMatches(c, pattern))
|
||||
}
|
||||
|
||||
/** The pattern that hides a whole row: "=" names its repository, a plain path
|
||||
* would hide one checkout only. */
|
||||
export function projectHidePattern(project: MatchTarget): string {
|
||||
const pattern = projectPattern(project)
|
||||
return checkoutsOf(project).length > 1 && isRooted(pattern) ? `=${pattern}` : pattern
|
||||
}
|
||||
|
||||
/** Excludes hide a repository row only as a whole: through "=", or with every
|
||||
* checkout excluded. An include naming any checkout shows the row. */
|
||||
export function projectVisible(project: MatchTarget, filter: { project: string[]; exclude: string[] }): boolean {
|
||||
if (filter.exclude.some(pattern => pattern.startsWith('=') && projectNamedBy(project, pattern))) return false
|
||||
if (checkoutsOf(project).every(c => filter.exclude.some(pattern => projectMatches(c, pattern)))) return false
|
||||
return filter.project.length === 0 || filter.project.some(pattern => projectNamedBy(project, pattern))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,12 @@ export type MenubarPayload = {
|
|||
sessions: number
|
||||
avgCostPerSession?: number
|
||||
sessionCountBasis?: 'identity' | 'partial'
|
||||
/** Clones and worktrees folded into this repository row, when several;
|
||||
* the costliest 50 of `checkoutCount`. */
|
||||
checkouts?: Array<{ id: string; cost: number }>
|
||||
checkoutCount?: number
|
||||
/** The one row for every temp-root folder outside a known repository. */
|
||||
temporary?: boolean
|
||||
sessionDetails: Array<{
|
||||
cost: number
|
||||
savingsUSD: number
|
||||
|
|
@ -1234,7 +1240,7 @@ export type TrayPrefs = {
|
|||
|
||||
export type ProjectFilter = { project: string[]; exclude: string[] }
|
||||
|
||||
export type ProjectRow = { name: string; path: string; cost: number; sessions: number }
|
||||
export type ProjectRow = { name: string; path: string; cost: number; sessions: number; checkouts?: Array<{ path: string; cost: number }>; checkoutCount?: number; temporary?: boolean }
|
||||
|
||||
export type ProjectsReport = { projects: ProjectRow[] }
|
||||
|
||||
|
|
|
|||
|
|
@ -201,6 +201,46 @@ describe('Settings', () => {
|
|||
|
||||
// A lifetime list runs to thousands of rows on a real machine, so the pane
|
||||
// leads with the costliest and narrows on a substring of the name or path.
|
||||
it('leaves out $0.00 rows unless a saved pattern names them, and unhides a repository by any checkout', async () => {
|
||||
mocks.getUnfilteredProjects.mockResolvedValue({
|
||||
projects: [
|
||||
{ name: 'codeburn', path: '/Users/x/codeburn', cost: 9, sessions: 3, checkouts: [{ path: '/Users/x/codeburn', cost: 6 }, { path: '/tmp/clone-3', cost: 3 }] },
|
||||
{ name: 'Temporary folders', path: '@temp', cost: 4, sessions: 9, temporary: true },
|
||||
{ name: 'zero', path: '/Users/x/zero', cost: 0.004, sessions: 1 },
|
||||
{ name: 'kept', path: '/Users/x/kept', cost: 0, sessions: 1 },
|
||||
],
|
||||
})
|
||||
mocks.getProjectFilter.mockResolvedValue({ project: [], exclude: ['/Users/x/kept', '=/tmp/clone-3'] })
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="month" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Projects' }))
|
||||
await screen.findByRole('switch', { name: 'Show /Users/x/codeburn' })
|
||||
expect(screen.getAllByRole('switch').map(node => node.getAttribute('aria-label'))).toEqual([
|
||||
'Show /Users/x/codeburn',
|
||||
'Show @temp',
|
||||
'Show /Users/x/kept',
|
||||
])
|
||||
expect(screen.getByText('Temporary folders')).toBeInTheDocument()
|
||||
const repo = screen.getByRole('switch', { name: 'Show /Users/x/codeburn' })
|
||||
expect(repo).toHaveAttribute('aria-checked', 'false')
|
||||
await user.click(repo)
|
||||
expect(mocks.setProjectFilter).toHaveBeenCalledWith({ project: [], exclude: ['/Users/x/kept'] })
|
||||
})
|
||||
|
||||
it('keeps a repository shown when one checkout is excluded, and hides it whole with "="', async () => {
|
||||
mocks.getUnfilteredProjects.mockResolvedValue({
|
||||
projects: [{ name: 'codeburn', path: '/Users/x/codeburn', cost: 9, sessions: 3, checkouts: [{ path: '/Users/x/codeburn', cost: 6 }, { path: '/tmp/clone-3', cost: 3 }] }],
|
||||
})
|
||||
mocks.getProjectFilter.mockResolvedValue({ project: [], exclude: ['/tmp/clone-3'] })
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="month" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Projects' }))
|
||||
const repo = await screen.findByRole('switch', { name: 'Show /Users/x/codeburn' })
|
||||
expect(repo).toHaveAttribute('aria-checked', 'true')
|
||||
await user.click(repo)
|
||||
expect(mocks.setProjectFilter).toHaveBeenCalledWith({ project: [], exclude: ['/tmp/clone-3', '=/Users/x/codeburn'] })
|
||||
})
|
||||
|
||||
it('sorts projects by lifetime cost and narrows them by a substring search', async () => {
|
||||
mocks.getUnfilteredProjects.mockResolvedValue({
|
||||
projects: [
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { readDailyBudget } from '../lib/budget'
|
|||
import { formatConverted, formatCount, formatUsd, shortenProjectPath } from '../lib/format'
|
||||
import { codeburn, normalizeCliError } from '../lib/ipc'
|
||||
import { t, useLocale, type LocaleChoice } from '../i18n'
|
||||
import { projectMatches, projectPattern, projectVisible } from '../lib/projectMatch'
|
||||
import { projectHidePattern, projectNamedBy, projectPattern, projectVisible } from '../lib/projectMatch'
|
||||
import { displayVersion, isIdeHost, shortcutLabel } from '../lib/platform'
|
||||
import { motionClass } from '../lib/motion'
|
||||
import { clearOverviewHeadlines } from '../lib/overviewSnapshot'
|
||||
|
|
@ -347,10 +347,14 @@ function ProjectsPane({ refreshToken, onConfigMutated }: { refreshToken: number;
|
|||
() => [...(report.data?.projects ?? [])].sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0)),
|
||||
[report.data],
|
||||
)
|
||||
// A row that rounds to $0.00 shows nothing, unless a saved pattern names it:
|
||||
// then it stays, so the switch can undo it.
|
||||
const listed = projects.filter(project => Math.round((project.cost ?? 0) * 100) > 0
|
||||
|| [...filter.project, ...filter.exclude].some(pattern => projectNamedBy(project, pattern)))
|
||||
const needle = search.trim().toLowerCase()
|
||||
const shown = needle
|
||||
? projects.filter(project => project.name.toLowerCase().includes(needle) || project.path.toLowerCase().includes(needle))
|
||||
: projects
|
||||
? listed.filter(project => project.name.toLowerCase().includes(needle) || project.path.toLowerCase().includes(needle))
|
||||
: listed
|
||||
|
||||
// One write at a time, or a second click drops the first.
|
||||
const apply = (next: ProjectFilter, clearInput = false): void => {
|
||||
|
|
@ -369,18 +373,18 @@ function ProjectsPane({ refreshToken, onConfigMutated }: { refreshToken: number;
|
|||
const toggle = (project: ProjectRow, visible: boolean): void => {
|
||||
if (visible) {
|
||||
// Widen the include list too, or it would keep hiding what was just shown.
|
||||
const exclude = filter.exclude.filter(entry => !projectMatches(project, entry))
|
||||
const include = filter.project.length > 0 && !filter.project.some(entry => projectMatches(project, entry))
|
||||
const exclude = filter.exclude.filter(entry => !projectNamedBy(project, entry))
|
||||
const include = filter.project.length > 0 && !filter.project.some(entry => projectNamedBy(project, entry))
|
||||
? [...filter.project, projectPattern(project)]
|
||||
: filter.project
|
||||
apply({ project: include, exclude })
|
||||
return
|
||||
}
|
||||
// Exclude wins over include in the CLI, so hiding is always one append.
|
||||
apply({ ...filter, exclude: [...filter.exclude, projectPattern(project)] })
|
||||
apply({ ...filter, exclude: [...filter.exclude, projectHidePattern(project)] })
|
||||
}
|
||||
|
||||
const orphans = report.data ? filter.exclude.filter(entry => !projects.some(project => projectMatches(project, entry))) : []
|
||||
const orphans = report.data ? filter.exclude.filter(entry => !projects.some(project => projectNamedBy(project, entry))) : []
|
||||
const hiddenCount = projects.filter(project => !projectVisible(project, filter)).length
|
||||
|
||||
return <section className="set-p set-p-wide on">
|
||||
|
|
@ -390,20 +394,20 @@ function ProjectsPane({ refreshToken, onConfigMutated }: { refreshToken: number;
|
|||
<button className="btnp r" disabled={busy} onClick={() => apply({ ...filter, project: [] })}>{t('settings.projects.showAll')}</button>
|
||||
</div></div>}
|
||||
<div className="card"><div className="about-sec set-last-sec">
|
||||
{projects.length > 0 && <div className="set-filter-form set-search-form">
|
||||
{listed.length > 0 && <div className="set-filter-form set-search-form">
|
||||
<input aria-label={t('settings.projects.searchAriaLabel')} className="set-input set-mono" placeholder={t('settings.projects.searchPlaceholder')} value={search} onChange={event => setSearch(event.target.value)} />
|
||||
{needle && <span className="set-cap">{t('settings.projects.countOf', { shown: shown.length.toLocaleString(), total: projects.length.toLocaleString() })}</span>}
|
||||
{needle && <span className="set-cap">{t('settings.projects.countOf', { shown: shown.length.toLocaleString(), total: listed.length.toLocaleString() })}</span>}
|
||||
</div>}
|
||||
{report.error ? <SettingsErrorText error={report.error} />
|
||||
: saved.error ? <SettingsErrorText error={saved.error} />
|
||||
: !report.data || !saved.data ? <p className="set-cap">{t('settings.projects.loading')}</p>
|
||||
: projects.length === 0 ? <p className="set-cap">{t('settings.projects.emptyNone')}</p>
|
||||
: listed.length === 0 ? <p className="set-cap">{t('settings.projects.emptyNone')}</p>
|
||||
: shown.length === 0 ? <p className="set-cap">{t('settings.projects.emptySearch')}</p>
|
||||
: shown.map(project => {
|
||||
const visible = projectVisible(project, filter)
|
||||
const pattern_ = projectPattern(project)
|
||||
return <div className="about-row" key={pattern_}>
|
||||
<span className="tx set-mono">{shortenProjectPath(project.path || project.name, 2)}<small>{pattern_}</small></span>
|
||||
<span className="tx set-mono">{project.temporary ? t('shell.project.temporary') : project.checkouts ? project.name : shortenProjectPath(project.path || project.name, 2)}<small>{pattern_}</small></span>
|
||||
<span className="r set-status"><span className="set-cap">{formatConverted(project.cost)} · {formatCount(project.sessions, 'session')}</span></span>
|
||||
<button type="button" role="switch" aria-checked={visible} aria-label={t('settings.projects.showAriaLabel', { pattern: pattern_ })} className={visible ? 'switch on' : 'switch'} disabled={busy} onClick={() => toggle(project, !visible)}><span className="switch-knob" /></button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
|||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
|
@ -546,6 +546,29 @@ describe('Spend', () => {
|
|||
expect(screen.queryByText(/^3 sessions$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the checkouts of a repository row and drills into all of them', async () => {
|
||||
const payload = makePayload(new Date())
|
||||
payload.current.topProjects = [{
|
||||
id: '/w/codeburn',
|
||||
name: 'codeburn',
|
||||
cost: 9,
|
||||
savingsUSD: 0,
|
||||
sessions: 2,
|
||||
sessionCountBasis: 'identity',
|
||||
checkouts: [{ id: '/w/codeburn', cost: 6 }, { id: '/tmp/scratch/clone-3', cost: 3 }],
|
||||
sessionDetails: [],
|
||||
}]
|
||||
getSpendFlow.mockResolvedValue(makeFlow())
|
||||
const onInvestigate = vi.fn()
|
||||
|
||||
render(<SpendContent period="week" provider="all" overview={polled(payload)} onInvestigate={onInvestigate} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /codeburn/ }))
|
||||
expect(screen.getByTitle('/tmp/scratch/clone-3')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /View sessions/ }))
|
||||
expect(onInvestigate).toHaveBeenCalledWith({ filters: expect.objectContaining({ projects: ['/w/codeburn', '/tmp/scratch/clone-3'] }) })
|
||||
})
|
||||
|
||||
it('keeps an exact source-only count unqualified', async () => {
|
||||
const payload = makePayload(new Date())
|
||||
payload.current.sessionCountBasis = 'identity'
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { SectionSkeleton } from '../components/Skeleton'
|
|||
import { StackedBars } from '../components/StackedBars'
|
||||
import { StaleBanner } from '../components/StaleBanner'
|
||||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatCount, formatUsd } from '../lib/format'
|
||||
import { formatCount, formatUsd, shortenProjectPath } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { contiguousDailyWindow, dataStartKey, localDateKey } from '../lib/period'
|
||||
import { reportMemoKey } from '../lib/reportMemoKey'
|
||||
|
|
@ -225,29 +225,40 @@ function ProjectBreakdown({ projects, onInvestigate }: { projects: Project[]; on
|
|||
projects.map((project, i) => {
|
||||
const rowKey = projectRowKey(project, i)
|
||||
const open = expanded === rowKey
|
||||
const name = project.temporary ? t('shell.project.temporary') : project.name
|
||||
// Sessions filter by exact checkout ids, so only a complete list drills.
|
||||
const drillable = (project.checkoutCount ?? 0) <= (project.checkouts?.length ?? 0)
|
||||
return (
|
||||
<Fragment key={rowKey}>
|
||||
<ListRow
|
||||
no={String(i + 1).padStart(2, '0')}
|
||||
title={project.name}
|
||||
title={name}
|
||||
sub={<span title={project.sessionCountBasis === 'identity' ? undefined : sessionCountHelp()}>{formatSessionCount(project.sessions, project.sessionCountBasis)}</span>}
|
||||
value={formatUsd(project.cost)}
|
||||
expanded={open}
|
||||
onClick={() => setExpanded(current => current === rowKey ? null : rowKey)}
|
||||
/>
|
||||
{open && (
|
||||
<div className="spend-proj-detail" role="region" aria-label={t('spend.project.sessionsAria', { name: project.name })}>
|
||||
<div className="spend-proj-detail" role="region" aria-label={t('spend.project.sessionsAria', { name })}>
|
||||
{/* Drill-through entry: canonical project id (the same one the
|
||||
session rows carry), so the destination matches exactly. */}
|
||||
{onInvestigate && (
|
||||
{onInvestigate && drillable && (
|
||||
<button
|
||||
className="ov-link spend-proj-drill"
|
||||
type="button"
|
||||
onClick={() => onInvestigate({ filters: projectFilters(project.id || project.name) })}
|
||||
onClick={() => onInvestigate({ filters: projectFilters(...(project.checkouts?.map(c => c.id) ?? [project.id || project.name])) })}
|
||||
>
|
||||
{t('spend.project.viewSessions')}
|
||||
</button>
|
||||
)}
|
||||
{project.checkouts?.map(checkout => (
|
||||
<div className="spend-proj-session" key={checkout.id}>
|
||||
<span className="sps-date" />
|
||||
<span className="sps-model" title={checkout.id}>{shortenProjectPath(checkout.id, 2)}</span>
|
||||
<span className="sps-calls" />
|
||||
<span className="sps-cost">{formatUsd(checkout.cost)}</span>
|
||||
</div>
|
||||
))}
|
||||
{project.sessionDetails.length ? (
|
||||
project.sessionDetails.map((session, j) => (
|
||||
<div className="spend-proj-session" key={`${session.date}-${j}`}>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'path'
|
|||
|
||||
import { getCodeburnCacheDir, RETIRED_PROVIDER_NAMES } from './cache-dir.js'
|
||||
import { sweepSupersededCacheFiles } from './cache-sweep.js'
|
||||
import { projectOriginKey } from './git-origin.js'
|
||||
import type { ProjectFilterTarget } from './parser.js'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
|
|
@ -336,7 +337,11 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// project rows; provider call counts and cost are unchanged, so no
|
||||
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. Days whose transcripts
|
||||
// are gone keep their old single-project attribution.
|
||||
export const DAILY_CACHE_VERSION = 69
|
||||
// v70: a day's project entry records the `origin` remote of its checkout, so
|
||||
// clones and worktrees of one repository still group once the folder is
|
||||
// deleted. Totals and the split are unchanged; surviving days re-derive to
|
||||
// pick it up, carried days stay as they were.
|
||||
export const DAILY_CACHE_VERSION = 70
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
@ -414,7 +419,7 @@ export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number
|
|||
/// `path` is the project's filesystem path when known — it is what display
|
||||
/// layers derive a friendly name from once the sessions that carried the
|
||||
/// mapping are gone.
|
||||
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string }
|
||||
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string; originKey?: string }
|
||||
|
||||
/// One project label can span several real paths (every Claude session started
|
||||
/// from the home folder shares `-Users-<name>`), so since v67 a day's project
|
||||
|
|
@ -429,7 +434,7 @@ export function projectDayKey(project: string, path?: string): string {
|
|||
|
||||
export function projectDayIdentity(key: string, stats: ProjectDayStats): ProjectFilterTarget & { projectPath: string } {
|
||||
const sep = key.indexOf(PROJECT_KEY_SEP)
|
||||
return { project: sep === -1 ? key : key.slice(0, sep), projectPath: stats.path ?? '' }
|
||||
return { project: sep === -1 ? key : key.slice(0, sep), projectPath: stats.path ?? '', ...(stats.originKey ? { originKey: stats.originKey } : {}) }
|
||||
}
|
||||
|
||||
export type ProviderDaySlice = {
|
||||
|
|
@ -629,6 +634,7 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
|
|||
savingsUSD: num(p.savingsUSD),
|
||||
sessions: num(p.sessions),
|
||||
...(typeof p.path === 'string' && p.path.length > 0 ? { path: p.path } : {}),
|
||||
...(typeof p.originKey === 'string' && p.originKey.length > 0 ? { originKey: p.originKey } : {}),
|
||||
})
|
||||
}
|
||||
return Object.keys(out).length > 0 ? { projects: out } : {}
|
||||
|
|
@ -831,7 +837,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
const now = new Date()
|
||||
const todayStr = toDateString(now)
|
||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
days = applyRetention(days.filter(d => d.date < todayStr), yesterdayStr)
|
||||
days = stampOrigins(applyRetention(days.filter(d => d.date < todayStr), yesterdayStr))
|
||||
// A trusted base can carry lastComputedDate >= today (clock skew wrote a
|
||||
// frozen today entry that the purge above just removed). Left as-is it would
|
||||
// make hydration skip the gap parse forever and the purged day would never
|
||||
|
|
@ -867,6 +873,22 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
return adopted
|
||||
}
|
||||
|
||||
/// Adopted days written before v70 learn the repository of each checkout that
|
||||
/// still exists, so the record outlives the folder. Days whose folder is gone
|
||||
/// keep their path alone.
|
||||
function stampOrigins(days: DailyEntry[]): DailyEntry[] {
|
||||
for (const day of days) {
|
||||
for (const holder of [day, ...Object.values(day.providers)]) {
|
||||
for (const p of Object.values(holder.projects ?? {})) {
|
||||
if (p.originKey || !p.path) continue
|
||||
const originKey = projectOriginKey(p.path)
|
||||
if (originKey) p.originKey = originKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
export async function saveDailyCache(cache: DailyCache): Promise<void> {
|
||||
const dir = getCodeburnCacheDir()
|
||||
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
|
||||
|
|
@ -1008,6 +1030,7 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
|
|||
acc.calls += num(p.calls)
|
||||
acc.savingsUSD += num(p.savingsUSD)
|
||||
if (!acc.path && typeof p.path === 'string') acc.path = p.path
|
||||
if (!acc.originKey && typeof p.originKey === 'string') acc.originKey = p.originKey
|
||||
// Same session dedup as the slice-level sessions above: a placeholder's
|
||||
// project sessions were already counted into the day when the fresh day
|
||||
// was built, so only the excess is added.
|
||||
|
|
@ -1140,7 +1163,7 @@ function subtractProjectStats(base: ProjectDayStats, sub: ProjectDayStats): Proj
|
|||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||||
return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}) }
|
||||
return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}), ...(base.originKey ? { originKey: base.originKey } : {}) }
|
||||
}
|
||||
|
||||
function subtractProjects(base: DailyEntry['projects'] | undefined, sub: DailyEntry['projects'] | undefined): DailyEntry['projects'] | undefined {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { planDisplayName } from './plans.js'
|
|||
import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js'
|
||||
import { BSU, patchStdoutForWindows } from './ink-win.js'
|
||||
import { startUserTimingGuard } from './user-timing-guard.js'
|
||||
import { isTemporaryProjectPath, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js'
|
||||
import { countSessions } from './session-output.js'
|
||||
|
||||
type View = 'dashboard' | 'optimize' | 'compare'
|
||||
|
|
@ -801,7 +802,29 @@ function getProjectBreakdownRowLimit(period: Period, dayMode = false): number {
|
|||
return dayMode ? 8 : period === 'all' || period === 'lifetime' || period === 'month' || period === '30days' ? 14 : 8
|
||||
}
|
||||
|
||||
function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget>; rows?: number }) {
|
||||
/// One row per repository: clones and worktrees sharing an `origin` fold into
|
||||
/// the costliest of them, and every other temp-root folder into one row; any
|
||||
/// other checkout stays its own row.
|
||||
export function foldProjectsByRepository(projects: ProjectSummary[]): Array<ProjectSummary & { repo?: string }> {
|
||||
const rows = new Map<string, ProjectSummary & { repo?: string }>()
|
||||
projects.forEach((p, i) => {
|
||||
const origin = projectOriginKey(p.projectPath)
|
||||
const temporary = !origin && isTemporaryProjectPath(p.projectPath)
|
||||
const key = origin ?? (temporary ? TEMPORARY_PROJECTS : `\0${i}`)
|
||||
const held = rows.get(key)
|
||||
if (!held) {
|
||||
rows.set(key, origin ? { ...p, repo: originRepoName(origin) } : temporary ? { ...p, repo: 'Temporary folders' } : p)
|
||||
return
|
||||
}
|
||||
if (p.totalCostUSD > held.totalCostUSD) held.projectPath = p.projectPath
|
||||
held.totalCostUSD += p.totalCostUSD
|
||||
held.sessions = [...held.sessions, ...p.sessions]
|
||||
})
|
||||
return [...rows.values()].sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||
}
|
||||
|
||||
function ProjectBreakdown({ projects: checkouts, pw, bw, budgets, rows = 14 }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget>; rows?: number }) {
|
||||
const projects = foldProjectsByRepository(checkouts)
|
||||
const maxCost = maxOf(projects.map(p => p.totalCostUSD), -Infinity)
|
||||
const hasBudgets = budgets && budgets.size > 0
|
||||
const headers = ['cost', 'avg/s', 'session', ...(hasBudgets ? ['overhead'] : [])]
|
||||
|
|
@ -829,7 +852,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects:
|
|||
key={`${project.project}-${i}`}
|
||||
panelWidth={pw}
|
||||
barWidth={projectBarWidth}
|
||||
label={shortProject(project.projectPath, labelWidth)}
|
||||
label={project.repo ? project.repo.slice(0, labelWidth) : shortProject(project.projectPath, labelWidth)}
|
||||
labelColor={DIM}
|
||||
bar={{ value: project.totalCostUSD, max: maxCost }}
|
||||
metrics={[
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { projectDayKey, type DailyEntry, type ProjectDayStats, type ProviderDayS
|
|||
import type { PeriodData } from './menubar-json.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type SessionSummary, type TaskCategory } from './types.js'
|
||||
import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
|
||||
import { projectOriginKey } from './git-origin.js'
|
||||
import { billableOutputTokens, modelRowKey } from './models.js'
|
||||
|
||||
function emptyEntry(date: string): DailyEntry {
|
||||
|
|
@ -72,7 +73,8 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn:
|
|||
// key instead of mutating the prototype link.
|
||||
let p = Object.hasOwn(projects, key) ? projects[key] : undefined
|
||||
if (!p) {
|
||||
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, ...(path ? { path } : {}) }
|
||||
const originKey = projectOriginKey(path)
|
||||
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, ...(path ? { path } : {}), ...(originKey ? { originKey } : {}) }
|
||||
Object.defineProperty(projects, key, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
return p
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
|
||||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||||
|
||||
/// The `origin` remote of a checkout, read straight from .git/config. Two clones
|
||||
/// or worktrees of the same repository share it, which is what lets surfaces
|
||||
|
|
@ -107,12 +110,124 @@ function originAt(repoRoot: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
/** `readGitOriginUrl` normalized, or null when the checkout has no origin. */
|
||||
export function gitOriginKey(repoRoot: string): string | null {
|
||||
const url = readGitOriginUrl(repoRoot)
|
||||
return url ? normalizeOriginUrl(url) : null
|
||||
// Agents delete their scratch clones, so the origin a folder had is kept in the
|
||||
// cache dir: while the folder exists the filesystem answers (and overwrites the
|
||||
// record, since scratch names get reused for other repos), once it is gone the
|
||||
// record does.
|
||||
const ORIGINS_FILE = 'git-origins.json'
|
||||
const keyByPath = new Map<string, string | null>()
|
||||
let recorded: Record<string, string> | null = null
|
||||
const pending = new Map<string, string | null>()
|
||||
let exitHooked = false
|
||||
|
||||
function readRecorded(): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(join(getCodeburnCacheDir(), ORIGINS_FILE), 'utf8'))
|
||||
const paths = (parsed as { paths?: unknown } | null)?.paths
|
||||
if (!paths || typeof paths !== 'object') return {}
|
||||
return Object.fromEntries(Object.entries(paths).filter((e): e is [string, string] => typeof e[1] === 'string'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalized `origin` of the checkout containing `path`, or null when it has
|
||||
* none. A path that no longer exists answers from what an earlier run saw. */
|
||||
export function gitOriginKey(path: string): string | null {
|
||||
const memo = keyByPath.get(path)
|
||||
if (memo !== undefined) return memo
|
||||
recorded ??= readRecorded()
|
||||
let key: string | null
|
||||
if (existsSync(path)) {
|
||||
const url = readGitOriginUrl(path)
|
||||
key = url ? normalizeOriginUrl(url) : null
|
||||
if ((Object.hasOwn(recorded, path) ? recorded[path] : null) !== key) {
|
||||
if (key) recorded[path] = key
|
||||
else delete recorded[path]
|
||||
// Lookups outside a parse (a filter over carried days) are kept too.
|
||||
if (!exitHooked) process.once('exit', saveGitOrigins)
|
||||
exitHooked = true
|
||||
pending.set(path, key)
|
||||
}
|
||||
} else {
|
||||
key = Object.hasOwn(recorded, path) ? recorded[path]! : null
|
||||
}
|
||||
keyByPath.set(path, key)
|
||||
return key
|
||||
}
|
||||
|
||||
/** A recorded project path as a native absolute path, or null. Codex strips
|
||||
* the leading slash from its cwds, so a slash-bearing relative path is rooted. */
|
||||
function nativeProjectPath(projectPath: string | undefined): string | null {
|
||||
const raw = (projectPath ?? '').trim().replace(/[\\/]+$/, '')
|
||||
if (!raw) return null
|
||||
if (isAbsolute(raw)) return raw
|
||||
if (process.platform === 'win32' || !raw.includes('/') || raw.startsWith('-')) return null
|
||||
return `/${raw}`
|
||||
}
|
||||
|
||||
/** The origin of a recorded project path. */
|
||||
export function projectOriginKey(projectPath: string | undefined): string | null {
|
||||
const path = nativeProjectPath(projectPath)
|
||||
return path ? gitOriginKey(path) : null
|
||||
}
|
||||
|
||||
/** The one list row (and filter pattern) for every folder under a temp root
|
||||
* that is not a checkout of a known repository: agents' scratch folders,
|
||||
* thousands of them, mostly deleted. */
|
||||
export const TEMPORARY_PROJECTS = '@temp'
|
||||
|
||||
// macOS reaches /tmp and /var through /private, so both spellings fold to one.
|
||||
function foldTempKey(path: string): string {
|
||||
const s = path.replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\/private(?=\/(?:tmp|var)(?:\/|$))/, '')
|
||||
return process.platform === 'win32' ? s.toLowerCase() : s
|
||||
}
|
||||
|
||||
let roots: string[] | null = null
|
||||
function tempRoots(): string[] {
|
||||
roots ??= [...new Set([tmpdir(), '/tmp', '/var/folders'].map(foldTempKey))]
|
||||
return roots
|
||||
}
|
||||
|
||||
export function isTemporaryProjectPath(projectPath: string | undefined): boolean {
|
||||
const path = nativeProjectPath(projectPath)
|
||||
if (!path) return false
|
||||
const key = foldTempKey(path)
|
||||
return tempRoots().some(root => key === root || key.startsWith(`${root}/`))
|
||||
}
|
||||
|
||||
/** "github.com/org/repo" -> "repo". */
|
||||
export function originRepoName(originKey: string): string {
|
||||
return originKey.split('/').filter(Boolean).pop() ?? originKey
|
||||
}
|
||||
|
||||
/** Writes what this process learned, merged over what another one may have
|
||||
* written meanwhile. Never fails the caller. */
|
||||
export function saveGitOrigins(): void {
|
||||
if (pending.size === 0) return
|
||||
try {
|
||||
const merged = readRecorded()
|
||||
for (const [path, key] of pending) {
|
||||
if (key) merged[path] = key
|
||||
else delete merged[path]
|
||||
}
|
||||
const target = join(getCodeburnCacheDir(), ORIGINS_FILE)
|
||||
const tmp = `${target}.${process.pid}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify({ version: 1, paths: merged }), { mode: 0o600 })
|
||||
renameSync(tmp, target)
|
||||
pending.clear()
|
||||
} catch {
|
||||
// A missed save only means a later run learns it again while the folder exists.
|
||||
}
|
||||
}
|
||||
|
||||
export function __setTempRoots(paths: string[] | null): void {
|
||||
roots = paths && paths.map(foldTempKey)
|
||||
}
|
||||
|
||||
export function __resetGitOriginCache(): void {
|
||||
originByRoot.clear()
|
||||
keyByPath.clear()
|
||||
pending.clear()
|
||||
recorded = null
|
||||
}
|
||||
|
|
|
|||
34
src/main.ts
34
src/main.ts
|
|
@ -7,7 +7,7 @@ import { cachedProjectIdentitiesForRange } from './daily-cache.js'
|
|||
import { reportUnmatchedProjectPatterns } from './project-filter-warnings.js'
|
||||
import { getVercelGatewayApiKey } from './providers/vercel-gateway.js'
|
||||
import { BILLING_FILTER_VALUES, ROUTE_FILTER_VALUES, filterProjectsByBillingRoute } from './billing-filter.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, aggregateOnlyCostUSD, excludesAggregateOnlyProviders, parseAllSessions, filterProjectsByName, filterProjectsByDateRange, mergeProjectSplits, clearSessionCache, setInteractiveScanUI, computeCorpusFingerprint, isSessionHydrationComplete, startProgressKeepalive, stopProgressKeepalive, withLoadWindow } from './parser.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, aggregateOnlyCostUSD, excludesAggregateOnlyProviders, parseAllSessions, filterProjectsByName, filterProjectsByDateRange, mergeProjectSplits, clearSessionCache, setInteractiveScanUI, computeCorpusFingerprint, isSessionHydrationComplete, startProgressKeepalive, stopProgressKeepalive, withLoadWindow, setExactProjectPaths } from './parser.js'
|
||||
import { allProviderNames, getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
|
|
@ -21,14 +21,14 @@ import { behavioralCallWeight } from './behavioral-weight.js'
|
|||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import type { AppliedFix } from './act/types.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { buildPeriodData, buildMenubarPayloadForRange, buildDurablePeriod, getDailyCacheConfigHash, SERVE_HYDRATION_ENV, type DurablePeriod } from './usage-aggregator.js'
|
||||
import { buildPayloadProjects, buildPeriodData, buildMenubarPayloadForRange, buildDurablePeriod, getDailyCacheConfigHash, SERVE_HYDRATION_ENV, type DurablePeriod } from './usage-aggregator.js'
|
||||
import { aggregateProjectsIntoDays } from './day-aggregator.js'
|
||||
import { buildPeriodDiffReport, defaultSevenDayRanges, diffSessions, dayKeyToRange, historyBasis, localRangeInfo } from './period-diff.js'
|
||||
import { loadStatusSnapshot, saveStatusSnapshot } from './session-cache.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
import { renderOverview } from './overview.js'
|
||||
import { runWebDashboard } from './web-dashboard.js'
|
||||
import { hostname } from 'os'
|
||||
import { homedir, hostname } from 'os'
|
||||
import { runShareServer } from './sharing/share-run.js'
|
||||
import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js'
|
||||
import { browse } from './sharing/discovery.js'
|
||||
|
|
@ -541,8 +541,10 @@ const program = new Command()
|
|||
.version(version)
|
||||
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
|
||||
.option('--timezone <zone>', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
|
||||
.option('--exact-project', 'A --project/--exclude path matches that folder only, not every checkout of its repository')
|
||||
|
||||
program.hook('preAction', async (thisCommand) => {
|
||||
setExactProjectPaths(thisCommand.opts<{ exactProject?: boolean }>().exactProject === true)
|
||||
const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
|
||||
if (tz) {
|
||||
try {
|
||||
|
|
@ -628,17 +630,21 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
|
|||
})
|
||||
|
||||
const sessionCountBasis = durable.data.sessionCountBasis
|
||||
const projectList = projects.map(p => ({
|
||||
name: p.project,
|
||||
path: p.projectPath,
|
||||
cost: convertCost(p.totalCostUSD),
|
||||
savings: convertCost(p.totalSavingsUSD),
|
||||
...(sessionCountIsExact(sessionCountBasis) && p.sessions.length > 0
|
||||
? { avgCostPerSession: convertCost(p.totalCostUSD / p.sessions.length) }
|
||||
// Same durable day set as the headline, so a project's row is what selecting
|
||||
// it reports (expired transcripts included), one row per repository.
|
||||
const projectList = buildPayloadProjects(projects, durable.days, homedir()).map(p => ({
|
||||
name: p.name,
|
||||
path: p.path ?? p.id ?? p.name,
|
||||
cost: convertCost(p.cost),
|
||||
savings: convertCost(p.savingsUSD),
|
||||
...(sessionCountIsExact(p.sessionCountBasis) && p.sessions > 0
|
||||
? { avgCostPerSession: convertCost(p.cost / p.sessions) }
|
||||
: {}),
|
||||
calls: p.totalApiCalls,
|
||||
sessions: p.sessions.length,
|
||||
...(sessionCountBasis ? { sessionCountBasis } : {}),
|
||||
calls: p.calls ?? 0,
|
||||
sessions: p.sessions,
|
||||
...(p.sessionCountBasis ? { sessionCountBasis: p.sessionCountBasis } : {}),
|
||||
...(p.temporary ? { temporary: true } : {}),
|
||||
...(p.checkouts ? { checkouts: p.checkouts.map(c => ({ path: c.id, cost: convertCost(c.cost) })), checkoutCount: p.checkoutCount } : {}),
|
||||
}))
|
||||
|
||||
const modelMap: Record<string, { calls: number; cost: number; savings: number; estimatedCost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; baselineModel: string }> = {}
|
||||
|
|
@ -1270,6 +1276,8 @@ program
|
|||
...queryScope,
|
||||
days: daysSelection ? [...daysSelection.days].sort() : undefined,
|
||||
claudeSourceTopology,
|
||||
// Changes what a --project path selects.
|
||||
exactProject: program.opts<{ exactProject?: boolean }>().exactProject === true,
|
||||
// Mirrors parser.ts's cacheKey: pricing-affecting config must
|
||||
// invalidate this snapshot the same way it invalidates the
|
||||
// parse-level memo, or an edited alias/override/savings config keeps
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export type ProjectCheckout = { id: string; cost: number }
|
||||
|
||||
/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
|
||||
/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
|
||||
/// when hydrating per-day cache entries.
|
||||
|
|
@ -53,7 +55,9 @@ export type PeriodData = {
|
|||
/// current tables (#638): their calls contribute $0 to `cost`. Optional so
|
||||
/// PeriodData producers that predate the field keep compiling.
|
||||
unpricedModels?: Array<{ model: string; calls: number; tokens: number }>
|
||||
projects?: Array<{ id?: string; name: string; cost: number; savingsUSD: number; sessions: number; sessionCountBasis?: SessionCountBasis; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }>; sessionId?: string; provider?: string }> }>
|
||||
/// `path` and `calls` are set by the durable builder; `checkouts` lists the
|
||||
/// clones and worktrees folded into a repository row, when there are several.
|
||||
projects?: Array<{ id?: string; name: string; path?: string; calls?: number; temporary?: boolean; checkouts?: ProjectCheckout[]; checkoutCount?: number; cost: number; savingsUSD: number; sessions: number; sessionCountBasis?: SessionCountBasis; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }>; sessionId?: string; provider?: string }> }>
|
||||
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
|
||||
topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string; sessionId?: string; provider?: string; projectKey?: string }>
|
||||
/// Workflow-intelligence rollups (issue: workflow intelligence). Optional so
|
||||
|
|
@ -404,6 +408,13 @@ export type MenubarPayload = {
|
|||
/// How `sessions` was derived. Omitted on older producers. `identity` is
|
||||
/// an exact unique count from surviving source files; `partial` is a lower bound.
|
||||
sessionCountBasis?: SessionCountBasis
|
||||
/// The checkouts (clones, worktrees) folded into this repository row, when
|
||||
/// there is more than one.
|
||||
checkouts?: ProjectCheckout[]
|
||||
/// All checkouts folded in; `checkouts` lists the costliest 50.
|
||||
checkoutCount?: number
|
||||
/// The row for every temp-root folder outside a known repository.
|
||||
temporary?: boolean
|
||||
sessionDetails: Array<{
|
||||
cost: number
|
||||
savingsUSD: number
|
||||
|
|
@ -715,6 +726,8 @@ function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['cur
|
|||
sessions: p.sessions,
|
||||
avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0,
|
||||
...(p.sessionCountBasis ? { sessionCountBasis: p.sessionCountBasis } : {}),
|
||||
...(p.checkouts ? { checkouts: p.checkouts, checkoutCount: p.checkoutCount } : {}),
|
||||
...(p.temporary ? { temporary: true } : {}),
|
||||
sessionDetails: (p.sessionDetails ?? []).map(s => ({
|
||||
cost: s.cost,
|
||||
savingsUSD: s.savingsUSD,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { normalizeAbsProjectPathKey } from './parser.js'
|
|||
import { dateKey } from './day-aggregator.js'
|
||||
import type { DailyEntry } from './daily-cache.js'
|
||||
import type { BudgetStatus, BudgetTier } from './budget.js'
|
||||
import { isTemporaryProjectPath, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js'
|
||||
|
||||
// Display-only helpers. The shared formatters omit thousands separators and
|
||||
// abbreviate; here we show full, comma-grouped numbers so the tables read like
|
||||
|
|
@ -161,15 +162,17 @@ export function renderOverview(
|
|||
const byCat = new Map<string, { cost: number; turns: number }>()
|
||||
const byTool = new Map<string, number>()
|
||||
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
|
||||
const byProject = new Map<string, { cost: number; sessions: number; sample: ProjectSummary }>()
|
||||
const byProject = new Map<string, { cost: number; sessions: number; sample: ProjectSummary; repo?: string }>()
|
||||
|
||||
sessions = countSessions(projects)
|
||||
for (const p of projects) {
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
calls += p.totalApiCalls
|
||||
const pkey = projectAggKey(p)
|
||||
const pe = byProject.get(pkey) ?? { cost: 0, sessions: 0, sample: p }
|
||||
const origin = projectOriginKey(p.projectPath)
|
||||
const temporary = !origin && isTemporaryProjectPath(p.projectPath)
|
||||
const pkey = origin ? `origin:${origin}` : temporary ? TEMPORARY_PROJECTS : projectAggKey(p)
|
||||
const pe = byProject.get(pkey) ?? { cost: 0, sessions: 0, sample: p, ...(origin ? { repo: originRepoName(origin) } : temporary ? { repo: 'Temporary folders' } : {}) }
|
||||
pe.cost += p.totalCostUSD
|
||||
pe.sessions += p.sessions.length
|
||||
byProject.set(pkey, pe)
|
||||
|
|
@ -369,7 +372,7 @@ export function renderOverview(
|
|||
out.push(heading('Top projects'))
|
||||
out.push(renderTable(c,
|
||||
[{ header: 'Project' }, { header: 'Cost', right: true }, { header: 'Sessions', right: true }],
|
||||
projRows.map(([key, v]) => [disambiguatedProjectLabel(key, v.sample, basenameCounts), formatCost(v.cost), formatCount(v.sessions)]),
|
||||
projRows.map(([key, v]) => [v.repo ?? disambiguatedProjectLabel(key, v.sample, basenameCounts), formatCost(v.cost), formatCount(v.sessions)]),
|
||||
))
|
||||
out.push('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { classifyWslCachePath, isWslUncPath, refreshWslHomes, wslMode } from './
|
|||
import { decideParseWorkers, parseFilesInOrder, ParseWorkerPool, type ClaudeWorkerParse, type ParseJob } from './parse-workers.js'
|
||||
import type { CodexFullParse } from './providers/codex.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
import { isTemporaryProjectPath, projectOriginKey, saveGitOrigins, TEMPORARY_PROJECTS } from './git-origin.js'
|
||||
import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
|
||||
import { gatewayIncludedInTotals } from './config.js'
|
||||
import { coverageFor, cursorImportPath, dropImportCoveredCalls, loadCursorImport, replacedProviders } from './cursor-import.js'
|
||||
|
|
@ -5021,25 +5022,50 @@ function expandTilde(pattern: string): string {
|
|||
|
||||
/// A pattern is normalized once and matched many times: the day cache runs the
|
||||
/// filter for every project of every day, and again per provider slice.
|
||||
type CompiledPattern = { rooted: true; anchor: string | null } | { rooted: false; needle: string }
|
||||
type CompiledPattern = { rooted: true; anchor: string | null; origin: string | null; exact: boolean } | { rooted: false; needle: string; temporary?: true }
|
||||
|
||||
export type ProjectFilterTarget = { project: string; projectPath?: string }
|
||||
/// `originKey` is the repository a day entry recorded for its path; absent, it
|
||||
/// is looked up from the path.
|
||||
export type ProjectFilterTarget = { project: string; projectPath?: string; originKey?: string }
|
||||
|
||||
function compile(patterns: readonly string[]): CompiledPattern[] {
|
||||
return patterns.map(pattern => isRootedProjectPattern(pattern)
|
||||
? { rooted: true as const, anchor: normalizeAbsProjectPathKey(expandTilde(pattern)) }
|
||||
: { rooted: false as const, needle: pattern.toLowerCase() })
|
||||
let exactProjectPaths = false
|
||||
|
||||
/** `--exact-project`: a rooted pattern names its folder alone, not its repository. */
|
||||
export function setExactProjectPaths(exact: boolean): void {
|
||||
exactProjectPaths = exact
|
||||
}
|
||||
|
||||
/// An absolute path names ONE project, so it anchors on a segment boundary (the
|
||||
/// isProxiedPath rule): "/a/proj" takes "/a/proj/sub" but not "/a/proj-ui-kit".
|
||||
/// "=/path" is one list row: its repository, or that folder without the folders
|
||||
/// under it, which are rows of their own. A plain path widens to its repository
|
||||
/// only to include: excluding one checkout must not hide its siblings, so an
|
||||
/// exclude names the repository only through "=".
|
||||
function compile(patterns: readonly string[], widen: boolean): CompiledPattern[] {
|
||||
return patterns.map(pattern => {
|
||||
if (pattern === TEMPORARY_PROJECTS || pattern === `=${TEMPORARY_PROJECTS}`) return { rooted: false as const, needle: '', temporary: true as const }
|
||||
const exact = pattern.startsWith('=') && isRootedProjectPattern(pattern.slice(1))
|
||||
if (!exact && !isRootedProjectPattern(pattern)) return { rooted: false as const, needle: pattern.toLowerCase() }
|
||||
const path = expandTilde(exact ? pattern.slice(1) : pattern)
|
||||
return { rooted: true as const, anchor: normalizeAbsProjectPathKey(path), origin: exactProjectPaths || !(widen || exact) ? null : projectOriginKey(path), exact }
|
||||
})
|
||||
}
|
||||
|
||||
/// A path inside a git checkout names its whole repository: every clone and
|
||||
/// worktree sharing the `origin` remote, and nothing else, so the picker row and
|
||||
/// the headline it selects are the same set. Any other absolute path names ONE
|
||||
/// project, so it anchors on a segment boundary (the isProxiedPath rule):
|
||||
/// "/a/proj" takes "/a/proj/sub" but not "/a/proj-ui-kit".
|
||||
/// Both sides key through normalizeAbsProjectPathKey (Windows casefolds, POSIX
|
||||
/// does not, #1260), and rootedness alone picks the branch, so "/" names none.
|
||||
function hit(entry: ProjectFilterTarget, pattern: CompiledPattern, key: string | null): boolean {
|
||||
if (pattern.rooted) {
|
||||
// A folder with no known origin (deleted before it was recorded) falls
|
||||
// back to the folder rule, so a repo path still takes its subfolders.
|
||||
const origin = pattern.origin && (entry.originKey ?? projectOriginKey(entry.projectPath))
|
||||
if (origin) return origin === pattern.origin
|
||||
const anchor = pattern.anchor
|
||||
return anchor !== null && key !== null && (key === anchor || key.startsWith(anchor + '/'))
|
||||
return anchor !== null && key !== null && (key === anchor || (!pattern.exact && key.startsWith(anchor + '/')))
|
||||
}
|
||||
if (pattern.temporary) return isTemporaryProjectPath(entry.projectPath) && !(entry.originKey ?? projectOriginKey(entry.projectPath))
|
||||
return entry.project.toLowerCase().includes(pattern.needle)
|
||||
|| (entry.projectPath ?? '').toLowerCase().includes(pattern.needle)
|
||||
}
|
||||
|
|
@ -5051,8 +5077,8 @@ export function makeProjectFilter(
|
|||
include?: readonly string[],
|
||||
exclude?: readonly string[],
|
||||
): (entry: ProjectFilterTarget) => boolean {
|
||||
const inc = compile(include ?? [])
|
||||
const exc = compile(exclude ?? [])
|
||||
const inc = compile(include ?? [], true)
|
||||
const exc = compile(exclude ?? [], false)
|
||||
// The key costs a trim, a global replace and three regex passes. The day cache
|
||||
// runs this per project, per day, per provider slice, so it is only paid when
|
||||
// some pattern is rooted and can actually read it.
|
||||
|
|
@ -6720,6 +6746,9 @@ async function runParseInner(
|
|||
|
||||
const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||
correlateCrossProviderPrSessions(result)
|
||||
// Learn each checkout's repository while its folder still exists.
|
||||
for (const p of result) projectOriginKey(p.projectPath)
|
||||
if (!readOnly) saveGitOrigins()
|
||||
// A snapshot is an explicitly stale, source-unvalidated view. Publishing it
|
||||
// into either exact-key or burst reuse can suppress the reconciliation that
|
||||
// the mounted dashboard starts immediately afterward for the full TTL.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { activityStreak } from './streak.js'
|
|||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, projectDayIdentity, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
import { spendProjectIdentity } from './spend-flow.js'
|
||||
import { isTemporaryProjectPath, originRepoName, projectOriginKey, TEMPORARY_PROJECTS } from './git-origin.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, excludeAggregateOnlyProjects, excludesAggregateOnlyProviders } from './parser.js'
|
||||
|
||||
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
|
||||
|
|
@ -500,7 +501,7 @@ export function excludeProviderFromDay(day: DailyEntry, provider: string): Daily
|
|||
}
|
||||
if (Object.values(left).some(v => v > 0)) {
|
||||
Object.defineProperty(projects, key, {
|
||||
value: { ...left, ...(p.path ? { path: p.path } : {}) },
|
||||
value: { ...left, ...(p.path ? { path: p.path } : {}), ...(p.originKey ? { originKey: p.originKey } : {}) },
|
||||
enumerable: true, writable: true, configurable: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -1259,6 +1260,10 @@ function sessionDetailsOf(sessions: SessionSummary[]): PayloadSessionDetail[] {
|
|||
}))
|
||||
}
|
||||
|
||||
const TEMPORARY_KEY = '\0temporary'
|
||||
const TEMPORARY_NAME = 'Temporary folders'
|
||||
const MAX_CHECKOUTS = 50
|
||||
|
||||
function displayBasename(path: string | undefined, fallback: string, home: string): string {
|
||||
if (!path) return fallback
|
||||
if (path === home || path === home + '/') return 'Home'
|
||||
|
|
@ -1314,10 +1319,12 @@ export function buildPayloadProjects(
|
|||
liveCost: number
|
||||
liveSavings: number
|
||||
liveSessions: number
|
||||
liveCalls: number
|
||||
}
|
||||
type Acc = {
|
||||
id: string
|
||||
path?: string
|
||||
originKey?: string
|
||||
fallbackName: string
|
||||
contribs: Map<string, Contrib>
|
||||
sessions: SessionSummary[]
|
||||
|
|
@ -1358,6 +1365,7 @@ export function buildPayloadProjects(
|
|||
liveCost: 0,
|
||||
liveSavings: 0,
|
||||
liveSessions: 0,
|
||||
liveCalls: 0,
|
||||
}
|
||||
acc.contribs.set(slug, c)
|
||||
}
|
||||
|
|
@ -1373,9 +1381,10 @@ export function buildPayloadProjects(
|
|||
path: string | undefined,
|
||||
cost: number,
|
||||
savingsUSD: number,
|
||||
occupancy: { sessions: number, sessionDays: number, maxDaySessions: number, calls: number },
|
||||
occupancy: { sessions: number, sessionDays: number, maxDaySessions: number, calls: number, originKey?: string },
|
||||
): Acc => {
|
||||
const acc = take(slug, path, slug)
|
||||
acc.originKey ??= occupancy.originKey
|
||||
const c = contribOf(acc, slug)
|
||||
c.hasCache = true
|
||||
c.cacheCost += cost
|
||||
|
|
@ -1403,7 +1412,7 @@ export function buildPayloadProjects(
|
|||
for (const s of p.sessions) rememberLiveSlug(s.project, p.projectPath)
|
||||
}
|
||||
|
||||
type Totals = { cost: number; savingsUSD: number; sessions: number; sessionDays: number; maxDaySessions: number; calls: number }
|
||||
type Totals = { cost: number; savingsUSD: number; sessions: number; sessionDays: number; maxDaySessions: number; calls: number; originKey?: string }
|
||||
const knownBySlug = new Map<string, Map<string, Totals>>()
|
||||
const pathlessBySlug = new Map<string, Totals>()
|
||||
const emptyTotals = (): Totals => ({ cost: 0, savingsUSD: 0, sessions: 0, sessionDays: 0, maxDaySessions: 0, calls: 0 })
|
||||
|
|
@ -1430,6 +1439,7 @@ export function buildPayloadProjects(
|
|||
}
|
||||
const acc = byId.get(id) ?? emptyTotals()
|
||||
addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls)
|
||||
acc.originKey ??= p.originKey
|
||||
byId.set(id, acc)
|
||||
} else {
|
||||
const acc = pathlessBySlug.get(name) ?? emptyTotals()
|
||||
|
|
@ -1487,6 +1497,7 @@ export function buildPayloadProjects(
|
|||
const c = contribOf(acc, p.project)
|
||||
c.liveCost += p.totalCostUSD
|
||||
c.liveSavings += p.totalSavingsUSD
|
||||
c.liveCalls += p.totalApiCalls
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -1504,6 +1515,7 @@ export function buildPayloadProjects(
|
|||
c.liveCost += s.totalCostUSD
|
||||
c.liveSavings += s.totalSavingsUSD
|
||||
c.liveSessions += 1
|
||||
c.liveCalls += s.apiCalls
|
||||
} else {
|
||||
c.liveSessions += 1
|
||||
}
|
||||
|
|
@ -1514,9 +1526,11 @@ export function buildPayloadProjects(
|
|||
const path = acc.path
|
||||
let cost = 0
|
||||
let savingsUSD = 0
|
||||
let calls = 0
|
||||
for (const c of acc.contribs.values()) {
|
||||
cost += c.hasCache ? c.cacheCost : c.liveCost
|
||||
savingsUSD += c.hasCache ? c.cacheSavings : c.liveSavings
|
||||
calls += c.hasCache ? c.cacheCalls : c.liveCalls
|
||||
}
|
||||
const liveUnique = uniqueCanonicalSessionCount(acc.sessions, acc.path)
|
||||
let cacheDayBound = 0
|
||||
|
|
@ -1543,27 +1557,66 @@ export function buildPayloadProjects(
|
|||
basename: displayBasename(path, acc.fallbackName, home),
|
||||
cost,
|
||||
savingsUSD,
|
||||
calls,
|
||||
sessions,
|
||||
sessionCountBasis,
|
||||
liveUnique,
|
||||
}
|
||||
})
|
||||
const basenameCounts = new Map<string, number>()
|
||||
for (const row of rows) basenameCounts.set(row.basename, (basenameCounts.get(row.basename) ?? 0) + 1)
|
||||
|
||||
return rows
|
||||
.map(({ acc, path, basename, cost, savingsUSD, sessions, sessionCountBasis }) => {
|
||||
const details = sessionDetailsOf(acc.sessions)
|
||||
return {
|
||||
id: acc.id,
|
||||
name: disambiguatedProjectName(path, basename, acc.fallbackName, basenameCounts),
|
||||
cost,
|
||||
savingsUSD,
|
||||
sessions,
|
||||
sessionCountBasis,
|
||||
...(details.length ? { sessionDetails: details } : {}),
|
||||
// Every checkout of one repository is one row, folded only after each
|
||||
// checkout's cache-or-live choice is made: folding first would let one clone's
|
||||
// cache row hide another clone's live-only spend under the same slug.
|
||||
const groups = new Map<string, typeof rows>()
|
||||
for (const row of rows) {
|
||||
const key = row.acc.originKey ?? projectOriginKey(row.path) ?? (isTemporaryProjectPath(row.path) ? TEMPORARY_KEY : `\0${row.acc.id}`)
|
||||
const held = groups.get(key)
|
||||
if (held) held.push(row)
|
||||
else groups.set(key, [row])
|
||||
}
|
||||
const repoNames = new Map<string, number>()
|
||||
for (const key of groups.keys()) if (!key.startsWith('\0')) repoNames.set(originRepoName(key), (repoNames.get(originRepoName(key)) ?? 0) + 1)
|
||||
|
||||
return [...groups].map(([key, members]) => {
|
||||
members.sort((a, b) => b.cost - a.cost)
|
||||
const lead = members[0]!
|
||||
const repo = key.startsWith('\0') ? null : key
|
||||
const temporary = key === TEMPORARY_KEY
|
||||
// A session with slices in two checkouts counts once for the repository.
|
||||
const keys = new Set<string>()
|
||||
let anonymous = 0
|
||||
for (const m of members) {
|
||||
for (const session of m.acc.sessions) {
|
||||
if (session.sessionId) keys.add(canonicalSessionCountKey(session, m.acc.path))
|
||||
else anonymous += 1
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
}
|
||||
const shared = members.reduce((sum, m) => sum + m.liveUnique, 0) - keys.size - anonymous
|
||||
const sessions = members.reduce((sum, m) => sum + m.sessions, 0) - shared
|
||||
const sessionCountBasis = members.some(m => m.sessionCountBasis === 'partial') ? 'partial' as const : lead.sessionCountBasis
|
||||
const details = sessionDetailsOf(members.flatMap(m => m.acc.sessions))
|
||||
return {
|
||||
id: lead.acc.id,
|
||||
name: temporary ? TEMPORARY_NAME
|
||||
: repo ? (repoNames.get(originRepoName(repo))! > 1 ? repo.split('/').slice(-2).join('/') : originRepoName(repo))
|
||||
: disambiguatedProjectName(lead.path, lead.basename, lead.acc.fallbackName, basenameCounts),
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
// A rooted path that still resolves to the repository, so selecting the
|
||||
// row scopes to all of it (Codex records cwds without the leading slash).
|
||||
path: temporary ? TEMPORARY_PROJECTS
|
||||
: ((repo && members.find(m => m.path && /^(\/|[a-zA-Z]:[\\/])/.test(m.path) && projectOriginKey(m.path) === repo)) || lead).path ?? lead.acc.id,
|
||||
cost: members.reduce((sum, m) => sum + m.cost, 0),
|
||||
savingsUSD: members.reduce((sum, m) => sum + m.savingsUSD, 0),
|
||||
calls: members.reduce((sum, m) => sum + m.calls, 0),
|
||||
sessions,
|
||||
sessionCountBasis,
|
||||
// Capped: the temporary row can hold thousands, and this rides every poll.
|
||||
...(members.length > 1 ? { checkouts: members.slice(0, MAX_CHECKOUTS).map(m => ({ id: m.acc.id, cost: m.cost })), checkoutCount: members.length } : {}),
|
||||
...(details.length ? { sessionDetails: details } : {}),
|
||||
}
|
||||
}).sort((a, b) => b.cost - a.cost)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe('codeburn status counts a session that spans days', () => {
|
|||
it('one real session spanning two days is one period project session', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-session-across-days-'))
|
||||
try {
|
||||
const cwd = '/tmp/same-session-project'
|
||||
const cwd = '/work/same-session-project'
|
||||
const sessionId = 'same-actual-session'
|
||||
const projectDir = join(home, '.claude', 'projects', 'project-a')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ async function writeSession(name: string, ageDays: number, outputTokens = 50): P
|
|||
type: 'assistant',
|
||||
sessionId: name,
|
||||
timestamp: at.toISOString(),
|
||||
cwd: '/tmp/proj',
|
||||
cwd: '/work/proj',
|
||||
message: {
|
||||
id: `msg-${name}`,
|
||||
type: 'message',
|
||||
|
|
@ -78,7 +78,7 @@ async function appendSessionCall(name: string, outputTokens: number): Promise<vo
|
|||
type: 'assistant',
|
||||
sessionId: name,
|
||||
timestamp,
|
||||
cwd: '/tmp/proj',
|
||||
cwd: '/work/proj',
|
||||
message: {
|
||||
id: `msg-${name}-appended`,
|
||||
type: 'message',
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { mkdtemp } from 'node:fs/promises'
|
|||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { __setTempRoots } from '../src/git-origin.js'
|
||||
import { currentTzKey, DAILY_CACHE_VERSION, toDateString, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { calculateCost, loadPricing } from '../src/models.js'
|
||||
import { computeSpendFlow } from '../src/spend-flow.js'
|
||||
|
|
@ -14,6 +15,10 @@ import { buildMenubarPayloadForRange, buildPayloadProjects, getDailyCacheConfigH
|
|||
|
||||
vi.setConfig({ testTimeout: 30_000 })
|
||||
|
||||
// The fixtures use /tmp as an ordinary parent folder, not as agent scratch space.
|
||||
beforeAll(() => __setTempRoots([]))
|
||||
afterAll(() => __setTempRoots(null))
|
||||
|
||||
const emptyTokens = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const PATTERNS = [
|
|||
'/Users/me/work/my-company', '/Users/me/work/my-company/', 'my-company', 'MY-COMPANY',
|
||||
'/a/Vault', '/a/vault', 'c:/work/vault', 'C:\\Work\\Vault', '//server/share', '/root/vault',
|
||||
'root/vault', 'me/work', '/', '//', '', '-Users-me-Web', 'thing', '/nope',
|
||||
'=/Users/me/work/my-company', '=/a/Vault', '=c:/work/vault', '=/root/vault',
|
||||
]
|
||||
|
||||
// This parity test lives in the CLI suite, not beside the module it covers:
|
||||
|
|
|
|||
216
tests/repo-grouping.test.ts
Normal file
216
tests/repo-grouping.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { loadDailyCache, projectDayIdentity, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
|
||||
import { __resetGitOriginCache, __setTempRoots, projectOriginKey, saveGitOrigins } from '../src/git-origin.js'
|
||||
import { filterProjectsByName, makeProjectFilter, setExactProjectPaths } from '../src/parser.js'
|
||||
import { spendProjectIdentity } from '../src/spend-flow.js'
|
||||
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
import { buildPayloadProjects } from '../src/usage-aggregator.js'
|
||||
|
||||
let root: string
|
||||
const savedCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
|
||||
function repo(dir: string, origin: string): string {
|
||||
mkdirSync(join(dir, '.git'), { recursive: true })
|
||||
writeFileSync(join(dir, '.git', 'config'), `[core]\n\tbare = false\n[remote "origin"]\n\turl = ${origin}\n`)
|
||||
return dir
|
||||
}
|
||||
|
||||
function worktree(main: string, dir: string): string {
|
||||
const admin = join(main, '.git', 'worktrees', 'wt')
|
||||
mkdirSync(admin, { recursive: true })
|
||||
writeFileSync(join(admin, 'commondir'), '../..\n')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, '.git'), `gitdir: ${admin}\n`)
|
||||
return dir
|
||||
}
|
||||
|
||||
function session(id: string, project: string, cost: number): SessionSummary {
|
||||
return {
|
||||
sessionId: id,
|
||||
project,
|
||||
firstTimestamp: '2026-09-07T12:00:00.000Z',
|
||||
lastTimestamp: '2026-09-07T12:01:00.000Z',
|
||||
totalCostUSD: cost,
|
||||
totalSavingsUSD: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {},
|
||||
skillBreakdown: {},
|
||||
subagentBreakdown: {},
|
||||
} as SessionSummary
|
||||
}
|
||||
|
||||
function live(projectPath: string, cost: number): ProjectSummary {
|
||||
const name = projectPath.split('/').pop()!
|
||||
return {
|
||||
project: name,
|
||||
projectPath,
|
||||
sessions: [session(`s-${name}`, name, cost)],
|
||||
totalCostUSD: cost,
|
||||
totalSavingsUSD: 0,
|
||||
totalApiCalls: 1,
|
||||
totalProxiedCostUSD: 0,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'repo-grouping-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache')
|
||||
mkdirSync(join(root, 'cache'))
|
||||
__resetGitOriginCache()
|
||||
setExactProjectPaths(false)
|
||||
// The fixtures live under the OS temp dir; only root/tmp counts as temporary here.
|
||||
__setTempRoots([join(root, 'tmp')])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
if (savedCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||||
else process.env['CODEBURN_CACHE_DIR'] = savedCacheDir
|
||||
__resetGitOriginCache()
|
||||
setExactProjectPaths(false)
|
||||
__setTempRoots(null)
|
||||
})
|
||||
|
||||
function fixtures() {
|
||||
const a1 = repo(join(root, 'work', 'codeburn'), 'git@github.com:getagentseal/codeburn.git')
|
||||
const a2 = repo(join(root, 'scratch', 'clone-7'), 'https://github.com/getagentseal/codeburn')
|
||||
const awt = worktree(a1, join(root, 'scratch', 'review-wt'))
|
||||
const other = repo(join(root, 'work', 'codeburn-app'), 'git@github.com:getagentseal/codeburn-app.git')
|
||||
const plain = join(root, 'work', 'codeburn-marketing')
|
||||
mkdirSync(plain, { recursive: true })
|
||||
return { a1, a2, awt, other, plain }
|
||||
}
|
||||
|
||||
describe('projects grouped by git repository', () => {
|
||||
it('folds two clones and a worktree into one row and keeps everything else apart', () => {
|
||||
const { a1, a2, awt, other, plain } = fixtures()
|
||||
const rows = buildPayloadProjects([live(a1, 5), live(a2, 3), live(awt, 2), live(other, 4), live(plain, 1)], null, homedir())
|
||||
|
||||
expect(rows.map(r => [r.name, r.cost])).toEqual([['codeburn', 10], ['codeburn-app', 4], ['codeburn-marketing', 1]])
|
||||
const group = rows[0]!
|
||||
expect(group.path).toBe(a1)
|
||||
expect(group.sessions).toBe(3)
|
||||
expect(group.checkouts?.map(c => c.id).sort()).toEqual([a1, a2, awt].map(p => spendProjectIdentity({ project: '', projectPath: p }).id).sort())
|
||||
expect(rows[1]!.checkouts).toBeUndefined()
|
||||
expect(rows[2]!.path).toBe(plain)
|
||||
// Folding never moves money.
|
||||
expect(rows.reduce((s, r) => s + r.cost, 0)).toBe(15)
|
||||
})
|
||||
|
||||
it('keeps a deleted clone in its repository', () => {
|
||||
const { a1, a2 } = fixtures()
|
||||
const days = aggregateProjectsIntoDays([live(a2, 3)])
|
||||
expect(Object.values(days[0]!.projects!)[0]!.originKey).toBe('github.com/getagentseal/codeburn')
|
||||
saveGitOrigins()
|
||||
|
||||
__resetGitOriginCache()
|
||||
rmSync(a2, { recursive: true, force: true })
|
||||
expect(projectOriginKey(a2)).toBe('github.com/getagentseal/codeburn')
|
||||
|
||||
// The day entry carries the origin itself, so losing the record changes nothing.
|
||||
unlinkSync(join(root, 'cache', 'git-origins.json'))
|
||||
__resetGitOriginCache()
|
||||
expect(projectOriginKey(a2)).toBeNull()
|
||||
const cached: DailyEntry = days[0]!
|
||||
// Fixture sessions have no turns, so give the day its spend by hand.
|
||||
Object.values(cached.projects!)[0]!.cost = 3
|
||||
const rows = buildPayloadProjects([live(a1, 5)], [cached], homedir())
|
||||
expect(rows.map(r => [r.name, r.cost])).toEqual([['codeburn', 8]])
|
||||
|
||||
const [key, stats] = Object.entries(cached.projects!)[0]!
|
||||
expect(makeProjectFilter([a1])(projectDayIdentity(key, stats))).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes a path inside one clone to the whole repository', () => {
|
||||
const { a1, a2, awt, other, plain } = fixtures()
|
||||
mkdirSync(join(a2, 'src'))
|
||||
const projects = [live(a1, 5), live(a2, 3), live(awt, 2), live(other, 4), live(plain, 1)]
|
||||
|
||||
expect(filterProjectsByName(projects, [join(a2, 'src')]).map(p => p.projectPath).sort()).toEqual([a1, a2, awt].sort())
|
||||
expect(filterProjectsByName(projects, [], [`=${a1}`]).map(p => p.projectPath).sort()).toEqual([other, plain].sort())
|
||||
expect(filterProjectsByName(projects, [plain]).map(p => p.projectPath)).toEqual([plain])
|
||||
|
||||
setExactProjectPaths(true)
|
||||
expect(filterProjectsByName(projects, [a2]).map(p => p.projectPath)).toEqual([a2])
|
||||
})
|
||||
|
||||
it('excludes one checkout by its path without hiding the rest of the repository', () => {
|
||||
const { a1, a2, awt, other } = fixtures()
|
||||
const projects = [live(a1, 5), live(a2, 3), live(awt, 2), live(other, 4)]
|
||||
expect(filterProjectsByName(projects, [], [a2]).map(p => p.projectPath).sort()).toEqual([a1, awt, other].sort())
|
||||
expect(filterProjectsByName(projects, [a1], [a2]).map(p => p.projectPath).sort()).toEqual([a1, awt].sort())
|
||||
})
|
||||
|
||||
it('takes the subfolders of a repository path whose origin they never recorded', () => {
|
||||
const { a1, other } = fixtures()
|
||||
const gone = join(a1, 'packages', 'deleted')
|
||||
const projects = [live(a1, 5), live(gone, 1), live(other, 4)]
|
||||
expect(filterProjectsByName(projects, [a1]).map(p => p.projectPath).sort()).toEqual([a1, gone].sort())
|
||||
expect(makeProjectFilter([a1])({ project: 'deleted', projectPath: gone })).toBe(true)
|
||||
})
|
||||
|
||||
it('counts a session with slices in two checkouts once on the repository row', () => {
|
||||
const { a1, a2, awt } = fixtures()
|
||||
const slice = (projectPath: string, cost: number, primary: boolean): ProjectSummary => {
|
||||
const p = live(projectPath, cost)
|
||||
p.sessions[0]!.sessionId = 'moved'
|
||||
p.sessions[0]!.projectSplit = { primaryProject: 'codeburn', primaryProjectPath: a1, primary }
|
||||
return p
|
||||
}
|
||||
const rows = buildPayloadProjects([slice(a1, 5, true), slice(a2, 3, false), live(awt, 1)], null, homedir())
|
||||
expect(rows.find(r => r.name === 'codeburn')!.sessions).toBe(2)
|
||||
})
|
||||
|
||||
it('takes one list row for a "=" path: the repository, or the folder without its sub-folders', () => {
|
||||
const { a1, a2, awt, plain } = fixtures()
|
||||
mkdirSync(join(plain, 'site'))
|
||||
const projects = [live(a1, 5), live(a2, 3), live(awt, 2), live(plain, 1), live(join(plain, 'site'), 1)]
|
||||
|
||||
expect(filterProjectsByName(projects, [`=${a2}`]).map(p => p.projectPath).sort()).toEqual([a1, a2, awt].sort())
|
||||
expect(filterProjectsByName(projects, [`=${plain}`]).map(p => p.projectPath)).toEqual([plain])
|
||||
expect(filterProjectsByName(projects, [plain]).map(p => p.projectPath)).toEqual([plain, join(plain, 'site')])
|
||||
})
|
||||
|
||||
it('collapses temp-root folders outside a repository into one row, and scopes to all of them', () => {
|
||||
const { a1, plain } = fixtures()
|
||||
const tmpClone = repo(join(root, 'tmp', 'agent-1', 'codeburn'), 'git@github.com:getagentseal/codeburn.git')
|
||||
const s1 = join(root, 'tmp', 'agent-2', 'scratch')
|
||||
const s2 = join(root, 'tmp', 'agent-3')
|
||||
mkdirSync(s1, { recursive: true })
|
||||
const gone = join(root, 'tmp', 'agent-4')
|
||||
const projects = [live(a1, 5), live(tmpClone, 1), live(s1, 2), live(s2, 3), live(gone, 0.5), live(plain, 1)]
|
||||
|
||||
const rows = buildPayloadProjects(projects, null, homedir())
|
||||
expect(rows.map(r => [r.name, r.cost, r.path])).toEqual([['codeburn', 6, a1], ['Temporary folders', 5.5, '@temp'], ['codeburn-marketing', 1, plain]])
|
||||
expect(rows[1]!.temporary).toBe(true)
|
||||
expect(rows[1]!.checkoutCount).toBe(3)
|
||||
|
||||
expect(filterProjectsByName(projects, ['=@temp']).map(p => p.projectPath).sort()).toEqual([s1, s2, gone].sort())
|
||||
expect(filterProjectsByName(projects, [], ['@temp']).map(p => p.projectPath).sort()).toEqual([a1, tmpClone, plain].sort())
|
||||
})
|
||||
|
||||
it('stamps the repository on days adopted from an older cache while the folder exists', async () => {
|
||||
const { a2 } = fixtures()
|
||||
const day = { date: '2026-09-07', cost: 3, savingsUSD: 0, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {}, projects: { [`clone-7\u0000${a2}`]: { cost: 3, calls: 1, savingsUSD: 0, sessions: 1, path: a2 } } }
|
||||
writeFileSync(join(root, 'cache', 'daily-cache.v68.json'), JSON.stringify({ version: 68, savingsConfigHash: '', lastComputedDate: '2026-09-07', days: [day], complete: true }))
|
||||
|
||||
const cache = await loadDailyCache()
|
||||
expect(Object.values(cache.days[0]!.projects!)[0]!.originKey).toBe('github.com/getagentseal/codeburn')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue