diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index 0ee8c911ec..dcf2854840 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -31,9 +31,11 @@ extension UsageStore { return } - let scopeSignature = accounts + let historyDays = max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) + let accountScopeSignature = accounts .map { "\($0.id)|\($0.cacheIdentity)" } .joined(separator: "\u{0}") + let scopeSignature = "\(historyDays)\u{0}\(accountScopeSignature)" if self.spendDashboardCodexCostCatchUpTask != nil, self.spendDashboardCodexCostCatchUpScopeSignature == scopeSignature { @@ -51,7 +53,7 @@ extension UsageStore { let context = SpendDashboardCodexCostCatchUpContext( token: token, accounts: accounts, - historyDays: SpendDashboardSource.scanDays, + historyDays: historyDays, scopeSignature: scopeSignature, providerConfigRevision: self.settings.providerConfigRevision(for: .codex), costUsageSettingsRevision: self.settings.costUsageSettingsRevision) @@ -245,6 +247,7 @@ extension UsageStore { && self.spendDashboardCodexCostCatchUpScopeSignature == context.scopeSignature && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision + && max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) == context.historyDays && self.settings.isCostUsageEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) && context.accounts.allSatisfy(SpendDashboardSource.codexAuthFingerprintMatches) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f459c144ff..f45ba2e4fa 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -312,19 +312,11 @@ public struct CostUsageFetcher: Sendable { } let scoped = CostUsageScanner.codexCache(cache, scopedTo: roots) - var progressHasher = Hasher() - for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) { - progressHasher.combine(path) - progressHasher.combine(usage.codexScanFileId) - progressHasher.combine(usage.parsedBytes) - progressHasher.combine(usage.size) - progressHasher.combine(usage.codexScanComplete) - } let hasIncompleteFile = scoped.files.values.contains { $0.codexScanComplete == false } let pending = cache.codexScanCatchUpPending == true || hasIncompleteFile return CodexScanCatchUpStatus( pending: pending, - progressKey: "\(scoped.files.count):\(progressHasher.finalize())", + progressKey: Self.codexScanCatchUpProgressKey(cache: cache, scoped: scoped), processedBytes: cache.codexScanProcessedBytes ?? 0, totalBytes: cache.codexScanTotalBytes ?? 0, completedFiles: cache.codexScanCompletedFiles ?? 0, @@ -332,6 +324,109 @@ public struct CostUsageFetcher: Sendable { staleSnapshotUpdatedAt: pending ? cache.codexPreviousReport?.updatedAt : nil) } + private static func codexScanCatchUpProgressKey( + cache: CostUsageCache, + scoped: CostUsageCache) -> String + { + var hasher = Hasher() + hasher.combine(cache.codexScanProcessedBytes) + hasher.combine(cache.codexScanTotalBytes) + hasher.combine(cache.codexScanCompletedFiles) + hasher.combine(cache.codexScanTotalFiles) + + for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) { + hasher.combine(path) + hasher.combine(usage.codexScanFileId) + hasher.combine(usage.parsedBytes) + hasher.combine(usage.size) + hasher.combine(usage.codexScanTargetSize) + hasher.combine(usage.codexScanComplete) + hasher.combine(usage.codexJSONLResumeState?.offset) + hasher.combine(usage.forkBaselineDependencyKey) + Self.combineCodexBufferedProgress(usage.codexBufferedSubagentLines, into: &hasher) + Self.combineCodexBufferedProgress(usage.codexBufferedUnresolvedForkLines, into: &hasher) + } + Self.combineCodexDiscoveryProgress(cache.codexSessionDiscovery, into: &hasher) + Self.combineCodexActiveLookbackProgress(cache.codexActiveLookbackState, into: &hasher) + return "\(scoped.files.count):\(hasher.finalize())" + } + + private static func combineCodexBufferedProgress( + _ lines: [CostUsageScanner.CodexBufferedFastLine]?, + into hasher: inout Hasher) + { + hasher.combine(lines?.count) + for line in lines ?? [] { + hasher.combine(line.lineIndex) + hasher.combine(line.ordinal) + hasher.combine(line.endOffset) + } + } + + private static func combineCodexDiscoveryProgress( + _ discovery: CostUsageCodexSessionDiscovery?, + into hasher: inout Hasher) + { + hasher.combine(discovery != nil) + guard let discovery else { return } + for root in discovery.roots.sorted() { + hasher.combine(root) + } + hasher.combine(discovery.generation) + hasher.combine(discovery.directoryPaths.count) + hasher.combine(discovery.nextDirectoryIndex) + hasher.combine(discovery.filePaths.count) + hasher.combine(discovery.nextFileIndex) + hasher.combine(discovery.directoryStamps.count) + hasher.combine(discovery.fileStamps.count) + hasher.combine(discovery.validationDirectoryIndex) + hasher.combine(discovery.isComplete) + if discovery.directoryPaths.indices.contains(discovery.nextDirectoryIndex) { + hasher.combine(discovery.directoryPaths[discovery.nextDirectoryIndex]) + } + if discovery.filePaths.indices.contains(discovery.nextFileIndex) { + hasher.combine(discovery.filePaths[discovery.nextFileIndex]) + } + hasher.combine(discovery.headScan?.path) + hasher.combine(discovery.headScan?.offset) + hasher.combine(discovery.headScan?.resumeState?.offset) + for (sessionID, path) in discovery.filePathBySessionId.sorted(by: { $0.key < $1.key }) { + hasher.combine(sessionID) + hasher.combine(path) + } + for sessionID in discovery.missingSessionIds.sorted() { + hasher.combine(sessionID) + } + for sessionID in discovery.pendingSessionIds.sorted() { + hasher.combine(sessionID) + } + } + + private static func combineCodexActiveLookbackProgress( + _ lookback: CostUsageCodexActiveLookbackState?, + into hasher: inout Hasher) + { + hasher.combine(lookback != nil) + guard let lookback else { return } + hasher.combine(lookback.scanSinceKey) + for root in lookback.rootPaths.sorted() { + hasher.combine(root) + } + for (root, day) in lookback.nextDayKeyByRoot.sorted(by: { $0.key < $1.key }) { + hasher.combine(root) + hasher.combine(day) + } + for root in lookback.completedRootPaths.sorted() { + hasher.combine(root) + } + for path in lookback.pendingFilePaths.sorted() { + hasher.combine(path) + } + for root in lookback.legacyRecursivePendingRootPaths.sorted() { + hasher.combine(root) + } + } + private static func codexHistoryCoverageIsEstablished( options: CostUsageScanner.Options) -> Bool { diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 014fceaf06..83922f39cd 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -97,6 +97,119 @@ extension CostUsageFetcherTests { #expect(covered.historyCoverageIsEstablished) } + @Test + func `codex catch-up progress includes discovery and active lookback cursors`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let rootPath = env.codexSessionsRoot.path + let firstPath = env.codexSessionsRoot.appendingPathComponent("first.jsonl").path + let secondPath = env.codexSessionsRoot.appendingPathComponent("second.jsonl").path + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.codexScanCatchUpPending = true + cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( + roots: [rootPath], + generation: "generation", + directoryStamps: [:], + directoryPaths: [rootPath], + nextDirectoryIndex: 0, + filePaths: [firstPath, secondPath], + nextFileIndex: 0, + fileStamps: [:], + headScan: .init(path: firstPath, offset: 32, resumeState: nil), + filePathBySessionId: [:], + missingSessionIds: [], + pendingSessionIds: ["pending-session"], + validationDirectoryIndex: 0, + isComplete: false) + cache.codexActiveLookbackState = CostUsageCodexActiveLookbackState( + scanSinceKey: "2026-04-01", + rootPaths: [rootPath], + nextDayKeyByRoot: [rootPath: "2026-04-02"], + pendingFilePaths: [firstPath]) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + + let fetcher = CostUsageFetcher(scannerOptions: options) + let baseline = await fetcher.codexScanCatchUpStatus() + let unchanged = await fetcher.codexScanCatchUpStatus() + #expect(unchanged.progressKey == baseline.progressKey) + + var discovery = try #require(cache.codexSessionDiscovery) + discovery.headScan = .init(path: firstPath, offset: 64, resumeState: nil) + cache.codexSessionDiscovery = discovery + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + let advancedHead = await fetcher.codexScanCatchUpStatus() + #expect(advancedHead.progressKey != baseline.progressKey) + + discovery.nextFileIndex = 1 + discovery.headScan = nil + cache.codexSessionDiscovery = discovery + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + let advancedFile = await fetcher.codexScanCatchUpStatus() + #expect(advancedFile.progressKey != advancedHead.progressKey) + + var lookback = try #require(cache.codexActiveLookbackState) + lookback.nextDayKeyByRoot[rootPath] = "2026-04-03" + cache.codexActiveLookbackState = lookback + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + let advancedLookback = await fetcher.codexScanCatchUpStatus() + #expect(advancedLookback.progressKey != advancedFile.progressKey) + } + + @Test + func `codex catch-up progress includes buffered retry cursors`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "buffered.jsonl", + tokens: 42) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + includePiSessions: false, + scannerOptions: options) + + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + let path = try #require(cache.files.keys.first) + var usage = try #require(cache.files[path]) + usage.codexBufferedSubagentLines = [CostUsageScanner.CodexBufferedFastLine( + lineIndex: 1, + ordinal: 1, + endOffset: 64, + line: .taskStarted(turnID: "synthetic-turn"))] + cache.files[path] = usage + cache.codexScanCatchUpPending = true + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + + let fetcher = CostUsageFetcher(scannerOptions: options) + let baseline = await fetcher.codexScanCatchUpStatus() + + usage.codexBufferedSubagentLines = [CostUsageScanner.CodexBufferedFastLine( + lineIndex: 1, + ordinal: 1, + endOffset: 128, + line: .taskStarted(turnID: "synthetic-turn"))] + cache.files[path] = usage + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + let advancedBuffer = await fetcher.codexScanCatchUpStatus() + + #expect(advancedBuffer.progressKey != baseline.progressKey) + } + @Test func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index c9ca73a5ce..5d87e99ed1 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1118,19 +1118,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 56, + line: 58, anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 248, + line: 251, anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 249, + line: 252, anchor: "&& self.isEnabled(.codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), @@ -1353,19 +1353,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 715, + line: 810, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 790, + line: 885, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 865, + line: 960, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -2909,7 +2909,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 246, + line: 248, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3379,7 +3379,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 539, + line: 634, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3387,7 +3387,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 567, + line: 662, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, @@ -3395,7 +3395,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 614, + line: 709, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3403,7 +3403,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 641, + line: 736, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex", "openai"], expectedReferenceCount: 5, @@ -3411,7 +3411,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1114, + line: 1209, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3419,7 +3419,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1365, + line: 1460, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift b/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift index 93a5419a75..e912147c14 100644 --- a/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift +++ b/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift @@ -58,6 +58,47 @@ struct UsageStoreSpendDashboardCodexCostCatchUpTests { #expect(store.spendDashboardCodexCostCatchUpActivity?.fractionCompleted == 1) } + @Test(arguments: [123, 248, 365]) + func `dashboard catch-up accelerates the configured history window`(historyDays: Int) async throws { + let receivedHistoryDays = try await Self.receivedHistoryDays( + configuredHistoryDays: historyDays, + suite: "configured-\(historyDays)") + + #expect(receivedHistoryDays == historyDays) + } + + @Test(arguments: [1, 7, 29]) + func `dashboard catch-up retains its thirty day floor`(historyDays: Int) async throws { + let receivedHistoryDays = try await Self.receivedHistoryDays( + configuredHistoryDays: historyDays, + suite: "floor-\(historyDays)") + + #expect(receivedHistoryDays == SpendDashboardSource.scanDays) + } + + @Test + func `changing the history window replaces the active catch-up context`() throws { + let store = try Self.makeStore(suite: "history-context") + let accounts = [Self.account(id: "account", cacheIdentity: "cache-account")] + store.settings.costUsageHistoryDays = 30 + store._test_spendDashboardCodexCostCatchUpStatusOverride = { _ in + Self.status(pending: true, key: "pending", processedBytes: 25) + } + store._test_spendDashboardCodexCostCatchUpSleepOverride = { _ in + try await Task.sleep(for: .seconds(60)) + } + + store.startSpendDashboardCodexCostCatchUpIfNeeded(accounts: accounts, mode: .accelerated) + let originalToken = try #require(store.spendDashboardCodexCostCatchUpToken) + + store.settings.costUsageHistoryDays = 123 + store.synchronizeSpendDashboardCodexCostCatchUp(accounts: accounts) + let replacementToken = try #require(store.spendDashboardCodexCostCatchUpToken) + + #expect(replacementToken != originalToken) + store.cancelSpendDashboardCodexCostCatchUp() + } + @Test func `a stalled account cache does not prevent a sibling cache from advancing`() async throws { let store = try Self.makeStore(suite: "stalled-sibling") @@ -159,6 +200,41 @@ struct UsageStoreSpendDashboardCodexCostCatchUpTests { environmentBase: [:]) } + private static func receivedHistoryDays( + configuredHistoryDays: Int, + suite: String) async throws -> Int + { + let store = try Self.makeStore(suite: suite) + let account = Self.account(id: "account", cacheIdentity: "cache-account") + store.settings.costUsageHistoryDays = configuredHistoryDays + var completed = false + var receivedHistoryDays: Int? + store._test_spendDashboardCodexCostCatchUpStatusOverride = { _ in + Self.status( + pending: !completed, + key: completed ? "complete" : "pending", + processedBytes: completed ? 100 : 25) + } + store._test_spendDashboardCodexCostCatchUpAdvanceOverride = { _, _, historyDays in + receivedHistoryDays = historyDays + completed = true + return Self.status(pending: false, key: "complete", processedBytes: 100) + } + store._test_spendDashboardCodexCostCatchUpSleepOverride = { _ in + await Task.yield() + } + store._test_spendDashboardCodexCostCatchUpResourceStateOverride = { + (.battery, true, .serious) + } + + store.startSpendDashboardCodexCostCatchUpIfNeeded(accounts: [account], mode: .accelerated) + await Self.waitUntil { + store.spendDashboardCodexCostCatchUpTask == nil + } + + return try #require(receivedHistoryDays) + } + private static func account(id: String, cacheIdentity: String) -> CodexSpendScanRequest { CodexSpendScanRequest( id: id,