Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export type ClaudeConfigSelector = {

export type MenubarPayload = {
generated: string
// Optional: older CLIs omit it. Present and true only on a stale read-only
// serve; absent otherwise. Absence must always be read as "assume fresh."
stale?: boolean
current: {
label: string
cost: number
Expand Down
12 changes: 10 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import Foundation
/// `current` is scoped to the requested period; the whole payload reflects that slice.
struct MenubarPayload: Codable, Sendable {
let generated: String
/// Present and `true` only when this payload was assembled from a
/// read-only stale serve. Absent — never `false` — on a fresh payload;
/// absence must be read as "assume fresh," including for payloads from
/// a CLI version that predates this field.
let stale: Bool?
let current: CurrentBlock
let optimize: OptimizeBlock
let history: HistoryBlock
Expand All @@ -15,8 +20,10 @@ struct MenubarPayload: Codable, Sendable {
optimize: OptimizeBlock,
history: HistoryBlock,
combined: CombinedUsage?,
claudeConfigs: ClaudeConfigSelector? = nil) {
claudeConfigs: ClaudeConfigSelector? = nil,
stale: Bool? = nil) {
self.generated = generated
self.stale = stale
self.current = current
self.optimize = optimize
self.history = history
Expand All @@ -25,12 +32,13 @@ struct MenubarPayload: Codable, Sendable {
}

enum CodingKeys: String, CodingKey {
case generated, current, optimize, history, combined, claudeConfigs
case generated, stale, current, optimize, history, combined, claudeConfigs
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
generated = try c.decode(String.self, forKey: .generated)
stale = try c.decodeIfPresent(Bool.self, forKey: .stale)
current = try c.decode(CurrentBlock.self, forKey: .current)
optimize = try c.decode(OptimizeBlock.self, forKey: .optimize)
history = try c.decode(HistoryBlock.self, forKey: .history)
Expand Down
10 changes: 10 additions & 0 deletions src/menubar-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export type ClaudeConfigSelector = {

export type MenubarPayload = {
generated: string
/// Optional. Present and `true` only when this payload was assembled from a
/// read-only stale serve (see `isSessionHydrationComplete` in `parser.ts`).
/// Omitted — never `false` — on a fresh/complete payload, so absence always
/// means "assume fresh," including for payloads from a CLI version that
/// predates this field.
stale?: boolean
current: {
label: string
cost: number
Expand Down Expand Up @@ -507,6 +513,7 @@ export function buildMenubarPayload(
breakdowns?: BreakdownArrays,
claudeConfigs?: ClaudeConfigSelector,
granularHistory?: GranularHistory,
stale?: boolean,
): MenubarPayload {
const payload: MenubarPayload = {
generated: new Date().toISOString(),
Expand Down Expand Up @@ -557,5 +564,8 @@ export function buildMenubarPayload(
if (claudeConfigs && claudeConfigs.options.length > 1) {
payload.claudeConfigs = claudeConfigs
}
if (stale) {
payload.stale = true
}
return payload
}
9 changes: 8 additions & 1 deletion src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,12 +569,18 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null
const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null

// Captured synchronously right after whichever branch's primary parse resolves —
// the ONLY safe read point for the module-level hydration global. Re-reading the
// global later would race against this function's own history-block re-parse and
// against concurrent requests (web-dashboard SWR, parallel MCP calls).
let hydrationComplete: boolean | undefined
let effectivelyScoped = false
if (isClaudeConfigScoped) {
// A config source scopes Claude usage only, so scan just Claude (main.ts
// rejects a contradictory non-Claude --provider). This also avoids parsing
// every other provider's corpus on each scoped refresh.
const rawProjects = fp(await parseAllSessions(periodInfo.range, 'claude'))
hydrationComplete = isSessionHydrationComplete()
const fullProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects
claudeConfigs = await claudeConfigSelector(fullProjects, requestedClaudeConfigSourceId)
const selectedSourceId = claudeConfigs?.selectedId ?? null
Expand All @@ -601,6 +607,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
exclude: opts.exclude,
daysSelection,
})
hydrationComplete = isSessionHydrationComplete()
currentData = durable.data
scanProjects = durable.liveProjects
scanRange = durable.scanRange
Expand Down Expand Up @@ -944,5 +951,5 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider)
const granularRange = opts.daysSelection?.range ?? scanRange
const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange)
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory)
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory, hydrationComplete === false ? true : undefined)
}
10 changes: 10 additions & 0 deletions tests/menubar-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,4 +413,14 @@ describe('buildMenubarPayload', () => {
],
})
})

it('sets stale:true when the caller reports an incomplete hydration', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, undefined, undefined, undefined, undefined, undefined, undefined, true)
expect(payload.stale).toBe(true)
})

