diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index e55aa37bb4..44f1fa42bc 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -72,6 +72,8 @@ struct SpendDashboardPane: View { self.store = store self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }, cachedLoader: { request in + await SpendDashboardSource.loadCached(request) })) } @@ -293,11 +295,12 @@ struct SpendDashboardPane: View { .frame(maxWidth: .infinity, minHeight: 220) } } else if self.controller.model.groups.isEmpty { + let emptyState = SpendDashboardEmptyState.make(isRefreshing: self.controller.isRefreshing) SpendDashboardPanel { ContentUnavailableView { - Label(L("No local cost history yet"), systemImage: "chart.bar.xaxis") + Label(emptyState.title, systemImage: "chart.bar.xaxis") } description: { - Text(L("Turn on cost tracking or refresh after using a supported provider.")) + Text(emptyState.message) } .frame(maxWidth: .infinity, minHeight: 220) } @@ -383,6 +386,22 @@ struct SpendDashboardPane: View { } } +struct SpendDashboardEmptyState: Equatable { + let title: String + let message: String + + static func make(isRefreshing: Bool) -> Self { + if isRefreshing { + return Self( + title: L("Refreshing"), + message: L("Local estimated cost history across supported providers.")) + } + return Self( + title: L("No local cost history yet"), + message: L("Turn on cost tracking or refresh after using a supported provider.")) + } +} + private struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup let requestedDays: Int diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index f56a80d75d..427f7567f4 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -120,6 +120,9 @@ struct CodexSpendSnapshotLoadContext: Sendable { enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + typealias CachedCodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async + -> CostUsageTokenSnapshot? + typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL static let scanDays = 30 @@ -255,6 +258,86 @@ enum SpendDashboardSource { }) } + /// Dashboard priming may only publish current admitted caches. Stale catch-up placeholders + /// (previous report or incompatible producer) stay hidden until live validation finishes. + static func admittedCachedCodexSnapshot( + from result: CostUsageFetcher.CachedCodexTokenSnapshotResult?) -> CostUsageTokenSnapshot? + { + guard let result, result.staleSnapshotUpdatedAt == nil else { return nil } + return result.snapshot + } + + static func loadCached(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + await self.loadCached(request, cacheRootResolver: { self.codexCacheRoot(for: $0) }) + } + + static func loadCached( + _ request: SpendDashboardLoadRequest, + cacheRootResolver: @escaping CodexCacheRootResolver) async -> SpendDashboardLoadResult + { + await self.loadCached( + request, + cacheRootResolver: cacheRootResolver, + cachedCodexSnapshotLoader: { context in + // Only prime from an admitted current cache. Previous-report / incompatible-producer + // placeholders carry staleSnapshotUpdatedAt while catch-up or producer upgrade is + // still pending; showing those as "validated" spend would reintroduce the upgrade + // risk that #2525's catch-up path is designed to retire. + let cached = await CostUsageFetcher(cacheRoot: context.cacheRoot) + .loadCachedCodexTokenSnapshotResultForScopedHome( + now: context.now, + codexHomePath: context.account.homePath, + historyDays: context.historyDays, + includePiSessions: false, + includeProjectAndSessionBreakdowns: false) + return Self.admittedCachedCodexSnapshot(from: cached) + }) + } + + static func loadCached( + _ request: SpendDashboardLoadRequest, + cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult + { + await self.loadCached( + request, + cacheRootResolver: { self.codexCacheRoot(for: $0) }, + cachedCodexSnapshotLoader: cachedCodexSnapshotLoader) + } + + private static func loadCached( + _ request: SpendDashboardLoadRequest, + cacheRootResolver: CodexCacheRootResolver, + cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult + { + var inputs = request.capturedInputs + for account in request.codexRequests { + guard !Task.isCancelled, + self.currentAuthFingerprint(for: account) == account.authFingerprint + else { continue } + let snapshot = await cachedCodexSnapshotLoader(CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRootResolver(account), + now: request.now, + force: false, + historyDays: Self.scanDays, + refreshPricingInBackground: false, + includePiSessions: false)) + guard !Task.isCancelled, + let snapshot, + self.currentAuthFingerprint(for: account) == account.authFingerprint + else { continue } + inputs.append(SpendDashboardModel.ProviderInput( + id: "codex:\(account.id)", + provider: .codex, + displayName: account.displayName, + modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, + snapshot: snapshot)) + } + // A cache miss is still pending fresh validation, not a new provider failure. Preserve + // failures already captured in the request so priming cannot briefly clear the warning. + return SpendDashboardLoadResult(inputs: inputs, failedSourceIDs: request.unavailableSourceIDs) + } + static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult @@ -498,7 +581,13 @@ enum SpendDashboardSource { } static func codexCacheRoot(for request: CodexSpendScanRequest) -> URL { - UsageStore.costUsageCacheDirectory() + let costUsageDirectory = UsageStore.costUsageCacheDirectory() + if request.source == .liveSystem { + // The live account reads the same local-home telemetry as UsageStore's ambient scanner. + // Reuse that cache instead of indexing the identical session corpus a second time. + return costUsageDirectory.deletingLastPathComponent() + } + return costUsageDirectory .appendingPathComponent("accounts", isDirectory: true) .appendingPathComponent(request.cacheIdentity, isDirectory: true) } @@ -577,6 +666,7 @@ final class SpendDashboardController { typealias RequestBuilder = @MainActor @Sendable (SpendDashboardRequestBuildMode) async -> SpendDashboardLoadRequest typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult + typealias CachedLoader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult private enum ReconciliationObservation: Sendable { case confirmedEmpty @@ -644,12 +734,14 @@ final class SpendDashboardController { } private enum LoadPhase: Sendable { + case priming case ordinary case forcing case reconciling(ForcedOutcome) var buildMode: SpendDashboardRequestBuildMode { switch self { + case .priming: .captureOnly case .ordinary: .refreshMissing case .forcing: .forceRefresh case .reconciling: .captureOnly @@ -658,7 +750,7 @@ final class SpendDashboardController { var manualRefreshOutstanding: Bool { switch self { - case .ordinary: false + case .priming, .ordinary: false case .forcing, .reconciling: true } } @@ -674,6 +766,7 @@ final class SpendDashboardController { private static let daysDefaultsKey = "settingsSpendDashboardDays" private let userDefaults: UserDefaults private let requestBuilder: RequestBuilder + private let cachedLoader: CachedLoader? private let loader: Loader private let nowProvider: @Sendable () -> Date private var loadTask: Task? @@ -685,11 +778,13 @@ final class SpendDashboardController { init( userDefaults: UserDefaults = .standard, requestBuilder: @escaping RequestBuilder, + cachedLoader: CachedLoader? = nil, loader: @escaping Loader = SpendDashboardSource.load, nowProvider: @escaping @Sendable () -> Date = { Date() }) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder + self.cachedLoader = cachedLoader self.loader = loader self.nowProvider = nowProvider self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) @@ -711,7 +806,18 @@ final class SpendDashboardController { { return } - let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + let ownershipChanged = previousConfiguration.map { + !Self.sameSourceOwnership($0, configuration) + } ?? false + let shouldPrime = self.cachedLoader != nil && + (self.lastSuccessfulConfiguration == nil || ownershipChanged) + let nextPhase: LoadPhase = if self.phase.manualRefreshOutstanding { + .forcing + } else if shouldPrime { + .priming + } else { + .ordinary + } self.startLoad(configuration: configuration, phase: nextPhase) } @@ -724,7 +830,7 @@ final class SpendDashboardController { self.loadTask?.cancel() let invalidatedSourceIDs = switch phase { case let .reconciling(outcome): outcome.invalidatedSourceIDs - case .ordinary, .forcing: + case .priming, .ordinary, .forcing: Self.invalidatedSourceIDs( previous: self.lastSuccessfulConfiguration, current: configuration) @@ -816,6 +922,27 @@ final class SpendDashboardController { } switch phase { + case .priming: + guard let cachedLoader = self.cachedLoader else { + self.startLoad(configuration: request.configuration, phase: .ordinary) + return + } + let result = await cachedLoader(request) + guard !Task.isCancelled, + generation == self.generation, + let latestConfiguration = self.configuration + else { return } + guard request.configuration == latestConfiguration else { + self.startLoad(configuration: latestConfiguration, phase: .ordinary) + return + } + self.apply( + request: request, + result: result, + invalidatedSourceIDs: invalidatedSourceIDs, + confirmedEmptySourceIDs: []) + self.startLoad(configuration: request.configuration, phase: .ordinary) + case .ordinary: let result = await self.loader(request) guard !Task.isCancelled, @@ -867,6 +994,7 @@ final class SpendDashboardController { { self.configuration = configuration let nextPhase: LoadPhase = switch phase { + case .priming: .priming case .ordinary: .ordinary case .forcing: .forcing case let .reconciling(outcome): @@ -965,7 +1093,11 @@ final class SpendDashboardController { self.loadedAt = now ?? self.nowProvider() self.rebuildModel() guard let configuration else { return } - let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + let nextPhase: LoadPhase = switch self.phase { + case .priming: .priming + case .ordinary: .ordinary + case .forcing, .reconciling: .forcing + } self.startLoad(configuration: configuration, phase: nextPhase) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e15afb6d77..bdc1c472c1 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -34,6 +34,16 @@ public struct CostUsageFetcher: Sendable { package let snapshot: CostUsageTokenSnapshot package let lastRefreshAt: Date? package let staleSnapshotUpdatedAt: Date? + + package init( + snapshot: CostUsageTokenSnapshot, + lastRefreshAt: Date?, + staleSnapshotUpdatedAt: Date? = nil) + { + self.snapshot = snapshot + self.lastRefreshAt = lastRefreshAt + self.staleSnapshotUpdatedAt = staleSnapshotUpdatedAt + } } package struct CodexScanCatchUpStatus: Sendable, Equatable { @@ -74,6 +84,7 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions = scannerOptions } + /// Public unscoped cached Codex snapshot loader. Scoped homes always return nil. public func loadCachedCodexTokenSnapshot( now: Date = Date(), codexHomePath: String? = nil, @@ -83,6 +94,9 @@ public struct CostUsageFetcher: Sendable { now: now, codexHomePath: codexHomePath, historyDays: historyDays, + allowScopedCodexHome: false, + includePiSessions: true, + includeProjectAndSessionBreakdowns: true, scannerOptions: self.scannerOptionsOverride()) } @@ -95,6 +109,9 @@ public struct CostUsageFetcher: Sendable { now: now, codexHomePath: codexHomePath, historyDays: historyDays, + allowScopedCodexHome: false, + includePiSessions: true, + includeProjectAndSessionBreakdowns: true, scannerOptions: self.scannerOptionsOverride()) } @@ -654,12 +671,18 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, + allowScopedCodexHome: Bool = false, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? { await self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, + allowScopedCodexHome: allowScopedCodexHome, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, scannerOptions: overrideScannerOptions)?.snapshot } @@ -667,12 +690,14 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, + allowScopedCodexHome: Bool = false, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CachedCodexTokenSnapshotResult? { - if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !codexHomePath.isEmpty - { + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + if scopedCodexHomePath?.isEmpty == false, !allowScopedCodexHome { return nil } @@ -680,7 +705,10 @@ public struct CostUsageFetcher: Sendable { // cooperative pool alongside the scans themselves. let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) - let options = overrideScannerOptions ?? CostUsageScanner.Options() + let options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .codex, + codexHomePath: codexHomePath) let until = now let since = options.calendar.date( byAdding: .day, @@ -690,6 +718,7 @@ public struct CostUsageFetcher: Sendable { since: since, until: until, calendar: options.calendar) + let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false let roots = CostUsageScanner.codexSessionsRoots(options: options) let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) let loadedCache = CostUsageCacheIO.loadCodexForMigration( @@ -734,16 +763,18 @@ public struct CostUsageFetcher: Sendable { nativeScanAt = scanAt scanTimes.append(scanAt) } - sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( - cache: cache, - range: range, - modelsDevCacheRoot: options.cacheRoot, - sessionRoots: roots) - if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { - projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + if includeProjectAndSessionBreakdowns { + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( cache: cache, range: range, - modelsDevCacheRoot: options.cacheRoot)) + modelsDevCacheRoot: options.cacheRoot, + sessionRoots: roots) + if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { + projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot)) + } } } } else if let incompatibleCache = loadedCache.incompatibleCache, @@ -767,13 +798,15 @@ public struct CostUsageFetcher: Sendable { } } - if let piResult = PiSessionCostScanner.loadCachedDailyReportResult( - provider: .codex, - since: since, - until: until, - now: now, - cacheRoot: options.cacheRoot, - calendar: options.calendar) + if includePiSessions, + shouldMergePiUsage, + let piResult = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot, + calendar: options.calendar) { reports.append(piResult.report) piMerged = true @@ -1253,6 +1286,41 @@ public struct CostUsageFetcher: Sendable { } extension CostUsageFetcher { + /// Dashboard-only scoped-home cache reader. Not part of the public CodexBarCore surface. + package func loadCachedCodexTokenSnapshotForScopedHome( + now: Date = Date(), + codexHomePath: String, + historyDays: Int = 30, + includePiSessions: Bool = false, + includeProjectAndSessionBreakdowns: Bool = true) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + allowScopedCodexHome: true, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + scannerOptions: self.scannerOptionsOverride()) + } + + package func loadCachedCodexTokenSnapshotResultForScopedHome( + now: Date = Date(), + codexHomePath: String, + historyDays: Int = 30, + includePiSessions: Bool = false, + includeProjectAndSessionBreakdowns: Bool = true) async -> CachedCodexTokenSnapshotResult? + { + await Self.loadCachedCodexTokenSnapshotResult( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + allowScopedCodexHome: true, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + scannerOptions: self.scannerOptionsOverride()) + } + fileprivate static func loadRemoteTokenSnapshot( provider: UsageProvider, environment: [String: String], diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 685fea2a03..567e20d422 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -155,11 +155,27 @@ struct CostUsageFetcherCacheSnapshotTests { now: hydratedAt, historyDays: 1, scannerOptions: options) + let nativeOnly = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: hydratedAt, + historyDays: 1, + includePiSessions: false, + includeProjectAndSessionBreakdowns: false, + scannerOptions: options) + let scoped = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: hydratedAt, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + allowScopedCodexHome: true, + scannerOptions: options) #expect(cached?.snapshot.sessionTokens == 207) #expect(cached?.snapshot.updatedAt == oldestScanTime) #expect(cached?.snapshot.updatedAt != hydratedAt) #expect(cached?.lastRefreshAt == nil) + #expect(nativeOnly?.snapshot.sessionTokens == 42) + #expect(nativeOnly?.snapshot.projects.isEmpty == true) + #expect(nativeOnly?.snapshot.sessions.isEmpty == true) + #expect(scoped?.snapshot.sessionTokens == 42) } @Test @@ -249,7 +265,23 @@ struct CostUsageFetcherCacheSnapshotTests { } @Test - func `cached codex token snapshot refuses expanded or managed scopes`() async throws { + func `public cached codex snapshot wrapper keeps unscoped home semantics`() async { + // Compatibility surface for external CodexBarCore clients (ClawSweeper #2397 P1). + let fetcher = CostUsageFetcher() + let emptyScoped = await fetcher.loadCachedCodexTokenSnapshot( + codexHomePath: "/synthetic/managed-codex-home", + historyDays: 7) + #expect(emptyScoped == nil) + + // Defaulted three-parameter public signature must remain call-compatible. + let ambient = await fetcher.loadCachedCodexTokenSnapshot( + now: Date(timeIntervalSince1970: 1_784_179_200), + historyDays: 7) + _ = ambient + } + + @Test + func `cached codex token snapshot keeps scoped homes opt in and validates their roots`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -279,9 +311,27 @@ struct CostUsageFetcherCacheSnapshotTests { codexHomePath: env.codexHomeRoot.path, historyDays: 1, scannerOptions: options) + let optedIn = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + allowScopedCodexHome: true, + scannerOptions: options) + let mismatchedHome = env.root.appendingPathComponent("other-codex-home", isDirectory: true) + try FileManager.default.createDirectory( + at: mismatchedHome.appendingPathComponent("sessions", isDirectory: true), + withIntermediateDirectories: true) + let mismatched = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: mismatchedHome.path, + historyDays: 1, + allowScopedCodexHome: true, + scannerOptions: options) #expect(expanded == nil) #expect(managed == nil) + #expect(optedIn?.sessionTokens == 42) + #expect(mismatched == nil) } @Test diff --git a/Tests/CodexBarTests/SpendDashboardCachedRefreshTestSupport.swift b/Tests/CodexBarTests/SpendDashboardCachedRefreshTestSupport.swift new file mode 100644 index 0000000000..395c43d5be --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCachedRefreshTestSupport.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation +@testable import CodexBar + +@MainActor +final class CachedRefreshControllerBox { + var controller: SpendDashboardController? +} + +actor CachedRefreshModeRecorder { + private(set) var values: [SpendDashboardRequestBuildMode] = [] + + func append(_ mode: SpendDashboardRequestBuildMode) { + self.values.append(mode) + } +} + +actor CachedRefreshCodexLoadRecorder { + private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] + + func record(_ context: CodexSpendSnapshotLoadContext) { + self.contexts.append(context) + } +} + +actor CachedRefreshRequestGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func suspend() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} + +actor CachedRefreshResultGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} + +actor CachedRefreshLoaderGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardCachedRefreshTests.swift b/Tests/CodexBarTests/SpendDashboardCachedRefreshTests.swift new file mode 100644 index 0000000000..15b5cb0566 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCachedRefreshTests.swift @@ -0,0 +1,316 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardCachedRefreshTests { + @Test + func `cached dashboard data renders before request building and fresh loading finish`() async { + let loaderGate = CachedRefreshLoaderGate() + let requestGate = CachedRefreshRequestGate() + let configuration = Self.configuration(account: "cached") + let cachedInput = Self.input(cost: 3) + let controller = SpendDashboardController( + requestBuilder: { mode in + if mode == .refreshMissing { + await requestGate.suspend() + } + return Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult(inputs: [cachedInput], failedSourceIDs: []) + }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForRequestGate(requestGate) + + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.isRefreshing) + #expect(await loaderGate.pendingCount == 0) + + await requestGate.resume() + await Self.waitForPendingCount(1, gate: loaderGate) + await loaderGate.resume(at: 0, result: .init(inputs: [Self.input(cost: 5)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + } + + @Test + func `replacement generation rejects stale priming cache completion`() async { + let cachedGate = CachedRefreshResultGate() + let loaderGate = CachedRefreshLoaderGate() + let controllerBox = CachedRefreshControllerBox() + let firstConfiguration = Self.configuration(account: "first") + let secondConfiguration = Self.configuration(account: "second") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request( + configuration: controllerBox.controller?.configuration ?? firstConfiguration, + force: mode.forcesLoader) + }, + cachedLoader: { request in await cachedGate.load(request) }, + loader: { request in await loaderGate.load(request) }) + controllerBox.controller = controller + + controller.update(configuration: firstConfiguration) + await Self.waitForCachedPendingCount(1, gate: cachedGate) + controller.update(configuration: secondConfiguration) + await Self.waitForCachedPendingCount(2, gate: cachedGate) + + await cachedGate.resume( + at: 1, + result: .init(inputs: [Self.input(cost: 2)], failedSourceIDs: [])) + await Self.waitForPendingCount(1, gate: loaderGate) + await loaderGate.resume( + at: 0, + result: .init(inputs: [Self.input(cost: 3)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + await cachedGate.resume( + at: 0, + result: .init(inputs: [Self.input(cost: 1)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.generation == 3) + } + + @Test + func `cache miss stays refreshing through one fresh validation pass`() async { + let loaderGate = CachedRefreshLoaderGate() + let modeRecorder = CachedRefreshModeRecorder() + let configuration = Self.configuration(account: "missing") + let controller = SpendDashboardController( + requestBuilder: { mode in + await modeRecorder.append(mode) + return Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in SpendDashboardLoadResult(inputs: [], failedSourceIDs: []) }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: loaderGate) + + #expect(controller.model.groups.isEmpty) + #expect(controller.isRefreshing) + #expect(await modeRecorder.values == [.captureOnly, .refreshMissing]) + + await loaderGate.resume(at: 0, result: .init(inputs: [], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.isEmpty) + #expect(await modeRecorder.values == [.captureOnly, .refreshMissing]) + } + + @Test + func `priming preserves captured failures while fresh validation is pending`() async { + let loaderGate = CachedRefreshLoaderGate() + let unavailableSourceID = UsageProvider.mistral.rawValue + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, unavailableSourceID], + codexAccountIdentities: ["cached"]) + let controller = SpendDashboardController( + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [unavailableSourceID], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + cachedLoader: { request in + await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { _ in nil }) + }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: loaderGate) + + #expect(controller.isRefreshing) + #expect(controller.failedSourceCount == 1) + + await loaderGate.resume( + at: 0, + result: .init(inputs: [], failedSourceIDs: [unavailableSourceID])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.failedSourceCount == 1) + } + + @Test + func `priming admits only current caches and rejects stale catch-up placeholders`() { + let snapshot = Self.input(cost: 9).snapshot + let admitted = CostUsageFetcher.CachedCodexTokenSnapshotResult( + snapshot: snapshot, + lastRefreshAt: Date(timeIntervalSince1970: 100), + staleSnapshotUpdatedAt: nil) + let stale = CostUsageFetcher.CachedCodexTokenSnapshotResult( + snapshot: snapshot, + lastRefreshAt: nil, + staleSnapshotUpdatedAt: Date(timeIntervalSince1970: 50)) + + #expect(SpendDashboardSource.admittedCachedCodexSnapshot(from: admitted)?.last30DaysCostUSD == snapshot + .last30DaysCostUSD) + #expect(SpendDashboardSource.admittedCachedCodexSnapshot(from: stale) == nil) + #expect(SpendDashboardSource.admittedCachedCodexSnapshot(from: nil) == nil) + } + + @Test + func `cached dashboard data rejects auth rotation during hydration`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardCachedRefreshTests-auth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let authURL = CodexAuthFingerprint.authFileURL(homePath: home.path) + let initialAuth = Data("{\"profile\":\"owner-one\"}".utf8) + try initialAuth.write(to: authURL, options: .atomic) + let account = CodexSpendScanRequest( + id: "account", + displayName: "Codex", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: initialAuth), + authFileWasReadable: true, + cacheIdentity: "cached-auth") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "account|cached-auth"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: false) + let cachedSnapshot = Self.input(cost: 3).snapshot + + let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { _ in + try? Data("{\"profile\":\"owner-two\"}".utf8).write(to: authURL, options: .atomic) + return cachedSnapshot + }) + + #expect(result.inputs.isEmpty) + } + + @Test + func `cached dashboard reuses ambient root only for live system`() async { + let liveAccount = CodexSpendScanRequest( + id: "live", + displayName: "Codex", + source: .liveSystem, + homePath: "/synthetic/live-codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "live-cache") + let profileAccount = CodexSpendScanRequest( + id: "profile", + displayName: "Codex profile", + source: .profileHome(path: "/synthetic/profile-codex-home"), + homePath: "/synthetic/profile-codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "profile-cache") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "live|live-cache,profile|profile-cache"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [liveAccount, profileAccount], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: false) + let recorder = CachedRefreshCodexLoadRecorder() + + let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { context in + await recorder.record(context) + return nil + }) + let contexts = await recorder.contexts + + #expect(result.inputs.isEmpty) + #expect(contexts.map(\.cacheRoot) == [ + UsageStore.costUsageCacheDirectory().deletingLastPathComponent(), + UsageStore.costUsageCacheDirectory() + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("profile-cache", isDirectory: true), + ]) + } + + private static func configuration(account: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [account]) + } + + private static func request( + configuration: SpendDashboardConfiguration, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: force) + } + + private static func input(cost: Double) -> SpendDashboardModel.ProviderInput { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + provider: .codex, + displayName: UsageProvider.codex.rawValue, + snapshot: snapshot) + } + + private static func waitForRequestGate(_ gate: CachedRefreshRequestGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for request builder") + } + + private static func waitForCachedPendingCount(_ count: Int, gate: CachedRefreshResultGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) cached results") + } + + private static func waitForPendingCount(_ count: Int, gate: CachedRefreshLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardPresentationTests.swift new file mode 100644 index 0000000000..0986ec0c2d --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardPresentationTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import CodexBar + +struct SpendDashboardPresentationTests { + @Test + func `empty dashboard reports refresh until validation finishes`() { + #expect(SpendDashboardEmptyState.make(isRefreshing: true).title == L("Refreshing")) + #expect(SpendDashboardEmptyState.make(isRefreshing: false).title == L("No local cost history yet")) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardScopedCacheTests.swift b/Tests/CodexBarTests/SpendDashboardScopedCacheTests.swift new file mode 100644 index 0000000000..66dab27f48 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardScopedCacheTests.swift @@ -0,0 +1,72 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardScopedCacheTests { + @Test + func `production cached dashboard loader reads a validated scoped cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 15) + let model = "openai/gpt-5.4" + _ = try env.writeCodexSessionFile( + day: day, + filename: "dashboard-cached.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 42, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ])) + _ = try await CostUsageFetcher(cacheRoot: env.cacheRoot).loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: SpendDashboardSource.scanDays, + includePiSessions: false) + let account = CodexSpendScanRequest( + id: "profile", + displayName: "Codex profile", + source: .profileHome(path: env.codexHomeRoot.path), + homePath: env.codexHomeRoot.path, + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "profile-cache") + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["profile|profile-cache"]), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: day, + force: false) + let cacheRoot = env.cacheRoot + + let result = await SpendDashboardSource.loadCached(request, cacheRootResolver: { _ in cacheRoot }) + + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.snapshot.sessionTokens == 42) + #expect(result.inputs.first?.snapshot.projects.isEmpty == true) + #expect(result.inputs.first?.snapshot.sessions.isEmpty == true) + } +} diff --git a/docs/pr2397-proof/README.md b/docs/pr2397-proof/README.md new file mode 100644 index 0000000000..14c40b8fc6 --- /dev/null +++ b/docs/pr2397-proof/README.md @@ -0,0 +1,4 @@ +# #2397 live proof assets + +- `exact-head-refreshing-15baefd7.png` — packaged debug app at git head `15baefd7`, isolated `CFFIXED_USER_HOME`, Usage & Spend pane showing the new **Refreshing** empty-state while validation is pending. +- `historical-*.png` — prior full cache-visible-during-refresh captures (same UX; pre-rebase head). Current-head admission is covered by unit tests on this branch. diff --git a/docs/pr2397-proof/exact-head-refreshing-15baefd7.png b/docs/pr2397-proof/exact-head-refreshing-15baefd7.png new file mode 100644 index 0000000000..e2ff41d446 Binary files /dev/null and b/docs/pr2397-proof/exact-head-refreshing-15baefd7.png differ diff --git a/docs/pr2397-proof/historical-after-validation.png b/docs/pr2397-proof/historical-after-validation.png new file mode 100644 index 0000000000..921b040dc1 Binary files /dev/null and b/docs/pr2397-proof/historical-after-validation.png differ diff --git a/docs/pr2397-proof/historical-cached-rows-during-refresh.png b/docs/pr2397-proof/historical-cached-rows-during-refresh.png new file mode 100644 index 0000000000..dab135111f Binary files /dev/null and b/docs/pr2397-proof/historical-cached-rows-during-refresh.png differ