Skip to content
Open
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
58 changes: 53 additions & 5 deletions Sources/CodexBar/SpendDashboardController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,19 @@ final class SpendDashboardController {
let confirmedEmptySourceIDs: Set<String>
}

private struct LoadedInputScope: Equatable {
let bucketTimeZoneIdentifier: String
let historyDays: Int

init(
configuration: SpendDashboardConfiguration,
input: SpendDashboardModel.ProviderInput)
{
self.bucketTimeZoneIdentifier = configuration.bucketCalendar.timeZone.identifier
self.historyDays = input.snapshot.historyDays
}
}

private enum LoadPhase: Sendable {
case ordinary
case forcing
Expand Down Expand Up @@ -1000,6 +1013,7 @@ final class SpendDashboardController {
private let nowProvider: @Sendable () -> Date
private var loadTask: Task<Void, Never>?
private var loadedInputs: [SpendDashboardModel.ProviderInput] = []
private var loadedInputScopes: [String: LoadedInputScope] = [:]
private var loadedAt = Date()
private var lastSuccessfulConfiguration: SpendDashboardConfiguration?
private var phase = LoadPhase.ordinary
Expand Down Expand Up @@ -1060,6 +1074,9 @@ final class SpendDashboardController {

if !invalidatedSourceIDs.isEmpty {
self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) }
for sourceID in invalidatedSourceIDs {
self.loadedInputScopes.removeValue(forKey: sourceID)
}
self.failedSourceCount = 0
self.rebuildModel()
}
Expand All @@ -1074,6 +1091,7 @@ final class SpendDashboardController {
!configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled
else {
self.loadedInputs = []
self.loadedInputScopes = [:]
self.failedSourceCount = 0
self.isRefreshing = false
self.lastSuccessfulConfiguration = configuration
Expand Down Expand Up @@ -1119,6 +1137,11 @@ final class SpendDashboardController {
let cachedIDs = Set(result.inputs.map(\.id))
self.loadedInputs.removeAll { cachedIDs.contains($0.id) }
self.loadedInputs.append(contentsOf: result.inputs)
for input in result.inputs {
self.loadedInputScopes[input.id] = LoadedInputScope(
configuration: request.configuration,
input: input)
}
self.loadedAt = request.now
self.failedSourceCount = result.failedSourceCount
self.refreshRetainedCodexDisplayNames(request.configuration.codexAccountDisplayNames)
Expand Down Expand Up @@ -1247,19 +1270,44 @@ final class SpendDashboardController {
let codexDisplayNames = request.configuration.codexAccountDisplayNames
self.refreshRetainedCodexDisplayNames(codexDisplayNames)
var nextInputs = result.inputs
var nextInputScopes = Dictionary(uniqueKeysWithValues: nextInputs.map { input in
(input.id, LoadedInputScope(configuration: request.configuration, input: input))
})
let unsafeSourceIDs = invalidatedSourceIDs
.union(result.invalidatedSourceIDs)
.union(confirmedEmptySourceIDs)
let incompleteCodexScopes = nextInputs.reduce(into: [String: LoadedInputScope]()) { scopes, input in
guard input.provider == .codex,
!input.snapshot.historyCoverageIsEstablished
else { return }
scopes[input.id] = LoadedInputScope(configuration: request.configuration, input: input)
}
if !incompleteCodexScopes.isEmpty {
let retainedInputs = self.loadedInputs.filter {
incompleteCodexScopes[$0.id] == self.loadedInputScopes[$0.id] &&
!unsafeSourceIDs.contains($0.id) &&
$0.provider == .codex &&
$0.snapshot.historyCoverageIsEstablished
}.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }
let retainedSourceIDs = Set(retainedInputs.map(\.id))
nextInputs.removeAll { retainedSourceIDs.contains($0.id) }
nextInputs.append(contentsOf: retainedInputs)
}
if !result.failedSourceIDs.isEmpty {
let freshIDs = Set(nextInputs.map(\.id))
let unsafeSourceIDs = invalidatedSourceIDs
.union(result.invalidatedSourceIDs)
.union(confirmedEmptySourceIDs)
nextInputs.append(contentsOf: self.loadedInputs.filter {
let retainedInputs = self.loadedInputs.filter {
result.failedSourceIDs.contains($0.id) &&
!unsafeSourceIDs.contains($0.id) &&
!freshIDs.contains($0.id)
}.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) })
}.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }
nextInputs.append(contentsOf: retainedInputs)
for input in retainedInputs {
nextInputScopes[input.id] = self.loadedInputScopes[input.id]
}
}
self.configuration = request.configuration
self.loadedInputs = nextInputs
self.loadedInputScopes = nextInputScopes
self.loadedAt = request.now
self.lastSuccessfulConfiguration = request.configuration
self.failedSourceCount = result.failedSourceCount
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ extension UsageStore {
}

func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) {
// A bounded Codex refresh can succeed with partial rows while catch-up remains pending.
// Keep the same-scope established snapshot until the scanner publishes another established
// result; account and history-window changes fail the current-publication lookup below.
if provider == .codex,
!snapshot.historyCoverageIsEstablished,
self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?
.snapshot?.historyCoverageIsEstablished == true
{
return
}
self.tokenSnapshots[provider.instanceID] = snapshot
self.publishTokenSnapshotState(snapshot, for: provider)
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,11 @@ public struct CostUsageFetcher: Sendable {
}

guard !reports.isEmpty else { return nil }
// `previous` is an exact report captured before the current bounded refresh became
// pending. Its rows remain established even though native catch-up is still active;
// `staleSnapshotUpdatedAt` keeps refresh scheduling and stale presentation explicit.
let displayedHistoryCoverageIsEstablished = nativeHistoryCoverageIsEstablished
|| staleSnapshotUpdatedAt != nil
// updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time
// would let stale token rows inherit app-start freshness (#1964). lastRefreshAt
// drives TTL suppression and stays native-only: a merged load must never delay a
Expand All @@ -978,7 +983,7 @@ public struct CostUsageFetcher: Sendable {
now: now,
historyDays: clampedHistoryDays,
calendar: options.calendar,
historyCoverageIsEstablished: Self.codexHistoryCoverageIsEstablished(options: options),
historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished,
costProvenance: .listPriceEstimate,
projects: Self.mergedProjectBreakdowns(projects),
sessions: sessions,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "8050a4faf4fddb96"
static let value = "a8843ee5c69a90fc"
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,18 @@ struct CostUsageCodexPreviousReport: Codable, Equatable {
var timeZoneIdentifier: String?
var roots: [String: Int64]?

init?(report: CostUsageDailyReport, cache: CostUsageCache) {
init?(
report: CostUsageDailyReport,
cache: CostUsageCache,
reportSinceKey: String,
reportUntilKey: String)
{
guard !report.data.isEmpty else { return nil }
self.data = report.data.map(Entry.init)
self.summary = report.summary.map(Summary.init)
self.updatedAtUnixMs = cache.lastScanUnixMs
self.scanSinceKey = cache.scanSinceKey
self.scanUntilKey = cache.scanUntilKey
self.scanSinceKey = reportSinceKey
self.scanUntilKey = reportUntilKey
self.timeZoneIdentifier = cache.timeZoneIdentifier
self.roots = cache.roots
}
Expand Down
13 changes: 10 additions & 3 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5176,8 +5176,11 @@ enum CostUsageScanner {
return previous
}

let sourceCache: CostUsageCache? = if !currentScanIsPending,
options.forceRescan,
// A routine bounded refresh can turn an established cache back into pending while it
// validates a growing active tail. Snapshot the established report before any refresh,
// not only explicit rescans, so presentation can remain stable until catch-up converges.
let sourceCache: CostUsageCache? = if plan.shouldRefresh,
!currentScanIsPending,
!cache.days.isEmpty
Comment on lines +5182 to 5184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the requested window in retained reports

When a shared Codex cache already spans 365 days but a routine 30-day refresh becomes bounded and pending, buildCodexReportFromCache creates a 30-day report while CostUsageCodexPreviousReport(report:cache:) labels it with the cache's wider scanSinceKey/scanUntilKey. A later 365-day dashboard or hydration request therefore passes matches, receives only the 30-day payload, and can treat it as established, hiding older spend until catch-up completes. Persist the actual requested report window rather than the cache's retained scan window.

Useful? React with 👍 / 👎.

{
cache
Expand All @@ -5197,7 +5200,11 @@ enum CostUsageScanner {
modelsDevCatalog: plan.modelsDevCatalog,
modelsDevCacheRoot: options.cacheRoot,
priorityTurns: plan.priorityTurns)
return CostUsageCodexPreviousReport(report: report, cache: sourceCache)
return CostUsageCodexPreviousReport(
report: report,
cache: sourceCache,
reportSinceKey: range.sinceKey,
reportUntilKey: range.untilKey)
}

static func codexPreviousReport(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -867,10 +867,11 @@ extension CostUsageStore {
else { return nil }
let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar)
let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range)
guard var previous = CostUsageCodexPreviousReport(report: report, cache: cache) else { return nil }
previous.scanSinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey
previous.scanUntilKey = reportWindow?.untilKey ?? cache.scanUntilKey
return previous
return CostUsageCodexPreviousReport(
report: report,
cache: cache,
reportSinceKey: sinceKey,
reportUntilKey: untilKey)
}

private static func fileAggregates(_ usage: CostUsageFileUsage) -> [CostUsageStoreDayAggregate] {
Expand Down
107 changes: 106 additions & 1 deletion Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,109 @@ struct CostUsageFetcherCacheSnapshotTests {
#expect(cached?.daily.map(\.date) == ["2026-04-08"])
}

@Test
func `bounded narrow tail refresh retains only its requested cached window`() async throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }

let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
let olderDay = try env.makeLocalNoon(year: 2026, month: 2, day: 7)
try Self.writeCodexSessionFile(
homeRoot: env.codexHomeRoot,
env: env,
day: olderDay,
filename: "older.jsonl",
tokens: 11)
let sessionURL = try Self.writeCodexSessionFile(
homeRoot: env.codexHomeRoot,
env: env,
day: day,
filename: "active-tail.jsonl",
tokens: 42)
var options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
cacheRoot: env.cacheRoot)
options.refreshMinIntervalSeconds = 0

let established = try await CostUsageFetcher.loadTokenSnapshot(
provider: .codex,
now: day,
historyDays: 365,
includePiSessions: false,
scannerOptions: options)
let establishedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
#expect(established.historyCoverageIsEstablished)
#expect(established.last30DaysTokens == 53)
#expect(establishedCache.codexScanCatchUpPending != true)

let appendedAt = day.addingTimeInterval(10)
let appendedLine = try env.jsonl([[
"type": "event_msg",
"timestamp": env.isoString(for: appendedAt),
"payload": [
"type": "token_count",
"info": [
"last_token_usage": [
"input_tokens": 84,
"cached_input_tokens": 0,
"output_tokens": 0,
],
"model": "openai/gpt-5.4",
],
],
]])
let handle = try FileHandle(forWritingTo: sessionURL)
try handle.seekToEnd()
try handle.write(contentsOf: Data(appendedLine.utf8))
try handle.close()
try FileManager.default.setAttributes([.modificationDate: appendedAt], ofItemAtPath: sessionURL.path)

options.maxCodexScanDurationPerRefresh = .leastNonzeroMagnitude
let partial = try await CostUsageFetcher.loadTokenSnapshot(
provider: .codex,
now: appendedAt,
historyDays: 30,
includePiSessions: false,
scannerOptions: options)
let pendingCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult(
now: appendedAt,
historyDays: 30,
includePiSessions: false,
scannerOptions: options)
let narrowSince = try #require(options.calendar.date(byAdding: .day, value: -29, to: day))
let wideSince = try #require(options.calendar.date(byAdding: .day, value: -364, to: day))
let narrowRange = CostUsageScanner.CostUsageDayRange(
since: narrowSince,
until: appendedAt,
calendar: options.calendar)
let wideRange = CostUsageScanner.CostUsageDayRange(
since: wideSince,
until: appendedAt,
calendar: options.calendar)
let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options)
let previous = try #require(pendingCache.codexPreviousReport)

#expect(!partial.historyCoverageIsEstablished)
#expect(partial.last30DaysTokens == 42)
#expect(pendingCache.codexScanCatchUpPending == true)
#expect(previous.report.data.map(\.date) == ["2026-04-08"])
#expect(previous.scanSinceKey == narrowRange.sinceKey)
#expect(previous.scanUntilKey == narrowRange.untilKey)
#expect(CostUsageScanner.codexPreviousReport(
cache: pendingCache,
range: narrowRange,
rootsFingerprint: rootsFingerprint) != nil)
#expect(CostUsageScanner.codexPreviousReport(
cache: pendingCache,
range: wideRange,
rootsFingerprint: rootsFingerprint) == nil)
#expect(cached?.snapshot.historyCoverageIsEstablished == true)
#expect(cached?.snapshot.last30DaysTokens == 42)
#expect(cached?.staleSnapshotUpdatedAt == established.updatedAt)
#expect(cached?.lastRefreshAt == nil)
}