it('omits stale on a normal fresh build', () => {
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
expect(payload.stale).toBeUndefined()
})
})
124 changes: 124 additions & 0 deletions tests/usage-aggregator-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, beforeAll, vi } from 'vitest'

import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js'
import type { ProjectSummary } from '../src/types.js'

const ts = new Date().toISOString()

function makeCall(savingsUSD: number, supplementary: boolean) {
return {
provider: 'copilot',
model: 'llama3.1:8b',
usage: {
inputTokens: 10,
outputTokens: supplementary ? 0 : 20,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 0.01,
savingsUSD,
savingsBaselineModel: 'gpt-4o',
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard' as const,
timestamp: ts,
bashCommands: [],
deduplicationKey: supplementary ? 'sav-supp' : 'sav-real',
...(supplementary ? { supplementaryAccounting: true } : {}),
}
}

const emptyCat = { turns: 0, costUSD: 0, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }

function sessionFor(sourceId: string, sourceLabel: string, sourcePath: string) {
return {
sessionId: `sess-${sourceId}`,
project: `proj-${sourceId}`,
firstTimestamp: ts,
lastTimestamp: ts,
totalCostUSD: 0.02,
totalSavingsUSD: 7,
totalInputTokens: 20,
totalOutputTokens: 20,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [{
userMessage: 'hi',
timestamp: ts,
sessionId: `sess-${sourceId}`,
category: 'coding',
retries: 0,
hasEdits: false,
assistantCalls: [makeCall(5, false), makeCall(2, true)],
}],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
subagentBreakdown: {},
categoryBreakdown: { coding: { ...emptyCat, turns: 1, costUSD: 0.02, savingsUSD: 7 } },
skillBreakdown: {},
source: { id: sourceId, label: sourceLabel, path: sourcePath, kind: 'claude-config' },
}
}

function fixtureProjects(): ProjectSummary[] {
return [{
project: 'proj-a',
projectPath: 'proj-a',
sessions: [sessionFor('claude-config:a', 'A', '/a')],
totalCostUSD: 0.02,
totalSavingsUSD: 7,
totalApiCalls: 1,
}, {
project: 'proj-b',
projectPath: 'proj-b',
sessions: [sessionFor('claude-config:b', 'B', '/b')],
totalCostUSD: 0.02,
totalSavingsUSD: 7,
totalApiCalls: 1,
}] as unknown as ProjectSummary[]
}

vi.mock('../src/parser.js', async (importOriginal) => {
const mod = await importOriginal<typeof import('../src/parser.js')>()
let parseCalls = 0
return {
...mod,
parseAllSessions: vi.fn(async () => {
parseCalls++
return fixtureProjects()
}),
isSessionHydrationComplete: vi.fn(() => parseCalls === 1),
}
})

describe('buildMenubarPayloadForRange: hydration freshness marker', () => {
beforeAll(async () => {
await loadPricing()
})

it('reflects the primary parse hydration, not a later bystander parse', async () => {
// Two parseAllSessions calls happen on the scoped branch: the primary one
// (claude-only) and a second one for the 365-day history block. The mock
// makes the primary complete (parseCalls === 1 -> true) and the second
// incomplete (parseCalls === 2 -> false). The payload must trust the
// primary outcome, captured immediately after it resolves.
const payload = await buildMenubarPayloadForRange(getDateRange('today'), {
provider: 'all',
optimize: false,
claudeConfigSourceId: 'claude-config:a',
})
expect(payload.stale).toBeUndefined()
})
})
1 change: 1 addition & 0 deletions tests/usage-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ describe('buildMenubarPayloadForRange', () => {
expect(payload.current.codexCredits).toBeGreaterThanOrEqual(0)
// optimize:false => scanAndDetect skipped => empty optimize block regardless of data
expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] })
expect(payload.stale).toBeUndefined()
})
})
Loading