mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 00:14:40 +00:00
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:
commit
ed8fa3af3d
7 changed files with 166 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
tests/usage-aggregator-freshness.test.ts
Normal file
124
tests/usage-aggregator-freshness.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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…
Add table
Add a link
Reference in a new issue