Merge pull request #1100 from getagentseal/fix/menubar-freshness-marker

payload: add-only stale marker for read-only stale menubar serves
This commit is contained in:
Resham Joshi 2026-08-22 12:43:06 -07:00 committed by GitHub
commit ed8fa3af3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 166 additions and 3 deletions

View file

@ -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

View file

@ -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
@ -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
@ -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)

View file

@ -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
@ -507,6 +513,7 @@ export function buildMenubarPayload(
breakdowns?: BreakdownArrays,
claudeConfigs?: ClaudeConfigSelector,
granularHistory?: GranularHistory,
stale?: boolean,
): MenubarPayload {
const payload: MenubarPayload = {
generated: new Date().toISOString(),
@ -557,5 +564,8 @@ export function buildMenubarPayload(
if (claudeConfigs && claudeConfigs.options.length > 1) {
payload.claudeConfigs = claudeConfigs
}
if (stale) {
payload.stale = true
}
return payload
}

View file

@ -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
@ -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
@ -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)
}

View file

@ -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()
})
})

View file

@ -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()
})
})

View file

@ -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()
})
})