From affd40e23024a972f12c5a9014212cb3bbb079d9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 22 Aug 2026 11:38:46 -0700 Subject: [PATCH] payload: add-only stale marker for read-only stale menubar serves (#771) isSessionHydrationComplete() (parser.ts, PR #937) already reports when a read-only serve skipped or staled real on-disk changes; thread it through buildMenubarPayload as an optional stale field, present and true only on a stale serve, always absent otherwise, so older/newer CLI-app pairs stay compatible. Mirrors the field into the desktop renderer types and the macOS menubar's Codable payload model (data layer only, no view change). Drafted with minimax/MiniMax-M3 via local gateway. --- app/renderer/lib/types.ts | 3 +++ .../CodeBurnMenubar/Data/MenubarPayload.swift | 12 ++++++++++-- src/menubar-json.ts | 10 ++++++++++ src/usage-aggregator.ts | 2 +- tests/menubar-json.test.ts | 10 ++++++++++ tests/usage-aggregator.test.ts | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 22a80d80..82614631 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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 diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index d09db99d..274c0865 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -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) diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 74354268..19d743fc 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -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 } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index d8b03817..805c5364 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -944,5 +944,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, isSessionHydrationComplete() ? undefined : true) } diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index cd447417..393482a2 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -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() + }) }) diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts index 1d7083a3..2e15a403 100644 --- a/tests/usage-aggregator.test.ts +++ b/tests/usage-aggregator.test.ts @@ -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() }) })