diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index c6f454e665..29bc640af9 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -1012,6 +1012,7 @@ final class SpendDashboardController { private let publicationHandler: PublicationHandler? private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] + private var loadedInputScopes: [String: SpendDashboardLoadedInputScope] = [:] private var loadedAt = Date() private var lastSuccessfulConfiguration: SpendDashboardConfiguration? private var phase = LoadPhase.ordinary @@ -1077,6 +1078,9 @@ final class SpendDashboardController { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } self.failedSourceIDs.subtract(invalidatedSourceIDs) self.confirmedEmptySourceIDs.subtract(invalidatedSourceIDs) + for sourceID in invalidatedSourceIDs { + self.loadedInputScopes.removeValue(forKey: sourceID) + } self.failedSourceCount = 0 self.rebuildModel() } @@ -1091,6 +1095,7 @@ final class SpendDashboardController { !configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled else { self.loadedInputs = [] + self.loadedInputScopes = [:] self.failedSourceIDs = [] self.confirmedEmptySourceIDs = [] self.openCodexObservation = .disabled @@ -1141,6 +1146,11 @@ final class SpendDashboardController { self.loadedInputs.removeAll { cachedIDs.contains($0.id) } self.loadedInputs.append(contentsOf: result.inputs) self.loadedInputs = Self.stableUniqueInputs(self.loadedInputs) + for input in result.inputs { + self.loadedInputScopes[input.id] = SpendDashboardLoadedInputScope( + configuration: request.configuration, + input: input) + } self.loadedAt = request.now self.failedSourceCount = result.failedSourceCount self.failedSourceIDs = result.failedSourceIDs @@ -1271,19 +1281,46 @@ 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, SpendDashboardLoadedInputScope(configuration: request.configuration, input: input)) + }) + let unsafeSourceIDs = invalidatedSourceIDs + .union(result.invalidatedSourceIDs) + .union(confirmedEmptySourceIDs) + var incompleteCodexScopes: [String: SpendDashboardLoadedInputScope] = [:] + for input in nextInputs + where input.provider == .codex && !input.snapshot.historyCoverageIsEstablished + { + incompleteCodexScopes[input.id] = SpendDashboardLoadedInputScope( + 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 = Self.stableUniqueInputs(nextInputs) + self.loadedInputScopes = nextInputScopes self.loadedAt = request.now self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount diff --git a/Sources/CodexBar/SpendDashboardLoadedInputScope.swift b/Sources/CodexBar/SpendDashboardLoadedInputScope.swift new file mode 100644 index 0000000000..04fa2bcc98 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardLoadedInputScope.swift @@ -0,0 +1,14 @@ +import Foundation + +struct SpendDashboardLoadedInputScope: Equatable, Sendable { + let bucketTimeZoneIdentifier: String + let historyDays: Int + + init( + configuration: SpendDashboardConfiguration, + input: SpendDashboardModel.ProviderInput) + { + self.bucketTimeZoneIdentifier = configuration.bucketCalendar.timeZone.identifier + self.historyDays = input.snapshot.historyDays + } +} diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index eb2864b089..43c158461b 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -119,6 +119,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) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index cc58477c52..e7f5ee9207 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -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 @@ -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, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index abc3eef6ac..bacf104c6e 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "2d17f4981b78d07f" + static let value = "6496ac5cffd5f58c" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift index 4af8c32169..2618488c3c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift @@ -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 } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 652336a574..3f38132d48 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -5186,8 +5186,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 { cache @@ -5207,7 +5210,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( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift index e9001f077c..50761fb6f0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift @@ -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] { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 90c03d14d1..52a33c9029 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -77,11 +77,17 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "2d17f4981b78d07f", // Current main before retained-report persistence; parsed rows unchanged. + "8050a4faf4fddb96", // PR base before retained-report persistence; parsed rows unchanged. "98da5914d2f6a9cd", // Pushed PR producer before retry signaling; persisted rows unchanged. "43609cc56f76a003", // 0.49.3 request-tier pricing; persisted row shape unchanged. "b975eb705f905b9a", // 0.49.0-0.49.2 SQLite producer with compatible rows. "47144baa8daccf52", // This branch changes only scan scheduling, discovery, and persistence bookkeeping. ] + static let incompatibleRetainedReportPredecessorParserHashes: Set = [ + "2d17f4981b78d07f", + "8050a4faf4fddb96", + ] /// Test-only crash injection: invoked inside `saveCodexCache`'s transaction after each /// persisted file with the running count, so a crash-safety harness can SIGKILL the @@ -347,7 +353,7 @@ extension CostUsageStore { } private func validateExistingDatabase(_ database: OpaquePointer) throws { - let state: (isCurrent: Bool, canAdoptPredecessor: Bool) + let state: (storedHash: String, isCurrent: Bool, canAdoptPredecessor: Bool) try Self.execute(database, "BEGIN") do { state = try self.databaseCompatibilityState(database) @@ -374,7 +380,7 @@ extension CostUsageStore { } try Self.validateDatabaseIntegrity(database) if lockedState.canAdoptPredecessor { - try self.adoptCompatiblePredecessor(database) + try self.adoptCompatiblePredecessor(database, storedHash: lockedState.storedHash) } try Self.execute(database, "COMMIT") } catch { @@ -384,6 +390,7 @@ extension CostUsageStore { } private func databaseCompatibilityState(_ database: OpaquePointer) throws -> ( + storedHash: String, isCurrent: Bool, canAdoptPredecessor: Bool) { @@ -401,7 +408,7 @@ extension CostUsageStore { && self.expectedSchemaVersion == Self.schemaVersion && Self.compatiblePredecessorParserHashes.contains(storedHash) && actualVersion == Int64(predecessorVersion) - return (isCurrent, canAdoptPredecessor) + return (storedHash, isCurrent, canAdoptPredecessor) } private static func validateDatabaseIntegrity(_ database: OpaquePointer) throws { @@ -413,7 +420,23 @@ extension CostUsageStore { } } - private func adoptCompatiblePredecessor(_ database: OpaquePointer) throws { + private func adoptCompatiblePredecessor(_ database: OpaquePointer, storedHash: String) throws { + if Self.incompatibleRetainedReportPredecessorParserHashes.contains(storedHash), + var metadata = try Self.readSingleton( + CostUsageStoreMetadata.self, + database: database, + table: "scan_metadata") + { + metadata.previousReportPayload = nil + let payload = try JSONEncoder().encode(metadata) + let metadataStatement = try Self.prepare( + database, + "UPDATE scan_metadata SET payload = ? WHERE id = 1") + defer { sqlite3_finalize(metadataStatement) } + Self.bind(payload, to: metadataStatement, at: 1) + try Self.stepDone(metadataStatement, database: database) + guard sqlite3_changes(database) == 1 else { throw StoreError.incompatibleSchema } + } let statement = try Self.prepare(database, "UPDATE meta SET value = ? WHERE key = 'parser_hash'") defer { sqlite3_finalize(statement) } Self.bind(self.expectedParserHash, to: statement, at: 1) diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index e12cf0d19e..c93e225aa5 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -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() @@ -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 @@ -741,6 +845,7 @@ struct CostUsageFetcherCacheSnapshotTests { ], ], ]).write(to: url, atomically: true, encoding: .utf8) + return url } private static func writePiCodexSessionFile( diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index 41b082c9f7..efaa0e4704 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -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, diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 6b4b87a67e..e656b6b7be 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -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( @@ -1005,6 +1007,8 @@ extension CostUsageStoreTests { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "2d17f4981b78d07f", + "8050a4faf4fddb96", "98da5914d2f6a9cd", "43609cc56f76a003", "b975eb705f905b9a", @@ -1057,6 +1061,34 @@ extension CostUsageStoreTests { "SELECT COUNT(*) FROM meta WHERE key = 'parser_hash' AND value = '\(CodexParserHash.value)'") == 1) } + @Test + func `retained report migration preserves compatible rows and clears stale payload`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let predecessorHash = "8050a4faf4fddb96" + let predecessorVersion = CostUsageStore.combinedSchemaVersion( + base: CostUsageStore.baseSchemaVersion, + parserHash: predecessorHash) + let predecessor = CostUsageStore( + cacheRoot: fixture.root, + schemaVersion: predecessorVersion, + parserHash: predecessorHash) + let file = Self.file(path: "/rollouts/retained-report.jsonl", day: "2026-08-01") + let aggregate = Self.aggregate(day: "2026-08-01", model: "gpt-5.6-sol", scale: 1) + #expect(await predecessor.upsertFile(file)) + #expect(await predecessor.replaceFileDayAggregates(path: file.path, aggregates: [aggregate])) + #expect(await predecessor.mergeDayAggregates([aggregate])) + #expect(await predecessor.setMetadata(Self.metadata())) + var expected = await predecessor.readSnapshot() + expected.metadata.previousReportPayload = nil + + let current = CostUsageStore(cacheRoot: fixture.root) + + #expect(await current.readSnapshot() == expected) + #expect(await current.rebuildCount == 0) + #expect(await current.configuration()?.userVersion == Int(CostUsageStore.schemaVersion)) + } + @Test(.timeLimit(.minutes(1))) func `concurrent predecessor adoption never rebuilds a valid store`() async throws { let fixture = try StoreFixture() @@ -1569,7 +1601,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, diff --git a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift index f41c554983..d38195942f 100644 --- a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift @@ -1,5 +1,6 @@ import CodexBarCore import Foundation +import os.lock import Testing @testable import CodexBar @@ -106,6 +107,113 @@ struct SpendDashboardCachedPresentationTests { #expect(controller.model.groups.isEmpty) } + @Test + func `successful incomplete Codex refresh keeps the retained established total`() async { + let gate = SpendDashboardCachedLoaderGate() + let configuration = Self.configuration(account: "account|cache") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input( + id: "codex:account", + cost: 9, + historyCoverageIsEstablished: false)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.failedSourceCount == 0) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:account"]) + } + + @Test + func `incomplete Codex refresh rejects a retained total from another bucket time zone`() async { + let gate = SpendDashboardCachedLoaderGate() + let utc = Self.configuration(account: "account|cache", bucketTimeZoneIdentifier: "UTC") + let pacific = Self.configuration( + account: "account|cache", + bucketTimeZoneIdentifier: "America/Los_Angeles") + let activeConfiguration = OSAllocatedUnfairLock(initialState: utc) + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request( + configuration: activeConfiguration.withLock { $0 }, + force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: utc) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.model.groups.first?.totalCost == 3) + + activeConfiguration.withLock { $0 = pacific } + controller.update(configuration: pacific) + await gate.resume(at: 0, result: .init(inputs: [], failedSourceIDs: [])) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input( + id: "codex:account", + cost: 9, + historyCoverageIsEstablished: false)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.failedSourceCount == 0) + #expect(controller.configuration?.bucketCalendar.timeZone.identifier == "America/Los_Angeles") + #expect(controller.model.groups.first?.timeZone.identifier == "America/Los_Angeles") + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.providers.first?.totalCost == nil) + } + + @Test + func `incomplete Codex refresh rejects a retained total from another history window`() async { + let gate = SpendDashboardCachedLoaderGate() + let configuration = Self.configuration(account: "account|cache") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3, historyDays: 30)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.model.groups.first?.totalCost == 3) + + await gate.resume(at: 0, result: .init( + inputs: [Self.input( + id: "codex:account", + cost: 9, + historyDays: SpendDashboardSource.scanDays, + historyCoverageIsEstablished: false)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.failedSourceCount == 0) + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.providers.first?.totalCost == nil) + } + @Test func `cached Codex totals stay bound to their account cache`() async { let first = Self.scanRequest(id: "first", cacheIdentity: "first-cache") @@ -172,11 +280,15 @@ struct SpendDashboardCachedPresentationTests { private nonisolated static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) - private static func configuration(account: String) -> SpendDashboardConfiguration { + private static func configuration( + account: String, + bucketTimeZoneIdentifier: String = "") -> SpendDashboardConfiguration + { SpendDashboardConfiguration( costUsageEnabled: true, providerIDs: [UsageProvider.codex.rawValue], - codexAccountIdentities: [account]) + codexAccountIdentities: [account], + bucketTimeZoneIdentifier: bucketTimeZoneIdentifier) } private static func request( @@ -209,7 +321,9 @@ struct SpendDashboardCachedPresentationTests { private nonisolated static func input( id: String? = nil, - cost: Double) -> SpendDashboardModel.ProviderInput + cost: Double, + historyDays: Int = 30, + historyCoverageIsEstablished: Bool = true) -> SpendDashboardModel.ProviderInput { let entry = CostUsageDailyReport.Entry( date: "2026-07-15", @@ -224,6 +338,8 @@ struct SpendDashboardCachedPresentationTests { sessionCostUSD: nil, last30DaysTokens: 10, last30DaysCostUSD: cost, + historyDays: historyDays, + historyCoverageIsEstablished: historyCoverageIsEstablished, daily: [entry], updatedAt: Self.fixtureNow) return SpendDashboardModel.ProviderInput( diff --git a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift index c2641ff963..a83c7b2b9b 100644 --- a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift +++ b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift @@ -6,6 +6,48 @@ import Testing @MainActor @Suite(.serialized) struct UsageStoreCodexCostCatchUpTests { + @Test + func `incomplete refresh cannot replace an established same-scope snapshot`() throws { + let store = try Self.makeStore(suite: "retains-established") + store.publishTokenSnapshot(Self.tokenSnapshot(cost: 3, now: Date()), for: .codex) + let establishedRevision = store.tokenSnapshotPublicationRevision(for: .codex) + + store.publishTokenSnapshot( + Self.tokenSnapshot( + cost: 9, + now: Date().addingTimeInterval(1), + historyCoverageIsEstablished: false), + for: .codex) + + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 3) + #expect(store.tokenSnapshot(for: .codex)?.historyCoverageIsEstablished == true) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == establishedRevision) + + store.publishTokenSnapshot( + Self.tokenSnapshot(cost: 4, now: Date().addingTimeInterval(2)), + for: .codex) + + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 4) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == establishedRevision + 1) + } + + @Test + func `incomplete refresh does not retain an established snapshot from another scope`() throws { + let store = try Self.makeStore(suite: "scope-change") + store.publishTokenSnapshot(Self.tokenSnapshot(cost: 3, now: Date()), for: .codex) + + store.settings.costUsageHistoryDays = 7 + store.publishTokenSnapshot( + Self.tokenSnapshot( + cost: 9, + now: Date().addingTimeInterval(1), + historyCoverageIsEstablished: false), + for: .codex) + + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 9) + #expect(store.tokenSnapshot(for: .codex)?.historyCoverageIsEstablished == false) + } + @Test func `bounded catch-up automatically publishes only the final stable snapshot`() async throws { let store = try Self.makeStore(suite: "publishes-final") @@ -328,12 +370,17 @@ struct UsageStoreCodexCostCatchUpTests { environmentBase: [:]) } - private static func tokenSnapshot(cost: Double, now: Date) -> CostUsageTokenSnapshot { + private static func tokenSnapshot( + cost: Double, + now: Date, + historyCoverageIsEstablished: Bool = true) -> CostUsageTokenSnapshot + { CostUsageTokenSnapshot( sessionTokens: 10, sessionCostUSD: cost, last30DaysTokens: 10, last30DaysCostUSD: cost, + historyCoverageIsEstablished: historyCoverageIsEstablished, daily: [CostUsageDailyReport.Entry( date: "2026-07-30", inputTokens: 4,