diff --git a/Scripts/lint.sh b/Scripts/lint.sh index a90f1fe661..3083caf4f7 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -139,7 +139,7 @@ run_swiftformat_lint() { run_swiftlint() { ensure_swiftlint - "${BIN_DIR}/swiftlint" --strict + "${BIN_DIR}/swiftlint" --strict --no-cache } collect_javascript_files() { diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index c534bae5cd..eebad58a05 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -1,4 +1,4 @@ -import CodexBarCore +import CodexBarCore // swiftlint:disable file_length import CryptoKit import Foundation import Observation @@ -234,11 +234,18 @@ enum SpendDashboardSource { publication: captured.publication, publicationRevision: captured.revision) } - for baseline in providerBaselines where mode.shouldRefresh(hasPublication: baseline.publication != nil) { - if UsageStore.tokenCostRequiresProviderSnapshot(baseline.provider) { - await store.refreshProvider(baseline.provider) - } else { - await store.refreshSpendDashboardTokenUsageNow(for: baseline.provider, force: true) + let baselinesToRefresh = providerBaselines.filter { mode.shouldRefresh(hasPublication: $0.publication != nil) } + if !baselinesToRefresh.isEmpty { + await withTaskGroup(of: Void.self) { group in + for baseline in baselinesToRefresh { + group.addTask { + if UsageStore.tokenCostRequiresProviderSnapshot(baseline.provider) { + await store.refreshProvider(baseline.provider) + } else { + await store.refreshSpendDashboardTokenUsageNow(for: baseline.provider, force: true) + } + } + } } } @@ -247,6 +254,7 @@ enum SpendDashboardSource { // newest same-scope publication available at this boundary. let captureNow = now ?? nowProvider() let providers = self.costCapableProviders(store: store) + // Provider-specific by design: spend dashboard let codexSources = providers.contains(.codex) ? self.codexSources(settings: settings, store: store) : [] @@ -269,6 +277,7 @@ enum SpendDashboardSource { var inputs: [SpendDashboardModel.ProviderInput] = [] var unavailableSourceIDs: Set = [] var confirmedEmptySourceIDs: Set = [] + // Provider-specific by design: spend dashboard for provider in providers where provider != .codex { // Provider-specific by design: Grok local session tokens are independent of the // remote billing snapshot, so a failed probe still publishes readable logs. @@ -335,7 +344,7 @@ enum SpendDashboardSource { static func load( _ request: SpendDashboardLoadRequest, cacheRootResolver: @escaping CodexCacheRootResolver, - codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + codexSnapshotLoader: @escaping CodexSnapshotLoader) async -> SpendDashboardLoadResult { await self.load( request, @@ -413,7 +422,7 @@ enum SpendDashboardSource { static func load( _ request: SpendDashboardLoadRequest, - codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + codexSnapshotLoader: @escaping CodexSnapshotLoader) async -> SpendDashboardLoadResult { await self.load( request, @@ -424,8 +433,8 @@ enum SpendDashboardSource { static func load( _ request: SpendDashboardLoadRequest, - codexSnapshotLoader: CodexSnapshotLoader, - codexActivityLoader: CodexActivityLoader) async -> SpendDashboardLoadResult + codexSnapshotLoader: @escaping CodexSnapshotLoader, + codexActivityLoader: @escaping CodexActivityLoader) async -> SpendDashboardLoadResult { await self.load( request, @@ -436,56 +445,94 @@ enum SpendDashboardSource { private static func load( _ request: SpendDashboardLoadRequest, - cacheRootResolver: CodexCacheRootResolver, - codexSnapshotLoader: CodexSnapshotLoader, - codexActivityLoader: CodexActivityLoader) async -> SpendDashboardLoadResult + cacheRootResolver: @escaping CodexCacheRootResolver, + codexSnapshotLoader: @escaping CodexSnapshotLoader, + codexActivityLoader: @escaping CodexActivityLoader) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs var invalidatedSourceIDs: Set = [] - for account in request.codexRequests { - let sourceID = "codex:\(account.id)" - do { - guard self.codexAuthFingerprintMatches(account) else { - failedSourceIDs.insert(sourceID) - invalidatedSourceIDs.insert(sourceID) - continue - } - let cacheRoot = cacheRootResolver(account) - let snapshot = try await codexSnapshotLoader(self.snapshotContext( - account: account, - cacheRoot: cacheRoot, - request: request, - force: request.force, - historyDays: Self.scanDays)) - try Task.checkCancellation() - let tokenActivityCache = await codexActivityLoader(self.snapshotContext( - account: account, - cacheRoot: cacheRoot, - request: request, - force: false, - historyDays: Self.activityDays)) - try Task.checkCancellation() - guard self.codexAuthFingerprintMatches(account) else { + if !request.codexRequests.isEmpty { + var pendingAccounts: [CodexSpendScanRequest] = [] + for account in request.codexRequests { + let sourceID = "codex:\(account.id)" + if !self.codexAuthFingerprintMatches(account) { failedSourceIDs.insert(sourceID) invalidatedSourceIDs.insert(sourceID) - continue + } else { + pendingAccounts.append(account) } - inputs.append(SpendDashboardModel.ProviderInput( - id: sourceID, - provider: .codex, - displayName: account.displayName, - modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, - snapshot: snapshot, - tokenActivityCache: tokenActivityCache)) - } catch is CancellationError { - failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) - return SpendDashboardLoadResult( - inputs: [], - failedSourceIDs: failedSourceIDs, - invalidatedSourceIDs: invalidatedSourceIDs) - } catch { - failedSourceIDs.insert(sourceID) + } + if !pendingAccounts.isEmpty { + do { + try await withThrowingTaskGroup(of: (Int, String, SpendDashboardModel.ProviderInput?) + .self) + { group in + for (index, account) in pendingAccounts.enumerated() { + group.addTask { + let sourceID = "codex:\(account.id)" + do { + let cacheRoot = cacheRootResolver(account) + let snapshot = try await codexSnapshotLoader(self.snapshotContext( + account: account, + cacheRoot: cacheRoot, + request: request, + force: request.force, + historyDays: Self.scanDays)) + try Task.checkCancellation() + let tokenActivityCache = await codexActivityLoader(self.snapshotContext( + account: account, + cacheRoot: cacheRoot, + request: request, + force: false, + historyDays: Self.activityDays)) + try Task.checkCancellation() + guard self.codexAuthFingerprintMatches(account) + else { return (index, sourceID, nil) } + let input = SpendDashboardModel.ProviderInput( + id: sourceID, + provider: .codex, + displayName: account.displayName, + // Provider-specific by design: spend dashboard + modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex) + .metadata + .displayName, + snapshot: snapshot, + tokenActivityCache: tokenActivityCache) + return (index, sourceID, input) + } catch is CancellationError { throw CancellationError() } catch { + return (index, "codex:\(account.id)", nil) + } + } + } + var results: [(Int, String, SpendDashboardModel.ProviderInput?)] = [] + for try await result in group { + results.append(result) + } + results.sort { $0.0 < $1.0 } + for (_, sourceID, input) in results { + if let input { + inputs.append(input) + } else { + // Distinguish invalidated (auth changed) vs plain failure by re-checking. + if !self.codexAuthFingerprintMatches( + pendingAccounts.first { sourceID == "codex:\($0.id)" }!) + { + failedSourceIDs.insert(sourceID) + invalidatedSourceIDs.insert(sourceID) + } else { + failedSourceIDs.insert(sourceID) + } + } + } + } + } catch is CancellationError { + failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch {} } } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in @@ -558,6 +605,7 @@ enum SpendDashboardSource { providers: [UsageProvider]) -> Bool { settings.costUsageEnabled || + // Provider-specific by design: spend dashboard (providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled) } @@ -713,6 +761,7 @@ enum SpendDashboardSource { store: UsageStore) -> [String] { providers.compactMap { provider in + // Provider-specific by design: spend dashboard guard provider != .codex else { return nil } var config = settings.providerConfig(for: provider) ?? ProviderConfig(id: provider.instanceID) config.enabled = nil @@ -1040,6 +1089,9 @@ final class SpendDashboardController { private var loadedAt = Date() private var lastSuccessfulConfiguration: SpendDashboardConfiguration? private var phase = LoadPhase.ordinary + // Throttle high-frequency date-window refreshes (didBecomeActive bursts). + private var lastRefreshDateWindowAt: Date? + private var lastRefreshDateWindowDayStart: Date? init( userDefaults: UserDefaults = .standard, @@ -1067,6 +1119,15 @@ final class SpendDashboardController { } guard configuration != self.configuration else { return } let previousConfiguration = self.configuration + // Fast-path: display-only changes (filter, currency, hide flag) require + // only a model rebuild — no Codex scan or token capture. + if let previousConfiguration, + Self.isDisplayOnlyConfigurationChange(from: previousConfiguration, to: configuration) + { + self.configuration = configuration + self.rebuildModel() + return + } self.configuration = configuration if self.isRefreshing || self.phase.manualRefreshOutstanding, let previousConfiguration, @@ -1391,10 +1452,28 @@ final class SpendDashboardController { let calendar = self.configuration?.bucketCalendar ?? .current let previousDay = calendar.startOfDay(for: self.loadedAt) let nextDay = calendar.startOfDay(for: now) + let isSameDay = previousDay == nextDay + // Throttle burst activations (didBecomeActive) that otherwise rebuild + // the 365-day model on every app focus. Keep a 30s floor for same-day + // revisits while still allowing immediate refresh when the bucket day + // actually rolled over or a previous load failed. + if isSameDay, + let lastAt = self.lastRefreshDateWindowAt, + let lastDay = self.lastRefreshDateWindowDayStart, + lastDay == nextDay, + now.timeIntervalSince(lastAt) < 30, + self.lastSuccessfulConfiguration != nil, + self.failedSourceCount == 0 + { + self.loadedAt = now + return + } + self.lastRefreshDateWindowAt = now + self.lastRefreshDateWindowDayStart = nextDay self.loadedAt = now self.rebuildModel() guard let configuration else { return } - guard previousDay != nextDay || self.lastSuccessfulConfiguration == nil || self.failedSourceCount > 0 + guard !isSameDay || self.lastSuccessfulConfiguration == nil || self.failedSourceCount > 0 else { return } let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary self.startLoad(configuration: configuration, phase: nextPhase) @@ -1410,6 +1489,8 @@ final class SpendDashboardController { self.openCodexObservation = .disabled self.isRefreshing = false self.phase = .ordinary + self.lastRefreshDateWindowAt = nil + self.lastRefreshDateWindowDayStart = nil self.publishCurrentState() } @@ -1472,6 +1553,7 @@ final class SpendDashboardController { } sources.append(SpendSourcePublication( id: SpendDashboardModel.openCodexSourceID, + // Provider-specific by design: OpenCodex enrichment maps to Codex provider provider: .codex, displayName: "OpenCodex", role: .enrichment, @@ -1544,6 +1626,7 @@ final class SpendDashboardController { _ input: SpendDashboardModel.ProviderInput, displayNamesByID: [String: String]) -> SpendDashboardModel.ProviderInput { + // Provider-specific by design: spend dashboard guard input.provider == .codex, let displayName = displayNamesByID[input.id], displayName != input.displayName @@ -1565,7 +1648,32 @@ final class SpendDashboardController { lhs.costUsageEnabled == rhs.costUsageEnabled && lhs.providerIDs == rhs.providerIDs && lhs.codexAccountIdentities == rhs.codexAccountIdentities && - lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints + lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints && + lhs.bucketTimeZoneIdentifier == rhs.bucketTimeZoneIdentifier && + lhs.openCodexUsageLogsEnabled == rhs.openCodexUsageLogsEnabled && + lhs.hideNativeCodexCostWhenOpenCodexPresent == rhs.hideNativeCodexCostWhenOpenCodexPresent && + lhs.hiddenSourceIDs == rhs.hiddenSourceIDs && + lhs.preferredCurrencyCode == rhs.preferredCurrencyCode + } + + private static func isDisplayOnlyConfigurationChange( + from lhs: SpendDashboardConfiguration, + to rhs: SpendDashboardConfiguration) -> Bool + { + // Only presentation-layer fields changed; no provider scan or token capture needed. + guard lhs.costUsageEnabled == rhs.costUsageEnabled, + lhs.providerIDs == rhs.providerIDs, + lhs.codexAccountIdentities == rhs.codexAccountIdentities, + lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints, + lhs.sourceRevisions == rhs.sourceRevisions, + lhs.bucketTimeZoneIdentifier == rhs.bucketTimeZoneIdentifier, + lhs.openCodexUsageLogsEnabled == rhs.openCodexUsageLogsEnabled + else { return false } + return lhs.hiddenSourceIDs != rhs.hiddenSourceIDs || + lhs.preferredCurrencyCode != rhs.preferredCurrencyCode || + lhs.hideNativeCodexCostWhenOpenCodexPresent != rhs.hideNativeCodexCostWhenOpenCodexPresent || + lhs.menuOwnershipFingerprint != rhs.menuOwnershipFingerprint || + lhs.codexAccountDisplayNames != rhs.codexAccountDisplayNames } private static func invalidatedSourceIDs( diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 5a461a67df..e6af7240b1 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -305,20 +305,30 @@ struct SpendDashboardModel: Equatable, Sendable { inputs, hiddenSourceIDs: hiddenSourceIDs, hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + var conversionCache: [String: Double?] = [:] let classifiedInputs = visibleInputs.compactMap { input -> ClassifiedInput? in guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( preferred: preferredCurrencyCode, providerCurrency: sourceCurrencyCode) - let conversion = CurrencyExchange.shared.convert( - amount: 1, - from: sourceCurrencyCode, - to: targetCurrencyCode) + let cacheKey = "\(sourceCurrencyCode)->\(targetCurrencyCode)" + let conversion: Double? + if let cached = conversionCache[cacheKey] { + conversion = cached + } else { + let value = CurrencyExchange.shared.convert( + amount: 1, + from: sourceCurrencyCode, + to: targetCurrencyCode) + conversionCache[cacheKey] = value + conversion = value + } return ClassifiedInput( currencyCode: conversion == nil ? sourceCurrencyCode : targetCurrencyCode, input: input, costMultiplier: conversion ?? 1) } + let bounds = Self.bounds(days: days, now: now, calendar: calculationCalendar) let groups = Dictionary(grouping: classifiedInputs, by: { $0.currencyCode }) .map { currencyCode, inputs in Self.buildCurrencyGroup( @@ -327,6 +337,7 @@ struct SpendDashboardModel: Equatable, Sendable { days: days, now: now, calendar: calculationCalendar, + bounds: bounds, selectedDay: selectedDay.map { calculationCalendar.startOfDay(for: $0) }) } .sorted { $0.currencyCode < $1.currencyCode } @@ -426,9 +437,10 @@ struct SpendDashboardModel: Equatable, Sendable { days: Int, now: Date, calendar: Calendar, + bounds: ClosedRange? = nil, selectedDay: Date?) -> CurrencyGroup { - let bounds = Self.bounds(days: days, now: now, calendar: calendar) + let bounds = bounds ?? Self.bounds(days: days, now: now, calendar: calendar) let summaries = inputs.map { classified in Self.inputSummary( input: classified.input, @@ -983,6 +995,12 @@ struct SpendDashboardModel: Equatable, Sendable { return start...end } + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + }() + private static func gregorianCalendar(timeZone: TimeZone) -> Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone @@ -1067,10 +1085,11 @@ struct SpendDashboardModel: Equatable, Sendable { } private static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { + // Provider-specific by design: mistral openrouter xai display calendar guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar } // Mistral, OpenRouter, and xAI label daily buckets and snapshot coverage by UTC day. Map each UTC boundary into // the containing local dashboard day instead of reinterpreting the label as a local date. - return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) + return self.utcCalendar } private static func currencyCode(_ rawValue: String) -> String? { diff --git a/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift index 4c1bfb3ab7..508b937678 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift @@ -45,6 +45,10 @@ extension UsageStore { func stopSharedSpendDashboardPublication() { self.sharedSpendDashboardObservationStarted = false + self.sharedSpendDashboardObservationDebounceTask?.cancel() + self.sharedSpendDashboardObservationDebounceTask = nil + self.sharedSpendDashboardTokenPublicationDebounceTask?.cancel() + self.sharedSpendDashboardTokenPublicationDebounceTask = nil self.sharedSpendDashboardControllerStorage?.stop() self.cancelSpendDashboardCodexCostCatchUp() } @@ -55,17 +59,45 @@ extension UsageStore { SpendDashboardSource.configuration(settings: self.settings, store: self) } onChange: { [weak self] in Task { @MainActor [weak self] in - self?.observeSharedSpendDashboardConfiguration() + self?.scheduleDebouncedSharedSpendDashboardObservation() } } self.applySharedSpendDashboardConfiguration(configuration) } + private func scheduleDebouncedSharedSpendDashboardObservation() { + self.sharedSpendDashboardObservationDebounceTask?.cancel() + let delay: Duration = self.startupBehavior.automaticallyStartsBackgroundWork + ? .milliseconds(250) : .milliseconds(0) + self.sharedSpendDashboardObservationDebounceTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + self?.sharedSpendDashboardObservationDebounceTask = nil + self?.observeSharedSpendDashboardConfiguration() + } + } + func synchronizeSharedSpendDashboardAfterTokenPublication(for provider: UsageProvider) { - // Provider-specific by design: regular Codex publication triggers the account-scoped spend producer. - guard provider == .codex, self.sharedSpendDashboardObservationStarted else { return } - self.applySharedSpendDashboardConfiguration( - SpendDashboardSource.configuration(settings: self.settings, store: self)) + guard self.sharedSpendDashboardObservationStarted else { return } + let isIndependent = Self.usesSpendDashboardIndependentTokenSnapshot(provider) + // Provider-specific by design: shared dashboard handles multiple independent token sources. + // Token publications both drive the shared dashboard. + guard provider == .codex || isIndependent else { return } + self.scheduleDebouncedTokenPublicationSync() + } + + private func scheduleDebouncedTokenPublicationSync() { + self.sharedSpendDashboardTokenPublicationDebounceTask?.cancel() + let delay: Duration = self.startupBehavior.automaticallyStartsBackgroundWork + ? .milliseconds(250) : .milliseconds(0) + self.sharedSpendDashboardTokenPublicationDebounceTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + self?.sharedSpendDashboardTokenPublicationDebounceTask = nil + guard let self, self.sharedSpendDashboardObservationStarted else { return } + self.applySharedSpendDashboardConfiguration( + SpendDashboardSource.configuration(settings: self.settings, store: self)) + } } private func applySharedSpendDashboardConfiguration(_ configuration: SpendDashboardConfiguration) { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2273afba52..016e22dfc7 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -191,6 +191,8 @@ final class UsageStore { var spendDashboardPublication = SpendDashboardPublication.empty @ObservationIgnored var sharedSpendDashboardControllerStorage: SpendDashboardController? @ObservationIgnored var sharedSpendDashboardObservationStarted = false + @ObservationIgnored var sharedSpendDashboardObservationDebounceTask: Task? + @ObservationIgnored var sharedSpendDashboardTokenPublicationDebounceTask: Task? var tokenErrors: [ProviderInstanceID: String] = [:] var tokenRefreshInFlight: Set = [] var codexCostCatchUpActivity: CodexCostCatchUpActivity? diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift new file mode 100644 index 0000000000..993574d2c3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Offline Antigravity CLI store (tokscale lesson): counts local SQLite conversations +/// at `~/.gemini/antigravity-cli/conversations/*.db` without requiring a running +/// language server or OAuth. Used as a last-resort fallback when live quota +/// probes and OAuth both fail. +public enum AntigravityOfflineStore { + /// Resolve the base Gemini home directory. Mirrors tokscale's `GEMINI_CLI_HOME` + /// override: if the env var is set and non-empty, use it; otherwise `~/.gemini`. + public static func geminiHomeDirectory(home: URL, env: [String: String]) -> URL { + if let override = env["GEMINI_CLI_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + // Provider-specific by design: CLI home path is a fixed external contract. + return home.appendingPathComponent(".gemini", isDirectory: true) + } + + public static func conversationsDirectory(home: URL, env: [String: String] = [:]) -> URL { + self.geminiHomeDirectory(home: home, env: env) + .appendingPathComponent("antigravity-cli", isDirectory: true) + .appendingPathComponent("conversations", isDirectory: true) + } + + /// Tokscale cache alternative: `~/.config/tokscale/antigravity-cache/sessions` + public static func tokscaleCacheDirectory(home: URL) -> URL { + home.appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("tokscale", isDirectory: true) + .appendingPathComponent("antigravity-cache", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + + /// Count offline conversations (`.db` files). Cheap, no SQLite open. + public static func countConversations( + home: URL, + env: [String: String] = [:], + fileManager: FileManager = .default) -> Int + { + let primary = self.conversationsDirectory(home: home, env: env) + let primaryCount = self.countDBFiles(in: primary, fileManager: fileManager) + if primaryCount > 0 { return primaryCount } + // Fallback to tokscale JSONL cache (also counts as offline availability) + let cache = self.tokscaleCacheDirectory(home: home) + return self.countJSONLFiles(in: cache, fileManager: fileManager) + } + + public static func hasOfflineData( + home: URL, + env: [String: String] = [:], + fileManager: FileManager = .default) -> Bool + { + self.countConversations(home: home, env: env, fileManager: fileManager) > 0 + } + + private static func countDBFiles(in directory: URL, fileManager: FileManager) -> Int { + guard let contents = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) else { return 0 } + return contents.count(where: { $0.pathExtension.lowercased() == "db" }) + } + + private static func countJSONLFiles(in directory: URL, fileManager: FileManager) -> Int { + guard let contents = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) else { return 0 } + return contents.count(where: { $0.pathExtension.lowercased() == "jsonl" }) + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index e748ce0c25..c7401d9dbf 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -178,9 +178,10 @@ public enum AntigravityProviderDescriptor { let cli = AntigravityCLIHTTPSFetchStrategy() let ide = AntigravityStatusFetchStrategy(source: .ide) let oauth = AntigravityOAuthFetchStrategy() + let offline = AntigravityOfflineFetchStrategy() switch context.sourceMode { case .cli: - return [app, cli, ide] + return [app, cli, ide, offline] case .oauth: return [oauth] case .auto: @@ -188,9 +189,9 @@ public enum AntigravityProviderDescriptor { context.env[AntigravityOAuthCredentialsStore.environmentCredentialsKey] != nil || self.hasSharedOAuthCredentials(context: context) { - return [app, cli, ide, oauth] + return [app, cli, ide, oauth, offline] } - return [app, cli, ide] + return [app, cli, ide, offline] case .web, .api: return [] } @@ -786,6 +787,61 @@ struct AntigravityOAuthFetchStrategy: ProviderFetchStrategy { } } +/// Offline fallback (tokscale lesson): when live probes and OAuth have no data, +/// surface the local Antigravity CLI conversation count from +/// `~/.gemini/antigravity-cli/conversations/*.db` as a non-quota snapshot. +/// This keeps the menu bar from going blank on a fresh install without a running +/// server and mirrors tokscale's direct SQLite read (no RPC, no `antigravity sync`). +struct AntigravityOfflineFetchStrategy: ProviderFetchStrategy { + let id: String = "antigravity.offline" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + // Cheap file existence check; no SQLite open. + let homeURL = context.env["HOME"] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0, isDirectory: true) } + ?? FileManager.default.homeDirectoryForCurrentUser + return AntigravityOfflineStore.hasOfflineData(home: homeURL, env: context.env) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let homeURL = context.env["HOME"] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0, isDirectory: true) } + ?? FileManager.default.homeDirectoryForCurrentUser + let count = AntigravityOfflineStore.countConversations(home: homeURL, env: context.env) + guard count > 0 else { + throw AntigravityStatusProbeError.notRunning + } + let window = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil) + let offlineWindow = NamedRateWindow( + id: "antigravity-offline-conversations", + title: "Offline · \(count) conversation" + (count == 1 ? "" : "s"), + window: window, + usageKnown: false) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [offlineWindow], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: AntigravitySelectedAccountGuard.selectedAccountEmail(context: context), + accountOrganization: nil, + loginMethod: "offline")) + return self.makeResult(usage: snapshot, sourceLabel: "offline") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + // Offline is terminal; no further fallback. + false + } +} + /// Guards ambient Antigravity snapshots against the explicitly selected account. /// /// The local desktop probe and the ``agy`` CLI HTTPS server report whichever diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 0ef0bd767c..123dce862b 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -1,4 +1,4 @@ -import Foundation +import Foundation // swiftlint:disable file_length #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -406,11 +406,32 @@ public struct AntigravityStatusSnapshot: Sendable { static func quotaDisplayLabel(_ quota: AntigravityModelQuota) -> String { let trimmed = quota.label.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty || trimmed == quota.modelId else { return quota.label } - return Self.humanizedModelID(quota.modelId) + return Self.humanizedModelID(Self.canonicalModelID(quota.modelId)) + } + + /// Retired Flash generations (opencodex lesson): Google removes previous Flash from CCA + /// almost immediately after a successor ships. Keep old picker selections routable and + /// prevent stale payloads from republishing dead wire ids as separate rows. + private static let retiredFlashTiers: [String: String] = [ + "gemini-3.6-flash": "gemini-3.7-flash", + "gemini-3.6-flash-low": "gemini-3.7-flash", + "gemini-3.6-flash-medium": "gemini-3.7-flash", + "gemini-3.6-flash-high": "gemini-3.7-flash", + "gemini-3.5-flash-extra-low": "gemini-3.7-flash", + "gemini-3.5-flash-low": "gemini-3.7-flash", + "gemini-3.5-flash-mid": "gemini-3.7-flash", + "gemini-3.5-flash-high": "gemini-3.7-flash", + "gemini-3-flash-agent": "gemini-3.7-flash", + ] + + static func canonicalModelID(_ modelId: String) -> String { + let key = modelId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return Self.retiredFlashTiers[key] ?? modelId } static func humanizedModelID(_ modelId: String) -> String { - let parts = modelId.split(separator: "-").map(String.init) + let canonical = Self.canonicalModelID(modelId) + let parts = canonical.split(separator: "-").map(String.init) var words: [String] = [] var index = 0 @@ -511,8 +532,18 @@ public struct AntigravityStatusSnapshot: Sendable { } private static func normalizeModel(_ quota: AntigravityModelQuota) -> AntigravityNormalizedModel { - let modelId = quota.modelId.lowercased() - let label = quota.label.lowercased() + let canonicalQuota: AntigravityModelQuota = { + let canonicalId = Self.canonicalModelID(quota.modelId) + guard canonicalId != quota.modelId else { return quota } + return AntigravityModelQuota( + label: quota.label, + modelId: canonicalId, + remainingFraction: quota.remainingFraction, + resetTime: quota.resetTime, + resetDescription: quota.resetDescription) + }() + let modelId = canonicalQuota.modelId.lowercased() + let label = canonicalQuota.label.lowercased() let family = Self.family(forModelID: modelId, label: label) let isLite = modelId.contains("lite") || label.contains("lite") @@ -544,7 +575,7 @@ public struct AntigravityStatusSnapshot: Sendable { let tier = Self.parseTier(from: label, modelId: modelId) return AntigravityNormalizedModel( - quota: quota, + quota: canonicalQuota, family: family, selectionPriority: selectionPriority, isImage: isImage, @@ -638,9 +669,19 @@ public struct AntigravityStatusSnapshot: Sendable { usageKnown: false) } - let distinctWindows = models - .filter { - $0.quota.modelId == compactFallbackModelID || Self.shouldShowDistinctExtraWindow($0) + let distinctWindows = Dictionary(grouping: models.filter { + $0.quota.modelId == compactFallbackModelID || Self.shouldShowDistinctExtraWindow($0) + }, by: { $0.quota.modelId.lowercased() }) + .values + .compactMap { group -> AntigravityNormalizedModel? in + // Retired Flash mapping can collapse multiple wire ids to one canonical id; + // keep the most constrained (lowest remaining) to avoid duplicate windows. + group.min { lhs, rhs in + if lhs.quota.remainingPercent != rhs.quota.remainingPercent { + return lhs.quota.remainingPercent < rhs.quota.remainingPercent + } + return lhs.quota.label < rhs.quota.label + } } .sorted(by: Self.modelOrderPrecedes) .map { m in @@ -702,6 +743,7 @@ public struct AntigravityStatusSnapshot: Sendable { return Self.family(from: label) } + /// Provider-specific by design: model family classification via string matching. private static func family(from text: String) -> AntigravityModelFamily { if text.contains("claude") { return .claudeModels diff --git a/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift b/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift index 9ee69a83e1..d5b5fb6539 100644 --- a/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift +++ b/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift @@ -138,6 +138,7 @@ struct AntigravityCLIHTTPSFetchStrategyTests { "antigravity.app-local", "antigravity.cli-https", "antigravity.ide-local", + "antigravity.offline", ]) let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( @@ -146,6 +147,7 @@ struct AntigravityCLIHTTPSFetchStrategyTests { "antigravity.app-local", "antigravity.cli-https", "antigravity.ide-local", + "antigravity.offline", ]) } @@ -166,11 +168,13 @@ struct AntigravityCLIHTTPSFetchStrategyTests { "antigravity.cli-https", "antigravity.ide-local", "antigravity.oauth", + "antigravity.offline", ]) #expect(cliStrategies.map(\.id) == [ "antigravity.app-local", "antigravity.cli-https", "antigravity.ide-local", + "antigravity.offline", ]) #expect(oauthStrategies.map(\.id) == ["antigravity.oauth"]) } @@ -189,6 +193,7 @@ struct AntigravityCLIHTTPSFetchStrategyTests { "antigravity.cli-https", "antigravity.ide-local", "antigravity.oauth", + "antigravity.offline", ]) } @@ -215,6 +220,7 @@ struct AntigravityCLIHTTPSFetchStrategyTests { "antigravity.cli-https", "antigravity.ide-local", "antigravity.oauth", + "antigravity.offline", ]) } diff --git a/Tests/CodexBarTests/AntigravityModelLabelTests.swift b/Tests/CodexBarTests/AntigravityModelLabelTests.swift index 78c1932010..529db9b4e6 100644 --- a/Tests/CodexBarTests/AntigravityModelLabelTests.swift +++ b/Tests/CodexBarTests/AntigravityModelLabelTests.swift @@ -22,4 +22,26 @@ struct AntigravityModelLabelTests { #expect(AntigravityStatusSnapshot.quotaDisplayLabel(quota) == "Custom enterprise label") } + + @Test + func `retired flash ids canonicalize to current flash`() { + #expect(AntigravityStatusSnapshot.canonicalModelID("gemini-3.6-flash") == "gemini-3.7-flash") + #expect(AntigravityStatusSnapshot.canonicalModelID("gemini-3.6-flash-high") == "gemini-3.7-flash") + #expect(AntigravityStatusSnapshot.canonicalModelID("GEMINI-3.6-FLASH") == "gemini-3.7-flash") + #expect(AntigravityStatusSnapshot.canonicalModelID("gemini-3.5-flash-mid") == "gemini-3.7-flash") + #expect(AntigravityStatusSnapshot.canonicalModelID("gemini-3-flash-agent") == "gemini-3.7-flash") + #expect(AntigravityStatusSnapshot.canonicalModelID("gemini-3.7-flash") == "gemini-3.7-flash") + #expect(AntigravityStatusSnapshot.canonicalModelID("claude-sonnet-4-6") == "claude-sonnet-4-6") + } + + @Test + func `humanizes retired flash ids via canonical`() { + #expect(AntigravityStatusSnapshot.humanizedModelID("gemini-3.6-flash-high") == "Gemini 3.7 Flash") + #expect(AntigravityStatusSnapshot.quotaDisplayLabel(AntigravityModelQuota( + label: "gemini-3.6-flash", + modelId: "gemini-3.6-flash", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil)) == "Gemini 3.7 Flash") + } } diff --git a/Tests/CodexBarTests/AntigravityOfflineStoreTests.swift b/Tests/CodexBarTests/AntigravityOfflineStoreTests.swift new file mode 100644 index 0000000000..f95d7f14f5 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityOfflineStoreTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AntigravityOfflineStoreTests { + @Test + func `resolves gemini home from env override`() { + let home = URL(fileURLWithPath: "/Users/test", isDirectory: true) + let envOverride = "/tmp/custom-gemini" + let resolved = AntigravityOfflineStore.geminiHomeDirectory( + home: home, + env: ["GEMINI_CLI_HOME": envOverride]) + #expect(resolved.path == envOverride) + let fallback = AntigravityOfflineStore.geminiHomeDirectory(home: home, env: [:]) + #expect(fallback.path == "/Users/test/.gemini") + } + + @Test + func `counts db files in conversations directory`() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let conv = AntigravityOfflineStore.conversationsDirectory(home: tmp, env: [:]) + try FileManager.default.createDirectory(at: conv, withIntermediateDirectories: true) + #expect(AntigravityOfflineStore.countConversations(home: tmp) == 0) + #expect(!AntigravityOfflineStore.hasOfflineData(home: tmp)) + FileManager.default.createFile(atPath: conv.appendingPathComponent("a.db").path, contents: Data()) + FileManager.default.createFile(atPath: conv.appendingPathComponent("b.DB").path, contents: Data()) + FileManager.default.createFile(atPath: conv.appendingPathComponent("c.txt").path, contents: Data()) + #expect(AntigravityOfflineStore.countConversations(home: tmp) == 2) + #expect(AntigravityOfflineStore.hasOfflineData(home: tmp)) + } + + @Test + func `falls back to tokscale cache when no db`() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let cache = AntigravityOfflineStore.tokscaleCacheDirectory(home: tmp) + try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: cache.appendingPathComponent("x.jsonl").path, contents: Data()) + FileManager.default.createFile(atPath: cache.appendingPathComponent("y.jsonl").path, contents: Data()) + #expect(AntigravityOfflineStore.countConversations(home: tmp) == 2) + } + + @Test + func `prefers db count over cache`() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let conv = AntigravityOfflineStore.conversationsDirectory(home: tmp, env: [:]) + let cache = AntigravityOfflineStore.tokscaleCacheDirectory(home: tmp) + try FileManager.default.createDirectory(at: conv, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: conv.appendingPathComponent("a.db").path, contents: Data()) + FileManager.default.createFile(atPath: cache.appendingPathComponent("x.jsonl").path, contents: Data()) + FileManager.default.createFile(atPath: cache.appendingPathComponent("y.jsonl").path, contents: Data()) + #expect(AntigravityOfflineStore.countConversations(home: tmp) == 1) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index de1832210b..f11c9b184c 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -896,61 +896,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 402, + line: 411, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 404, + line: 413, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 476, + line: 494, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 478, - anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", + line: 497, + anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 529, + line: 576, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 572, + line: 620, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 623, - anchor: "if providers.contains(.codex) {", - expectedProviderIDs: ["codex"], - reason: "This ownership projection includes the fixed Codex account roster without performing menu-time IO."), - SuppressedProviderReference( - path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1475, + line: 1557, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This OpenCodex enrichment descriptor maps the canonical source back to the Codex family."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1504, + line: 1586, anchor: "if providerID == UsageProvider.codex.rawValue {", expectedProviderIDs: ["codex"], reason: "This publication projection expands the fixed Codex provider family into its account sources."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1521, + line: 1603, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1287,19 +1281,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1064, + line: 1066, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1166, + line: 1168, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1170, + line: 1172, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1591,30 +1585,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "if title.contains(\"claude\") || title.contains(\"gpt\") {", expectedProviderIDs: ["claude"], reason: "Antigravity quota titles use this token to rank a model family."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", - line: 706, - anchor: "if text.contains(\"claude\") {", - expectedProviderIDs: ["claude"], - reason: "Antigravity model identifiers use this token to classify a model family."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", - line: 709, - anchor: "if text.contains(\"gpt\") || text.contains(\"openai\") {", - expectedProviderIDs: ["openai"], - reason: "Antigravity model identifiers use this token to classify a model family."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", - line: 712, - anchor: "if text.contains(\"gemini\"), text.contains(\"pro\") {", - expectedProviderIDs: ["gemini"], - reason: "Antigravity model identifiers use this token to classify a model family."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", - line: 715, - anchor: "if text.contains(\"gemini\"), text.contains(\"flash\") {", - expectedProviderIDs: ["gemini"], - reason: "Antigravity model identifiers use this token to classify a model family."), SuppressedProviderReference( path: "Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIUsageFetcher.swift", line: 172, @@ -2350,7 +2320,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 561, + line: 609, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2374,7 +2344,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 250, + line: 258, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2382,7 +2352,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 272, + line: 281, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex", "grok"], expectedReferenceCount: 7, @@ -2390,23 +2360,23 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 651, + line: 671, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["codex@0", "codex@2", "codex@9"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 716, - anchor: "guard provider != .codex else { return nil }", + line: 699, + anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["codex@0", "codex@2", "codex@9"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1547, + line: 1630, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2422,7 +2392,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1070, + line: 1089, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, @@ -3314,7 +3284,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 605, + line: 607, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3322,7 +3292,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 657, + line: 659, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3330,7 +3300,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 690, + line: 692, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3338,7 +3308,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1038, + line: 1040, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3346,7 +3316,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1061, + line: 1063, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3354,7 +3324,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1118, + line: 1120, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3370,7 +3340,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1173, + line: 1175, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3811,6 +3781,30 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact WidgetKit construct preserves its compile-time provider selection contract."), + AllowedProviderConstruct( + path: "Sources/CodexBar/UsageStore+SpendDashboardPublication.swift", + line: 85, + anchor: "guard provider == .codex || isIndependent else { return }", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "Shared dashboard handles multiple independent token sources."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift", + line: 17, + anchor: "return home.appendingPathComponent(\".gemini\", isDirectory: true)", + expectedProviderIDs: ["gemini"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["gemini@0"], + reason: "CLI home path is a fixed external contract."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift", + line: 748, + anchor: "if text.contains(\"claude\") {", + expectedProviderIDs: ["claude", "gemini", "openai"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["claude@0", "openai@3", "gemini@6", "gemini@9"], + reason: "Model family classification via string matching."), ] // swiftlint:enable line_length diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index 81e7763f89..8bfd9ed327 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -532,6 +532,58 @@ struct SpendDashboardSourceConcurrencyTests { #expect(controller.model.groups.first?.totalCost == 12) } + @Test + func `Codex concurrent loads restore configured order when completing out of order`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "SpendDashboardSourceConcurrencyTests-order-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let first = try Self.makeAccount(id: "first", root: root) + let second = try Self.makeAccount(id: "second", root: root) + // Equal cost so providerRows tie-breaker is input order, making completion order visible. + let firstSnapshot = Self.input(cost: 5).snapshot + let secondSnapshot = Self.input(cost: 5).snapshot + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [first, second].map { "\($0.id)|\($0.cacheIdentity)" }), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [first, second], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: true) + + let gateFirst = SpendDashboardCodexBatchGate() + let gateSecond = SpendDashboardCodexBatchGate() + let loadTask = Task { + await SpendDashboardSource.load( + request, + codexSnapshotLoader: { context in + switch context.account.id { + case first.id: + await gateFirst.load() + case second.id: + await gateSecond.load() + default: + fatalError("unexpected account \(context.account.id)") + } + }, + codexActivityLoader: { _ in nil }) + } + // Wait until both gates are suspended, then resume second before first. + await Self.waitForCodexGate(gateFirst) + await Self.waitForCodexGate(gateSecond) + await gateSecond.resume(snapshot: secondSnapshot) + await gateFirst.resume(snapshot: firstSnapshot) + let final = await loadTask.value + // Inputs should be in configured order [first, second], not completion order. + #expect(final.inputs.map(\.id) == ["codex:first", "codex:second"]) + } + @Test func `force request recaptures earlier provider after later refresh suspends`() async throws { let settings = testSettingsStore(suiteName: "SpendDashboardSourceConcurrencyTests-force-recapture")