@Test
func `cached codex token snapshot keeps the cache scan time as updatedAt`() async throws {
let env = try CostUsageTestEnvironment()
Expand Down Expand Up @@ -702,12 +805,13 @@ struct CostUsageFetcherCacheSnapshotTests {
#expect(travelled == nil)
}

@discardableResult
private static func writeCodexSessionFile(
homeRoot: URL,
env: CostUsageTestEnvironment,
day: Date,
filename: String,
tokens: Int) throws
tokens: Int) throws -> URL
{
let comps = Calendar.current.dateComponents([.year, .month, .day], from: day)
let dir = homeRoot
Expand Down Expand Up @@ -741,6 +845,7 @@ struct CostUsageFetcherCacheSnapshotTests {
],
],
]).write(to: url, atomically: true, encoding: .utf8)
return url
}

private static func writePiCodexSessionFile(
Expand Down
6 changes: 5 additions & 1 deletion Tests/CodexBarTests/CostUsagePerformanceGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,11 @@ struct CostUsagePerformanceGateTests {
rebuildingCache.timeZoneIdentifier = options.calendar.timeZone.identifier
rebuildingCache.roots = priorCache.roots
rebuildingCache.codexScanCatchUpPending = true
rebuildingCache.codexPreviousReport = CostUsageCodexPreviousReport(report: priorReport, cache: priorCache)
rebuildingCache.codexPreviousReport = CostUsageCodexPreviousReport(
report: priorReport,
cache: priorCache,
reportSinceKey: range.sinceKey,
reportUntilKey: range.untilKey)
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: rebuildingCache)
var report = CostUsageScanner.loadDailyReport(
provider: .codex,
Expand Down
8 changes: 6 additions & 2 deletions Tests/CodexBarTests/CostUsageStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,9 @@ extension CostUsageStoreTests {
modelsUsed: nil,
modelBreakdowns: nil),
], summary: nil),
cache: cache)
cache: cache,
reportSinceKey: "2026-08-01",
reportUntilKey: "2026-08-01")

func save(_ cache: CostUsageCache) {
_ = store.syncSaveCodexCache(
Expand Down Expand Up @@ -1569,7 +1571,9 @@ extension CostUsageStoreTests {
modelsUsed: nil,
modelBreakdowns: nil),
], summary: nil),
cache: cache)
cache: cache,
reportSinceKey: "2026-06-01",
reportUntilKey: "2026-07-01")

let result = store.syncSaveCodexCache(
cache,
Expand Down
Loading