diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 4dac7fa3b1..7ae8500582 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -142,17 +142,11 @@ func spendDashboardModelHistoryPresentation( struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore - @State private var controller: SpendDashboardController @State private var isVisible = false init(settings: SettingsStore, store: UsageStore) { self.settings = settings 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) - })) } var body: some View { @@ -194,7 +188,6 @@ struct SpendDashboardPane: View { } .onDisappear { self.isVisible = false - self.controller.stop() } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in self.controller.refreshDateWindow() @@ -211,6 +204,10 @@ struct SpendDashboardPane: View { SpendDashboardSource.configuration(settings: self.settings, store: self.store) } + private var controller: SpendDashboardController { + self.store.sharedSpendDashboardController() + } + private var header: some View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 4) { diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 429c91a4c4..c6f454e665 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -15,6 +15,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { let openCodexUsageLogsEnabled: Bool let hideNativeCodexCostWhenOpenCodexPresent: Bool let hiddenSourceIDs: [String] + let menuOwnershipFingerprint: String init( costUsageEnabled: Bool, @@ -27,7 +28,8 @@ struct SpendDashboardConfiguration: Equatable, Sendable { bucketTimeZoneIdentifier: String = "", openCodexUsageLogsEnabled: Bool = false, hideNativeCodexCostWhenOpenCodexPresent: Bool = false, - hiddenSourceIDs: [String] = []) + hiddenSourceIDs: [String] = [], + menuOwnershipFingerprint: String = "") { self.costUsageEnabled = costUsageEnabled self.preferredCurrencyCode = preferredCurrencyCode @@ -40,6 +42,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { self.openCodexUsageLogsEnabled = openCodexUsageLogsEnabled self.hideNativeCodexCostWhenOpenCodexPresent = hideNativeCodexCostWhenOpenCodexPresent self.hiddenSourceIDs = hiddenSourceIDs + self.menuOwnershipFingerprint = menuOwnershipFingerprint } var bucketCalendar: Calendar { @@ -57,6 +60,12 @@ struct CodexSpendScanRequest: Equatable, Sendable { let cacheIdentity: String } +struct CodexSpendSourceDescriptor: Sendable { + let identity: String + let displayName: String + let request: CodexSpendScanRequest? +} + enum SpendDashboardRequestBuildMode: Equatable, Sendable { case refreshMissing case forceRefresh @@ -104,18 +113,28 @@ struct SpendDashboardLoadRequest: Sendable { } struct SpendDashboardLoadResult: Sendable { + enum OpenCodexObservation: Sendable, Equatable { + case disabled + case available + case confirmedEmpty + case unavailable + } + let inputs: [SpendDashboardModel.ProviderInput] let failedSourceIDs: Set let invalidatedSourceIDs: Set + let openCodexObservation: OpenCodexObservation init( inputs: [SpendDashboardModel.ProviderInput], failedSourceIDs: Set, - invalidatedSourceIDs: Set = []) + invalidatedSourceIDs: Set = [], + openCodexObservation: OpenCodexObservation = .disabled) { self.inputs = inputs self.failedSourceIDs = failedSourceIDs self.invalidatedSourceIDs = invalidatedSourceIDs + self.openCodexObservation = openCodexObservation } var failedSourceCount: Int { @@ -151,14 +170,14 @@ enum SpendDashboardSource { static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { store.discardSpendDashboardTokenPublicationsIfCostUsageDisabled() let providers = self.costCapableProviders(store: store) - let codexRequests = providers.contains(.codex) - ? self.codexRequests(settings: settings, store: store) + let codexSources = providers.contains(.codex) + ? self.codexSources(settings: settings, store: store) : [] return self.configuration( settings: settings, store: store, providers: providers, - codexRequests: codexRequests) + codexSources: codexSources) } @MainActor @@ -166,14 +185,14 @@ enum SpendDashboardSource { settings: SettingsStore, store: UsageStore, providers: [UsageProvider], - codexRequests: [CodexSpendScanRequest]) -> SpendDashboardConfiguration + codexSources: [CodexSpendSourceDescriptor]) -> SpendDashboardConfiguration { SpendDashboardConfiguration( - costUsageEnabled: settings.costUsageEnabled, + costUsageEnabled: self.spendCollectionEnabled(settings: settings, providers: providers), preferredCurrencyCode: settings.preferredCurrencyCode, providerIDs: providers.map(\.rawValue), - codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, - codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + codexAccountIdentities: codexSources.map(\.identity), + codexAccountDisplayNames: self.codexDisplayNamesByID(codexSources), sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -182,7 +201,10 @@ enum SpendDashboardSource { bucketTimeZoneIdentifier: settings.costUsageBucketTimeZoneIdentifier, openCodexUsageLogsEnabled: settings.openCodexUsageLogsEnabled, hideNativeCodexCostWhenOpenCodexPresent: settings.hideNativeCodexCostWhenOpenCodexPresent, - hiddenSourceIDs: settings.spendDashboardHiddenSourceIDs) + hiddenSourceIDs: settings.spendDashboardHiddenSourceIDs, + menuOwnershipFingerprint: self.menuOwnershipFingerprint( + settings: settings, + providers: providers)) } @MainActor @@ -194,7 +216,8 @@ enum SpendDashboardSource { nowProvider: @escaping @Sendable () -> Date = { Date() }) async -> SpendDashboardLoadRequest { store.discardSpendDashboardTokenPublicationsIfCostUsageDisabled() - guard settings.costUsageEnabled else { + let initialProviders = self.costCapableProviders(store: store) + guard self.spendCollectionEnabled(settings: settings, providers: initialProviders) else { return SpendDashboardLoadRequest( configuration: self.configuration(settings: settings, store: store), capturedInputs: [], @@ -204,7 +227,6 @@ enum SpendDashboardSource { force: mode.forcesLoader) } - let initialProviders = self.costCapableProviders(store: store) let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in let captured = self.capturedTokenPublication(store: store, provider: provider) return ( @@ -225,14 +247,15 @@ enum SpendDashboardSource { // newest same-scope publication available at this boundary. let captureNow = now ?? nowProvider() let providers = self.costCapableProviders(store: store) - let codexRequests = providers.contains(.codex) - ? self.codexRequests(settings: settings, store: store) + let codexSources = providers.contains(.codex) + ? self.codexSources(settings: settings, store: store) : [] + let codexRequests = codexSources.compactMap(\.request) let configuration = self.configuration( settings: settings, store: store, providers: providers, - codexRequests: codexRequests) + codexSources: codexSources) guard configuration.costUsageEnabled else { return SpendDashboardLoadRequest( configuration: configuration, @@ -364,9 +387,11 @@ enum SpendDashboardSource { modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, snapshot: snapshot)) } + let openCodex = self.mergingOpenCodexInputsWithObservation(inputs, request: request) return SpendDashboardLoadResult( - inputs: self.mergingOpenCodexInputs(inputs, request: request), - failedSourceIDs: request.unavailableSourceIDs) + inputs: openCodex.inputs, + failedSourceIDs: request.unavailableSourceIDs, + openCodexObservation: openCodex.observation) } static func load( @@ -454,10 +479,12 @@ enum SpendDashboardSource { failedSourceIDs.formUnion(lateInvalidatedSourceIDs) invalidatedSourceIDs.formUnion(lateInvalidatedSourceIDs) inputs.removeAll { lateInvalidatedSourceIDs.contains($0.id) } + let openCodex = self.mergingOpenCodexInputsWithObservation(inputs, request: request) return SpendDashboardLoadResult( - inputs: self.mergingOpenCodexInputs(inputs, request: request), + inputs: openCodex.inputs, failedSourceIDs: failedSourceIDs, - invalidatedSourceIDs: invalidatedSourceIDs) + invalidatedSourceIDs: invalidatedSourceIDs, + openCodexObservation: openCodex.observation) } private static func snapshotContext( @@ -478,99 +505,6 @@ enum SpendDashboardSource { calendar: request.configuration.bucketCalendar) } - static func mergingOpenCodexInputs( - _ inputs: [SpendDashboardModel.ProviderInput], - request: SpendDashboardLoadRequest) -> [SpendDashboardModel.ProviderInput] - { - guard request.configuration.openCodexUsageLogsEnabled, - !request.configuration.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) - else { return inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } } - let environment = ProcessInfo.processInfo.environment - guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return inputs } - let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) - guard let entries = try? store.loadEntries(logURL: logURL), - !entries.isEmpty - else { return inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } } - - let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( - entries: entries, - now: request.now, - historyDays: Self.scanDays, - calendar: request.configuration.bucketCalendar) - var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } - - for (provider, supplement) in snapshots { - guard Self.shouldPublishOpenCodexSnapshot(supplement) else { continue } - // Provider-specific by design: hide-native keeps OpenCodex on its own Codex row - // so visibleInputs can drop overlapping native Codex snapshots. - if provider == .codex, - request.configuration.hideNativeCodexCostWhenOpenCodexPresent - { - merged.append(SpendDashboardModel.ProviderInput( - provider: provider, - displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, - snapshot: supplement, - sourceKind: .openCodex)) - continue - } - if let index = Self.preferredMergeIndex(for: provider, in: merged) { - merged[index] = Self.mergeProviderInput( - merged[index], - supplement: supplement, - request: request) - } else { - merged.append(SpendDashboardModel.ProviderInput( - provider: provider, - displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, - snapshot: supplement, - sourceKind: .openCodex)) - } - } - return merged - } - - static func preferredMergeIndex( - for provider: UsageProvider, - in inputs: [SpendDashboardModel.ProviderInput]) -> Int? - { - // Provider-specific by design: OpenCodex fan-out merges into the native Codex subscription row when exactly one - // exists. - if provider == .codex { - let codexIndices = inputs.indices.filter { inputs[$0].provider == .codex } - guard codexIndices.count == 1 else { return nil } - return codexIndices.first - } - let matching = inputs.indices.filter { inputs[$0].provider == provider } - guard matching.count == 1 else { - return inputs.firstIndex(where: { $0.provider == provider && $0.sourceKind == .native }) - } - return matching.first - } - - private static func mergeProviderInput( - _ input: SpendDashboardModel.ProviderInput, - supplement: CostUsageTokenSnapshot, - request: SpendDashboardLoadRequest) -> SpendDashboardModel.ProviderInput - { - SpendDashboardModel.ProviderInput( - id: input.id, - provider: input.provider, - displayName: input.displayName, - modelProviderName: input.modelProviderName, - snapshot: OpenCodexUsageFanOut.mergeSnapshots( - input.snapshot, - supplement, - now: request.now, - historyDays: self.scanDays, - calendar: request.configuration.bucketCalendar), - tokenActivityCache: input.tokenActivityCache, - sourceKind: input.sourceKind) - } - - static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { - !snapshot.daily.isEmpty || !snapshot.sessions.isEmpty - } - private static func loadCodexSnapshot( _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot { @@ -597,15 +531,29 @@ enum SpendDashboardSource { @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledFirstPartyProvidersForDisplay().filter { - ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsTokenCost + store.settings.isCostUsageEffectivelyEnabled(for: $0) } } + @MainActor + private static func spendCollectionEnabled( + settings: SettingsStore, + providers: [UsageProvider]) -> Bool + { + settings.costUsageEnabled || + (providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled) + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { + self.codexSources(settings: settings, store: store).compactMap(\.request) + } + + @MainActor + static func codexSources(settings: SettingsStore, store: UsageStore) -> [CodexSpendSourceDescriptor] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts let providerName = store.metadata(for: .codex).displayName - return accounts.enumerated().compactMap { index, account in + return accounts.enumerated().map { index, account in let homePath: String? = switch account.selectionSource { case .liveSystem: settings.liveSystemCodexHomePath(forActiveSource: .liveSystem) @@ -614,13 +562,65 @@ enum SpendDashboardSource { case let .profileHome(path): settings.profileCodexHomePath(forActiveSource: .profileHome(path: path)) } - return self.codexRequest( + let request = self.codexRequest( account: account, homePath: homePath, + providerName: providerName, + index: index, + count: accounts.count, + bucketTimeZoneIdentifier: settings.costUsageBucketTimeZoneIdentifier) + let cacheIdentity = request?.cacheIdentity ?? self.sha256([ + account.id, + self.sourceToken(account.selectionSource), + CodexHomeScope.normalizedHomePath(homePath) ?? "unavailable-home", + CodexAuthFingerprint.normalize(account.authFingerprint) ?? "missing-auth", + settings.costUsageBucketTimeZoneIdentifier, + ].joined(separator: "\u{0}")) + let displayName = request?.displayName ?? self.codexDisplayName( providerName: providerName, index: index, count: accounts.count) + return CodexSpendSourceDescriptor( + identity: "\(account.id)|\(cacheIdentity)", + displayName: displayName, + request: request) + } + } + + @MainActor + static func currentMenuOwnershipFingerprint(settings: SettingsStore, store: UsageStore) -> String { + self.menuOwnershipFingerprint( + settings: settings, + providers: self.costCapableProviders(store: store)) + } + + @MainActor + private static func menuOwnershipFingerprint( + settings: SettingsStore, + providers: [UsageProvider]) -> String + { + var parts = providers.map { provider in + "\(provider.rawValue):\(settings.providerConfigRevision(for: provider))" + } + parts.append("bucket:\(settings.costUsageBucketTimeZoneIdentifier)") + if providers.contains(.codex) { + parts.append(contentsOf: settings.codexVisibleAccountProjection.visibleAccounts.map { account in + let homePath: String? = switch account.selectionSource { + case .liveSystem: + settings.liveSystemCodexHomePath(forActiveSource: .liveSystem) + case let .managedAccount(id): + settings.managedCodexRemoteHomePath(forActiveSource: .managedAccount(id: id)) + case let .profileHome(path): + settings.profileCodexHomePath(forActiveSource: .profileHome(path: path)) + } + return [ + account.id, + self.sourceToken(account.selectionSource), + CodexHomeScope.normalizedHomePath(homePath) ?? "unavailable-home", + ].joined(separator: "|") + }) } + return self.sha256(parts.joined(separator: "\u{0}")) } @MainActor @@ -629,15 +629,17 @@ enum SpendDashboardSource { settings: SettingsStore, store: UsageStore) -> [String] { - var revisions = ["settings:\(settings.configRevision)"] + var revisions: [String] = [] + // Provider-specific by design: regular Codex publication is a refresh trigger; account caches remain authority. if providers.contains(.codex) { revisions.append("codex-dashboard:\(store.spendDashboardCodexCostCatchUpRevision)") + revisions.append("codex-current:\(store.tokenSnapshotPublicationRevision(for: .codex))") } if settings.openCodexUsageLogsEnabled { revisions.append("opencodex:\(settings.costUsageSettingsRevision)") } revisions += providers.compactMap { provider in - // Provider-specific by design: Codex revisions come from catch-up, not captured token publications. + // Provider-specific by design: Codex inputs come from account caches, not provider-global snapshots. guard provider != .codex else { return nil } let current: CurrentProviderConfigTokenPublication? = if UsageStore.usesSpendDashboardIndependentTokenSnapshot(provider) { @@ -711,7 +713,7 @@ enum SpendDashboardSource { .map { store.tokenAccountSnapshotCacheKey(provider: provider, account: $0) } ?? "ambient" return "\(provider.rawValue):\(self.sha256(encoded)):\(self.sha256(scope)):" + - self.sha256(accountOwnership) + self.sha256("\(accountOwnership)\u{0}\(settings.costUsageBucketTimeZoneIdentifier)") } } @@ -759,7 +761,8 @@ enum SpendDashboardSource { homePath: String?, providerName: String, index: Int, - count: Int) -> CodexSpendScanRequest? + count: Int, + bucketTimeZoneIdentifier: String = "") -> CodexSpendScanRequest? { guard let homePath = CodexHomeScope.normalizedHomePath(homePath) else { return nil } var isDirectory: ObjCBool = false @@ -776,10 +779,9 @@ enum SpendDashboardSource { sourceToken, homePath, authFingerprint ?? "missing-auth", + bucketTimeZoneIdentifier, ].joined(separator: "\u{0}")) - let displayName = count == 1 - ? providerName - : "\(providerName) · #\(codexBarLocalizedInteger(index + 1))" + let displayName = self.codexDisplayName(providerName: providerName, index: index, count: count) return CodexSpendScanRequest( id: account.id, displayName: displayName, @@ -790,9 +792,16 @@ enum SpendDashboardSource { cacheIdentity: cacheIdentity) } - private static func codexDisplayNamesByID(_ requests: [CodexSpendScanRequest]) -> [String: String] { - requests.reduce(into: [:]) { result, request in - result["codex:\(request.id)"] = request.displayName + private static func codexDisplayName(providerName: String, index: Int, count: Int) -> String { + count == 1 + ? providerName + : "\(providerName) · #\(codexBarLocalizedInteger(index + 1))" + } + + private static func codexDisplayNamesByID(_ sources: [CodexSpendSourceDescriptor]) -> [String: String] { + sources.reduce(into: [:]) { result, source in + guard let separator = source.identity.lastIndex(of: "|") else { return } + result["codex:\(source.identity[.. SpendDashboardLoadRequest typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult typealias CachedLoader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult + typealias PublicationHandler = @MainActor @Sendable (SpendDashboardPublication) -> Void private enum ReconciliationObservation: Sendable { case confirmedEmpty @@ -985,6 +995,7 @@ final class SpendDashboardController { } private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) + private(set) var publication = SpendDashboardPublication.empty private(set) var isRefreshing = false private(set) var failedSourceCount = 0 private(set) var generation: UInt64 = 0 @@ -998,6 +1009,7 @@ final class SpendDashboardController { private let cachedLoader: CachedLoader? private let loader: Loader private let nowProvider: @Sendable () -> Date + private let publicationHandler: PublicationHandler? private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() @@ -1009,13 +1021,15 @@ final class SpendDashboardController { requestBuilder: @escaping RequestBuilder, cachedLoader: CachedLoader? = nil, loader: @escaping Loader = SpendDashboardSource.load, - nowProvider: @escaping @Sendable () -> Date = { Date() }) + nowProvider: @escaping @Sendable () -> Date = { Date() }, + publicationHandler: PublicationHandler? = nil) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder self.cachedLoader = cachedLoader self.loader = loader self.nowProvider = nowProvider + self.publicationHandler = publicationHandler self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) } @@ -1036,6 +1050,7 @@ final class SpendDashboardController { // Same-owner revision churn during an in-flight load adopts the newer // configuration and lets the current pass finish once; handleBuiltRequest // reconciles any remaining drift after apply. + self.publishCurrentState() return } let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary @@ -1060,6 +1075,8 @@ final class SpendDashboardController { if !invalidatedSourceIDs.isEmpty { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } + self.failedSourceIDs.subtract(invalidatedSourceIDs) + self.confirmedEmptySourceIDs.subtract(invalidatedSourceIDs) self.failedSourceCount = 0 self.rebuildModel() } @@ -1074,6 +1091,9 @@ final class SpendDashboardController { !configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled else { self.loadedInputs = [] + self.failedSourceIDs = [] + self.confirmedEmptySourceIDs = [] + self.openCodexObservation = .disabled self.failedSourceCount = 0 self.isRefreshing = false self.lastSuccessfulConfiguration = configuration @@ -1084,6 +1104,7 @@ final class SpendDashboardController { } self.isRefreshing = true + self.publishCurrentState() self.loadTask = Task { [weak self] in guard let self else { return } if shouldPrimeCachedCodex, let cachedLoader = self.cachedLoader { @@ -1119,8 +1140,11 @@ final class SpendDashboardController { let cachedIDs = Set(result.inputs.map(\.id)) self.loadedInputs.removeAll { cachedIDs.contains($0.id) } self.loadedInputs.append(contentsOf: result.inputs) + self.loadedInputs = Self.stableUniqueInputs(self.loadedInputs) self.loadedAt = request.now self.failedSourceCount = result.failedSourceCount + self.failedSourceIDs = result.failedSourceIDs + self.openCodexObservation = result.openCodexObservation self.refreshRetainedCodexDisplayNames(request.configuration.codexAccountDisplayNames) self.rebuildModel() } @@ -1259,10 +1283,13 @@ final class SpendDashboardController { }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }) } self.configuration = request.configuration - self.loadedInputs = nextInputs + self.loadedInputs = Self.stableUniqueInputs(nextInputs) self.loadedAt = request.now self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount + self.failedSourceIDs = result.failedSourceIDs + self.confirmedEmptySourceIDs = confirmedEmptySourceIDs + self.openCodexObservation = result.openCodexObservation self.isRefreshing = false self.phase = .ordinary self.loadTask = nil @@ -1301,11 +1328,15 @@ final class SpendDashboardController { inputs.append(input) capturedIDs.insert(input.id) } + let openCodex = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + inputs, + request: outcome.request) return ReconciledOutcome( result: SpendDashboardLoadResult( - inputs: SpendDashboardSource.mergingOpenCodexInputs(inputs, request: outcome.request), + inputs: openCodex.inputs, failedSourceIDs: forceFailed.union(barrierFailed), - invalidatedSourceIDs: invalidated), + invalidatedSourceIDs: invalidated, + openCodexObservation: openCodex.observation), confirmedEmptySourceIDs: outcome.confirmedEmptySourceIDs) } @@ -1319,7 +1350,7 @@ final class SpendDashboardController { guard days != self.selectedDays else { return } self.selectedDays = days self.userDefaults.set(days, forKey: Self.daysDefaultsKey) - self.rebuildModel() + self.rebuildModel(publish: false) } func selectDay(_ day: Date?) { @@ -1327,7 +1358,7 @@ final class SpendDashboardController { let normalized = day.map { calendar.startOfDay(for: $0) } guard normalized != self.selectedDay else { return } self.selectedDay = normalized - self.rebuildModel() + self.rebuildModel(publish: false) } func refreshDateWindow(now: Date? = nil) { @@ -1348,11 +1379,16 @@ final class SpendDashboardController { self.loadTask?.cancel() self.loadTask = nil self.configuration = nil + self.loadedInputs = [] + self.failedSourceIDs = [] + self.confirmedEmptySourceIDs = [] + self.openCodexObservation = .disabled self.isRefreshing = false self.phase = .ordinary + self.publishCurrentState() } - private func rebuildModel() { + private func rebuildModel(publish: Bool = true) { let configuration = self.configuration self.model = SpendDashboardModel.build( inputs: self.loadedInputs, @@ -1363,6 +1399,107 @@ final class SpendDashboardController { hiddenSourceIDs: Set(configuration?.hiddenSourceIDs ?? []), hideNativeCodexWhenOpenCodexPresent: configuration?.hideNativeCodexCostWhenOpenCodexPresent ?? false, selectedDay: self.selectedDay) + if publish { + self.publishCurrentState() + } + } + + @ObservationIgnored private var failedSourceIDs: Set = [] + @ObservationIgnored private var confirmedEmptySourceIDs: Set = [] + @ObservationIgnored private var openCodexObservation: SpendDashboardLoadResult.OpenCodexObservation = .disabled + @ObservationIgnored private var publicationRevision: UInt64 = 0 + + private func publishCurrentState() { + self.publicationRevision &+= 1 + let inputByID = Dictionary(uniqueKeysWithValues: self.loadedInputs.map { ($0.id, $0) }) + let sourceIDs = self.orderedSourceIDs(inputByID: inputByID) + var sources = sourceIDs.compactMap { sourceID -> SpendSourcePublication? in + let input = inputByID[sourceID] + guard let provider = input?.provider ?? self.provider(for: sourceID) else { return nil } + let state: SpendSourcePublication.State = if input != nil { + self.failedSourceIDs.contains(sourceID) ? .staleLastKnown : .available + } else if self.confirmedEmptySourceIDs.contains(sourceID) { + .confirmedEmpty + } else if self.isRefreshing { + .loading + } else { + .unavailable + } + return SpendSourcePublication( + id: sourceID, + provider: provider, + displayName: input?.displayName ?? self.displayName(for: sourceID, provider: provider), + role: input?.sourceKind == .openCodex ? .enrichment : .subscription, + state: state) + } + if self.configuration?.openCodexUsageLogsEnabled == true, + !sources.contains(where: { $0.id == SpendDashboardModel.openCodexSourceID }), + self.configuration?.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) != true + { + let state: SpendSourcePublication.State = if self.isRefreshing { + .loading + } else { + switch self.openCodexObservation { + case .available: .available + case .confirmedEmpty: .confirmedEmpty + case .unavailable, .disabled: .unavailable + } + } + sources.append(SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: state)) + } + let publication = SpendDashboardPublication( + revision: self.publicationRevision, + generation: self.generation, + configuration: self.configuration, + loadedAt: self.loadedAt, + isRefreshing: self.isRefreshing, + inputs: self.loadedInputs, + sources: sources) + self.publication = publication + self.publicationHandler?(publication) + } + + private static func stableUniqueInputs( + _ inputs: [SpendDashboardModel.ProviderInput]) -> [SpendDashboardModel.ProviderInput] + { + var seen: Set = [] + return inputs.filter { seen.insert($0.id).inserted } + } + + private func orderedSourceIDs( + inputByID: [String: SpendDashboardModel.ProviderInput]) -> [String] + { + var ids: [String] = [] + for providerID in self.configuration?.providerIDs ?? [] { + if providerID == UsageProvider.codex.rawValue { + ids.append(contentsOf: (self.configuration?.codexAccountIdentities ?? []).compactMap { identity in + guard let separator = identity.lastIndex(of: "|") else { return nil } + return "codex:\(identity[.. = [] + return ids.filter { seen.insert($0).inserted } + } + + private func provider(for sourceID: String) -> UsageProvider? { + if sourceID.hasPrefix("codex:") { return .codex } + return UsageProvider(rawValue: sourceID) + } + + private func displayName(for sourceID: String, provider: UsageProvider) -> String { + self.configuration?.codexAccountDisplayNames[sourceID] + ?? ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName } private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c7392a0d67..b06a2b24da 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -347,9 +347,13 @@ struct SpendDashboardModel: Equatable, Sendable { hideNativeCodexWhenOpenCodexPresent: Bool) -> [ProviderInput] { var filtered = inputs.filter { !hiddenSourceIDs.contains($0.id) } - let hasOpenCodex = filtered.contains { $0.sourceKind == .openCodex } + // Provider-specific by design: only a canonical OpenCodex Codex row may replace native Codex rows. + let hasOpenCodex = filtered.contains { + $0.id == Self.openCodexSourceID && + $0.provider == .codex && + $0.sourceKind == .openCodex + } if hideNativeCodexWhenOpenCodexPresent, hasOpenCodex { - // Provider-specific by design: the OpenCodex source can explicitly replace native Codex rows. filtered.removeAll { $0.sourceKind == .native && $0.provider == .codex } } return filtered diff --git a/Sources/CodexBar/SpendDashboardPublication.swift b/Sources/CodexBar/SpendDashboardPublication.swift new file mode 100644 index 0000000000..087f27218b --- /dev/null +++ b/Sources/CodexBar/SpendDashboardPublication.swift @@ -0,0 +1,194 @@ +import CodexBarCore +import Foundation + +struct SpendSourcePublication: Sendable, Equatable { + enum Role: Sendable, Equatable { + case subscription + case enrichment + } + + enum State: Sendable, Equatable { + case loading + case available + case confirmedEmpty + case unavailable + case staleLastKnown + } + + let id: String + let provider: UsageProvider? + let displayName: String + let role: Role + let state: State +} + +struct SpendDashboardPublication: Sendable { + let revision: UInt64 + let generation: UInt64 + let configuration: SpendDashboardConfiguration? + let loadedAt: Date + let isRefreshing: Bool + let inputs: [SpendDashboardModel.ProviderInput] + let sources: [SpendSourcePublication] + + static let empty = SpendDashboardPublication( + revision: 0, + generation: 0, + configuration: nil, + loadedAt: .distantPast, + isRefreshing: false, + inputs: [], + sources: []) + + func model( + requestedDays: Int, + now: Date, + calendar: Calendar, + preferredCurrencyCode: String, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false, + selectedDay: Date? = nil, + providerScope: Set? = nil) -> SpendDashboardModel + { + let staleSourceIDs = Set(self.sources.compactMap { source in + source.state == .staleLastKnown ? source.id : nil + }) + let inputs = self.inputs.filter { input in + (providerScope?.contains(input.provider) ?? true) && !staleSourceIDs.contains(input.id) + } + return SpendDashboardModel.build( + inputs: inputs, + requestedDays: requestedDays, + now: now, + calendar: calendar, + preferredCurrencyCode: preferredCurrencyCode, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent, + selectedDay: selectedDay) + } + + func subscriptionCount( + providerScope: Set, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int + { + providerScope.reduce(into: 0) { count, provider in + let rosterSources = self.subscriptionRosterSources(for: provider) + let coverageSources = self.coverageSources( + for: provider, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + if rosterSources.isEmpty, coverageSources.isEmpty { + count += hiddenSourceIDs.contains(provider.rawValue) ? 0 : 1 + } else { + count += coverageSources.count + } + } + } + + func knownCostSubscriptionCount( + model: SpendDashboardModel, + providerScope: Set, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int + { + let knownInputIDs = Set(model.groups.flatMap(\.providers).compactMap { row in + row.totalCost == nil ? nil : row.id + }) + return self.knownSubscriptionCount( + knownInputIDs: knownInputIDs, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + } + + func knownTokenSubscriptionCount( + model: SpendDashboardModel, + providerScope: Set, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int + { + let knownInputIDs = Set(model.groups.flatMap(\.providers).compactMap { row in + row.totalTokens == nil ? nil : row.id + }) + return self.knownSubscriptionCount( + knownInputIDs: knownInputIDs, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + } + + private func knownSubscriptionCount( + knownInputIDs: Set, + providerScope: Set, + hiddenSourceIDs: Set, + hideNativeCodexWhenOpenCodexPresent: Bool) -> Int + { + providerScope.reduce(into: 0) { count, provider in + count += self.coverageSources( + for: provider, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + .count { source in + source.state == .confirmedEmpty || + (source.state == .available && knownInputIDs.contains(source.id)) + } + } + } + + private func subscriptionRosterSources(for provider: UsageProvider) -> [SpendSourcePublication] { + self.sources.filter { $0.provider == provider && $0.role == .subscription } + } + + private func coverageSources( + for provider: UsageProvider, + hiddenSourceIDs: Set, + hideNativeCodexWhenOpenCodexPresent: Bool) -> [SpendSourcePublication] + { + let rosterSources = self.subscriptionRosterSources(for: provider) + .filter { !hiddenSourceIDs.contains($0.id) } + // Provider-specific by design: OpenCodex replaces Codex coverage only with a canonical Codex payload. + guard provider == .codex else { return rosterSources } + let visibleOpenCodexInputIDs: Set = Set(self.inputs.compactMap { input -> String? in + guard input.provider == .codex, + input.sourceKind == .openCodex, + !hiddenSourceIDs.contains(input.id) + else { return nil } + return input.id + }) + let inputBackedEnrichmentSources = self.sources.filter { + $0.provider == .codex && + $0.role == .enrichment && + visibleOpenCodexInputIDs.contains($0.id) + } + let canonicalReplacement = inputBackedEnrichmentSources.first { + $0.id == SpendDashboardModel.openCodexSourceID + } + if hideNativeCodexWhenOpenCodexPresent, + let canonicalReplacement, + canonicalReplacement.state == SpendSourcePublication.State.available + { + return [canonicalReplacement] + } + if rosterSources.isEmpty, !inputBackedEnrichmentSources.isEmpty { + return inputBackedEnrichmentSources + } + // Provider-specific by design: only canonical Codex enrichment can replace Codex subscription coverage. + guard self.subscriptionRosterSources(for: provider).isEmpty, + !hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID), + let openCodexObservation = self.sources.first(where: { + $0.id == SpendDashboardModel.openCodexSourceID && + $0.provider == .codex && + $0.role == .enrichment + }) + else { return rosterSources } + let hasCodexReplacementInput = self.inputs.contains { + $0.id == SpendDashboardModel.openCodexSourceID && + $0.provider == .codex && + $0.sourceKind == .openCodex + } + return hasCodexReplacementInput || openCodexObservation.state == .confirmedEmpty + ? [openCodexObservation] + : rosterSources + } +} diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift new file mode 100644 index 0000000000..b4567bb4e7 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardSource { + static func mergingOpenCodexInputs( + _ inputs: [SpendDashboardModel.ProviderInput], + request: SpendDashboardLoadRequest) -> [SpendDashboardModel.ProviderInput] + { + self.mergingOpenCodexInputsWithObservation(inputs, request: request).inputs + } + + static func mergingOpenCodexInputsWithObservation( + _ inputs: [SpendDashboardModel.ProviderInput], + request: SpendDashboardLoadRequest, + environment: [String: String] = ProcessInfo.processInfo.environment, + entryLoader: ((URL) throws -> [OpenCodexUsageEntry])? = nil) -> ( + inputs: [SpendDashboardModel.ProviderInput], + observation: SpendDashboardLoadResult.OpenCodexObservation) + { + guard request.configuration.openCodexUsageLogsEnabled, + !request.configuration.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) + else { + return ( + inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, + .disabled) + } + guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) + } + let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) + let entries: [OpenCodexUsageEntry] + do { + entries = try entryLoader?(logURL) ?? store.loadEntries(logURL: logURL) + } catch { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) + } + guard !entries.isEmpty else { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .confirmedEmpty) + } + + let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, + now: request.now, + historyDays: Self.scanDays, + calendar: request.configuration.bucketCalendar) + var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } + var published = false + + for (provider, supplement) in snapshots { + guard Self.shouldPublishOpenCodexSnapshot(supplement) else { continue } + published = true + // Provider-specific by design: hide-native keeps OpenCodex on its own Codex row + // so visibleInputs can drop overlapping native Codex snapshots. + if provider == .codex, + request.configuration.hideNativeCodexCostWhenOpenCodexPresent + { + merged.append(SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: provider, + displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, + snapshot: supplement, + sourceKind: .openCodex)) + continue + } + if let index = Self.preferredMergeIndex(for: provider, in: merged) { + merged[index] = Self.mergeProviderInput( + merged[index], + supplement: supplement, + request: request) + } else { + merged.append(SpendDashboardModel.ProviderInput( + provider: provider, + displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, + snapshot: supplement, + sourceKind: .openCodex)) + } + } + return (merged, published ? .available : .confirmedEmpty) + } + + static func preferredMergeIndex( + for provider: UsageProvider, + in inputs: [SpendDashboardModel.ProviderInput]) -> Int? + { + // Provider-specific by design: OpenCodex fan-out merges into the native Codex subscription row when exactly one + // exists. + if provider == .codex { + let codexIndices = inputs.indices.filter { inputs[$0].provider == .codex } + guard codexIndices.count == 1 else { return nil } + return codexIndices.first + } + let matching = inputs.indices.filter { inputs[$0].provider == provider } + guard matching.count == 1 else { + return inputs.firstIndex(where: { $0.provider == provider && $0.sourceKind == .native }) + } + return matching.first + } + + private static func mergeProviderInput( + _ input: SpendDashboardModel.ProviderInput, + supplement: CostUsageTokenSnapshot, + request: SpendDashboardLoadRequest) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + snapshot: OpenCodexUsageFanOut.mergeSnapshots( + input.snapshot, + supplement, + now: request.now, + historyDays: self.scanDays, + calendar: request.configuration.bucketCalendar), + tokenActivityCache: input.tokenActivityCache, + sourceKind: input.sourceKind) + } + + static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { + !snapshot.daily.isEmpty || !snapshot.sessions.isEmpty + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index dcfb182466..f23df67253 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -577,11 +577,18 @@ extension StatusItemController { let t0 = CACurrentMediaTime() defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } - let spendModel = self.overviewSpendDashboardModel(providers: providerScopes.spend) - if !spendModel.groups.isEmpty { + let spendProviders = providerScopes.spend + let spendModel = self.overviewSpendDashboardModel(providers: spendProviders) + let spendProviderCount = self.overviewSpendSubscriptionCount(providers: spendProviders) + if spendProviderCount > 0 { + let knownCounts = self.overviewSpendKnownSubscriptionCounts( + providers: spendProviders, + model: spendModel) let spendSummary = OverviewSpendSummary( model: spendModel, - providerCount: providerScopes.spend.count) + providerCount: spendProviderCount, + knownCostProviderCount: knownCounts.cost, + knownTokenProviderCount: knownCounts.tokens) let summaryItem = self.makeMenuCardItem( OverviewSpendSummaryCardView( summary: spendSummary, diff --git a/Sources/CodexBar/StatusItemController+OverviewSpend.swift b/Sources/CodexBar/StatusItemController+OverviewSpend.swift index 2c80586564..c34015549d 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSpend.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -11,21 +11,34 @@ struct OverviewSpendSummary: Equatable { let provenanceText: String let isPartial: Bool - init(model: SpendDashboardModel, providerCount: Int) { + init( + model: SpendDashboardModel, + providerCount: Int, + knownCostProviderCount: Int? = nil, + knownTokenProviderCount: Int? = nil) + { let includedProviders = model.groups.flatMap(\.providers) let providerCount = max(max(0, providerCount), includedProviders.count) let pricedProviderCount = includedProviders.count { $0.totalCost != nil } let tokenProviderCount = includedProviders.count { $0.totalTokens != nil } - let isPartial = pricedProviderCount > 0 && pricedProviderCount < providerCount + let resolvedKnownCostProviderCount = knownCostProviderCount.map { + min(providerCount, max(pricedProviderCount, $0)) + } + let isPartial = pricedProviderCount > 0 && + (resolvedKnownCostProviderCount ?? pricedProviderCount) < providerCount self.isPartial = isPartial - self.primarySpendText = model.groups.isEmpty - ? L("Spend unavailable") - : model.groups.map { group in + if model.groups.isEmpty { + self.primarySpendText = providerCount > 0 && resolvedKnownCostProviderCount == providerCount + ? L("No usage yet") + : L("Spend unavailable") + } else { + self.primarySpendText = model.groups.map { group in let text = spendDashboardGroupCostText(group) guard isPartial, group.totalCost != nil, !text.hasPrefix("~") else { return text } return "~\(text)" }.joined(separator: " · ") + } self.providerCoverageText = L( "%d of %d subscriptions have spend", pricedProviderCount, @@ -34,11 +47,18 @@ struct OverviewSpendSummary: Equatable { let tokens = Self.safeTokenSum(model.groups.compactMap(\.totalTokens)) self.tokenText = tokens.map { let value = ShareStatsFormatting.compactCount($0) - let isPartial = tokenProviderCount < providerCount + let resolvedKnownTokenProviderCount = knownTokenProviderCount.map { + min(providerCount, max(tokenProviderCount, $0)) + } + let isPartial = if let resolvedKnownTokenProviderCount { + resolvedKnownTokenProviderCount < providerCount + } else { + tokenProviderCount < providerCount + } return L("%@ tokens", isPartial ? "~\(value)" : value) } - let coveredDays = includedProviders.count < providerCount + let coveredDays = (resolvedKnownCostProviderCount ?? includedProviders.count) < providerCount ? 0 : model.groups.map(\.coveredDayCount).min() ?? 0 self.historyCoverageText = spendDashboardCoverageText( @@ -123,10 +143,73 @@ struct OverviewSpendSummaryCardView: View { } extension StatusItemController { + func overviewSpendSubscriptionCount(providers: [UsageProvider]) -> Int { + let providerScope = Set(providers) + let publication = self.store.spendDashboardPublication + guard let configuration = publication.configuration, + configuration.menuOwnershipFingerprint == SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: self.settings, + store: self.store) + else { + return providerScope.count + } + return publication.subscriptionCount( + providerScope: providerScope, + hiddenSourceIDs: Set(self.settings.spendDashboardHiddenSourceIDs), + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent) + } + + func overviewSpendKnownSubscriptionCounts( + providers: [UsageProvider], + model: SpendDashboardModel) -> (cost: Int, tokens: Int) + { + let providerScope = Set(providers) + let publication = self.store.spendDashboardPublication + guard let configuration = publication.configuration, + configuration.menuOwnershipFingerprint == SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: self.settings, + store: self.store) + else { return (0, 0) } + let hiddenSourceIDs = Set(self.settings.spendDashboardHiddenSourceIDs) + return ( + publication.knownCostSubscriptionCount( + model: model, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent), + publication.knownTokenSubscriptionCount( + model: model, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent)) + } + func overviewSpendDashboardModel( providers: [UsageProvider], now: Date = Date()) -> SpendDashboardModel { + let publication = self.store.spendDashboardPublication + if let configuration = publication.configuration { + guard configuration.menuOwnershipFingerprint == SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: self.settings, + store: self.store) + else { + return SpendDashboardModel.build( + inputs: [], + requestedDays: self.settings.costUsageHistoryDays, + now: now, + calendar: self.settings.costUsageBucketCalendar, + preferredCurrencyCode: self.settings.preferredCurrencyCode) + } + return publication.model( + requestedDays: self.settings.costUsageHistoryDays, + now: now, + calendar: self.settings.costUsageBucketCalendar, + preferredCurrencyCode: self.settings.preferredCurrencyCode, + hiddenSourceIDs: Set(self.settings.spendDashboardHiddenSourceIDs), + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent, + providerScope: Set(providers)) + } let inputs = providers.compactMap { provider -> SpendDashboardModel.ProviderInput? in guard let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot else { return nil diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index e6085be31c..394e0554d6 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -35,6 +35,7 @@ extension StatusItemController { self.manualRefreshTasks.removeAll() self.store.cancelForcedRefreshEnrichment() self.store.cancelRequiredRefresh() + self.store.stopSharedSpendDashboardPublication() self.menuCardRefreshMonitor.resetManualRefresh() self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = nil diff --git a/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift new file mode 100644 index 0000000000..4c1bfb3ab7 --- /dev/null +++ b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Observation + +@MainActor +extension UsageStore { + func sharedSpendDashboardController() -> SpendDashboardController { + if let controller = self.sharedSpendDashboardControllerStorage { + return controller + } + let controller = SpendDashboardController( + requestBuilder: { [weak self] mode in + guard let self else { + return SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(), + force: false) + } + return await SpendDashboardSource.makeRequest( + settings: self.settings, + store: self, + mode: mode) + }, + cachedLoader: { request in + await SpendDashboardSource.loadCached(request) + }, + publicationHandler: { [weak self] publication in + self?.spendDashboardPublication = publication + }) + self.sharedSpendDashboardControllerStorage = controller + return controller + } + + func startSharedSpendDashboardPublication() { + guard !self.sharedSpendDashboardObservationStarted else { return } + self.sharedSpendDashboardObservationStarted = true + self.observeSharedSpendDashboardConfiguration() + } + + func stopSharedSpendDashboardPublication() { + self.sharedSpendDashboardObservationStarted = false + self.sharedSpendDashboardControllerStorage?.stop() + self.cancelSpendDashboardCodexCostCatchUp() + } + + private func observeSharedSpendDashboardConfiguration() { + guard self.sharedSpendDashboardObservationStarted else { return } + let configuration = withObservationTracking { + SpendDashboardSource.configuration(settings: self.settings, store: self) + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.observeSharedSpendDashboardConfiguration() + } + } + self.applySharedSpendDashboardConfiguration(configuration) + } + + 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)) + } + + private func applySharedSpendDashboardConfiguration(_ configuration: SpendDashboardConfiguration) { + // Provider-specific by design: Codex's multi-account 365-day scanner is the shared source producer. + let codexRequests = configuration.providerIDs.contains(UsageProvider.codex.rawValue) + ? SpendDashboardSource.codexRequests(settings: self.settings, store: self) + : [] + self.synchronizeSpendDashboardCodexCostCatchUp(accounts: codexRequests) + self.sharedSpendDashboardController().update(configuration: configuration) + } +} diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 9fcf20e433..eb2864b089 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -111,8 +111,7 @@ extension UsageStore { publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) else { return nil } return CurrentProviderConfigTokenPublication( - snapshot: publication.snapshot, - publicationRevision: publication.publicationRevision) + snapshot: publication.snapshot, publicationRevision: publication.publicationRevision) } func tokenSnapshotPublicationRevision(for provider: UsageProvider) -> UInt64 { @@ -136,6 +135,7 @@ extension UsageStore { publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + self.synchronizeSharedSpendDashboardAfterTokenPublication(for: provider) } func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 299c767018..2273afba52 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -59,6 +59,7 @@ extension UsageStore { _ = self.statuses _ = self.tokenSnapshotPublications _ = self.spendDashboardTokenPublications + _ = self.spendDashboardPublication.revision _ = self.historicalPaceRevision return 0 } @@ -187,6 +188,9 @@ final class UsageStore { var tokenSnapshotPublicationRevisions: [ProviderInstanceID: UInt64] = [:] var spendDashboardTokenPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:] var spendDashboardTokenPublicationRevisions: [ProviderInstanceID: UInt64] = [:] + var spendDashboardPublication = SpendDashboardPublication.empty + @ObservationIgnored var sharedSpendDashboardControllerStorage: SpendDashboardController? + @ObservationIgnored var sharedSpendDashboardObservationStarted = false var tokenErrors: [ProviderInstanceID: String] = [:] var tokenRefreshInFlight: Set = [] var codexCostCatchUpActivity: CodexCostCatchUpActivity? @@ -539,6 +543,7 @@ final class UsageStore { loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.hydrateCachedTokenSnapshots() + self.startSharedSpendDashboardPublication() self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 04ace16921..265f655b8d 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -896,40 +896,64 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 362, + line: 385, 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: 364, + line: 387, 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: 434, + line: 459, 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: 436, + line: 461, 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: 578, + line: 512, 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: 607, + line: 555, 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: 606, + 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: 1450, + 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: 1479, + 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: 1496, + anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", + expectedProviderIDs: ["codex"], + reason: "This publication projection maps stable Codex account source IDs back to their provider family."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+CodexStackedMenu.swift", line: 26, @@ -962,7 +986,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The memory-pressure debug fixture installs its synthetic entry in the Codex cache slot."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1126, + line: 1133, anchor: "controller.refreshOpenMenuIfStillVisible(menu, provider: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1263,19 +1287,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1059, + line: 1064, 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: 1161, + line: 1166, 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: 1165, + line: 1170, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2181,7 +2205,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 340, + line: 337, anchor: "self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2189,7 +2213,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 499, + line: 496, anchor: ".count { $0.provider == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2286,15 +2310,23 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 154, - anchor: "let codexRequests = providers.contains(.codex)", + line: 544, + anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "This exact shared construct preserves the provider-owned local ledger when global scanning is off."), + AllowedProviderConstruct( + path: "Sources/CodexBar/SpendDashboardController.swift", + line: 173, + anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], 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: 208, + line: 230, anchor: "let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2302,15 +2334,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 228, - anchor: "let codexRequests = providers.contains(.codex)", + line: 250, + anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], 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: 249, + line: 272, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2318,15 +2350,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 633, + line: 634, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@8"], + 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: 697, + line: 699, anchor: "guard provider != .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2334,7 +2366,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: 1385, + line: 1522, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2350,7 +2382,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: 1066, + line: 1070, anchor: "guard provider == .mistral || provider == .openrouter else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter"], expectedReferenceCount: 2, @@ -2470,7 +2502,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1159, + line: 1166, anchor: "return .provider((self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex).instanceID)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2478,7 +2510,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1172, + line: 1179, anchor: "return self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3238,7 +3270,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 600, + line: 605, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3246,7 +3278,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 652, + line: 657, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3254,7 +3286,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 685, + line: 690, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3262,7 +3294,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1033, + line: 1038, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3270,7 +3302,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1056, + line: 1061, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3278,7 +3310,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1113, + line: 1118, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3294,7 +3326,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1168, + line: 1173, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index e78b5554c9..2fc04609e3 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -836,6 +836,14 @@ struct SpendDashboardModelTests { index: 1, count: 2)) #expect(changedRequest.cacheIdentity != request.cacheIdentity) + let rebucketedRequest = try #require(SpendDashboardSource.codexRequest( + account: account, + homePath: request.homePath, + providerName: "Codex", + index: 1, + count: 2, + bucketTimeZoneIdentifier: "Pacific/Kiritimati")) + #expect(rebucketedRequest.cacheIdentity != request.cacheIdentity) let authData = Data("{\"tokens\":\"synthetic\"}".utf8) try authData.write(to: CodexAuthFingerprint.authFileURL(homePath: home.path)) diff --git a/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift b/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift index ffebe8f12c..84416eea66 100644 --- a/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift @@ -6,6 +6,42 @@ import Testing @MainActor @Suite(.serialized) struct SpendDashboardOpenCodexSourceTests { + @Test + func `OpenCodex publication distinguishes unavailable and confirmed empty`() { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [], + codexAccountIdentities: [], + openCodexUsageLogsEnabled: true) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_787_079_600), + force: false) + + let unavailable = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["TESTING_LIBRARY_VERSION": "1"]) + #expect(unavailable.observation == .unavailable) + + let confirmedEmpty = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["OPENCODEX_HOME": "/tmp/opencodex-publication-test"], + entryLoader: { _ in [] }) + #expect(confirmedEmpty.observation == .confirmedEmpty) + + let failed = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["OPENCODEX_HOME": "/tmp/opencodex-publication-test"], + entryLoader: { _ in throw CocoaError(.fileReadCorruptFile) }) + #expect(failed.observation == .unavailable) + } + @Test func `OpenCodex-only configuration still starts a dashboard load`() async { let gate = SpendDashboardLoaderGate() diff --git a/Tests/CodexBarTests/SpendDashboardPublicationTests.swift b/Tests/CodexBarTests/SpendDashboardPublicationTests.swift new file mode 100644 index 0000000000..6fddd23f4b --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardPublicationTests.swift @@ -0,0 +1,821 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardPublicationTests { + @Test + func `shared source observation follows regular Codex publication and bucket ownership`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-source-observation") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + settings.costUsageBucketTimeZoneIdentifier = "UTC" + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let initial = SpendDashboardSource.configuration(settings: settings, store: store) + store.startSharedSpendDashboardPublication() + defer { store.stopSharedSpendDashboardPublication() } + await Self.waitUntil { + store.spendDashboardPublication.configuration?.sourceRevisions == initial.sourceRevisions + } + + store._setTokenSnapshotForTesting( + Self.input(id: "codex", provider: .codex, cost: 1).snapshot, + provider: .codex) + let afterRegularCodexPublication = SpendDashboardSource.configuration(settings: settings, store: store) + await Self.waitUntil { + store.spendDashboardPublication.configuration?.sourceRevisions == + afterRegularCodexPublication.sourceRevisions + } + + #expect(afterRegularCodexPublication.sourceRevisions != initial.sourceRevisions) + #expect(store.spendDashboardPublication.configuration?.sourceRevisions == + afterRegularCodexPublication.sourceRevisions) + + settings.costUsageBucketTimeZoneIdentifier = "Pacific/Kiritimati" + let rebucketed = SpendDashboardSource.configuration(settings: settings, store: store) + await Self.waitUntil { + store.spendDashboardPublication.configuration?.menuOwnershipFingerprint == + rebucketed.menuOwnershipFingerprint + } + + #expect(rebucketed.menuOwnershipFingerprint != afterRegularCodexPublication.menuOwnershipFingerprint) + #expect(rebucketed.sourceOwnershipFingerprints != afterRegularCodexPublication.sourceOwnershipFingerprints) + #expect(store.spendDashboardPublication.configuration?.menuOwnershipFingerprint == + rebucketed.menuOwnershipFingerprint) + } + + @Test + func `shared publication starts and stops in-flight Codex dashboard catch-up`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-codex-catch-up") + settings.costUsageEnabled = true + let metadata = try #require(ProviderRegistry.shared.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + let missingLiveHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try Self.writeCodexAuthFile(homeURL: profileHome) + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + settings.updateProviderConfig(provider: .codex) { config in + config.codexProfileHomePaths = [profileHome.path] + config.codexActiveSource = .profileHome(path: profileHome.path) + } + defer { + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: profileHome) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + var statusLoadCount = 0 + store._test_spendDashboardCodexCostCatchUpStatusOverride = { _ in + statusLoadCount += 1 + return CostUsageFetcher.CodexScanCatchUpStatus( + pending: true, + progressKey: "pending", + processedBytes: 1, + totalBytes: 2, + completedFiles: 0, + totalFiles: 1) + } + store._test_spendDashboardCodexCostCatchUpSleepOverride = { _ in + try await Task.sleep(for: .seconds(60)) + } + store._test_spendDashboardCodexCostCatchUpResourceStateOverride = { + (.battery, true, .serious) + } + + store.startSharedSpendDashboardPublication() + await Self.waitUntil { + statusLoadCount > 0 && store.spendDashboardCodexCostCatchUpTask != nil + } + store.stopSharedSpendDashboardPublication() + + #expect(statusLoadCount > 0) + #expect(store.spendDashboardCodexCostCatchUpTask == nil) + #expect(store.spendDashboardCodexCostCatchUpActivity == nil) + } + + @Test + func `usage store owns one shared controller and mirrors its publication`() { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-shared-owner") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let first = store.sharedSpendDashboardController() + let second = store.sharedSpendDashboardController() + + #expect(first === second) + + first.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: [])) + + #expect(store.spendDashboardPublication.revision > 0) + #expect(store.spendDashboardPublication.configuration?.costUsageEnabled == false) + } + + @Test + func `controller atomically publishes canonical inputs and source truth states`() async { + let fixture = Self.fixture() + let controller = SpendDashboardController( + requestBuilder: { _ in fixture.request }, + loader: { _ in fixture.result }) + + controller.update(configuration: fixture.request.configuration) + await Self.waitUntil { !controller.isRefreshing } + + let publication = controller.publication + let sources = Dictionary(uniqueKeysWithValues: publication.sources.map { ($0.id, $0) }) + let inputs = Dictionary(uniqueKeysWithValues: publication.inputs.map { ($0.id, $0) }) + + #expect(Set(sources.keys) == ["openai", "claude", "gemini", "codex:first", "codex:second"]) + #expect(sources["openai"]?.state == .available) + #expect(inputs["openai"]?.id == "openai") + #expect(sources["claude"]?.state == .confirmedEmpty) + #expect(inputs["claude"]?.id == nil) + #expect(sources["gemini"]?.state == .unavailable) + #expect(inputs["gemini"]?.id == nil) + #expect(sources["codex:first"]?.state == .available) + #expect(inputs["codex:first"]?.id == "codex:first") + #expect(sources["codex:second"]?.state == .available) + #expect(inputs["codex:second"]?.id == "codex:second") + #expect(publication.subscriptionCount(providerScope: [.codex, .openai, .claude, .gemini]) == 5) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex, .openai, .claude, .gemini]) == 4) + } + + @Test + func `profile-home path containing pipe preserves full account identity`() async { + let pipeContainingPath = "profile:/Users/test|data|home" + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["\(pipeContainingPath)|cache-identity"]) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Self.now, + force: false) + let expectedID = "codex:\(pipeContainingPath)" + let result = SpendDashboardLoadResult( + inputs: [Self.input(id: expectedID, provider: .codex, cost: 4)], + failedSourceIDs: []) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in result }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + + let sourceIDs = Set(controller.publication.sources.map(\.id)) + #expect(sourceIDs == [expectedID]) + #expect(controller.publication.subscriptionCount(providerScope: [.codex]) == 1) + #expect(controller.publication.inputs.first?.id == expectedID) + } + + @Test + func `failed refresh publishes retained input as stale last known`() async throws { + let initialConfiguration = Self.configuration(revision: "claude:first") + let replacementConfiguration = Self.configuration(revision: "claude:second") + let script = SpendDashboardPublicationScript( + requests: [ + Self.request(configuration: initialConfiguration), + Self.request(configuration: replacementConfiguration, unavailableSourceIDs: ["claude"]), + ], + results: [ + SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 3)], + failedSourceIDs: []), + SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["claude"]), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await script.nextRequest(mode: mode) }, + loader: { request in await script.nextResult(request: request) }) + + controller.update(configuration: initialConfiguration) + await Self.waitUntil { !controller.isRefreshing } + controller.update(configuration: replacementConfiguration) + await Self.waitUntil { !controller.isRefreshing && controller.generation == 2 } + + let source = try #require(controller.publication.sources.first { $0.id == "claude" }) + #expect(source.state == .staleLastKnown) + #expect(controller.publication.inputs.first { $0.id == "claude" }?.snapshot.last30DaysCostUSD == 3) + let overview = controller.publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + #expect(overview.groups.isEmpty) + } + + @Test + func `visible Codex source without a loadable input remains unavailable`() async throws { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["readable|cache-a", "unreadable|cache-b"]) + let request = Self.request(configuration: configuration) + let result = SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:readable", provider: .codex, cost: 2)], + failedSourceIDs: []) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in result }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + + let unreadable = try #require(controller.publication.sources.first { $0.id == "codex:unreadable" }) + #expect(unreadable.state == .unavailable) + } + + @Test + func `confirmed empty subscription completes the subtotal without adding spend`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [Self.input(id: "openai", provider: .openai, cost: 7)], + sources: [ + SpendSourcePublication( + id: "openai", + provider: .openai, + displayName: "OpenAI", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "claude", + provider: .claude, + displayName: "Claude", + role: .subscription, + state: .confirmedEmpty), + ]) + let scope: Set = [.openai, .claude] + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: scope) + let summary = OverviewSpendSummary( + model: model, + providerCount: publication.subscriptionCount(providerScope: scope), + knownCostProviderCount: publication.knownCostSubscriptionCount(model: model, providerScope: scope), + knownTokenProviderCount: publication.knownTokenSubscriptionCount(model: model, providerScope: scope)) + + #expect(summary.providerCoverageText == "1 of 2 subscriptions have spend") + #expect(!summary.isPartial) + #expect(summary.primarySpendText == "$7.00") + } + + @Test + func `available unpriced source keeps cost subtotal partial`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [ + Self.input(id: "openai", provider: .openai, cost: 7), + Self.input(id: "claude", provider: .claude, cost: nil), + ], + sources: [ + SpendSourcePublication( + id: "openai", + provider: .openai, + displayName: "OpenAI", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "claude", + provider: .claude, + displayName: "Claude", + role: .subscription, + state: .available), + ]) + let scope: Set = [.openai, .claude] + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: scope) + let summary = OverviewSpendSummary( + model: model, + providerCount: publication.subscriptionCount(providerScope: scope), + knownCostProviderCount: publication.knownCostSubscriptionCount(model: model, providerScope: scope), + knownTokenProviderCount: publication.knownTokenSubscriptionCount(model: model, providerScope: scope)) + + #expect(summary.isPartial) + #expect(summary.primarySpendText == "~$7.00") + } + + @Test + func `hiding every account source leaves no phantom provider denominator`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [], + sources: [ + SpendSourcePublication( + id: "codex:first", + provider: .codex, + displayName: "Codex 1", + role: .subscription, + state: .unavailable), + SpendSourcePublication( + id: "codex:second", + provider: .codex, + displayName: "Codex 2", + role: .subscription, + state: .unavailable), + ]) + + #expect(publication.subscriptionCount( + providerScope: [.codex], + hiddenSourceIDs: ["codex:first", "codex:second"]) == 0) + } + + @Test + func `OpenCodex replacement is one known coverage source for multiple native accounts`() { + let nativeInputs = [ + Self.input(id: "codex:first", provider: .codex, cost: 2), + Self.input(id: "codex:second", provider: .codex, cost: 3), + ] + let openCodex = SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.input(id: "unused", provider: .codex, cost: 8).snapshot, + sourceKind: .openCodex) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: nativeInputs + [openCodex], + sources: [ + SpendSourcePublication( + id: "codex:first", + provider: .codex, + displayName: "Codex 1", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "codex:second", + provider: .codex, + displayName: "Codex 2", + role: .subscription, + state: .available), + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .available), + ]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + hideNativeCodexWhenOpenCodexPresent: true, + providerScope: [.codex]) + + #expect(model.groups.flatMap(\.providers).map(\.id) == [SpendDashboardModel.openCodexSourceID]) + #expect(publication.subscriptionCount( + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + } + + @Test + func `non-Codex OpenCodex enrichment does not replace native Codex`() { + let nativeCodex = Self.input(id: "codex:first", provider: .codex, cost: 2) + let openCodexKimi = SpendDashboardModel.ProviderInput( + id: "kimi", + provider: .kimi, + displayName: "Kimi", + snapshot: Self.input(id: "unused", provider: .kimi, cost: 8).snapshot, + sourceKind: .openCodex) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [nativeCodex, openCodexKimi], + sources: [ + SpendSourcePublication( + id: nativeCodex.id, + provider: .codex, + displayName: nativeCodex.displayName, + role: .subscription, + state: .available), + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .available), + SpendSourcePublication( + id: openCodexKimi.id, + provider: .kimi, + displayName: openCodexKimi.displayName, + role: .enrichment, + state: .available), + ]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + hideNativeCodexWhenOpenCodexPresent: true, + providerScope: [.codex, .kimi]) + + #expect(Set(model.groups.flatMap(\.providers).map(\.id)) == [nativeCodex.id, openCodexKimi.id]) + #expect(publication.subscriptionCount( + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + } + + @Test + func `visible standalone OpenCodex remains when every native Codex account is hidden`() { + let nativeInputs = [ + Self.input(id: "codex:first", provider: .codex, cost: 2), + Self.input(id: "codex:second", provider: .codex, cost: 3), + ] + let standaloneOpenCodex = SpendDashboardModel.ProviderInput( + id: UsageProvider.codex.rawValue, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.input(id: "unused", provider: .codex, cost: 8).snapshot, + sourceKind: .openCodex) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: nativeInputs + [standaloneOpenCodex], + sources: [ + SpendSourcePublication( + id: "codex:first", + provider: .codex, + displayName: "Codex 1", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "codex:second", + provider: .codex, + displayName: "Codex 2", + role: .subscription, + state: .available), + SpendSourcePublication( + id: standaloneOpenCodex.id, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .available), + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex logs", + role: .enrichment, + state: .available), + ]) + let hidden: Set = ["codex:first", "codex:second"] + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + hiddenSourceIDs: hidden, + providerScope: [.codex]) + + #expect(model.groups.flatMap(\.providers).map(\.id) == [standaloneOpenCodex.id]) + #expect(publication.subscriptionCount(providerScope: [.codex], hiddenSourceIDs: hidden) == 1) + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex], + hiddenSourceIDs: hidden) == 1) + } + + @Test + func `confirmed empty OpenCodex-only source is a known zero`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [], + sources: [ + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .confirmedEmpty), + ]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: [.codex]) + let providerCount = publication.subscriptionCount(providerScope: [.codex]) + let knownCostCount = publication.knownCostSubscriptionCount(model: model, providerScope: [.codex]) + let summary = OverviewSpendSummary( + model: model, + providerCount: providerCount, + knownCostProviderCount: knownCostCount, + knownTokenProviderCount: publication.knownTokenSubscriptionCount( + model: model, + providerScope: [.codex])) + + #expect(providerCount == 1) + #expect(knownCostCount == 1) + #expect(summary.primarySpendText == "No usage yet") + } + + @Test + func `controller publishes one canonical OpenCodex source identity`() async { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [], + openCodexUsageLogsEnabled: true) + let request = Self.request(configuration: configuration) + let openCodex = SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.input(id: "unused", provider: .codex, cost: 8).snapshot, + sourceKind: .openCodex) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in + SpendDashboardLoadResult( + inputs: [openCodex], + failedSourceIDs: [], + openCodexObservation: .available) + }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.publication.sources.map(\.id) == [SpendDashboardModel.openCodexSourceID]) + } + + @Test + func `empty and unavailable catalogs have distinct summary copy`() { + let model = SpendDashboardModel(requestedDays: 30, groups: []) + let empty = OverviewSpendSummary( + model: model, + providerCount: 2, + knownCostProviderCount: 2, + knownTokenProviderCount: 2) + let unavailable = OverviewSpendSummary( + model: model, + providerCount: 2, + knownCostProviderCount: 0, + knownTokenProviderCount: 0) + + #expect(empty.primarySpendText == "No usage yet") + #expect(unavailable.primarySpendText == "Spend unavailable") + } + + @Test + func `overview projection is synchronous and reuses published inputs without loading`() async { + let fixture = Self.fixture() + let calls = SpendDashboardPublicationLoadCounter() + let controller = SpendDashboardController( + requestBuilder: { _ in fixture.request }, + loader: { _ in + await calls.recordLoad() + return fixture.result + }) + + controller.update(configuration: fixture.request.configuration) + await Self.waitUntil { !controller.isRefreshing } + let callsBeforeProjection = await calls.count + + let model = controller.publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: [.codex]) + let providerIDs = model.groups.flatMap { group in + group.providers.map(\.id) + } + + #expect(await calls.count == callsBeforeProjection) + #expect(Set(providerIDs) == ["codex:first", "codex:second"]) + #expect(model.groups.first?.totalCost == 5) + } + + private static func fixture() -> (request: SpendDashboardLoadRequest, result: SpendDashboardLoadResult) { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [ + UsageProvider.codex.rawValue, + UsageProvider.openai.rawValue, + UsageProvider.claude.rawValue, + UsageProvider.gemini.rawValue, + ], + codexAccountIdentities: ["first|first-cache", "second|second-cache"]) + let openAI = Self.input(id: "openai", provider: .openai, cost: 7) + return ( + request: SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [openAI], + unavailableSourceIDs: ["gemini"], + confirmedEmptySourceIDs: ["claude"], + codexRequests: [], + now: Self.now, + force: false), + result: SpendDashboardLoadResult( + inputs: [ + openAI, + Self.input(id: "codex:first", provider: .codex, cost: 2), + Self.input(id: "codex:second", provider: .codex, cost: 3), + ], + failedSourceIDs: ["gemini"])) + } + + private static func configuration(revision: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:stable-owner"], + sourceRevisions: [revision]) + } + + private static func request( + configuration: SpendDashboardConfiguration, + unavailableSourceIDs: Set = []) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: unavailableSourceIDs, + codexRequests: [], + now: self.now, + force: false) + } + + private static func input( + id: String, + provider: UsageProvider, + cost: Double?) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: self.now)) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for Spend Dashboard publication") + } + + private static func writeCodexAuthFile(homeURL: URL) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: [ + "email": "shared-publication@example.com", + "chatgpt_plan_type": "pro", + ]) + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + let token = "\(base64URL(header)).\(base64URL(payload))." + let auth = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": token, + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} + +private actor SpendDashboardPublicationScript { + private var requests: [SpendDashboardLoadRequest] + private var results: [SpendDashboardLoadResult] + + init(requests: [SpendDashboardLoadRequest], results: [SpendDashboardLoadResult]) { + self.requests = requests + self.results = results + } + + func nextRequest(mode: SpendDashboardRequestBuildMode) -> SpendDashboardLoadRequest { + precondition(!self.requests.isEmpty, "Unexpected Spend Dashboard publication request") + let request = self.requests.removeFirst() + return SpendDashboardLoadRequest( + configuration: request.configuration, + capturedInputs: request.capturedInputs, + unavailableSourceIDs: request.unavailableSourceIDs, + confirmedEmptySourceIDs: request.confirmedEmptySourceIDs, + codexRequests: request.codexRequests, + now: request.now, + force: mode.forcesLoader) + } + + func nextResult(request: SpendDashboardLoadRequest) -> SpendDashboardLoadResult { + guard !self.results.isEmpty else { + Issue.record("Unexpected Spend Dashboard publication load for \(request.configuration.providerIDs)") + return SpendDashboardLoadResult(inputs: [], failedSourceIDs: []) + } + return self.results.removeFirst() + } +} + +private actor SpendDashboardPublicationLoadCounter { + private(set) var count = 0 + + func recordLoad() { + self.count += 1 + } +} diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index ef426a8f5d..81e7763f89 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -447,6 +447,8 @@ struct SpendDashboardSourceConcurrencyTests { controller.update(configuration: replacement) #expect(controller.generation == inFlightGeneration) #expect(controller.configuration == replacement) + #expect(controller.publication.configuration == replacement) + #expect(controller.publication.isRefreshing) await loaderGate.resume( result: SpendDashboardLoadResult( diff --git a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift index 7721054c4f..7468247e62 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift @@ -65,6 +65,163 @@ extension StatusMenuTests { #expect(group.dailyPoints.map(\.day) == [bucketStart]) } + @Test + func `shared overview keeps Codex local ledger when global cost tracking is off`() async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = false + settings.codexLocalSessionCostLedgerEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date(timeIntervalSince1970: 1_787_079_600) + let configuration = SpendDashboardSource.configuration(settings: settings, store: store) + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly, + now: now) + let input = SpendDashboardModel.ProviderInput( + id: "codex:local", + provider: .codex, + displayName: "Codex", + snapshot: CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 4, + last30DaysTokens: 10, + last30DaysCostUSD: 4, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-17", + inputTokens: 5, + outputTokens: 5, + totalTokens: 10, + costUSD: 4, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now)) + store.spendDashboardPublication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: configuration, + loadedAt: now, + isRefreshing: false, + inputs: [input], + sources: [ + SpendSourcePublication( + id: input.id, + provider: .codex, + displayName: input.displayName, + role: .subscription, + state: .available), + ]) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(configuration.costUsageEnabled) + #expect(configuration.providerIDs == [UsageProvider.codex.rawValue]) + #expect(request.configuration.costUsageEnabled) + #expect(request.configuration.providerIDs == [UsageProvider.codex.rawValue]) + #expect(controller.overviewSpendDashboardModel(providers: [.codex], now: now).groups.first?.totalCost == 4) + } + + @Test + func `overview consumes shared publication without starting a loader`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + let providers: [UsageProvider] = [.codex, .claude] + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date(timeIntervalSince1970: 1_787_079_600) + func input(id: String, provider: UsageProvider, cost: Double) -> SpendDashboardModel.ProviderInput { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: id, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-17", + inputTokens: 5, + outputTokens: 5, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now)) + } + let inputs = [ + input(id: "codex:first", provider: .codex, cost: 2), + input(id: "codex:second", provider: .codex, cost: 3), + input(id: "claude", provider: .claude, cost: 7), + ] + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: providers.map(\.rawValue), + codexAccountIdentities: ["first|cache-a", "second|cache-b"], + menuOwnershipFingerprint: SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: settings, + store: store)) + store.spendDashboardPublication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: configuration, + loadedAt: now, + isRefreshing: false, + inputs: inputs, + sources: inputs.map { + SpendSourcePublication( + id: $0.id, + provider: $0.provider, + displayName: $0.displayName, + role: .subscription, + state: .available) + }) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(store.sharedSpendDashboardControllerStorage == nil) + let model = controller.overviewSpendDashboardModel(providers: providers, now: now) + #expect(store.sharedSpendDashboardControllerStorage == nil) + #expect(Set(model.groups.flatMap(\.providers).map(\.id)) == ["codex:first", "codex:second", "claude"]) + #expect(model.groups.first?.totalCost == 12) + #expect(controller.overviewSpendSubscriptionCount(providers: providers) == 3) + + guard let claudeMetadata = ProviderRegistry.shared.metadata[.claude] else { + Issue.record("Claude metadata missing") + return + } + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + let staleOwnerModel = controller.overviewSpendDashboardModel(providers: providers, now: now) + #expect(staleOwnerModel.groups.isEmpty) + } + @Test func `overview accounts for all six selected providers while summing only available spend`() { let settings = self.makeSettings() diff --git a/docs/research/shared-spend-source-publications.md b/docs/research/shared-spend-source-publications.md new file mode 100644 index 0000000000..aff4af09e9 --- /dev/null +++ b/docs/research/shared-spend-source-publications.md @@ -0,0 +1,55 @@ +# Shared spend-source publications + +Research date: 2026-08-18 + +## Decision + +CodexBar should expose one app-scoped, main-actor publication of immutable spend-source inputs and states. Async producers refresh that publication outside the menu-rendering path. The Overview menu and Usage & Spend dashboard synchronously project the same publication at different densities. + +The publication must preserve source identity and truth state: + +- Stable source key: provider instance, account/cache identity when applicable, and source kind. +- State: available snapshot, confirmed empty, or unavailable/failed. Absence is not equivalent to confirmed zero. +- Identity metadata: provider-config revision, ownership/scope fingerprint, request generation, and capture time. +- Atomic replacement: consumers see one complete immutable catalog, never a partially mutated dictionary. + +Every async producer captures identity before suspension and validates cancellation, generation, provider configuration, and ownership again before publication. Cancellation is only a performance tool; identity validation is the correctness boundary. + +## Why this fits CodexBar + +The existing implementation already contains most of the required primitives: + +- `TokenSnapshotPublication` carries snapshot, publication revision, provider-config revision, and scope signature. +- `UsageStore` exposes synchronous current-config validation for provider publications. +- Independent dashboard refresh captures identity before loading and revalidates it after suspension. +- `SpendDashboardController` already has immutable request/result values, generation checks, and source-ownership reconciliation. +- Multi-account Codex already uses stable `codex:` source identifiers and cache identities. + +The architectural gap is that the richer 365-day, multi-account Codex, and OpenCodex result set remains private to a preferences-pane-owned controller. The Overview therefore reads a narrower provider-global cache and cannot achieve source parity. + +## UI lifecycle constraint + +Menu construction must remain synchronous and cache-only. It must not perform a network request, filesystem scan, or wait for an async refresh. A publication change can invalidate the next menu build, but structural or height-changing mutations should be deferred while AppKit is tracking an open menu. + +## Required regression coverage + +1. Account A completes after selection changes to account B: A never appears in B's source slot or total. +2. A cancelled non-cooperative loader completes: generation and ownership validation reject it. +3. Two Codex accounts plus OpenCodex remain independently identified and aggregate exactly once. +4. Confirmed-empty, unavailable, failed, and cached-stale states remain distinguishable. +5. Overview and dashboard build from the same published inputs and produce the same math for the same period/calendar. +6. Publication during menu tracking does not structurally rebuild or resize the open menu. + +## Primary sources + +- [The Swift Programming Language: Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) +- [Apple WWDC25: Embracing Swift concurrency](https://developer.apple.com/videos/play/wwdc2025/268/) +- [Apple AsyncStream documentation](https://developer.apple.com/documentation/Swift/AsyncStream) +- [Swift Evolution SE-0314: AsyncStream and AsyncThrowingStream](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0314-async-stream.md) +- [Apple Task.cancel documentation](https://developer.apple.com/documentation/Swift/Task/cancel%28%29) +- [Swift Evolution SE-0304: Structured concurrency](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0304-structured-concurrency.md) +- [Swift Evolution SE-0306: Actors](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md) +- [Apple Observation: withObservationTracking](https://developer.apple.com/documentation/observation/withobservationtracking%28_%3Aonchange%3A%29) +- [Apple NSMenuDelegate: menuNeedsUpdate](https://developer.apple.com/documentation/appkit/nsmenudelegate/menuneedsupdate%28_%3A%29) +- [Apple NSMenuDelegate: menuWillOpen](https://developer.apple.com/documentation/appkit/nsmenudelegate/menuwillopen%28_%3A%29) +- [Apple Menu Programming Guide: Views in Menu Items](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MenuList/Articles/ViewsInMenuItems.html)