From 631b4d6267bae0b87183082db5184ad5b0e10ce1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 00:52:41 -0700 Subject: [PATCH 1/4] feat: cut Codex cost persistence over to SQLite --- .../CodexLocalProjectUsageIndexer.swift | 10 +- .../CodexWorkspaceUsageFingerprint.swift | 8 +- .../CodexWorkspaceUsageSidecar.swift | 36 +- Sources/CodexBarCore/CostUsageFetcher.swift | 51 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 1159 ------------ .../CostUsage/CostUsageCacheModels.swift | 361 ++++ .../CostUsage/CostUsageClaudeCache.swift | 50 + .../CostUsageScanner+CacheHelpers.swift | 31 +- .../CostUsage/CostUsageScanner+Claude.swift | 64 +- .../Vendored/CostUsage/CostUsageScanner.swift | 96 +- .../CostUsage/CostUsageStore+CodexCache.swift | 642 +++++++ .../CostUsage/CostUsageStore+Reads.swift | 72 +- .../CostUsage/CostUsageStore+Retention.swift | 186 +- .../CostUsage/CostUsageStore+Writes.swift | 154 +- .../Vendored/CostUsage/CostUsageStore.swift | 100 +- .../CostUsage/CostUsageStoreModels.swift | 14 +- .../CodexCompactSubagentAccountingTests.swift | 6 +- .../CodexForkAppendResumeTests.swift | 8 +- .../CodexLocalProjectUsageTests.swift | 26 +- ...exSubagentAccountingIntegrationTests.swift | 4 +- Tests/CodexBarTests/CostUsageCacheTests.swift | 1677 ----------------- .../CostUsageCalendarTests.swift | 8 +- .../CostUsageCancellationTests.swift | 15 +- .../CostUsageFetcherCacheSnapshotTests.swift | 69 +- .../CodexBarTests/CostUsageFetcherTests.swift | 20 +- .../CostUsagePerformanceGateTests.swift | 73 +- .../CostUsageScannerBreakdownTests.swift | 96 +- .../CostUsageScannerClaudeFableTests.swift | 2 +- .../CostUsageStoreCutoverTests.swift | 185 ++ Tests/CodexBarTests/CostUsageStoreTests.swift | 103 +- .../ProviderArchitectureGatekeeperTests.swift | 6 - .../UsageStoreCachedTokenHydrationTests.swift | 4 +- .../CodexWarmCacheResumeLinuxTests.swift | 3 +- 34 files changed, 2130 insertions(+), 3211 deletions(-) delete mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift delete mode 100644 Tests/CodexBarTests/CostUsageCacheTests.swift create mode 100644 Tests/CodexBarTests/CostUsageStoreCutoverTests.swift diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift index 3e25b2b47c..3ed67a8187 100644 --- a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift +++ b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift @@ -69,8 +69,7 @@ enum CodexLocalProjectUsageIndexer { checkCancellation: checkCancellation) try checkCancellation?() - let cache = CostUsageCacheIO.load( - provider: .codex, + let cache = CostUsageStoreAccess.read( cacheRoot: scannerOptions.cacheRoot, calendar: scannerOptions.calendar) let catalogResult = CodexThreadCatalogReader.loadResult(options: scannerOptions) @@ -158,8 +157,7 @@ enum CodexLocalProjectUsageIndexer { since: since, until: until, calendar: options.calendar) - let cache = cacheOverride ?? CostUsageCacheIO.load( - provider: .codex, + let cache = cacheOverride ?? CostUsageStoreAccess.read( cacheRoot: options.cacheRoot, calendar: options.calendar) let catalog = catalogOverride ?? CodexThreadCatalogReader.load(options: options) @@ -698,7 +696,7 @@ extension CodexLocalProjectUsageIndexer { let revisionParts = identity.rootsFingerprint.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" } let indexRevision = ([ identity.scopeSignature, - context.cache.producerKey ?? "", + CostUsageStore.cacheGeneration, context.cache.codexPricingKey ?? "", ] + revisionParts) .joined(separator: "|") @@ -913,7 +911,7 @@ extension CodexLocalProjectUsageIndexer { { let roots = self.rootsFingerprint(CostUsageScanner.codexRootsFingerprint(options: options)) var parts = roots.sorted { $0.key < $1.key }.map { "root:\($0.key)=\($0.value)" } - parts.append("producer=\(cache.producerKey ?? "")") + parts.append("store=\(CostUsageStore.cacheGeneration)") parts.append("pricing=\(cache.codexPricingKey ?? "")") parts.append("priorityMetadata=\(cache.codexPriorityMetadataKey ?? "")") if let catalogFingerprint { diff --git a/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift b/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift index 4602f8810e..1d110dacd4 100644 --- a/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift +++ b/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift @@ -22,7 +22,9 @@ struct CodexWorkspaceUsageFingerprintPayload: Encodable { let priorityCostNanos: [String: [String: Int64]]? let standardTokens: [String: [String: Int]]? let priorityTokens: [String: [String: Int]]? - let rows: [CostUsageScanner.CodexUsageRow]? + let rowCount: Int? + let lastRowIndex: Int? + let tokenIndexAnchor: CostUsageCodexTokenIndexAnchor? init(usage: CostUsageFileUsage) { self.days = usage.days @@ -38,7 +40,9 @@ struct CodexWorkspaceUsageFingerprintPayload: Encodable { self.priorityCostNanos = usage.codexPriorityCostNanos self.standardTokens = usage.codexStandardTokens self.priorityTokens = usage.codexPriorityTokens - self.rows = usage.codexRows + self.rowCount = usage.codexRows?.count + self.lastRowIndex = usage.codexRows?.compactMap(\.eventIndex).max() + self.tokenIndexAnchor = usage.codexTokenIndexAnchor } } diff --git a/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift b/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift index 28f58a518c..af22db1999 100644 --- a/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift +++ b/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift @@ -47,7 +47,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { self.size = usage.size self.parsedBytes = usage.parsedBytes ?? -1 self.sessionID = usage.codexSession?.sessionId ?? usage.sessionId ?? "" - self.producerKey = cache.producerKey ?? "" + self.producerKey = CostUsageStore.cacheGeneration self.pricingKey = cache.codexPricingKey ?? "" self.contentFingerprint = usage.codexWorkspaceUsageFingerprintValue() } @@ -126,7 +126,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { else { return nil } } if let cache { - guard Self.columnString(statement, at: 3) == cache.producerKey, + guard Self.columnString(statement, at: 3) == CostUsageStore.cacheGeneration, Self.columnString(statement, at: 4) == cache.codexPricingKey, Self.columnString(statement, at: 5) == Self.cacheFingerprint(cache) else { return nil } @@ -531,7 +531,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { else { throw SidecarError.statementFailed } defer { sqlite3_finalize(statement) } Self.bind(generation, to: statement, at: 1) - guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) } } private func importChangedRollouts( @@ -625,7 +625,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { let rootsData = try JSONEncoder().encode(rootsFingerprint) Self.bind(rootsData, to: state, at: 2) Self.bind(catalog.fingerprint, to: state, at: 3) - Self.bind(cache.producerKey, to: state, at: 4) + Self.bind(CostUsageStore.cacheGeneration, to: state, at: 4) Self.bind(cache.codexPricingKey, to: state, at: 5) Self.bind(Self.cacheFingerprint(cache), to: state, at: 6) sqlite3_bind_int64(state, 7, Int64((snapshot.updatedAt.timeIntervalSince1970 * 1000).rounded())) @@ -697,7 +697,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { Self.bind(identity.contentFingerprint, to: statement, at: 18) sqlite3_bind_int(statement, 19, Self.hasCompleteEventDetail(usage) ? 1 : 0) Self.bind(generation, to: statement, at: 20) - guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) } } private static func insertDaily(path: String, usage: CostUsageFileUsage, db: OpaquePointer?) throws { @@ -725,7 +725,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { Self.bind(usage.codexStandardCostNanos?[day]?[model], to: statement, at: 10) Self.bind(usage.codexPriorityCostNanos?[day]?[model], to: statement, at: 11) Self.bind(usage.codexPrioritySurchargeNanos?[day]?[model], to: statement, at: 12) - guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) } } } } @@ -738,6 +738,20 @@ struct CodexWorkspaceUsageSidecar: Sendable { input_tokens, cached_input_tokens, output_tokens, known_cost_nanos, unpriced_tokens, pricing_model, pricing_mode, reasoning_tokens ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(rollout_path, event_index) DO UPDATE SET + timestamp_ms = excluded.timestamp_ms, + day = excluded.day, + canonical_model = excluded.canonical_model, + raw_model = excluded.raw_model, + turn_id = excluded.turn_id, + input_tokens = excluded.input_tokens, + cached_input_tokens = excluded.cached_input_tokens, + output_tokens = excluded.output_tokens, + known_cost_nanos = excluded.known_cost_nanos, + unpriced_tokens = excluded.unpriced_tokens, + pricing_model = excluded.pricing_model, + pricing_mode = excluded.pricing_mode, + reasoning_tokens = excluded.reasoning_tokens """ guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } defer { sqlite3_finalize(statement) } @@ -760,7 +774,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { Self.bind(row.pricingModel, to: statement, at: 13) Self.bind(row.pricingMode, to: statement, at: 14) Self.bind(row.reasoning.map(Int64.init), to: statement, at: 15) - guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) } } } @@ -770,7 +784,7 @@ struct CodexWorkspaceUsageSidecar: Sendable { else { throw SidecarError.statementFailed } defer { sqlite3_finalize(statement) } Self.bind(path, to: statement, at: 1) - guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) } } } @@ -955,11 +969,17 @@ struct CodexWorkspaceUsageSidecar: Sendable { private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + private static func sqliteFailure(_ db: OpaquePointer?) -> SidecarError { + guard let db, let message = sqlite3_errmsg(db) else { return .writeFailed } + return .sqlite(String(cString: message)) + } + private enum SidecarError: Error { case openFailed case incompatibleSchema case statementFailed case writeFailed + case sqlite(String) } #endif } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index ae6de0bfab..f459c144ff 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -304,24 +304,10 @@ public struct CostUsageFetcher: Sendable { { let roots = CostUsageScanner.codexSessionsRoots(options: options) let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) - let loadedCache = CostUsageCacheIO.loadCodexForMigration( + let cache = CostUsageStoreAccess.read( cacheRoot: options.cacheRoot, calendar: options.calendar) - let cache = loadedCache.cache guard cache.roots == rootsFingerprint else { - if let incompatibleCache = loadedCache.incompatibleCache, - incompatibleCache.roots == rootsFingerprint - { - let staleSnapshotUpdatedAt: Date? = if incompatibleCache.lastScanUnixMs > 0 { - Date(timeIntervalSince1970: TimeInterval(incompatibleCache.lastScanUnixMs) / 1000) - } else { - nil - } - return CodexScanCatchUpStatus( - pending: true, - progressKey: "producer-upgrade", - staleSnapshotUpdatedAt: staleSnapshotUpdatedAt) - } return CodexScanCatchUpStatus(pending: false, progressKey: "scope-mismatch") } @@ -553,7 +539,9 @@ public struct CostUsageFetcher: Sendable { if provider == .codex { let roots = CostUsageScanner.codexSessionsRoots(options: options.scanOptions) let cache = CostUsageScanner.codexCache( - CostUsageCacheIO.load(provider: .codex, cacheRoot: options.scanOptions.cacheRoot), + CostUsageStoreAccess.read( + cacheRoot: options.scanOptions.cacheRoot, + calendar: options.scanOptions.calendar), scopedTo: roots) let range = CostUsageScanner.CostUsageDayRange( since: since, until: now, calendar: options.scanOptions.calendar) @@ -735,9 +723,9 @@ public struct CostUsageFetcher: Sendable { let roots = CostUsageScanner.codexSessionsRoots(options: options) let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) let cache = CostUsageScanner.codexCache( - CostUsageCacheIO.loadCodexForMigration( + CostUsageStoreAccess.read( cacheRoot: options.cacheRoot, - calendar: options.calendar).cache, + calendar: options.calendar), scopedTo: roots) guard cache.timeZoneIdentifier == options.calendar.timeZone.identifier, cache.roots == rootsFingerprint, @@ -793,8 +781,8 @@ public struct CostUsageFetcher: Sendable { return nil } - // Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the - // cooperative pool alongside the scans themselves. + // Snapshot assembly can touch many SQLite rows; keep it off the cooperative pool + // alongside the scans themselves. let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) let options = Self.resolvedScannerOptions( @@ -813,11 +801,11 @@ public struct CostUsageFetcher: Sendable { let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false let roots = CostUsageScanner.codexSessionsRoots(options: options) let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) - let loadedCache = CostUsageCacheIO.loadCodexForMigration( + let loadedCache = CostUsageStoreAccess.read( cacheRoot: options.cacheRoot, calendar: options.calendar) let cache = CostUsageScanner.codexCache( - loadedCache.cache, + loadedCache, scopedTo: roots) var reports: [CostUsageDailyReport] = [] var projects: [CostUsageProjectBreakdown] = [] @@ -869,25 +857,6 @@ public struct CostUsageFetcher: Sendable { } } } - } else if let incompatibleCache = loadedCache.incompatibleCache, - incompatibleCache.timeZoneIdentifier == range.calendar.timeZone.identifier, - !incompatibleCache.days.isEmpty, - incompatibleCache.roots == rootsFingerprint, - !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: incompatibleCache) - { - let daily = CostUsageScanner.buildCodexReportFromCache( - cache: incompatibleCache, - range: range, - modelsDevCacheRoot: options.cacheRoot) - if !daily.data.isEmpty { - reports.append(daily) - if incompatibleCache.lastScanUnixMs > 0 { - let scanAt = Date( - timeIntervalSince1970: TimeInterval(incompatibleCache.lastScanUnixMs) / 1000) - staleSnapshotUpdatedAt = scanAt - scanTimes.append(scanAt) - } - } } if includePiSessions, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index afe20eef51..f19687bb2c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "89e80f722cad05c8" + static let value = "97cf82ab7b18b255" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift deleted file mode 100644 index a7fc12f98d..0000000000 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ /dev/null @@ -1,1159 +0,0 @@ -import Foundation - -enum CostUsageCacheIO { - /// Persistence budgets for the Codex cost cache. The artifact holds one entry per - /// scanned session file plus per-file detail (rows, turn IDs, token snapshots) and is - /// decoded and encoded as a single JSON document on every scan, so an unbounded corpus - /// can otherwise grow it to multiple gigabytes. These bounds mirror the scan-side byte - /// budgets. In-window entries are only dropped by the last-resort budget trim, which - /// marks the artifact for catch-up so reports recover on the next refresh. - static let maxCacheFileBytes: Int = 256 * 1024 * 1024 - static let maxCacheFileEntries: Int = 25000 - /// Artifacts above this size are refused at load time and rebuilt by the bounded - /// scanner instead of being decoded in one shot. `JSONDecoder` materializes the whole - /// object graph at roughly an order of magnitude over the artifact size (#2637 traced - /// multi-GiB `MALLOC_LARGE` spikes to exactly this decode), so the cap stays close to - /// the save budget: `save` bounds artifacts to `maxCacheFileBytes`, and when protected - /// entries (resuming sessions, fork parents) cannot be trimmed further it may overshoot - /// only up to this load cap. Anything above the cap is a legacy or foreign artifact - /// that is cheaper to rebuild bounded than to decode in one shot. - static let maxCacheLoadBytes: Int = 320 * 1024 * 1024 - - /// Producer keys from older parser hashes whose caches are still valid under the current - /// delta semantics. #2037 invalidated earlier keys; every rotation since #2632 (append-safe - /// fork resume, bounded persistence, provider-special-case refactors, catch-up report - /// calendar normalization) preserved stored totals and cache layout, so all shipped - /// predecessors back to #2632 remain reusable. - private static let compatibleCodexProducerKeys: Set = [ - "codex:cu:p1cd29792d9ca2b11", - "codex:cu:p37aedd661c4272a8", - "codex:cu:p6c0f1fa950e63467", - "codex:cu:paa27d287348e79b5", - "codex:cu:p843ca061c36bbea1", - ] - - /// Parsing and attribution changes rotate the Codex parser producer key. - /// Increment this artifact version only when the stored schema or cache layout becomes incompatible. - private static func artifactVersion(for provider: UsageProvider) -> Int { - // Provider-specific by design: scanner parser/schema compatibility versions differ by cache producer. - switch provider { - case .codex: - 11 - case .claude, .vertexai: - 6 - default: - 1 - } - } - - private static func defaultCacheRoot() -> URL { - let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - return root.appendingPathComponent("CodexBar", isDirectory: true) - } - - static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { - let root = cacheRoot ?? self.defaultCacheRoot() - let artifactVersion = self.artifactVersion(for: provider) - return root - .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("\(provider.rawValue)-v\(artifactVersion).json", isDirectory: false) - } - - static func load( - provider: UsageProvider, - cacheRoot: URL? = nil, - producerKey: String? = nil, - calendar: Calendar? = nil, - maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCache - { - let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - // Provider-specific by design: only Codex persistence carries bounded resume/discovery scan state. - // Only Codex has bounded persistence pruning on save; other providers would be - // rejected, rebuilt, and written oversized again on every refresh. - let effectiveMaxBytes = provider == .codex ? maxCacheBytes : Int.max - let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) - let compatibleProducerKeys = producerKey == nil && provider == .codex - ? self.compatibleCodexProducerKeys - : [] - if let decoded = self.loadCache( - at: url, - expectedProducerKey: expectedProducerKey, - compatibleProducerKeys: compatibleProducerKeys, - maxBytes: effectiveMaxBytes) - { - if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { - return CostUsageCache() - } - return decoded - } - return CostUsageCache() - } - - static func loadCodexForMigration( - cacheRoot: URL? = nil, - producerKey: String? = nil, - calendar: Calendar? = nil, - maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCodexCacheLoadResult - { - let url = self.cacheFileURL(provider: .codex, cacheRoot: cacheRoot) - guard let decoded = self.decodeCache(at: url, maxBytes: maxCacheBytes) else { - return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: nil) - } - if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { - return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: nil) - } - - let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: .codex) - let compatibleProducerKeys = producerKey == nil ? self.compatibleCodexProducerKeys : [] - if decoded.producerKey == expectedProducerKey - || decoded.producerKey.map(compatibleProducerKeys.contains) == true - { - return CostUsageCodexCacheLoadResult(cache: decoded, incompatibleCache: nil) - } - - // Never reuse parser-dependent offsets or totals from an incompatible producer. The - // caller may still convert its last visible report into a compact, explicitly stale - // presentation while the current producer rebuilds from byte zero. - guard decoded.producerKey != nil else { - return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: nil) - } - return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: decoded) - } - - private static func loadCache( - at url: URL, - expectedProducerKey: String?, - compatibleProducerKeys: Set, - maxBytes: Int) -> CostUsageCache? - { - guard let decoded = self.decodeCache(at: url, maxBytes: maxBytes) else { return nil } - if let expectedProducerKey { - guard decoded.producerKey == expectedProducerKey - || decoded.producerKey.map(compatibleProducerKeys.contains) == true - else { return nil } - } - return decoded - } - - private static func decodeCache(at url: URL, maxBytes: Int) -> CostUsageCache? { - let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? - .int64Value ?? 0 - guard fileSize <= maxBytes else { return nil } - guard let data = try? Data(contentsOf: url) else { return nil } - guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) - else { return nil } - guard decoded.version == 1 else { return nil } - return decoded - } - - static func save( - provider: UsageProvider, - cache: CostUsageCache, - cacheRoot: URL? = nil, - producerKey: String? = nil, - calendar: Calendar = .current, - requestedScanWindow: (sinceKey: String, untilKey: String)? = nil, - reportWindow: (sinceKey: String, untilKey: String)? = nil, - maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes, - maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries, - maxCacheLoadBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) - { - let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - - var cache = cache - cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) - cache.timeZoneIdentifier = calendar.timeZone.identifier - - if provider == .codex { - _ = Self.pruneCodexCacheForBudget( - &cache, - requestedScanWindow: requestedScanWindow, - calendar: calendar, - maxCacheBytes: maxCacheBytes, - maxCacheEntries: maxCacheEntries, - previousArtifactBytes: Self.fileSize(at: url)) - // Estimate before materializing the document so a refresh that grew the cache - // stays bounded even when the previous artifact was within budget. - if Self.estimatedCodexCacheBytes(cache) > maxCacheBytes { - Self.pruneCodexCacheForBudget( - &cache, - requestedScanWindow: requestedScanWindow, - calendar: calendar, - maxCacheBytes: maxCacheBytes, - maxCacheEntries: maxCacheEntries, - previousArtifactBytes: nil, - force: true) - _ = Self.trimInWindowEntriesForBudget( - &cache, - calendar: calendar, - maxCacheBytes: maxCacheBytes, - reportWindow: reportWindow) - } - } - - var data = (try? JSONEncoder().encode(cache)) ?? Data() - if provider == .codex, data.count > maxCacheBytes { - // The estimate underestimated the payload; prune again so the artifact stays - // loadable and the next refresh never hits the load-refusal rebuild loop. - _ = Self.pruneCodexCacheForBudget( - &cache, - requestedScanWindow: requestedScanWindow, - calendar: calendar, - maxCacheBytes: maxCacheBytes, - maxCacheEntries: maxCacheEntries, - previousArtifactBytes: nil, - force: true) - data = (try? JSONEncoder().encode(cache)) ?? Data() - var iterations = 0 - while data.count > maxCacheBytes, iterations < 4 { - iterations += 1 - let strippedDetail = Self.stripAllInWindowDetailForBudget( - &cache, - calendar: calendar, - reportWindow: reportWindow) - let clearedLookback = Self.clearActiveLookbackForBudget(&cache) - let prunedOrphans = Self.pruneOrphanedDiscovery(&cache, maxCacheBytes: maxCacheBytes) - guard strippedDetail || clearedLookback || prunedOrphans else { break } - data = (try? JSONEncoder().encode(cache)) ?? Data() - } - // The loop can stall with the payload still above the save budget when every - // remaining byte belongs to protected entries. That overshoot is bounded by - // `maxCacheLoadBytes` below; the artifact stays loadable, so the next refresh - // keeps trimming instead of entering a full-rebuild loop. - } - if provider == .codex, data.count > maxCacheLoadBytes { - // Enforcement could not shrink the payload below what `load` accepts (e.g. the - // bulk lives in unstrippable resume/buffered state). Persisting it would make - // every launch decode a multi-GiB document just to refuse it; drop the artifact - // instead so the bounded scanner rebuilds from scratch. - try? FileManager.default.removeItem(at: url) - return - } - try? data.write(to: url, options: [.atomic]) - } - - // swiftlint:disable function_parameter_count - /// Bounds the Codex cache artifact when the corpus has outgrown the persistence budget. - /// The all-time accumulation lives in per-file entries whose usage days fall outside the - /// current scan window; the current report never reads those entries, and dropping them - /// (with the same day-aggregate subtraction the scanner uses) keeps the artifact from - /// growing without limit. Entries that are still resuming or that in-window forks depend - /// on are preserved so bounded scans and fork baselines keep making progress. Priority - /// turn IDs outside the window are only consulted for in-window rows, so they are trimmed - /// to the window as well. - private static func pruneCodexCacheForBudget( - _ cache: inout CostUsageCache, - requestedScanWindow: (sinceKey: String, untilKey: String)?, - calendar: Calendar, - maxCacheBytes: Int, - maxCacheEntries: Int, - previousArtifactBytes: Int64?, - force: Bool = false) -> Bool - { - // Prune against the active requested scan window (what the current report reads), - // not the historically widened retained union persisted in the cache. - let sinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey - let untilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey - guard let sinceKey, let untilKey else { return false } - let overBudget = force || cache.files.count > maxCacheEntries - || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) - guard overBudget else { return false } - - let outOfWindowCandidates = cache.files.keys.filter { key in - guard let usage = cache.files[key] else { return false } - if usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) { return false } - if usage.codexScanComplete == false { return false } - if usage.codexJSONLResumeState != nil { return false } - if usage.hasBufferedCodexForkRetryLines { return false } - if Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) { - return false - } - return true - } - // Protect parents referenced by entries that survive pruning. A stale child that is - // removed in this pass must not keep its stale parent alive. - let survivingKeys = Set(cache.files.keys).subtracting(outOfWindowCandidates) - let survivingParentIDs: [String] = survivingKeys.compactMap { key in - guard let usage = cache.files[key] else { return nil } - if usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey { - return nil - } - return usage.forkedFromId - } - let survivingParentSessionIDs = Set(survivingParentIDs) - let outOfWindowKeys = outOfWindowCandidates.filter { key in - guard let sessionId = cache.files[key]?.sessionId else { return true } - return !survivingParentSessionIDs.contains(sessionId) - } - var removedPaths: Set = [] - var removedSessionIDs: Set = [] - for key in outOfWindowKeys { - guard let old = cache.files.removeValue(forKey: key) else { continue } - removedPaths.insert(key) - if let sessionId = old.sessionId { - removedSessionIDs.insert(sessionId) - } - CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) - } - if !removedPaths.isEmpty { - Self.pruneDiscovery(&cache, removedPaths: removedPaths, removedSessionIDs: removedSessionIDs) - } - if !outOfWindowKeys.isEmpty, requestedScanWindow != nil { - // Entries outside the requested window are gone; narrow persisted coverage so a - // later refresh does not treat them as in-window again. - cache.scanSinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey - cache.scanUntilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey - } - - let inWindow: (String) -> Bool = { key in - CostUsageScanner.CostUsageDayRange.isInRange( - dayKey: key, - since: sinceKey, - until: untilKey) - } - var trimmedTurnIDs = false - if let idsByDay = cache.codexPriorityTurnIDsByDay { - let trimmed = idsByDay.filter { inWindow($0.key) } - cache.codexPriorityTurnIDsByDay = trimmed.isEmpty ? nil : trimmed - trimmedTurnIDs = trimmed.count != idsByDay.count - } - if let turnKeys = cache.codexPriorityTurnKeys { - let trimmed = turnKeys.filter { inWindow($0.key) } - cache.codexPriorityTurnKeys = trimmed.isEmpty ? nil : trimmed - trimmedTurnIDs = trimmedTurnIDs || trimmed.count != turnKeys.count - } - return !outOfWindowKeys.isEmpty || trimmedTurnIDs - } - - // swiftlint:enable function_parameter_count - - /// Drops the oldest completed in-window entries until the estimated payload fits the - /// byte budget. This is the last line of defense for window-heavy corpora: dropping - /// entries (with the same day-aggregate subtraction the scanner uses) keeps the artifact - /// loadable, so the load cap never rejects what `save` can produce and refreshes cannot - /// fall into a permanent full-rebuild loop. Dropped in-window files are rediscovered and - /// rescanned by the bounded scanner on later refreshes. - private static func trimInWindowEntriesForBudget( - _ cache: inout CostUsageCache, - calendar: Calendar, - maxCacheBytes: Int, - reportWindow: (sinceKey: String, untilKey: String)?) -> Bool - { - guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } - let candidates: [(key: String, usage: CostUsageFileUsage)] = cache.files.compactMap { key, usage in - let inWindow = usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) - || Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) - guard inWindow else { return nil } - if usage.codexScanComplete == false { return nil } - if usage.codexJSONLResumeState != nil { return nil } - if usage.hasBufferedCodexForkRetryLines { return nil } - return (key, usage) - } - guard !candidates.isEmpty else { return false } - // Protect parents referenced by entries that survive this trim; a child that is - // removed here must not keep its stale parent protected. Lineage-only children do - // not resolve inherited parent totals, so their parents need no protection either. - let candidateKeys = Set(candidates.map(\.key)) - let survivingParentIDs: [String] = cache.files.compactMap { key, usage in - if candidateKeys.contains(key) { return nil } - if usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey { - return nil - } - return usage.forkedFromId - } - let protectedParentIDsExcludingLineageOnly = Set(survivingParentIDs) - let protected = candidates.filter { candidate in - guard let sessionId = candidate.usage.sessionId else { return false } - return protectedParentIDsExcludingLineageOnly.contains(sessionId) - } - let droppable = candidates.filter { candidate in - guard let sessionId = candidate.usage.sessionId else { return true } - return !protectedParentIDsExcludingLineageOnly.contains(sessionId) - } - // Preserve the complete report from the untrimmed cache so catch-up displays full - // totals instead of the reduced window after a restart. - let preTrimCache = cache - let previousReport = cache.codexPreviousReport == nil - ? Self.previousReportForCatchUp( - cache: preTrimCache, - calendar: calendar, - reportWindow: reportWindow) - : nil - - // Drop oldest usage first so recent sessions keep their fork-baseline detail. - let oldestFirst = droppable.sorted { lhs, rhs in - let lhsDay = lhs.usage.days.keys.min() ?? "9999" - let rhsDay = rhs.usage.days.keys.min() ?? "9999" - return lhsDay < rhsDay - } - var estimated = Self.estimatedCodexCacheBytes(cache) - let target = max(1, (maxCacheBytes * 3) / 4) - var droppedKeys: [String] = [] - for (index, candidate) in oldestFirst.enumerated() where estimated > target { - // Always keep at least the newest entry so the artifact retains window data even - // when a single entry alone exceeds the target. - guard index < oldestFirst.count - 1 else { break } - droppedKeys.append(candidate.key) - estimated -= Self.estimatedFileUsageBytes(candidate.usage) - } - // A dropped parent may still be required by the newest survivor we keep. Never delete - // it; compact it instead so the retained child can resolve its fork baseline later. - var stripped = Self.compactParentsRequiredBySurvivors( - &cache, - droppedKeys: &droppedKeys) - var removedPaths: Set = [] - var removedSessionIDs: Set = [] - for key in droppedKeys { - guard let old = cache.files.removeValue(forKey: key) else { continue } - removedPaths.insert(key) - if let sessionId = old.sessionId { - removedSessionIDs.insert(sessionId) - } - CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) - } - // A protected parent referenced by an incomplete/buffered child cannot be dropped, - // but its rebuildable detail can still be compacted when it alone exceeds the budget. - let protectedBySize = protected.sorted { lhs, rhs in - Self.estimatedFileUsageBytes(lhs.usage) > Self.estimatedFileUsageBytes(rhs.usage) - } - for candidate in protectedBySize where estimated > target { - Self.stripFileUsageDetail(&cache, key: candidate.key) - stripped = true - estimated -= Self.estimatedFileUsageBytes(candidate.usage) - } - // A sole in-window entry can still exceed the budget alone. Strip its rebuildable - // detail (keeping identity, day aggregates, totals, and cost data) and force a - // bounded full re-read, so the persisted artifact always fits the load cap. - if estimated > target, let survivor = oldestFirst.last { - Self.stripFileUsageDetail(&cache, key: survivor.key) - stripped = true - } - if !removedPaths.isEmpty { - Self.pruneDiscovery(&cache, removedPaths: removedPaths, removedSessionIDs: removedSessionIDs) - } - if !removedPaths.isEmpty || stripped { - // Dropped or stripped in-window entries would under-report until the next refresh; - // mark the cache as needing catch-up so a cold restart re-scans them promptly. - cache.codexScanCatchUpPending = true - cache.lastScanUnixMs = 0 - if cache.codexPreviousReport == nil { - cache.codexPreviousReport = previousReport - } - } - return !droppedKeys.isEmpty || stripped - } - - /// Compacts (instead of dropping) parents that the entries kept by this trim still - /// reference, so retained fork children can resolve their baselines on later catch-up. - private static func compactParentsRequiredBySurvivors( - _ cache: inout CostUsageCache, - droppedKeys: inout [String]) -> Bool - { - let droppedSet = Set(droppedKeys) - let survivorsAfterDrop = cache.files.keys.filter { !droppedSet.contains($0) } - let neededBySurvivors: Set = Set(survivorsAfterDrop.compactMap { key in - guard let usage = cache.files[key] else { return nil } - if usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey { - return nil - } - return usage.forkedFromId - }) - var compactedAny = false - for key in droppedKeys where cache.files[key]?.sessionId.map(neededBySurvivors.contains) == true { - Self.stripFileUsageDetail(&cache, key: key) - droppedKeys.removeAll { $0 == key } - compactedAny = true - } - return compactedAny - } - - private static func previousReportForCatchUp( - cache: CostUsageCache, - calendar: Calendar, - reportWindow: (sinceKey: String, untilKey: String)?) -> CostUsageCodexPreviousReport? - { - // Preserve the user-facing report window, not the scan bounds (which the scanner - // pads by one day on each side). - guard let sinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey, - let untilKey = reportWindow?.untilKey ?? cache.scanUntilKey, - let since = CostUsageScanner.parseDayKey(sinceKey, calendar: calendar), - let until = CostUsageScanner.parseDayKey(untilKey, calendar: calendar) - else { return nil } - let range = CostUsageScanner.CostUsageDayRange( - since: since, - until: until, - calendar: calendar) - let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) - guard var previous = CostUsageCodexPreviousReport(report: report, cache: cache) else { - return nil - } - // Persist the bounds that match the report data (the user report window), not the - // scan-padded cache bounds, so matching never serves narrower data than requested. - previous.scanSinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey - previous.scanUntilKey = reportWindow?.untilKey ?? cache.scanUntilKey - return previous - } - - /// Last-resort enforcement for payloads the heuristic estimate underestimated: strips - /// rebuildable detail from every completed in-window entry (keeping identity, day - /// aggregates, totals, cost data, and fork metadata) and marks the artifact for - /// catch-up, so the persisted size always fits the load cap. - private static func stripAllInWindowDetailForBudget( - _ cache: inout CostUsageCache, - calendar: Calendar, - reportWindow: (sinceKey: String, untilKey: String)?) -> Bool - { - guard let sinceKey = cache.scanSinceKey, let untilKey = cache.scanUntilKey else { return false } - let preStripCache = cache - var strippedAny = false - for key in cache.files.keys { - guard let usage = cache.files[key] else { continue } - let inWindow = usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) - || Self.isRecentlyActive(usage, calendar: calendar, sinceKey: sinceKey, untilKey: untilKey) - guard inWindow, usage.codexScanComplete != false else { continue } - Self.stripFileUsageDetail(&cache, key: key) - strippedAny = true - } - if strippedAny { - cache.codexScanCatchUpPending = true - cache.lastScanUnixMs = 0 - if cache.codexPreviousReport == nil { - cache.codexPreviousReport = Self.previousReportForCatchUp( - cache: preStripCache, - calendar: calendar, - reportWindow: reportWindow) - } - } - return strippedAny - } - - /// Removes discovery records for session files that were pruned from `files` so the - /// persisted discovery state stays bounded with the artifact. - private static func pruneDiscovery( - _ cache: inout CostUsageCache, - removedPaths: Set, - removedSessionIDs: Set) - { - guard var discovery = cache.codexSessionDiscovery, !removedPaths.isEmpty else { return } - discovery.filePaths.removeAll { removedPaths.contains($0) } - discovery.fileStamps = discovery.fileStamps.filter { !removedPaths.contains($0.key) } - discovery.filePathBySessionId = discovery.filePathBySessionId.filter { - !removedSessionIDs.contains($0.key) - } - discovery.missingSessionIds.removeAll { removedSessionIDs.contains($0) } - discovery.pendingSessionIds.removeAll { removedSessionIDs.contains($0) } - if let head = discovery.headScan, removedPaths.contains(head.path) { - discovery.headScan = nil - } - // Cursors may point past the shortened arrays; reset them so the next discovery - // pass re-enqueues remaining files instead of finishing immediately. - discovery.nextFileIndex = 0 - discovery.nextDirectoryIndex = 0 - discovery.validationDirectoryIndex = 0 - // A compacted discovery is no longer complete; the scanner re-enqueues current files - // under its bounded budget instead of trusting stale coverage. - discovery.isComplete = false - cache.codexSessionDiscovery = discovery - } - - /// Strips rebuildable per-file detail from the sole oversized survivor so the artifact - /// stays within the byte budget. Day aggregates, totals, cost data, identity, and fork - /// metadata are kept; a zero `parsedBytes` forces a bounded full re-read on the next - /// refresh so any rebuilt index covers the whole file. - private static func stripFileUsageDetail(_ cache: inout CostUsageCache, key: String) { - guard var usage = cache.files[key] else { return } - usage.codexRows = nil - usage.codexTurnIDs = nil - usage.codexTokenSnapshots = nil - usage.codexTokenCheckpoints = nil - usage.codexTokenTimestampsMonotonic = nil - usage.codexTokenIndexAnchor = nil - usage.seenRawTotals = nil - usage.hasDivergentTotals = nil - usage.hasInterleavedTotals = nil - usage.lastRawTotalsBaseline = nil - usage.lastRawTotalsWatermark = nil - usage.parsedBytes = 0 - usage.codexCostCacheComplete = nil - usage.codexScanComplete = false - usage.codexScanFileId = nil - cache.files[key] = usage - } - - /// Cheap upper-bound-ish estimate of the encoded JSON payload, used to decide whether to - /// prune before materializing the document. Deliberately conservative per-entry overhead - /// so the estimate triggers at or before the real byte budget. - private static func estimatedCodexCacheBytes(_ cache: CostUsageCache) -> Int { - var bytes = 4096 - bytes += cache.files.count * 160 - for usage in cache.files.values { - bytes += Self.estimatedFileUsageBytes(usage) - } - if let idsByDay = cache.codexPriorityTurnIDsByDay { - for (day, ids) in idsByDay { - bytes += day.count + 32 + ids.count * 48 - } - } - if let turnKeys = cache.codexPriorityTurnKeys { - for (key, value) in turnKeys { - bytes += key.count + value.count + 48 - } - } - if let discovery = cache.codexSessionDiscovery { - bytes += discovery.filePaths.count * 110 - bytes += discovery.fileStamps.count * 100 - bytes += discovery.filePathBySessionId.count * 80 - bytes += discovery.missingSessionIds.count * 48 - bytes += discovery.pendingSessionIds.count * 48 - bytes += discovery.directoryPaths.count * 90 - bytes += discovery.directoryStamps.count * 70 - } - if let lookback = cache.codexActiveLookbackState { - bytes += lookback.pendingFilePaths.count * 110 - bytes += lookback.legacyRecursivePendingRootPaths.count * 90 - bytes += lookback.completedRootPaths.count * 90 - bytes += lookback.rootPaths.count * 90 - bytes += lookback.nextDayKeyByRoot.count * 60 - } - return bytes - } - - /// Compacts the persisted active-lookback state when it keeps the artifact over budget. - /// Pending file paths are moved into the discovery queue (so no queued scan work is - /// lost). Legacy recursive roots are left untouched: the discovery directory queue is - /// only consumed by fork-parent lookup, not by the ordinary refresh file list, so - /// migrating them there would silently skip recently modified archived sessions. - private static func clearActiveLookbackForBudget(_ cache: inout CostUsageCache) -> Bool { - guard var lookback = cache.codexActiveLookbackState, - !lookback.pendingFilePaths.isEmpty - else { return false } - let pendingPaths = lookback.pendingFilePaths - if !pendingPaths.isEmpty { - var discovery = cache.codexSessionDiscovery - if discovery == nil { - discovery = CostUsageCodexSessionDiscovery( - roots: lookback.rootPaths, - generation: nil, - directoryStamps: [:], - directoryPaths: [], - nextDirectoryIndex: 0, - filePaths: [], - nextFileIndex: 0, - fileStamps: [:], - headScan: nil, - filePathBySessionId: [:], - missingSessionIds: [], - pendingSessionIds: [], - validationDirectoryIndex: 0, - isComplete: false) - } - var seen = Set(discovery?.filePaths ?? []) - for path in pendingPaths where !seen.contains(path) { - discovery?.filePaths.append(path) - seen.insert(path) - } - cache.codexSessionDiscovery = discovery - } - lookback.pendingFilePaths = [] - cache.codexActiveLookbackState = lookback - return true - } - - /// Removes session-id mappings that point to paths neither in the pending discovery - /// queue nor in the parsed `files` set, and compacts missing/pending session IDs to the - /// byte budget. Orphaned mappings come from sessions that were deleted or pruned in an - /// earlier pass and can dominate the artifact without contributing anything; the pending - /// path queue itself is left untouched. - private static func pruneOrphanedDiscovery( - _ cache: inout CostUsageCache, - maxCacheBytes: Int) -> Bool - { - guard var discovery = cache.codexSessionDiscovery else { return false } - let knownPaths = Set(cache.files.keys) - let queuedPaths = Set(discovery.filePaths) - let before = discovery.filePathBySessionId.count - discovery.filePathBySessionId = discovery.filePathBySessionId.filter { _, path in - queuedPaths.contains(path) || knownPaths.contains(path) - } - let mappingsChanged = discovery.filePathBySessionId.count != before - - // Compress missing/pending session-ID lists to what the remaining byte budget can - // hold, sharing one capacity across both lists. They are rediscoverable bookkeeping, - // not parsed data. - let idBytes = 48 - let baseEstimate = Self.estimatedCodexCacheBytes(cache) - - (discovery.missingSessionIds.count + discovery.pendingSessionIds.count) * idBytes - let keepCount = max(0, (maxCacheBytes - baseEstimate) / idBytes) - let keepMissing = min(discovery.missingSessionIds.count, keepCount) - let keepPending = min(discovery.pendingSessionIds.count, max(0, keepCount - keepMissing)) - let missingChanged = discovery.missingSessionIds.count > keepMissing - if missingChanged { - discovery.missingSessionIds = Array(discovery.missingSessionIds.prefix(keepMissing)) - } - let pendingChanged = discovery.pendingSessionIds.count > keepPending - if pendingChanged { - discovery.pendingSessionIds = Array(discovery.pendingSessionIds.prefix(keepPending)) - } - guard mappingsChanged || missingChanged || pendingChanged else { return false } - cache.codexSessionDiscovery = discovery - return true - } - - private static func estimatedFileUsageBytes(_ usage: CostUsageFileUsage) -> Int { - var bytes = 240 - for (day, models) in usage.days { - bytes += day.count + 32 - for (model, packed) in models { - bytes += model.count + 40 + packed.count * 10 - } - } - bytes += (usage.codexRows?.count ?? 0) * 140 - bytes += (usage.codexTurnIDs?.count ?? 0) * 56 - bytes += (usage.codexTokenSnapshots?.count ?? 0) * 96 - bytes += (usage.codexTokenCheckpoints?.count ?? 0) * 84 - bytes += (usage.seenRawTotals?.count ?? 0) * 72 - for map in [ - usage.codexCostNanos, - usage.codexPrioritySurchargeNanos, - usage.codexStandardCostNanos, - usage.codexPriorityCostNanos, - ].compactMap(\.self) { - for (day, values) in map { - bytes += day.count + 32 + values.count * 72 - } - } - for map in [usage.codexStandardTokens, usage.codexPriorityTokens].compactMap(\.self) { - for (day, values) in map { - bytes += day.count + 32 + values.count * 40 - } - } - return bytes - } - - /// A session file whose modification time falls inside the scan window is active even - /// when it has produced no usage rows yet (e.g. a session started today); dropping it - /// would make every refresh rediscover and fully parse it. - private static func isRecentlyActive( - _ usage: CostUsageFileUsage, - calendar: Calendar, - sinceKey: String, - untilKey: String) -> Bool - { - guard usage.mtimeUnixMs > 0 else { return false } - let scanCalendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) - let mtimeDayKey = CostUsageScanner.CostUsageDayRange.dayKey( - from: Date(timeIntervalSince1970: TimeInterval(usage.mtimeUnixMs) / 1000), - calendar: scanCalendar) - return CostUsageScanner.CostUsageDayRange.isInRange( - dayKey: mtimeDayKey, - since: sinceKey, - until: untilKey) - } - - private static func fileSize(at url: URL) -> Int64? { - (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value - } - - static func currentProducerKey( - provider: UsageProvider, - parserHash: String = CodexParserHash.value) -> String? - { - // Provider-specific by design: only the Codex incremental parser persists a producer hash. - guard provider == .codex else { return nil } - return "\(provider.rawValue):cu:p\(parserHash)" - } -} - -struct CostUsageCodexCacheLoadResult { - var cache: CostUsageCache - var incompatibleCache: CostUsageCache? -} - -struct CostUsageCache: Codable { - var version: Int = 1 - var producerKey: String? - var lastScanUnixMs: Int64 = 0 - var scanSinceKey: String? - var scanUntilKey: String? - var timeZoneIdentifier: String? - var codexPricingKey: String? - var codexPriorityMetadataKey: String? - var codexProjectMetadataVersion: Int? - var codexPriorityTurnKeys: [String: String]? - var codexPriorityTurnIDsByDay: [String: [String]]? - /// True when the last bounded scan left readable Codex work for a background catch-up pass. - var codexScanCatchUpPending: Bool? - var codexScanProcessedBytes: Int64? - var codexScanTotalBytes: Int64? - var codexScanCompletedFiles: Int? - var codexScanTotalFiles: Int? - /// Last user-visible report retained only while an incompatible or forced rebuild catches up. - var codexPreviousReport: CostUsageCodexPreviousReport? - /// Persistent session-id discovery and generation-scoped negative lookups for fork parents. - var codexSessionDiscovery: CostUsageCodexSessionDiscovery? - /// Resumable bounded discovery for recently modified rollouts in older date partitions. - var codexActiveLookbackState: CostUsageCodexActiveLookbackState? - - /// filePath -> file usage - var files: [String: CostUsageFileUsage] = [:] - - /// dayKey -> model -> packed usage - var days: [String: [String: [Int]]] = [:] - - /// rootPath -> mtime (for Claude roots) - var roots: [String: Int64]? -} - -struct CostUsageCodexActiveLookbackState: Codable { - var scanSinceKey: String - var rootPaths: [String] - var nextDayKeyByRoot: [String: String] = [:] - var completedRootPaths: [String] = [] - var pendingFilePaths: [String] = [] - var legacyRecursivePendingRootPaths: [String] = [] -} - -struct CostUsageCodexSessionDiscovery: Codable { - struct DirectoryStamp: Codable, Equatable { - var mtimeUnixMs: Int64 - var jsonlFileCount: Int - } - - struct FileStamp: Codable, Equatable { - var mtimeUnixMs: Int64 - var size: Int64 - var fileId: String? - } - - struct HeadScan: Codable { - var path: String - var offset: Int64 - var resumeState: CostUsageJsonl.ResumeState? - } - - var roots: [String] - var generation: String? - var directoryStamps: [String: DirectoryStamp] - var directoryPaths: [String] - var nextDirectoryIndex: Int - var filePaths: [String] - var nextFileIndex: Int - var fileStamps: [String: FileStamp] - var headScan: HeadScan? - var filePathBySessionId: [String: String] - var missingSessionIds: [String] - var pendingSessionIds: [String] - var validationDirectoryIndex: Int - var isComplete: Bool -} - -struct CostUsageCodexPreviousReport: Codable, Equatable { - struct ModelBreakdown: Codable, Equatable { - var modelName: String - var costUSD: Double? - var totalTokens: Int? - var requestCount: Int? - var standardCostUSD: Double? - var priorityCostUSD: Double? - var standardTokens: Int? - var priorityTokens: Int? - - init(_ breakdown: CostUsageDailyReport.ModelBreakdown) { - self.modelName = breakdown.modelName - self.costUSD = breakdown.costUSD - self.totalTokens = breakdown.totalTokens - self.requestCount = breakdown.requestCount - self.standardCostUSD = breakdown.standardCostUSD - self.priorityCostUSD = breakdown.priorityCostUSD - self.standardTokens = breakdown.standardTokens - self.priorityTokens = breakdown.priorityTokens - } - - var dailyReportValue: CostUsageDailyReport.ModelBreakdown { - CostUsageDailyReport.ModelBreakdown( - modelName: self.modelName, - costUSD: self.costUSD, - totalTokens: self.totalTokens, - requestCount: self.requestCount, - standardCostUSD: self.standardCostUSD, - priorityCostUSD: self.priorityCostUSD, - standardTokens: self.standardTokens, - priorityTokens: self.priorityTokens) - } - } - - struct Entry: Codable, Equatable { - var date: String - var inputTokens: Int? - var cacheReadTokens: Int? - var cacheCreationTokens: Int? - var outputTokens: Int? - var totalTokens: Int? - var requestCount: Int? - var costUSD: Double? - var modelsUsed: [String]? - var modelBreakdowns: [ModelBreakdown]? - - init(_ entry: CostUsageDailyReport.Entry) { - self.date = entry.date - self.inputTokens = entry.inputTokens - self.cacheReadTokens = entry.cacheReadTokens - self.cacheCreationTokens = entry.cacheCreationTokens - self.outputTokens = entry.outputTokens - self.totalTokens = entry.totalTokens - self.requestCount = entry.requestCount - self.costUSD = entry.costUSD - self.modelsUsed = entry.modelsUsed - self.modelBreakdowns = entry.modelBreakdowns?.map(ModelBreakdown.init) - } - - var dailyReportValue: CostUsageDailyReport.Entry { - CostUsageDailyReport.Entry( - date: self.date, - inputTokens: self.inputTokens, - outputTokens: self.outputTokens, - cacheReadTokens: self.cacheReadTokens, - cacheCreationTokens: self.cacheCreationTokens, - totalTokens: self.totalTokens, - requestCount: self.requestCount, - costUSD: self.costUSD, - modelsUsed: self.modelsUsed, - modelBreakdowns: self.modelBreakdowns?.map(\.dailyReportValue)) - } - } - - struct Summary: Codable, Equatable { - var totalInputTokens: Int? - var totalOutputTokens: Int? - var cacheReadTokens: Int? - var cacheCreationTokens: Int? - var totalTokens: Int? - var totalCostUSD: Double? - - init(_ summary: CostUsageDailyReport.Summary) { - self.totalInputTokens = summary.totalInputTokens - self.totalOutputTokens = summary.totalOutputTokens - self.cacheReadTokens = summary.cacheReadTokens - self.cacheCreationTokens = summary.cacheCreationTokens - self.totalTokens = summary.totalTokens - self.totalCostUSD = summary.totalCostUSD - } - - var dailyReportValue: CostUsageDailyReport.Summary { - CostUsageDailyReport.Summary( - totalInputTokens: self.totalInputTokens, - totalOutputTokens: self.totalOutputTokens, - cacheReadTokens: self.cacheReadTokens, - cacheCreationTokens: self.cacheCreationTokens, - totalTokens: self.totalTokens, - totalCostUSD: self.totalCostUSD) - } - } - - var data: [Entry] - var summary: Summary? - var updatedAtUnixMs: Int64 - var scanSinceKey: String? - var scanUntilKey: String? - var timeZoneIdentifier: String? - var roots: [String: Int64]? - - init?( - report: CostUsageDailyReport, - cache: CostUsageCache) - { - guard !report.data.isEmpty else { return nil } - self.data = report.data.map(Entry.init) - self.summary = report.summary.map(Summary.init) - self.updatedAtUnixMs = cache.lastScanUnixMs - self.scanSinceKey = cache.scanSinceKey - self.scanUntilKey = cache.scanUntilKey - self.timeZoneIdentifier = cache.timeZoneIdentifier - self.roots = cache.roots - } - - var report: CostUsageDailyReport { - CostUsageDailyReport( - data: self.data.map(\.dailyReportValue), - summary: self.summary?.dailyReportValue) - } - - var updatedAt: Date? { - guard self.updatedAtUnixMs > 0 else { return nil } - return Date(timeIntervalSince1970: TimeInterval(self.updatedAtUnixMs) / 1000) - } - - func matches( - scanSinceKey: String, - scanUntilKey: String, - timeZoneIdentifier: String, - roots: [String: Int64]) -> Bool - { - guard self.timeZoneIdentifier == timeZoneIdentifier, - self.roots == roots, - let cachedSince = self.scanSinceKey, - let cachedUntil = self.scanUntilKey - else { return false } - return scanSinceKey >= cachedSince && scanUntilKey <= cachedUntil - } -} - -struct CostUsageFileUsage: Codable { - var mtimeUnixMs: Int64 - var size: Int64 - var days: [String: [String: [Int]]] - var parsedBytes: Int64? - var lastModel: String? - var lastTotals: CostUsageCodexTotals? - var lastCountedTotals: CostUsageCodexTotals? - var lastRawTotalsBaseline: CostUsageCodexTotals? - var lastRawTotalsWatermark: CostUsageCodexTotals? - var seenRawTotals: [CostUsageCodexTotals]? - var hasDivergentTotals: Bool? - var hasInterleavedTotals: Bool? - var lastCodexTurnID: String? - var sessionId: String? - var forkedFromId: String? - var forkBaselineDependencyKey: String? - var projectPath: String? - var canonicalProjectPath: String? - var codexCostCacheComplete: Bool? - var codexSession: CostUsageCodexSessionMetadata? - var codexCostNanos: [String: [String: Int64]]? - var codexPrioritySurchargeNanos: [String: [String: Int64]]? - var codexStandardCostNanos: [String: [String: Int64]]? - var codexPriorityCostNanos: [String: [String: Int64]]? - var codexStandardTokens: [String: [String: Int]]? - var codexPriorityTokens: [String: [String: Int]]? - var codexTurnIDs: [String]? - /// Refreshed by Codex normalization paths, never by sidecar cache validation. - var codexWorkspaceContentFingerprint: String? - var codexRows: [CostUsageScanner.CodexUsageRow]? - /// Compact token events used to resolve fork baselines without rereading an entire parent rollout. - var codexTokenSnapshots: [CostUsageCodexTokenSnapshot]? - /// Sparse accumulator states for bounded lookup inside `codexTokenSnapshots`. - var codexTokenCheckpoints: [CostUsageCodexTokenCheckpoint]? - /// Allows binary-search and early-stop lookup only when event timestamps follow file order. - var codexTokenTimestampsMonotonic: Bool? - /// Validates that the indexed JSONL prefix was not rewritten before an append. - var codexTokenIndexAnchor: CostUsageCodexTokenIndexAnchor? - var claudeRows: [CostUsageScanner.ClaudeUsageRow]? - /// Identity and latest observed size for an in-progress bounded Codex parse. - var codexScanFileId: String? - var codexScanTargetSize: Int64? - var codexScanComplete: Bool? - var codexJSONLResumeState: CostUsageJsonl.ResumeState? - /// Compact relevant events retained while a subagent rollout awaits full-shape classification. - var codexBufferedSubagentLines: [CostUsageScanner.CodexBufferedFastLine]? - /// Parsed events retained when an ordinary fork is waiting for its parent baseline. - var codexBufferedUnresolvedForkLines: [CostUsageScanner.CodexBufferedFastLine]? - - var hasBufferedCodexForkRetryLines: Bool { - self.codexBufferedSubagentLines?.isEmpty == false - || self.codexBufferedUnresolvedForkLines?.isEmpty == false - } -} - -struct CostUsageCodexSessionMetadata: Codable, Equatable { - var sessionId: String? - var forkedFromId: String? - var cwd: String? - var title: String? - var startedAtUnixMs: Int64? - var latestActivityUnixMs: Int64? - - var isEmpty: Bool { - self.sessionId == nil - && self.forkedFromId == nil - && self.cwd == nil - && self.title == nil - && self.startedAtUnixMs == nil - && self.latestActivityUnixMs == nil - } - - func merging(_ newer: CostUsageCodexSessionMetadata) -> CostUsageCodexSessionMetadata { - CostUsageCodexSessionMetadata( - sessionId: newer.sessionId ?? self.sessionId, - forkedFromId: newer.forkedFromId ?? self.forkedFromId, - cwd: newer.cwd ?? self.cwd, - title: newer.title ?? self.title, - startedAtUnixMs: Self.earlier(self.startedAtUnixMs, newer.startedAtUnixMs), - latestActivityUnixMs: Self.later(self.latestActivityUnixMs, newer.latestActivityUnixMs)) - } - - private static func earlier(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { - switch (lhs, rhs) { - case let (lhs?, rhs?): min(lhs, rhs) - case let (lhs?, nil): lhs - case let (nil, rhs?): rhs - case (nil, nil): nil - } - } - - private static func later(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { - switch (lhs, rhs) { - case let (lhs?, rhs?): max(lhs, rhs) - case let (lhs?, nil): lhs - case let (nil, rhs?): rhs - case (nil, nil): nil - } - } -} - -struct CostUsageCodexTotals: Codable, Equatable { - var input: Int - var cached: Int - var output: Int - var reasoning: Int? - - init(input: Int, cached: Int, output: Int, reasoning: Int? = nil) { - self.input = input - self.cached = cached - self.output = output - self.reasoning = reasoning - } -} - -struct CostUsageCodexTokenSnapshot: Codable, Equatable { - var timestamp: String - var last: CostUsageCodexTotals? - var total: CostUsageCodexTotals? - var endOffset: Int64? - - init( - timestamp: String, - last: CostUsageCodexTotals?, - total: CostUsageCodexTotals?, - endOffset: Int64? = nil) - { - self.timestamp = timestamp - self.last = last - self.total = total - self.endOffset = endOffset - } -} - -struct CostUsageCodexTokenAccumulatorState: Codable, Equatable { - var countedTotals: CostUsageCodexTotals? - var rawTotalsBaseline: CostUsageCodexTotals? - var sawDivergentTotals: Bool - var rawTotalsWatermark: CostUsageCodexTotals? - var seenRawTotals: [CostUsageCodexTotals] - var sawInterleavedTotals: Bool -} - -struct CostUsageCodexTokenCheckpoint: Codable, Equatable { - /// Index of the last token event already folded into `state`. - var eventIndex: Int - var timestamp: String - var endOffset: Int64 - var state: CostUsageCodexTokenAccumulatorState -} - -struct CostUsageCodexTokenIndexAnchor: Codable, Equatable { - var indexedBytes: Int64 - var windowStart: Int64 - var sha256: String -} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift new file mode 100644 index 0000000000..58c393a145 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift @@ -0,0 +1,361 @@ +import Foundation + +/// In-memory working set for one bounded scan. Codex persists this shape as normalized +/// `CostUsageStore` rows; Claude and Vertex use their independent compact JSON cache. +struct CostUsageCache: Codable, @unchecked Sendable { + var version: Int = 1 + var lastScanUnixMs: Int64 = 0 + var scanSinceKey: String? + var scanUntilKey: String? + var timeZoneIdentifier: String? + var codexPricingKey: String? + var codexPriorityMetadataKey: String? + var codexProjectMetadataVersion: Int? + var codexPriorityTurnKeys: [String: String]? + var codexPriorityTurnIDsByDay: [String: [String]]? + var codexScanCatchUpPending: Bool? + var codexScanProcessedBytes: Int64? + var codexScanTotalBytes: Int64? + var codexScanCompletedFiles: Int? + var codexScanTotalFiles: Int? + var codexPreviousReport: CostUsageCodexPreviousReport? + var codexSessionDiscovery: CostUsageCodexSessionDiscovery? + var codexActiveLookbackState: CostUsageCodexActiveLookbackState? + var files: [String: CostUsageFileUsage] = [:] + var days: [String: [String: [Int]]] = [:] + var roots: [String: Int64]? +} + +struct CostUsageCodexActiveLookbackState: Codable { + var scanSinceKey: String + var rootPaths: [String] + var nextDayKeyByRoot: [String: String] = [:] + var completedRootPaths: [String] = [] + var pendingFilePaths: [String] = [] + var legacyRecursivePendingRootPaths: [String] = [] +} + +struct CostUsageCodexSessionDiscovery: Codable { + struct DirectoryStamp: Codable, Equatable { + var mtimeUnixMs: Int64 + var jsonlFileCount: Int + } + + struct FileStamp: Codable, Equatable { + var mtimeUnixMs: Int64 + var size: Int64 + var fileId: String? + } + + struct HeadScan: Codable { + var path: String + var offset: Int64 + var resumeState: CostUsageJsonl.ResumeState? + } + + var roots: [String] + var generation: String? + var directoryStamps: [String: DirectoryStamp] + var directoryPaths: [String] + var nextDirectoryIndex: Int + var filePaths: [String] + var nextFileIndex: Int + var fileStamps: [String: FileStamp] + var headScan: HeadScan? + var filePathBySessionId: [String: String] + var missingSessionIds: [String] + var pendingSessionIds: [String] + var validationDirectoryIndex: Int + var isComplete: Bool +} + +struct CostUsageCodexPreviousReport: Codable, Equatable { + struct ModelBreakdown: Codable, Equatable { + var modelName: String + var costUSD: Double? + var totalTokens: Int? + var requestCount: Int? + var standardCostUSD: Double? + var priorityCostUSD: Double? + var standardTokens: Int? + var priorityTokens: Int? + + init(_ breakdown: CostUsageDailyReport.ModelBreakdown) { + self.modelName = breakdown.modelName + self.costUSD = breakdown.costUSD + self.totalTokens = breakdown.totalTokens + self.requestCount = breakdown.requestCount + self.standardCostUSD = breakdown.standardCostUSD + self.priorityCostUSD = breakdown.priorityCostUSD + self.standardTokens = breakdown.standardTokens + self.priorityTokens = breakdown.priorityTokens + } + + var dailyReportValue: CostUsageDailyReport.ModelBreakdown { + CostUsageDailyReport.ModelBreakdown( + modelName: self.modelName, + costUSD: self.costUSD, + totalTokens: self.totalTokens, + requestCount: self.requestCount, + standardCostUSD: self.standardCostUSD, + priorityCostUSD: self.priorityCostUSD, + standardTokens: self.standardTokens, + priorityTokens: self.priorityTokens) + } + } + + struct Entry: Codable, Equatable { + var date: String + var inputTokens: Int? + var cacheReadTokens: Int? + var cacheCreationTokens: Int? + var outputTokens: Int? + var totalTokens: Int? + var requestCount: Int? + var costUSD: Double? + var modelsUsed: [String]? + var modelBreakdowns: [ModelBreakdown]? + + init(_ entry: CostUsageDailyReport.Entry) { + self.date = entry.date + self.inputTokens = entry.inputTokens + self.cacheReadTokens = entry.cacheReadTokens + self.cacheCreationTokens = entry.cacheCreationTokens + self.outputTokens = entry.outputTokens + self.totalTokens = entry.totalTokens + self.requestCount = entry.requestCount + self.costUSD = entry.costUSD + self.modelsUsed = entry.modelsUsed + self.modelBreakdowns = entry.modelBreakdowns?.map(ModelBreakdown.init) + } + + var dailyReportValue: CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: self.date, + inputTokens: self.inputTokens, + outputTokens: self.outputTokens, + cacheReadTokens: self.cacheReadTokens, + cacheCreationTokens: self.cacheCreationTokens, + totalTokens: self.totalTokens, + requestCount: self.requestCount, + costUSD: self.costUSD, + modelsUsed: self.modelsUsed, + modelBreakdowns: self.modelBreakdowns?.map(\.dailyReportValue)) + } + } + + struct Summary: Codable, Equatable { + var totalInputTokens: Int? + var totalOutputTokens: Int? + var cacheReadTokens: Int? + var cacheCreationTokens: Int? + var totalTokens: Int? + var totalCostUSD: Double? + + init(_ summary: CostUsageDailyReport.Summary) { + self.totalInputTokens = summary.totalInputTokens + self.totalOutputTokens = summary.totalOutputTokens + self.cacheReadTokens = summary.cacheReadTokens + self.cacheCreationTokens = summary.cacheCreationTokens + self.totalTokens = summary.totalTokens + self.totalCostUSD = summary.totalCostUSD + } + + var dailyReportValue: CostUsageDailyReport.Summary { + CostUsageDailyReport.Summary( + totalInputTokens: self.totalInputTokens, + totalOutputTokens: self.totalOutputTokens, + cacheReadTokens: self.cacheReadTokens, + cacheCreationTokens: self.cacheCreationTokens, + totalTokens: self.totalTokens, + totalCostUSD: self.totalCostUSD) + } + } + + var data: [Entry] + var summary: Summary? + var updatedAtUnixMs: Int64 + var scanSinceKey: String? + var scanUntilKey: String? + var timeZoneIdentifier: String? + var roots: [String: Int64]? + + init?(report: CostUsageDailyReport, cache: CostUsageCache) { + guard !report.data.isEmpty else { return nil } + self.data = report.data.map(Entry.init) + self.summary = report.summary.map(Summary.init) + self.updatedAtUnixMs = cache.lastScanUnixMs + self.scanSinceKey = cache.scanSinceKey + self.scanUntilKey = cache.scanUntilKey + self.timeZoneIdentifier = cache.timeZoneIdentifier + self.roots = cache.roots + } + + var report: CostUsageDailyReport { + CostUsageDailyReport(data: self.data.map(\.dailyReportValue), summary: self.summary?.dailyReportValue) + } + + var updatedAt: Date? { + guard self.updatedAtUnixMs > 0 else { return nil } + return Date(timeIntervalSince1970: TimeInterval(self.updatedAtUnixMs) / 1000) + } + + func matches( + scanSinceKey: String, + scanUntilKey: String, + timeZoneIdentifier: String, + roots: [String: Int64]) -> Bool + { + guard self.timeZoneIdentifier == timeZoneIdentifier, + self.roots == roots, + let cachedSince = self.scanSinceKey, + let cachedUntil = self.scanUntilKey + else { return false } + return scanSinceKey >= cachedSince && scanUntilKey <= cachedUntil + } +} + +struct CostUsageFileUsage: Codable { + var mtimeUnixMs: Int64 + var size: Int64 + var days: [String: [String: [Int]]] + var parsedBytes: Int64? + var lastModel: String? + var lastTotals: CostUsageCodexTotals? + var lastCountedTotals: CostUsageCodexTotals? + var lastRawTotalsBaseline: CostUsageCodexTotals? + var lastRawTotalsWatermark: CostUsageCodexTotals? + var seenRawTotals: [CostUsageCodexTotals]? + var hasDivergentTotals: Bool? + var hasInterleavedTotals: Bool? + var lastCodexTurnID: String? + var sessionId: String? + var forkedFromId: String? + var forkBaselineDependencyKey: String? + var projectPath: String? + var canonicalProjectPath: String? + var codexCostCacheComplete: Bool? + var codexSession: CostUsageCodexSessionMetadata? + var codexCostNanos: [String: [String: Int64]]? + var codexPrioritySurchargeNanos: [String: [String: Int64]]? + var codexStandardCostNanos: [String: [String: Int64]]? + var codexPriorityCostNanos: [String: [String: Int64]]? + var codexStandardTokens: [String: [String: Int]]? + var codexPriorityTokens: [String: [String: Int]]? + var codexTurnIDs: [String]? + var codexWorkspaceContentFingerprint: String? + var codexRows: [CostUsageScanner.CodexUsageRow]? + var codexTokenSnapshots: [CostUsageCodexTokenSnapshot]? + var codexTokenCheckpoints: [CostUsageCodexTokenCheckpoint]? + var codexTokenTimestampsMonotonic: Bool? + var codexTokenIndexAnchor: CostUsageCodexTokenIndexAnchor? + var claudeRows: [CostUsageScanner.ClaudeUsageRow]? + var codexScanFileId: String? + var codexScanTargetSize: Int64? + var codexScanComplete: Bool? + var codexJSONLResumeState: CostUsageJsonl.ResumeState? + var codexBufferedSubagentLines: [CostUsageScanner.CodexBufferedFastLine]? + var codexBufferedUnresolvedForkLines: [CostUsageScanner.CodexBufferedFastLine]? + + var hasBufferedCodexForkRetryLines: Bool { + self.codexBufferedSubagentLines?.isEmpty == false + || self.codexBufferedUnresolvedForkLines?.isEmpty == false + } +} + +struct CostUsageCodexSessionMetadata: Codable, Equatable { + var sessionId: String? + var forkedFromId: String? + var cwd: String? + var title: String? + var startedAtUnixMs: Int64? + var latestActivityUnixMs: Int64? + + var isEmpty: Bool { + self.sessionId == nil && self.forkedFromId == nil && self.cwd == nil && self.title == nil + && self.startedAtUnixMs == nil && self.latestActivityUnixMs == nil + } + + func merging(_ newer: CostUsageCodexSessionMetadata) -> CostUsageCodexSessionMetadata { + CostUsageCodexSessionMetadata( + sessionId: newer.sessionId ?? self.sessionId, + forkedFromId: newer.forkedFromId ?? self.forkedFromId, + cwd: newer.cwd ?? self.cwd, + title: newer.title ?? self.title, + startedAtUnixMs: Self.earlier(self.startedAtUnixMs, newer.startedAtUnixMs), + latestActivityUnixMs: Self.later(self.latestActivityUnixMs, newer.latestActivityUnixMs)) + } + + private static func earlier(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + switch (lhs, rhs) { + case let (lhs?, rhs?): min(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } + + private static func later(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + switch (lhs, rhs) { + case let (lhs?, rhs?): max(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } +} + +struct CostUsageCodexTotals: Codable, Equatable { + var input: Int + var cached: Int + var output: Int + var reasoning: Int? + + init(input: Int, cached: Int, output: Int, reasoning: Int? = nil) { + self.input = input + self.cached = cached + self.output = output + self.reasoning = reasoning + } +} + +struct CostUsageCodexTokenSnapshot: Codable, Equatable { + var timestamp: String + var last: CostUsageCodexTotals? + var total: CostUsageCodexTotals? + var endOffset: Int64? + + init( + timestamp: String, + last: CostUsageCodexTotals?, + total: CostUsageCodexTotals?, + endOffset: Int64? = nil) + { + self.timestamp = timestamp + self.last = last + self.total = total + self.endOffset = endOffset + } +} + +struct CostUsageCodexTokenAccumulatorState: Codable, Equatable { + var countedTotals: CostUsageCodexTotals? + var rawTotalsBaseline: CostUsageCodexTotals? + var sawDivergentTotals: Bool + var rawTotalsWatermark: CostUsageCodexTotals? + var seenRawTotals: [CostUsageCodexTotals] + var sawInterleavedTotals: Bool +} + +struct CostUsageCodexTokenCheckpoint: Codable, Equatable { + var eventIndex: Int + var timestamp: String + var endOffset: Int64 + var state: CostUsageCodexTokenAccumulatorState +} + +struct CostUsageCodexTokenIndexAnchor: Codable, Equatable { + var indexedBytes: Int64 + var windowStart: Int64 + var sha256: String +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift new file mode 100644 index 0000000000..b053e86d42 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Claude and Vertex retain their small transcript cache. Codex deliberately has no route +/// through this JSON I/O boundary; its only persistence authority is `CostUsageStore`. +enum CostUsageClaudeCacheIO { + private static func defaultCacheRoot() -> URL { + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root.appendingPathComponent("CodexBar", isDirectory: true) + } + + static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { + precondition(provider == .claude || provider == .vertexai) + let root = cacheRoot ?? self.defaultCacheRoot() + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("\(provider.rawValue)-v6.json", isDirectory: false) + } + + static func load( + provider: UsageProvider, + cacheRoot: URL? = nil, + calendar: Calendar? = nil) -> CostUsageCache + { + let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) + guard let data = try? Data(contentsOf: url), + let cache = try? JSONDecoder().decode(CostUsageCache.self, from: data), + cache.version == 1 + else { return CostUsageCache() } + if let calendar, cache.timeZoneIdentifier != calendar.timeZone.identifier { + return CostUsageCache() + } + return cache + } + + static func save( + provider: UsageProvider, + cache: CostUsageCache, + cacheRoot: URL? = nil, + calendar: Calendar = .current) + { + let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + var cache = cache + cache.timeZoneIdentifier = calendar.timeZone.identifier + guard let data = try? JSONEncoder().encode(cache) else { return } + try? data.write(to: url, options: [.atomic]) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 31524c43bd..983f82c1e2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1159,6 +1159,13 @@ extension CostUsageScanner { let initialCountedTotals = cached.lastCountedTotals ?? cached.lastTotals let initialRawTotalsBaseline = cached.lastRawTotalsBaseline ?? cached.lastTotals let initialHasDivergentTotals = cached.hasDivergentTotals ?? (cached.lastTotals == nil) + let initialAccumulatorState = CostUsageCodexTokenAccumulatorState( + countedTotals: initialCountedTotals, + rawTotalsBaseline: initialRawTotalsBaseline, + sawDivergentTotals: initialHasDivergentTotals, + rawTotalsWatermark: cached.lastRawTotalsWatermark, + seenRawTotals: cached.seenRawTotals ?? [], + sawInterleavedTotals: cached.hasInterleavedTotals ?? false) // Correctness-critical interleave state is watermark + interleaved flag (+ counted/raw). // `seenRawTotals` is optional precision only and must not gate incremental resume (#2037). let hasIncompleteInterleaveState = @@ -1240,6 +1247,12 @@ extension CostUsageScanner { sessionId: sessionId, fileIdentity: input.metadata.path, state: &state) + let pricedUniqueRows = Self.codexRowsWithPricingAudit( + uniqueRows, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + context.workRecorder?.record(processed: uniqueRows.count, repriced: pricedUniqueRows.count) let migratedCached = sessionAlreadyContributed ? Self.codexFileUsageByFilteringRows(migrated, rows: retainedCachedRows, context: context) @@ -1316,13 +1329,18 @@ extension CostUsageScanner { migratedCached.codexPriorityTokens, splitMaps.priorityTokens), codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: uniqueRows), - codexRows: Self.codexRowsWithPricingAudit( - Self.mergeCodexRows(retainedCachedRows, rows: uniqueRows, sessionId: sessionId) ?? [], - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot), + codexRows: Self.mergeCodexRows( + retainedCachedRows, + rows: pricedUniqueRows, + sessionId: sessionId), codexTokenSnapshots: mergedTokenSnapshots, - codexTokenCheckpoints: Self.codexTokenCheckpoints(for: mergedTokenSnapshots), + codexTokenCheckpoints: isBufferedForkResume && startOffset == input.metadata.size + ? migratedCached.codexTokenCheckpoints + : Self.appendingCodexTokenCheckpoints( + delta.tokenSnapshots, + to: migratedCached.codexTokenCheckpoints ?? [], + startingEventIndex: migratedCached.codexTokenSnapshots?.count ?? 0, + initialState: initialAccumulatorState), codexTokenTimestampsMonotonic: Self.codexTokenTimestampsAreMonotonic(mergedTokenSnapshots), codexTokenIndexAnchor: Self.codexTokenIndexAnchor( fileURL: input.fileURL, @@ -1391,6 +1409,7 @@ extension CostUsageScanner { sessionId: sessionId, fileIdentity: input.metadata.path, state: &state) + context.workRecorder?.record(processed: uniqueRows.count, repriced: uniqueRows.count) if let sessionId, state.contributingSessionIds.contains(sessionId), uniqueRows.isEmpty, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index e3f4ff8ceb..d7d5ce462c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -31,7 +31,9 @@ extension CostUsageScanner { fileManager: FileManager = .default, workingDirectory: URL? = nil) -> [URL] { - if let override = options.claudeProjectsRoots { return override } + if let override = options.claudeProjectsRoots { + return override + } var roots: [URL] = [] @@ -123,13 +125,19 @@ extension CostUsageScanner { } func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } + if let n = v as? NSNumber { + return n.intValue + } return 0 } func toBool(_ value: Any?) -> Bool { - if let bool = value as? Bool { return bool } - if let number = value as? NSNumber { return number.boolValue } + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } return false } @@ -181,7 +189,9 @@ extension CostUsageScanner { total: cacheCreate) let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) let output = max(0, toInt(usage["output_tokens"])) - if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } + if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { + return + } let cost = CostUsagePricing.claudeCostUSD( model: model, @@ -438,13 +448,25 @@ extension CostUsageScanner { // Fallback: check for explicit Vertex AI metadata fields var candidates: [[String: Any]] = [obj] - if let metadata = obj["metadata"] as? [String: Any] { candidates.append(metadata) } - if let request = obj["request"] as? [String: Any] { candidates.append(request) } - if let context = obj["context"] as? [String: Any] { candidates.append(context) } - if let client = obj["client"] as? [String: Any] { candidates.append(client) } + if let metadata = obj["metadata"] as? [String: Any] { + candidates.append(metadata) + } + if let request = obj["request"] as? [String: Any] { + candidates.append(request) + } + if let context = obj["context"] as? [String: Any] { + candidates.append(context) + } + if let client = obj["client"] as? [String: Any] { + candidates.append(client) + } if let message = obj["message"] as? [String: Any] { - if let metadata = message["metadata"] as? [String: Any] { candidates.append(metadata) } - if let request = message["request"] as? [String: Any] { candidates.append(request) } + if let metadata = message["metadata"] as? [String: Any] { + candidates.append(metadata) + } + if let request = message["request"] as? [String: Any] { + candidates.append(request) + } } return candidates.contains { Self.containsVertexAIMetadata(in: $0) } @@ -473,9 +495,13 @@ extension CostUsageScanner { return true } if let nested = value as? [String: Any] { - if Self.containsVertexAIMetadata(in: nested) { return true } + if Self.containsVertexAIMetadata(in: nested) { + return true + } } else if let array = value as? [Any] { - if Self.containsVertexAIMetadata(in: array) { return true } + if Self.containsVertexAIMetadata(in: array) { + return true + } } } @@ -485,7 +511,9 @@ extension CostUsageScanner { private static func containsVertexAIMetadata(in array: [Any]) -> Bool { for entry in array { if let dict = entry as? [String: Any] { - if self.containsVertexAIMetadata(in: dict) { return true } + if self.containsVertexAIMetadata(in: dict) { + return true + } } } @@ -640,7 +668,9 @@ extension CostUsageScanner { guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } guard values.isRegularFile == true else { continue } let size = Int64(values.fileSize ?? 0) - if size <= 0 { continue } + if size <= 0 { + continue + } let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) @@ -661,7 +691,7 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load( + var cache = CostUsageClaudeCacheIO.load( provider: provider, cacheRoot: options.cacheRoot, calendar: range.calendar) @@ -716,7 +746,7 @@ extension CostUsageScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save( + CostUsageClaudeCacheIO.save( provider: provider, cache: cache, cacheRoot: options.cacheRoot, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 992c5fc2b1..0eb6afa009 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -43,6 +43,32 @@ enum CostUsageScanner { case excludeVertexAI } + struct CodexScanWorkMetrics: Equatable, Sendable { + var usageRowsProcessed: Int + var usageRowsRepriced: Int + } + + final class CodexScanWorkRecorder: @unchecked Sendable { + private let lock = NSLock() + private var processed = 0 + private var repriced = 0 + + func record(processed: Int, repriced: Int) { + self.lock.lock() + self.processed += max(0, processed) + self.repriced += max(0, repriced) + self.lock.unlock() + } + + func snapshot() -> CodexScanWorkMetrics { + self.lock.lock() + defer { self.lock.unlock() } + return CodexScanWorkMetrics( + usageRowsProcessed: self.processed, + usageRowsRepriced: self.repriced) + } + } + struct Options { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? @@ -64,6 +90,7 @@ enum CostUsageScanner { var maxCodexScanDurationPerRefresh: TimeInterval? /// Prefer newest session files first so recent usage lands before catch-up work. var preferNewestCodexSessionsFirst: Bool = true + var codexScanWorkRecorderForTesting: CodexScanWorkRecorder? init( codexSessionsRoot: URL? = nil, @@ -76,7 +103,8 @@ enum CostUsageScanner { maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, maxCodexScanDurationPerRefresh: TimeInterval? = nil, - preferNewestCodexSessionsFirst: Bool = true) + preferNewestCodexSessionsFirst: Bool = true, + codexScanWorkRecorderForTesting: CodexScanWorkRecorder? = nil) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots @@ -89,6 +117,7 @@ enum CostUsageScanner { self.maxCodexScanBytesPerRefresh = max(0, maxCodexScanBytesPerRefresh) self.maxCodexScanDurationPerRefresh = maxCodexScanDurationPerRefresh.map { max(0, $0) } self.preferNewestCodexSessionsFirst = preferNewestCodexSessionsFirst + self.codexScanWorkRecorderForTesting = codexScanWorkRecorderForTesting } } @@ -684,6 +713,34 @@ enum CostUsageScanner { return checkpoints } + /// Extends sparse checkpoints from the persisted terminal accumulator. Only the appended + /// token events are folded; the already-indexed prefix is never replayed. + static func appendingCodexTokenCheckpoints( + _ events: [CostUsageCodexTokenSnapshot], + to checkpoints: [CostUsageCodexTokenCheckpoint], + startingEventIndex: Int, + initialState: CostUsageCodexTokenAccumulatorState) -> [CostUsageCodexTokenCheckpoint] + { + guard !events.isEmpty else { return checkpoints } + var accumulator = CodexSnapshotAccumulator(state: initialState) + var appended: [CostUsageCodexTokenCheckpoint] = [] + var lastCheckpointOffset = checkpoints.last?.endOffset ?? 0 + for (offset, event) in events.enumerated() { + _ = accumulator.apply(last: event.last, total: event.total) + guard let endOffset = event.endOffset else { continue } + let reachedStride = endOffset - lastCheckpointOffset >= Self.codexTokenCheckpointStride + let isLastEvent = offset == events.index(before: events.endIndex) + guard reachedStride || isLastEvent else { continue } + appended.append(CostUsageCodexTokenCheckpoint( + eventIndex: startingEventIndex + offset, + timestamp: event.timestamp, + endOffset: endOffset, + state: accumulator.state)) + lastCheckpointOffset = endOffset + } + return checkpoints + appended + } + static func codexTokenTimestampsAreMonotonic( _ events: [CostUsageCodexTokenSnapshot]) -> Bool { @@ -721,6 +778,7 @@ enum CostUsageScanner { let resources: CodexScanResources let checkCancellation: CancellationCheck? let scanBudget: CodexScanBudget? + let workRecorder: CodexScanWorkRecorder? } final class CodexCanonicalProjectPathResolver { @@ -4391,16 +4449,13 @@ enum CostUsageScanner { private static func loadCodexCache( options: Options, - range: CostUsageDayRange) -> CostUsageCodexCacheLoadResult + range: CostUsageDayRange) -> CostUsageStoreLoad { - CostUsageCacheIO.loadCodexForMigration( - cacheRoot: options.cacheRoot, - calendar: range.calendar) + CostUsageStoreAccess.load(cacheRoot: options.cacheRoot, calendar: range.calendar) } private static func codexPreviousReportCandidate( cache: CostUsageCache, - incompatibleCache: CostUsageCache?, range: CostUsageDayRange, plan: CodexRefreshPlan, options: Options) -> CostUsageCodexPreviousReport? @@ -4417,11 +4472,9 @@ enum CostUsageScanner { return previous } - let sourceCache: CostUsageCache? = if let incompatibleCache { - incompatibleCache - } else if !currentScanIsPending, - options.forceRescan, - !cache.days.isEmpty + let sourceCache: CostUsageCache? = if !currentScanIsPending, + options.forceRescan, + !cache.days.isEmpty { cache } else { @@ -4459,12 +4512,17 @@ enum CostUsageScanner { return previous } - private static func saveCodexCache(_ cache: CostUsageCache, options: Options, range: CostUsageDayRange) { - // Provider-specific by design: Codex scans persist resume and report-window metadata. - CostUsageCacheIO.save( - provider: .codex, + private static func saveCodexCache( + _ cache: CostUsageCache, + store: CostUsageStore, + options: Options, + range: CostUsageDayRange) + { + // The serial scan queue remains the per-process writer boundary. The store actor owns + // the sole writable connection; app and CLI readers take independent WAL snapshots. + CostUsageStoreAccess.save( + store: store, cache: cache, - cacheRoot: options.cacheRoot, calendar: range.calendar, requestedScanWindow: (sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey), reportWindow: (sinceKey: range.sinceKey, untilKey: range.untilKey)) @@ -4483,7 +4541,6 @@ enum CostUsageScanner { let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) let previousReport = Self.codexPreviousReportCandidate( cache: cache, - incompatibleCache: loadedCache.incompatibleCache, range: range, plan: plan, options: options) @@ -4693,7 +4750,7 @@ enum CostUsageScanner { } cache.lastScanUnixMs = nowMs try checkCancellation?() - Self.saveCodexCache(cache, options: options, range: range) + Self.saveCodexCache(cache, store: loadedCache.store, options: options, range: range) } if let previous = Self.codexPreviousReport( @@ -4850,7 +4907,8 @@ enum CostUsageScanner { changedPriorityTurnIDs: plan.changedPriorityTurnIDs, resources: resources, checkCancellation: checkCancellation, - scanBudget: scanBudget) + scanBudget: scanBudget, + workRecorder: options.codexScanWorkRecorderForTesting) } static func sortedCodexSessionFilesNewestFirst(_ files: [URL]) -> [URL] { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift new file mode 100644 index 0000000000..c81110c1a6 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift @@ -0,0 +1,642 @@ +import Foundation + +extension CostUsageStore { + static let defaultRowBudget = 25000 + static let defaultFileBudgetBytes: Int64 = 256 * 1024 * 1024 + + func loadCodexCache(calendar: Calendar) -> CostUsageCache { + _ = self.removeLegacyCodexArtifactIfPresent() + let snapshot = self.readSnapshot() + guard snapshot.metadata.timeZoneIdentifier == nil + || snapshot.metadata.timeZoneIdentifier == calendar.timeZone.identifier + else { return CostUsageCache() } + return Self.cache(from: snapshot) + } + + @discardableResult + func saveCodexCache( + _ cache: CostUsageCache, + calendar: Calendar, + requestedScanWindow: (sinceKey: String, untilKey: String), + reportWindow: (sinceKey: String, untilKey: String)? = nil) -> CostUsageStoreBudgetResult + { + let previous = self.readSnapshot() + let canReuseStoredRows = previous.metadata.timeZoneIdentifier == calendar.timeZone.identifier + self.deleteRemovedFiles(previous: previous, cache: cache) + for (path, usage) in cache.files.sorted(by: { $0.key < $1.key }) { + let oldFile = previous.files.first { $0.path == path } + let oldSnapshotCount = previous.tokenSnapshots.count(where: { $0.path == path }) + let oldRowCount = previous.usageRows.count(where: { $0.path == path }) + self.persistFile( + path: path, + usage: usage, + baseline: PersistedFileBaseline( + file: oldFile, + snapshotCount: oldSnapshotCount, + rowCount: oldRowCount, + canReuseRows: canReuseStoredRows), + calendar: calendar) + } + _ = self.replaceDayAggregates(Self.globalAggregates(cache: cache)) + _ = self.setMetadata(Self.metadata(cache: cache, calendar: calendar)) + _ = self.setDiscoveryState(Self.discoveryState(cache.codexSessionDiscovery)) + _ = self.setLookbackState(Self.lookbackState(cache.codexActiveLookbackState)) + let result = self.enforceBudgets( + maxRows: Self.defaultRowBudget, + maxFileBytes: Self.defaultFileBudgetBytes, + requestedSinceDay: requestedScanWindow.sinceKey, + requestedUntilDay: requestedScanWindow.untilKey, + calendar: calendar) + if result.catchUpRequired, self.fetchMetadata().previousReportPayload == nil, + let previous = Self.previousReport(cache: cache, calendar: calendar, reportWindow: reportWindow) + { + var metadata = self.fetchMetadata() + metadata.previousReportPayload = try? JSONEncoder().encode(previous) + _ = self.setMetadata(metadata) + } + return result + } +} + +// MARK: - Cache conversion + +extension CostUsageStore { + private struct StoredFileDetails: Codable { + var lastTotals: CostUsageCodexTotals? + var projectPath: String? + var canonicalProjectPath: String? + var costCacheComplete: Bool? + var session: CostUsageCodexSessionMetadata? + var workspaceFingerprint: String? + var hasRows: Bool + var hasTurnIDs: Bool + var hasTokenSnapshots: Bool + var hasSeenRawTotals: Bool + var divergentTotals: Bool? + var interleavedTotals: Bool? + var hasCostNanos: Bool + var hasPrioritySurchargeNanos: Bool + var hasStandardCostNanos: Bool + var hasPriorityCostNanos: Bool + var hasStandardTokens: Bool + var hasPriorityTokens: Bool + var costNanos: [String: [String: Int64]]? + var prioritySurchargeNanos: [String: [String: Int64]]? + var standardCostNanos: [String: [String: Int64]]? + var priorityCostNanos: [String: [String: Int64]]? + var standardTokens: [String: [String: Int]]? + var priorityTokens: [String: [String: Int]]? + } + + private struct StoredPriorityState: Codable { + var turnKeys: [String: String]? + var turnIDsByDay: [String: [String]]? + } + + private struct DayModelKey: Hashable { + var day: String + var model: String + } + + private struct PersistedFileBaseline { + var file: CostUsageStoreFile? + var snapshotCount: Int + var rowCount: Int + var canReuseRows: Bool + } + + private static func cache(from snapshot: CostUsageStoreSnapshot) -> CostUsageCache { + var cache = CostUsageCache() + let metadata = snapshot.metadata + cache.lastScanUnixMs = metadata.lastScanUnixMs + cache.scanSinceKey = metadata.scanSinceDay + cache.scanUntilKey = metadata.scanUntilDay + cache.timeZoneIdentifier = metadata.timeZoneIdentifier + cache.codexPricingKey = metadata.pricingKey + cache.codexPriorityMetadataKey = metadata.priorityMetadataKey + cache.codexScanCatchUpPending = metadata.catchUpPending + cache.codexScanProcessedBytes = metadata.processedBytes + cache.codexScanTotalBytes = metadata.totalBytes + cache.codexScanCompletedFiles = metadata.completedFiles + cache.codexScanTotalFiles = metadata.totalFiles + cache.roots = metadata.rootMtimes + cache.codexProjectMetadataVersion = metadata.projectMetadataVersion + cache.codexPreviousReport = metadata.previousReportPayload.flatMap { + try? JSONDecoder().decode(CostUsageCodexPreviousReport.self, from: $0) + } + if let priority = metadata.priorityTurnStatePayload.flatMap({ + try? JSONDecoder().decode(StoredPriorityState.self, from: $0) + }) { + cache.codexPriorityTurnKeys = priority.turnKeys + cache.codexPriorityTurnIDsByDay = priority.turnIDsByDay + } + cache.codexSessionDiscovery = snapshot.discoveryState.flatMap(Self.discovery(from:)) + cache.codexActiveLookbackState = snapshot.lookbackState.map(Self.lookback(from:)) + + let snapshotsByPath = Dictionary(grouping: snapshot.tokenSnapshots, by: \.path) + let rowsByPath = Dictionary(grouping: snapshot.usageRows, by: \.path) + let aggregatesByPath = Dictionary(grouping: snapshot.fileDayAggregates, by: \.path) + let lineageByPath = Dictionary(uniqueKeysWithValues: snapshot.forkLineage.map { ($0.path, $0) }) + let buffersByPath = Dictionary(grouping: snapshot.bufferedLines, by: \.path) + let accumulatorByPath = Dictionary(uniqueKeysWithValues: snapshot.accumulators.map { ($0.path, $0) }) + + for file in snapshot.files { + guard let detailsData = file.scanState.detailsPayload, + let details = try? JSONDecoder().decode(StoredFileDetails.self, from: detailsData) + else { continue } + let aggregates = (aggregatesByPath[file.path] ?? []).map(\.aggregate) + let rows = (rowsByPath[file.path] ?? []).compactMap { + try? JSONDecoder().decode(CostUsageScanner.CodexUsageRow.self, from: $0.payload) + } + let tokenSnapshots = (snapshotsByPath[file.path] ?? []).map(Self.tokenSnapshot(from:)) + let lineage = lineageByPath[file.path] + let accumulator = accumulatorByPath[file.path] + let buffers = buffersByPath[file.path] ?? [] + let usage = CostUsageFileUsage( + mtimeUnixMs: file.mtimeUnixMs, + size: file.size, + days: Self.days(from: aggregates), + parsedBytes: file.parsedBytes, + lastModel: file.scanState.lastModel, + lastTotals: details.lastTotals, + lastCountedTotals: Self.totals(from: accumulator?.countedTotals), + lastRawTotalsBaseline: Self.totals(from: accumulator?.rawTotalsBaseline), + lastRawTotalsWatermark: Self.totals(from: accumulator?.rawTotalsWatermark), + seenRawTotals: details.hasSeenRawTotals + ? accumulator?.seenRawTotals.compactMap(Self.totals(from:)) ?? [] + : nil, + hasDivergentTotals: details.divergentTotals, + hasInterleavedTotals: details.interleavedTotals, + lastCodexTurnID: file.scanState.lastTurnID, + sessionId: file.sessionID, + forkedFromId: lineage?.forkedFromID, + forkBaselineDependencyKey: lineage?.dependencyKey, + projectPath: details.projectPath, + canonicalProjectPath: details.canonicalProjectPath, + codexCostCacheComplete: details.costCacheComplete, + codexSession: details.session, + codexCostNanos: details.costNanos, + codexPrioritySurchargeNanos: details.prioritySurchargeNanos, + codexStandardCostNanos: details.standardCostNanos, + codexPriorityCostNanos: details.priorityCostNanos, + codexStandardTokens: details.standardTokens, + codexPriorityTokens: details.priorityTokens, + codexTurnIDs: details.hasTurnIDs ? CostUsageScanner.codexTurnIDs(rows: rows) ?? [] : nil, + codexWorkspaceContentFingerprint: details.workspaceFingerprint, + codexRows: details.hasRows ? rows : nil, + codexTokenSnapshots: details.hasTokenSnapshots ? tokenSnapshots : nil, + codexTokenCheckpoints: details.hasTokenSnapshots + ? CostUsageScanner.codexTokenCheckpoints(for: tokenSnapshots) : nil, + codexTokenTimestampsMonotonic: file.scanState.tokenTimestampsMonotonic, + codexTokenIndexAnchor: file.anchor.map { + CostUsageCodexTokenIndexAnchor( + indexedBytes: $0.indexedBytes, + windowStart: $0.windowStart, + sha256: $0.sha256) + }, + claudeRows: nil, + codexScanFileId: file.scanState.fileIdentity, + codexScanTargetSize: file.scanState.targetSize, + codexScanComplete: file.scanState.isComplete, + codexJSONLResumeState: file.scanState.resumePayload.flatMap { + try? JSONDecoder().decode(CostUsageJsonl.ResumeState.self, from: $0) + }, + codexBufferedSubagentLines: Self.bufferedLines(buffers, kind: .subagent), + codexBufferedUnresolvedForkLines: Self.bufferedLines(buffers, kind: .unresolvedFork)) + cache.files[file.path] = usage + } + cache.days = Self.days(from: snapshot.dayAggregates) + return cache + } + + private func persistFile( + path: String, + usage: CostUsageFileUsage, + baseline: PersistedFileBaseline, + calendar: Calendar) + { + let snapshots = (usage.codexTokenSnapshots ?? []).enumerated().map { + Self.tokenSnapshot(path: path, eventIndex: $0.offset, snapshot: $0.element, calendar: calendar) + } + let rows = (usage.codexRows ?? []).enumerated().compactMap { index, row -> CostUsageStoreUsageRow? in + guard let payload = try? JSONEncoder().encode(row) else { return nil } + return CostUsageStoreUsageRow(path: path, rowIndex: index, payload: payload) + } + let details = StoredFileDetails( + lastTotals: usage.lastTotals, + projectPath: usage.projectPath, + canonicalProjectPath: usage.canonicalProjectPath, + costCacheComplete: usage.codexCostCacheComplete, + session: usage.codexSession, + workspaceFingerprint: usage.codexWorkspaceContentFingerprint, + hasRows: usage.codexRows != nil, + hasTurnIDs: usage.codexTurnIDs != nil, + hasTokenSnapshots: usage.codexTokenSnapshots != nil, + hasSeenRawTotals: usage.seenRawTotals != nil, + divergentTotals: usage.hasDivergentTotals, + interleavedTotals: usage.hasInterleavedTotals, + hasCostNanos: usage.codexCostNanos != nil, + hasPrioritySurchargeNanos: usage.codexPrioritySurchargeNanos != nil, + hasStandardCostNanos: usage.codexStandardCostNanos != nil, + hasPriorityCostNanos: usage.codexPriorityCostNanos != nil, + hasStandardTokens: usage.codexStandardTokens != nil, + hasPriorityTokens: usage.codexPriorityTokens != nil, + costNanos: usage.codexCostNanos, + prioritySurchargeNanos: usage.codexPrioritySurchargeNanos, + standardCostNanos: usage.codexStandardCostNanos, + priorityCostNanos: usage.codexPriorityCostNanos, + standardTokens: usage.codexStandardTokens, + priorityTokens: usage.codexPriorityTokens) + let file = CostUsageStoreFile( + path: path, + inode: Self.inode(from: usage.codexScanFileId), + mtimeUnixMs: usage.mtimeUnixMs, + size: usage.size, + parsedBytes: usage.parsedBytes, + anchor: usage.codexTokenIndexAnchor.map { + CostUsageStoreValidationAnchor( + indexedBytes: $0.indexedBytes, + windowStart: $0.windowStart, + sha256: $0.sha256) + }, + scanState: CostUsageStoreScanState( + targetSize: usage.codexScanTargetSize, + isComplete: usage.codexScanComplete != false, + resumePayload: usage.codexJSONLResumeState.flatMap { try? JSONEncoder().encode($0) }, + tokenTimestampsMonotonic: usage.codexTokenTimestampsMonotonic, + nextUsageRowIndex: CostUsageScanner.nextCodexUsageRowIndex(usage.codexRows), + lastModel: usage.lastModel, + lastTurnID: usage.lastCodexTurnID, + fileIdentity: usage.codexScanFileId, + detailsPayload: try? JSONEncoder().encode(details)), + sessionID: usage.sessionId, + coverageSinceDay: usage.days.keys.min(), + coverageUntilDay: usage.days.keys.max(), + updatedAtUnixMs: max(usage.mtimeUnixMs, usage.codexSession?.latestActivityUnixMs ?? 0)) + _ = self.upsertFile(file) + + let oldParsedBytes = baseline.file?.parsedBytes ?? 0 + let newParsedBytes = file.parsedBytes ?? 0 + let appendSafe = baseline.canReuseRows + && baseline.file?.scanState.fileIdentity == file.scanState.fileIdentity + && oldParsedBytes < newParsedBytes + if baseline.canReuseRows, oldParsedBytes == newParsedBytes, baseline.snapshotCount == snapshots.count { + // Stable cursor: the persisted prefix is already authoritative. + } else if appendSafe, baseline.snapshotCount <= snapshots.count { + _ = self.appendTokenSnapshots(Array(snapshots.dropFirst(baseline.snapshotCount))) + } else { + _ = self.replaceTokenSnapshots(path: path, snapshots: snapshots) + } + if baseline.canReuseRows, oldParsedBytes == newParsedBytes, baseline.rowCount == rows.count { + // Stable cursor: metadata/aggregate updates do not rewrite historical rows. + } else if appendSafe, baseline.rowCount <= rows.count { + _ = self.appendUsageRows(Array(rows.dropFirst(baseline.rowCount))) + } else { + _ = self.replaceUsageRows(path: path, rows: rows) + } + _ = self.replaceFileDayAggregates(path: path, aggregates: Self.fileAggregates(usage)) + _ = self.upsertForkLineage(CostUsageStoreForkLineage( + path: path, + sessionID: usage.sessionId, + forkedFromID: usage.forkedFromId, + forkTimestamp: nil, + dependencyKey: usage.forkBaselineDependencyKey, + subagentState: nil, + accountingState: nil)) + self.persistBuffers(path: path, usage: usage) + _ = self.upsertAccumulator(CostUsageStoreAccumulator( + path: path, + eventCount: snapshots.count, + nextUsageRowIndex: CostUsageScanner.nextCodexUsageRowIndex(usage.codexRows), + countedTotals: Self.totals(usage.lastCountedTotals), + rawTotalsBaseline: Self.totals(usage.lastRawTotalsBaseline), + rawTotalsWatermark: Self.totals(usage.lastRawTotalsWatermark), + sawDivergentTotals: usage.hasDivergentTotals ?? false, + sawInterleavedTotals: usage.hasInterleavedTotals ?? false, + seenRawTotals: (usage.seenRawTotals ?? []).map(Self.totals), + updatedAtUnixMs: file.updatedAtUnixMs)) + } +} + +// MARK: - Aggregate and metadata conversion + +extension CostUsageStore { + private static func metadata(cache: CostUsageCache, calendar: Calendar) -> CostUsageStoreMetadata { + let priority = StoredPriorityState( + turnKeys: cache.codexPriorityTurnKeys, + turnIDsByDay: cache.codexPriorityTurnIDsByDay) + return CostUsageStoreMetadata( + lastScanUnixMs: cache.lastScanUnixMs, + scanSinceDay: cache.scanSinceKey, + scanUntilDay: cache.scanUntilKey, + timeZoneIdentifier: calendar.timeZone.identifier, + pricingKey: cache.codexPricingKey, + priorityMetadataKey: cache.codexPriorityMetadataKey, + catchUpPending: cache.codexScanCatchUpPending == true, + processedBytes: cache.codexScanProcessedBytes, + totalBytes: cache.codexScanTotalBytes, + completedFiles: cache.codexScanCompletedFiles, + totalFiles: cache.codexScanTotalFiles, + rootMtimes: cache.roots, + previousReportPayload: cache.codexPreviousReport.flatMap { try? JSONEncoder().encode($0) }, + priorityTurnStatePayload: try? JSONEncoder().encode(priority), + projectMetadataVersion: cache.codexProjectMetadataVersion) + } + + private static func previousReport( + cache: CostUsageCache, + calendar: Calendar, + reportWindow: (sinceKey: String, untilKey: String)?) -> CostUsageCodexPreviousReport? + { + guard let sinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey, + let untilKey = reportWindow?.untilKey ?? cache.scanUntilKey, + let since = CostUsageScanner.parseDayKey(sinceKey, calendar: calendar), + let until = CostUsageScanner.parseDayKey(untilKey, calendar: calendar) + else { return nil } + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar) + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) + guard var previous = CostUsageCodexPreviousReport(report: report, cache: cache) else { return nil } + previous.scanSinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey + previous.scanUntilKey = reportWindow?.untilKey ?? cache.scanUntilKey + return previous + } + + private static func fileAggregates(_ usage: CostUsageFileUsage) -> [CostUsageStoreDayAggregate] { + var keys = Set() + func addKeys(_ map: [String: [String: some Any]]?) { + for (day, models) in map ?? [:] { + for model in models.keys { + keys.insert(DayModelKey(day: day, model: model)) + } + } + } + addKeys(usage.days) + addKeys(usage.codexCostNanos) + addKeys(usage.codexPrioritySurchargeNanos) + addKeys(usage.codexStandardCostNanos) + addKeys(usage.codexPriorityCostNanos) + addKeys(usage.codexStandardTokens) + addKeys(usage.codexPriorityTokens) + for row in usage.codexRows ?? [] { + keys.insert(DayModelKey(day: row.day, model: row.model)) + } + return keys.map { key in + let packed = usage.days[key.day]?[key.model] ?? [] + let rows = (usage.codexRows ?? []).filter { $0.day == key.day && $0.model == key.model } + return CostUsageStoreDayAggregate( + day: key.day, + model: key.model, + inputTokens: Int64(packed[safe: 0] ?? 0), + cachedTokens: Int64(packed[safe: 1] ?? 0), + outputTokens: Int64(packed[safe: 2] ?? 0), + reasoningTokens: Int64(rows.compactMap(\.reasoning).reduce(0, +)), + requestCount: Int64(rows.count), + knownCostNanos: usage.codexCostNanos?[key.day]?[key.model] ?? 0, + prioritySurchargeNanos: usage.codexPrioritySurchargeNanos?[key.day]?[key.model] ?? 0, + unpricedTokens: Int64(rows.compactMap(\.unpricedTokens).reduce(0, +)), + standardCostNanos: usage.codexStandardCostNanos?[key.day]?[key.model] ?? 0, + priorityCostNanos: usage.codexPriorityCostNanos?[key.day]?[key.model] ?? 0, + standardTokens: Int64(usage.codexStandardTokens?[key.day]?[key.model] ?? 0), + priorityTokens: Int64(usage.codexPriorityTokens?[key.day]?[key.model] ?? 0)) + }.sorted { ($0.day, $0.model) < ($1.day, $1.model) } + } + + private static func globalAggregates(cache: CostUsageCache) -> [CostUsageStoreDayAggregate] { + var values: [DayModelKey: CostUsageStoreDayAggregate] = [:] + for (day, models) in cache.days { + for (model, packed) in models { + var aggregate = CostUsageStoreDayAggregate.zero(day: day, model: model) + aggregate.inputTokens = Int64(packed[safe: 0] ?? 0) + aggregate.cachedTokens = Int64(packed[safe: 1] ?? 0) + aggregate.outputTokens = Int64(packed[safe: 2] ?? 0) + values[DayModelKey(day: day, model: model)] = aggregate + } + } + for usage in cache.files.values { + for aggregate in self.fileAggregates(usage) { + let key = DayModelKey(day: aggregate.day, model: aggregate.model) + guard var value = values[key] else { continue } + value.reasoningTokens += aggregate.reasoningTokens + value.requestCount += aggregate.requestCount + value.knownCostNanos += aggregate.knownCostNanos + value.prioritySurchargeNanos += aggregate.prioritySurchargeNanos + value.unpricedTokens += aggregate.unpricedTokens + value.standardCostNanos += aggregate.standardCostNanos + value.priorityCostNanos += aggregate.priorityCostNanos + value.standardTokens += aggregate.standardTokens + value.priorityTokens += aggregate.priorityTokens + values[key] = value + } + } + return values.values.sorted { ($0.day, $0.model) < ($1.day, $1.model) } + } + + private static func days(from aggregates: [CostUsageStoreDayAggregate]) -> [String: [String: [Int]]] { + var values: [String: [String: [Int]]] = [:] + for aggregate in aggregates { + values[aggregate.day, default: [:]][aggregate.model] = [ + Self.int(aggregate.inputTokens), + Self.int(aggregate.cachedTokens), + Self.int(aggregate.outputTokens), + ] + } + return values + } +} + +// MARK: - Opaque state conversion + +extension CostUsageStore { + private static func discoveryState(_ value: CostUsageCodexSessionDiscovery?) -> CostUsageStoreDiscoveryState? { + value.map { + CostUsageStoreDiscoveryState( + roots: $0.roots, + generation: $0.generation, + directoryPaths: $0.directoryPaths, + nextDirectoryIndex: $0.nextDirectoryIndex, + filePaths: $0.filePaths, + nextFileIndex: $0.nextFileIndex, + filePathBySessionID: $0.filePathBySessionId, + missingSessionIDs: $0.missingSessionIds, + pendingSessionIDs: $0.pendingSessionIds, + validationDirectoryIndex: $0.validationDirectoryIndex, + isComplete: $0.isComplete, + payload: try? JSONEncoder().encode($0)) + } + } + + private static func discovery(from value: CostUsageStoreDiscoveryState) -> CostUsageCodexSessionDiscovery? { + value.payload.flatMap { try? JSONDecoder().decode(CostUsageCodexSessionDiscovery.self, from: $0) } + } + + private static func lookbackState(_ value: CostUsageCodexActiveLookbackState?) -> CostUsageStoreLookbackState? { + value.map { + CostUsageStoreLookbackState( + scanSinceDay: $0.scanSinceKey, + rootPaths: $0.rootPaths, + nextDayByRoot: $0.nextDayKeyByRoot, + completedRootPaths: $0.completedRootPaths, + pendingFilePaths: $0.pendingFilePaths, + legacyRecursivePendingRootPaths: $0.legacyRecursivePendingRootPaths) + } + } + + private static func lookback(from value: CostUsageStoreLookbackState) -> CostUsageCodexActiveLookbackState { + CostUsageCodexActiveLookbackState( + scanSinceKey: value.scanSinceDay, + rootPaths: value.rootPaths, + nextDayKeyByRoot: value.nextDayByRoot, + completedRootPaths: value.completedRootPaths, + pendingFilePaths: value.pendingFilePaths, + legacyRecursivePendingRootPaths: value.legacyRecursivePendingRootPaths) + } + + private static func tokenSnapshot( + path: String, + eventIndex: Int, + snapshot: CostUsageCodexTokenSnapshot, + calendar: Calendar) -> CostUsageStoreTokenSnapshot + { + let date = CostUsageScanner.dateFromTimestamp(snapshot.timestamp) + return CostUsageStoreTokenSnapshot( + path: path, + eventIndex: eventIndex, + timestamp: snapshot.timestamp, + timestampUnixMs: date.map { Int64($0.timeIntervalSince1970 * 1000) }, + day: date.map { CostUsageScanner.CostUsageDayRange.dayKey(from: $0, calendar: calendar) }, + last: Self.totals(snapshot.last), + total: Self.totals(snapshot.total), + endOffset: snapshot.endOffset) + } + + private static func tokenSnapshot(from value: CostUsageStoreTokenSnapshot) -> CostUsageCodexTokenSnapshot { + CostUsageCodexTokenSnapshot( + timestamp: value.timestamp, + last: self.totals(from: value.last), + total: self.totals(from: value.total), + endOffset: value.endOffset) + } + + private func persistBuffers(path: String, usage: CostUsageFileUsage) { + let pairs: [(CostUsageStoreBufferedLineKind, [CostUsageScanner.CodexBufferedFastLine]?)] = [ + (.subagent, usage.codexBufferedSubagentLines), + (.unresolvedFork, usage.codexBufferedUnresolvedForkLines), + ] + for (kind, source) in pairs { + let lines = (source ?? []).enumerated().compactMap { index, line -> CostUsageStoreBufferedLine? in + guard let payload = try? JSONEncoder().encode(line) else { return nil } + return CostUsageStoreBufferedLine( + path: path, + kind: kind, + lineIndex: index, + ordinal: nil, + endOffset: nil, + payload: payload) + } + _ = self.replaceBufferedLines(path: path, kind: kind, lines: lines) + } + } + + private static func bufferedLines( + _ values: [CostUsageStoreBufferedLine], + kind: CostUsageStoreBufferedLineKind) -> [CostUsageScanner.CodexBufferedFastLine]? + { + let lines = values.filter { $0.kind == kind }.compactMap { + try? JSONDecoder().decode(CostUsageScanner.CodexBufferedFastLine.self, from: $0.payload) + } + return lines.isEmpty ? nil : lines + } + + private func deleteRemovedFiles( + previous: CostUsageStoreSnapshot, + cache: CostUsageCache) + { + for path in previous.files.map(\.path) where cache.files[path] == nil { + _ = self.deleteFile(path: path) + } + } + + private static func inode(from identity: String?) -> Int64? { + identity?.split(separator: ":").last.flatMap { Int64($0) } + } + + private static func totals(_ value: CostUsageCodexTotals?) -> CostUsageStoreTotals? { + value.map { CostUsageStoreTotals( + input: Int64($0.input), + cached: Int64($0.cached), + output: Int64($0.output), + reasoning: $0.reasoning.map(Int64.init)) } + } + + private static func totals(_ value: CostUsageCodexTotals) -> CostUsageStoreTotals { + CostUsageStoreTotals( + input: Int64(value.input), + cached: Int64(value.cached), + output: Int64(value.output), + reasoning: value.reasoning.map(Int64.init)) + } + + private static func totals(from value: CostUsageStoreTotals?) -> CostUsageCodexTotals? { + value.map { CostUsageCodexTotals( + input: Self.int($0.input), + cached: Self.int($0.cached), + output: Self.int($0.output), + reasoning: $0.reasoning.map(Self.int)) } + } + + private static func int(_ value: Int64) -> Int { + Int(exactly: value) ?? (value < 0 ? Int.min : Int.max) + } +} + +// MARK: - Synchronous scanner bridge + +struct CostUsageStoreLoad: @unchecked Sendable { + var store: CostUsageStore + var cache: CostUsageCache +} + +enum CostUsageStoreAccess { + static func load(cacheRoot: URL?, calendar: Calendar) -> CostUsageStoreLoad { + let store = CostUsageStore(cacheRoot: cacheRoot) + let cache = store.syncLoadCodexCache(calendar: calendar) + return CostUsageStoreLoad(store: store, cache: cache) + } + + static func read(cacheRoot: URL?, calendar: Calendar = .current) -> CostUsageCache { + self.load(cacheRoot: cacheRoot, calendar: calendar).cache + } + + /// Test and maintenance mutation seam for metadata-only edits. Scanner writes should keep + /// using the loaded store instance so one actor owns the full read/scan/write cycle. + @discardableResult + static func replace( + cacheRoot: URL?, + cache: CostUsageCache, + calendar: Calendar = .current) -> CostUsageStoreBudgetResult + { + let loaded = self.load(cacheRoot: cacheRoot, calendar: calendar) + let since = cache.scanSinceKey ?? "0000-01-01" + let until = cache.scanUntilKey ?? "9999-12-31" + return self.save( + store: loaded.store, + cache: cache, + calendar: calendar, + requestedScanWindow: (sinceKey: since, untilKey: until)) + } + + @discardableResult + static func save( + store: CostUsageStore, + cache: CostUsageCache, + calendar: Calendar, + requestedScanWindow: (sinceKey: String, untilKey: String), + reportWindow: (sinceKey: String, untilKey: String)? = nil) -> CostUsageStoreBudgetResult + { + store.syncSaveCodexCache( + cache, + calendar: calendar, + requestedScanWindow: requestedScanWindow, + reportWindow: reportWindow) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Reads.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Reads.swift index 712b11185e..3e43aace43 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Reads.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Reads.swift @@ -25,6 +25,12 @@ extension CostUsageStore { } } + func fetchUsageRows(path: String) -> [CostUsageStoreUsageRow] { + self.withDatabase(default: []) { database in + try Self.readUsageRows(database, path: path) + } + } + func fetchDayAggregates(sinceDay: String, untilDay: String) -> [CostUsageStoreDayAggregate] { guard sinceDay <= untilDay else { return [] } return self.withDatabase(default: []) { database in @@ -96,6 +102,7 @@ extension CostUsageStore { metadata: .empty, files: [], tokenSnapshots: [], + usageRows: [], fileDayAggregates: [], dayAggregates: [], forkLineage: [], @@ -112,6 +119,7 @@ extension CostUsageStore { table: "scan_metadata") ?? .empty, files: Self.readFiles(database), tokenSnapshots: Self.readTokenSnapshots(database, path: nil), + usageRows: Self.readUsageRows(database, path: nil), fileDayAggregates: Self.readFileDayAggregates(database, path: nil), dayAggregates: Self.readDayAggregates(database, sinceDay: nil, untilDay: nil), forkLineage: Self.readForkLineage(database, path: nil), @@ -233,7 +241,38 @@ extension CostUsageStore { day: self.columnText(statement, at: 4), last: self.decodeTotals(statement, startingAt: 5), total: self.decodeTotals(statement, startingAt: 9), - endOffset: sqlite3_column_int64(statement, 13))) + endOffset: self.columnInt64(statement, at: 13))) + result = sqlite3_step(statement) + } + guard result == SQLITE_DONE else { throw StoreError.sqlite(result) } + return values + } + + static func readUsageRows( + _ database: OpaquePointer, + path: String?) throws -> [CostUsageStoreUsageRow] + { + var sql = """ + SELECT f.path, r.row_index, r.payload + FROM usage_rows r JOIN files f ON f.id = r.file_id + """ + if path != nil { + sql += " WHERE f.path = ?" + } + sql += " ORDER BY f.path, r.row_index" + let statement = try self.prepare(database, sql) + defer { sqlite3_finalize(statement) } + if let path { + self.bind(path, to: statement, at: 1) + } + var values: [CostUsageStoreUsageRow] = [] + var result = sqlite3_step(statement) + while result == SQLITE_ROW { + guard let path = self.columnText(statement, at: 0), + let rowIndex = Int(exactly: sqlite3_column_int64(statement, 1)), + let payload = self.columnData(statement, at: 2) + else { throw StoreError.invalidData } + values.append(CostUsageStoreUsageRow(path: path, rowIndex: rowIndex, payload: payload)) result = sqlite3_step(statement) } guard result == SQLITE_DONE else { throw StoreError.sqlite(result) } @@ -247,8 +286,8 @@ extension CostUsageStore { { var sql = """ SELECT day, model, input_tokens, cached_tokens, output_tokens, reasoning_tokens, - request_count, known_cost_nanos, unpriced_tokens, standard_cost_nanos, - priority_cost_nanos, standard_tokens, priority_tokens + request_count, known_cost_nanos, priority_surcharge_nanos, unpriced_tokens, + standard_cost_nanos, priority_cost_nanos, standard_tokens, priority_tokens FROM day_aggregates """ if sinceDay != nil, untilDay != nil { @@ -276,11 +315,12 @@ extension CostUsageStore { reasoningTokens: sqlite3_column_int64(statement, 5), requestCount: sqlite3_column_int64(statement, 6), knownCostNanos: sqlite3_column_int64(statement, 7), - unpricedTokens: sqlite3_column_int64(statement, 8), - standardCostNanos: sqlite3_column_int64(statement, 9), - priorityCostNanos: sqlite3_column_int64(statement, 10), - standardTokens: sqlite3_column_int64(statement, 11), - priorityTokens: sqlite3_column_int64(statement, 12))) + prioritySurchargeNanos: sqlite3_column_int64(statement, 8), + unpricedTokens: sqlite3_column_int64(statement, 9), + standardCostNanos: sqlite3_column_int64(statement, 10), + priorityCostNanos: sqlite3_column_int64(statement, 11), + standardTokens: sqlite3_column_int64(statement, 12), + priorityTokens: sqlite3_column_int64(statement, 13))) result = sqlite3_step(statement) } guard result == SQLITE_DONE else { throw StoreError.sqlite(result) } @@ -293,8 +333,9 @@ extension CostUsageStore { { var sql = """ SELECT f.path, a.day, a.model, a.input_tokens, a.cached_tokens, a.output_tokens, - a.reasoning_tokens, a.request_count, a.known_cost_nanos, a.unpriced_tokens, - a.standard_cost_nanos, a.priority_cost_nanos, a.standard_tokens, a.priority_tokens + a.reasoning_tokens, a.request_count, a.known_cost_nanos, a.priority_surcharge_nanos, + a.unpriced_tokens, a.standard_cost_nanos, a.priority_cost_nanos, + a.standard_tokens, a.priority_tokens FROM file_day_aggregates a JOIN files f ON f.id = a.file_id """ if path != nil { @@ -324,11 +365,12 @@ extension CostUsageStore { reasoningTokens: sqlite3_column_int64(statement, 6), requestCount: sqlite3_column_int64(statement, 7), knownCostNanos: sqlite3_column_int64(statement, 8), - unpricedTokens: sqlite3_column_int64(statement, 9), - standardCostNanos: sqlite3_column_int64(statement, 10), - priorityCostNanos: sqlite3_column_int64(statement, 11), - standardTokens: sqlite3_column_int64(statement, 12), - priorityTokens: sqlite3_column_int64(statement, 13)))) + prioritySurchargeNanos: sqlite3_column_int64(statement, 9), + unpricedTokens: sqlite3_column_int64(statement, 10), + standardCostNanos: sqlite3_column_int64(statement, 11), + priorityCostNanos: sqlite3_column_int64(statement, 12), + standardTokens: sqlite3_column_int64(statement, 13), + priorityTokens: sqlite3_column_int64(statement, 14)))) result = sqlite3_step(statement) } guard result == SQLITE_DONE else { throw StoreError.sqlite(result) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift index 3f1fcd11e7..6e6bf7dd1a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Retention.swift @@ -63,14 +63,6 @@ extension CostUsageStore { try self.stepDone(deleteFile, database: database) } - let deleteSnapshots = try self.prepare(database, """ - DELETE FROM token_snapshots WHERE day IS NOT NULL AND (day < ? OR day > ?) - """) - defer { sqlite3_finalize(deleteSnapshots) } - self.bind(sinceDay, to: deleteSnapshots, at: 1) - self.bind(untilDay, to: deleteSnapshots, at: 2) - try self.stepDone(deleteSnapshots, database: database) - let deleteFileAggregates = try self.prepare( database, "DELETE FROM file_day_aggregates WHERE day < ? OR day > ?") @@ -174,39 +166,73 @@ extension CostUsageStore { // MARK: - Budgets and vacuum extension CostUsageStore { - func enforceBudgets(maxRows: Int, maxFileBytes: Int64) -> CostUsageStoreBudgetResult { + /// The row budget is the SQLite equivalent of the former 25k cache-entry cap: one + /// retained session file is one entry. Dependent token and usage rows are bounded by + /// the independent 256 MiB database cap, so active append/fork state is not discarded + /// merely because a single session contains many events. + func enforceBudgets( + maxRows: Int, + maxFileBytes: Int64, + requestedSinceDay: String? = nil, + requestedUntilDay: String? = nil, + calendar: Calendar = .current) -> CostUsageStoreBudgetResult + { let fallback = CostUsageStoreBudgetResult(deletedRows: 0, rowCount: 0, fileBytes: 0) return self.withDatabase(default: fallback) { database in let initialRows = try Self.rowCount(database) - if let metadata = try Self.readSingleton( + let initialBytes = Self.fileSize(at: self.databaseURL) + let metadata = try Self.readSingleton( CostUsageStoreMetadata.self, database: database, - table: "scan_metadata"), - let sinceDay = metadata.scanSinceDay, - let untilDay = metadata.scanUntilDay, - sinceDay <= untilDay + table: "scan_metadata") + let sinceDay = requestedSinceDay ?? metadata?.scanSinceDay + let untilDay = requestedUntilDay ?? metadata?.scanUntilDay + let rowLimit = max(0, maxRows) + let byteLimit = max(0, maxFileBytes) + if initialRows > Int64(rowLimit) || initialBytes > byteLimit, + let sinceDay, let untilDay, sinceDay <= untilDay { _ = try Self.prune(database, sinceDay: sinceDay, untilDay: untilDay) } - let rowLimit = max(0, maxRows) + var catchUpRequired = false while try Self.rowCount(database) > Int64(rowLimit) { - guard try Self.deleteOldestRetainedRow(database) else { break } + guard try Self.deleteOldestRetainedFile( + database, + sinceDay: sinceDay, + calendar: calendar) else { break } + catchUpRequired = true } try Self.reclaimFreePages(database) - let byteLimit = max(0, maxFileBytes) var fileBytes = Self.fileSize(at: self.databaseURL) while fileBytes > byteLimit { - guard try Self.deleteOldestRetainedRow(database) else { break } + guard try Self.deleteOldestRetainedFile( + database, + sinceDay: sinceDay, + calendar: calendar) + else { + guard try Self.stripOldestRebuildableDetail(database) else { break } + catchUpRequired = true + try Self.markCatchUpRequired(database) + try Self.reclaimFreePages(database) + fileBytes = Self.fileSize(at: self.databaseURL) + continue + } + catchUpRequired = true try Self.reclaimFreePages(database) fileBytes = Self.fileSize(at: self.databaseURL) } + if catchUpRequired { + try Self.rebuildDayAggregates(database) + try Self.markCatchUpRequired(database) + } let finalRows = try Self.rowCount(database) return CostUsageStoreBudgetResult( deletedRows: Int(max(0, initialRows - finalRows)), rowCount: Int(finalRows), - fileBytes: fileBytes) + fileBytes: fileBytes, + catchUpRequired: catchUpRequired) } } @@ -217,35 +243,113 @@ extension CostUsageStore { } } - private static func deleteOldestRetainedRow(_ database: OpaquePointer) throws -> Bool { - let statements = [ - "DELETE FROM files WHERE id = (SELECT id FROM files ORDER BY updated_at_ms, id LIMIT 1)", - """ - DELETE FROM day_aggregates WHERE rowid = ( - SELECT rowid FROM day_aggregates ORDER BY day, model LIMIT 1 - ) - """, - ] - for sql in statements { - try self.execute(database, sql) - if sqlite3_changes(database) > 0 { - return true + private static func deleteOldestRetainedFile( + _ database: OpaquePointer, + sinceDay: String?, + calendar: Calendar) throws -> Bool + { + let statement = try self.prepare(database, """ + SELECT f.id, f.path, f.mtime_ms, f.coverage_since_day, f.coverage_until_day + FROM files f + WHERE f.scan_complete = 1 + AND NOT EXISTS (SELECT 1 FROM buffered_lines b WHERE b.file_id = f.id) + AND NOT EXISTS ( + SELECT 1 FROM fork_lineage child + JOIN fork_lineage parent ON parent.session_id = child.forked_from_id + WHERE parent.file_id = f.id + AND child.file_id != f.id + AND (child.dependency_key IS NULL OR child.dependency_key != ?) + ) + ORDER BY f.updated_at_ms, f.id + """) + defer { sqlite3_finalize(statement) } + self.bind(CostUsageScanner.codexForkDependencyNotRequiredKey, to: statement, at: 1) + let activeSinceMs = sinceDay.flatMap { CostUsageScanner.parseDayKey($0, calendar: calendar) } + .map { Int64($0.timeIntervalSince1970 * 1000) } + var result = sqlite3_step(statement) + while result == SQLITE_ROW { + let mtime = sqlite3_column_int64(statement, 2) + let coverageSince = self.columnText(statement, at: 3) + let coverageUntil = self.columnText(statement, at: 4) + let zeroDayRecentlyActive = coverageSince == nil && coverageUntil == nil + && activeSinceMs.map { mtime >= $0 } == true + if !zeroDayRecentlyActive { + let fileID = sqlite3_column_int64(statement, 0) + try self.execute(database, "DELETE FROM files WHERE id = \(fileID)") + return sqlite3_changes(database) > 0 } + result = sqlite3_step(statement) } + guard result == SQLITE_DONE else { throw StoreError.sqlite(result) } return false } private static func rowCount(_ database: OpaquePointer) throws -> Int64 { - try self.scalarInt(database, """ - SELECT - (SELECT COUNT(*) FROM files) + - (SELECT COUNT(*) FROM token_snapshots) + - (SELECT COUNT(*) FROM file_day_aggregates) + - (SELECT COUNT(*) FROM day_aggregates) + - (SELECT COUNT(*) FROM fork_lineage) + - (SELECT COUNT(*) FROM buffered_lines) + - (SELECT COUNT(*) FROM accumulators) + try self.scalarInt(database, "SELECT COUNT(*) FROM files") + } + + private static func stripOldestRebuildableDetail(_ database: OpaquePointer) throws -> Bool { + let statement = try self.prepare(database, """ + SELECT f.id, f.scan_state + FROM files f + WHERE NOT EXISTS (SELECT 1 FROM buffered_lines b WHERE b.file_id = f.id) + AND ((SELECT COUNT(*) FROM token_snapshots t WHERE t.file_id = f.id) > 0 + OR (SELECT COUNT(*) FROM usage_rows r WHERE r.file_id = f.id) > 0) + ORDER BY f.updated_at_ms, f.id + LIMIT 1 """) + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, + let stateData = self.columnData(statement, at: 1), + var state = try? JSONDecoder().decode(CostUsageStoreScanState.self, from: stateData) + else { return false } + let fileID = sqlite3_column_int64(statement, 0) + state.isComplete = false + state.resumePayload = nil + state.tokenTimestampsMonotonic = nil + state.nextUsageRowIndex = nil + let updatedState = try JSONEncoder().encode(state) + let update = try self.prepare(database, """ + UPDATE files + SET parsed_bytes = 0, anchor_indexed_bytes = NULL, anchor_window_start = NULL, + anchor_sha256 = NULL, scan_state = ?, scan_complete = 0 + WHERE id = ? + """) + defer { sqlite3_finalize(update) } + self.bind(updatedState, to: update, at: 1) + sqlite3_bind_int64(update, 2, fileID) + try self.stepDone(update, database: database) + try self.execute(database, "DELETE FROM token_snapshots WHERE file_id = \(fileID)") + try self.execute(database, "DELETE FROM usage_rows WHERE file_id = \(fileID)") + try self.execute(database, "DELETE FROM accumulators WHERE file_id = \(fileID)") + return true + } + + private static func rebuildDayAggregates(_ database: OpaquePointer) throws { + try self.execute(database, "DELETE FROM day_aggregates") + try self.execute(database, """ + INSERT INTO day_aggregates ( + day, model, input_tokens, cached_tokens, output_tokens, reasoning_tokens, + request_count, known_cost_nanos, priority_surcharge_nanos, unpriced_tokens, + standard_cost_nanos, priority_cost_nanos, standard_tokens, priority_tokens + ) + SELECT day, model, SUM(input_tokens), SUM(cached_tokens), SUM(output_tokens), + SUM(reasoning_tokens), SUM(request_count), SUM(known_cost_nanos), + SUM(priority_surcharge_nanos), SUM(unpriced_tokens), SUM(standard_cost_nanos), + SUM(priority_cost_nanos), SUM(standard_tokens), SUM(priority_tokens) + FROM file_day_aggregates + GROUP BY day, model + """) + } + + private static func markCatchUpRequired(_ database: OpaquePointer) throws { + var metadata = try self.readSingleton( + CostUsageStoreMetadata.self, + database: database, + table: "scan_metadata") ?? .empty + metadata.catchUpPending = true + metadata.lastScanUnixMs = 0 + try self.writeSingleton(metadata, database: database, table: "scan_metadata") } private static func reclaimFreePages(_ database: OpaquePointer) throws { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Writes.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Writes.swift index bed2e362da..3ce5bdb45b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Writes.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+Writes.swift @@ -92,7 +92,7 @@ extension CostUsageStore { Self.bind(snapshot.day, to: statement, at: 5) Self.bindTotals(snapshot.last, to: statement, startingAt: 6) Self.bindTotals(snapshot.total, to: statement, startingAt: 10) - sqlite3_bind_int64(statement, 14, snapshot.endOffset) + Self.bind(snapshot.endOffset, to: statement, at: 14) try Self.stepDone(statement, database: database) } } @@ -100,6 +100,45 @@ extension CostUsageStore { } } + @discardableResult + func replaceTokenSnapshots(path: String, snapshots: [CostUsageStoreTokenSnapshot]) -> Bool { + guard snapshots.allSatisfy({ $0.path == path }) else { return false } + return self.withDatabase(default: false) { database in + try Self.inTransaction(database) { + let delete = try Self.prepare(database, """ + DELETE FROM token_snapshots WHERE file_id = (SELECT id FROM files WHERE path = ?) + """) + defer { sqlite3_finalize(delete) } + Self.bind(path, to: delete, at: 1) + try Self.stepDone(delete, database: database) + try Self.insertTokenSnapshots(database, snapshots: snapshots) + } + return true + } + } + + @discardableResult + func replaceUsageRows(path: String, rows: [CostUsageStoreUsageRow]) -> Bool { + guard rows.allSatisfy({ $0.path == path }) else { return false } + return self.withDatabase(default: false) { database in + try Self.inTransaction(database) { + try Self.replaceUsageRows(database, path: path, rows: rows) + } + return true + } + } + + @discardableResult + func appendUsageRows(_ rows: [CostUsageStoreUsageRow]) -> Bool { + guard !rows.isEmpty else { return true } + return self.withDatabase(default: false) { database in + try Self.inTransaction(database) { + try Self.insertUsageRows(database, rows: rows) + } + return true + } + } + @discardableResult func replaceFileDayAggregates( path: String, @@ -118,9 +157,10 @@ extension CostUsageStore { let insert = try Self.prepare(database, """ INSERT INTO file_day_aggregates ( file_id, day, model, input_tokens, cached_tokens, output_tokens, - reasoning_tokens, request_count, known_cost_nanos, unpriced_tokens, - standard_cost_nanos, priority_cost_nanos, standard_tokens, priority_tokens - ) VALUES ((SELECT id FROM files WHERE path = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + reasoning_tokens, request_count, known_cost_nanos, priority_surcharge_nanos, + unpriced_tokens, standard_cost_nanos, priority_cost_nanos, + standard_tokens, priority_tokens + ) VALUES ((SELECT id FROM files WHERE path = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """) defer { sqlite3_finalize(insert) } for aggregate in aggregates { @@ -145,9 +185,9 @@ extension CostUsageStore { let statement = try Self.prepare(database, """ INSERT INTO day_aggregates ( day, model, input_tokens, cached_tokens, output_tokens, reasoning_tokens, - request_count, known_cost_nanos, unpriced_tokens, standard_cost_nanos, - priority_cost_nanos, standard_tokens, priority_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + request_count, known_cost_nanos, priority_surcharge_nanos, unpriced_tokens, + standard_cost_nanos, priority_cost_nanos, standard_tokens, priority_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(day, model) DO UPDATE SET input_tokens = input_tokens + excluded.input_tokens, cached_tokens = cached_tokens + excluded.cached_tokens, @@ -155,6 +195,7 @@ extension CostUsageStore { reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens, request_count = request_count + excluded.request_count, known_cost_nanos = known_cost_nanos + excluded.known_cost_nanos, + priority_surcharge_nanos = priority_surcharge_nanos + excluded.priority_surcharge_nanos, unpriced_tokens = unpriced_tokens + excluded.unpriced_tokens, standard_cost_nanos = standard_cost_nanos + excluded.standard_cost_nanos, priority_cost_nanos = priority_cost_nanos + excluded.priority_cost_nanos, @@ -174,11 +215,78 @@ extension CostUsageStore { return true } } + + @discardableResult + func replaceDayAggregates(_ aggregates: [CostUsageStoreDayAggregate]) -> Bool { + self.withDatabase(default: false) { database in + try Self.inTransaction(database) { + try Self.execute(database, "DELETE FROM day_aggregates") + try Self.insertDayAggregates(database, aggregates: aggregates) + } + return true + } + } } // MARK: - Lineage, buffers, discovery, and accumulators extension CostUsageStore { + static func insertTokenSnapshots( + _ database: OpaquePointer, + snapshots: [CostUsageStoreTokenSnapshot]) throws + { + let statement = try self.prepare(database, """ + INSERT INTO token_snapshots ( + file_id, event_index, timestamp, timestamp_ms, day, + last_input, last_cached, last_output, last_reasoning, + total_input, total_cached, total_output, total_reasoning, end_offset + ) VALUES ((SELECT id FROM files WHERE path = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(file_id, event_index) DO UPDATE SET + timestamp = excluded.timestamp, timestamp_ms = excluded.timestamp_ms, day = excluded.day, + last_input = excluded.last_input, last_cached = excluded.last_cached, + last_output = excluded.last_output, last_reasoning = excluded.last_reasoning, + total_input = excluded.total_input, total_cached = excluded.total_cached, + total_output = excluded.total_output, total_reasoning = excluded.total_reasoning, + end_offset = excluded.end_offset + """) + defer { sqlite3_finalize(statement) } + for snapshot in snapshots { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + self.bind(snapshot.path, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, Int64(snapshot.eventIndex)) + self.bind(snapshot.timestamp, to: statement, at: 3) + self.bind(snapshot.timestampUnixMs, to: statement, at: 4) + self.bind(snapshot.day, to: statement, at: 5) + self.bindTotals(snapshot.last, to: statement, startingAt: 6) + self.bindTotals(snapshot.total, to: statement, startingAt: 10) + self.bind(snapshot.endOffset, to: statement, at: 14) + try self.stepDone(statement, database: database) + } + } + + static func insertDayAggregates( + _ database: OpaquePointer, + aggregates: [CostUsageStoreDayAggregate]) throws + { + let statement = try self.prepare(database, """ + INSERT INTO day_aggregates ( + day, model, input_tokens, cached_tokens, output_tokens, reasoning_tokens, + request_count, known_cost_nanos, priority_surcharge_nanos, unpriced_tokens, + standard_cost_nanos, priority_cost_nanos, standard_tokens, priority_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """) + defer { sqlite3_finalize(statement) } + for aggregate in aggregates { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + self.bind(aggregate.day, to: statement, at: 1) + self.bind(aggregate.model, to: statement, at: 2) + self.bindAggregateValues(aggregate, to: statement, startingAt: 3) + try self.stepDone(statement, database: database) + } + } + @discardableResult func upsertForkLineage(_ lineage: CostUsageStoreForkLineage) -> Bool { self.withDatabase(default: false) { database in @@ -332,6 +440,37 @@ extension CostUsageStore { // MARK: - Write helpers extension CostUsageStore { + static func replaceUsageRows( + _ database: OpaquePointer, + path: String, + rows: [CostUsageStoreUsageRow]) throws + { + let delete = try self.prepare(database, """ + DELETE FROM usage_rows WHERE file_id = (SELECT id FROM files WHERE path = ?) + """) + defer { sqlite3_finalize(delete) } + self.bind(path, to: delete, at: 1) + try self.stepDone(delete, database: database) + try self.insertUsageRows(database, rows: rows) + } + + static func insertUsageRows(_ database: OpaquePointer, rows: [CostUsageStoreUsageRow]) throws { + let statement = try self.prepare(database, """ + INSERT INTO usage_rows (file_id, row_index, payload) + VALUES ((SELECT id FROM files WHERE path = ?), ?, ?) + ON CONFLICT(file_id, row_index) DO UPDATE SET payload = excluded.payload + """) + defer { sqlite3_finalize(statement) } + for row in rows { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + self.bind(row.path, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, Int64(row.rowIndex)) + self.bind(row.payload, to: statement, at: 3) + try self.stepDone(statement, database: database) + } + } + static func inTransaction(_ database: OpaquePointer, _ operation: () throws -> T) throws -> T { try self.execute(database, "BEGIN IMMEDIATE") do { @@ -367,6 +506,7 @@ extension CostUsageStore { aggregate.reasoningTokens, aggregate.requestCount, aggregate.knownCostNanos, + aggregate.prioritySurchargeNanos, aggregate.unpricedTokens, aggregate.standardCostNanos, aggregate.priorityCostNanos, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index bef9c58974..ac016e79ee 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation #if canImport(SQLite3) @@ -10,6 +11,30 @@ import CSQLite3 /// connection; Phase 2 can keep its existing scan-queue serialization while independent /// app and CLI readers use WAL snapshots through separate read-only connections. actor CostUsageStore { + private final class StoreSerialExecutor: SerialExecutor, @unchecked Sendable { + private let queue: DispatchQueue + + init(label: String) { + self.queue = DispatchQueue(label: label, qos: .utility) + } + + func enqueue(_ job: consuming ExecutorJob) { + let unownedJob = UnownedJob(job) + let executor = self.asUnownedSerialExecutor() + self.queue.async { + unownedJob.runSynchronously(on: executor) + } + } + + func checkIsolated() { + dispatchPrecondition(condition: .onQueue(self.queue)) + } + + func sync(_ operation: () throws -> T) rethrows -> T { + try self.queue.sync(execute: operation) + } + } + private final class SQLiteConnection: @unchecked Sendable { private(set) var handle: OpaquePointer? @@ -29,12 +54,19 @@ actor CostUsageStore { } static let databaseFilename = "cost-usage.sqlite" - static let baseSchemaVersion = 1 + static let baseSchemaVersion = 2 static let schemaVersion = CostUsageStore.combinedSchemaVersion( base: CostUsageStore.baseSchemaVersion, parserHash: CodexParserHash.value) + static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" + + private nonisolated let executor = StoreSerialExecutor( + label: "com.steipete.codexbar.cost-usage-store") + nonisolated var unownedExecutor: UnownedSerialExecutor { + self.executor.asUnownedSerialExecutor() + } - let databaseURL: URL + nonisolated let databaseURL: URL private let expectedSchemaVersion: Int32 private let expectedParserHash: String private var connection: SQLiteConnection? @@ -46,6 +78,7 @@ actor CostUsageStore { parserHash: String = CodexParserHash.value) { let root = cacheRoot ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) self.databaseURL = root .appendingPathComponent("cost-usage", isDirectory: true) .appendingPathComponent(Self.databaseFilename, isDirectory: false) @@ -64,6 +97,33 @@ actor CostUsageStore { } } +extension CostUsageStore { + nonisolated func syncLoadCodexCache(calendar: Calendar) -> CostUsageCache { + self.executor.sync { + self.assumeIsolated { store in + store.loadCodexCache(calendar: calendar) + } + } + } + + nonisolated func syncSaveCodexCache( + _ cache: CostUsageCache, + calendar: Calendar, + requestedScanWindow: (sinceKey: String, untilKey: String), + reportWindow: (sinceKey: String, untilKey: String)? = nil) -> CostUsageStoreBudgetResult + { + self.executor.sync { + self.assumeIsolated { store in + store.saveCodexCache( + cache, + calendar: calendar, + requestedScanWindow: requestedScanWindow, + reportWindow: reportWindow) + } + } + } +} + // MARK: - Connection lifecycle extension CostUsageStore { @@ -172,6 +232,31 @@ extension CostUsageStore { self.connection = SQLiteConnection(handle: database) } } + + /// The Codex JSON cache is derived data, so the SQLite cutover deliberately rebuilds + /// from session files instead of importing an old monolithic snapshot. Keep the legacy + /// filename knowledge confined to this one cleanup boundary. + func removeLegacyCodexArtifactIfPresent() -> Bool { + let directory = self.databaseURL.deletingLastPathComponent() + let legacyFilename = "codex-v11.json" + let legacyURL = directory.appendingPathComponent(legacyFilename) + guard FileManager.default.fileExists(atPath: legacyURL.path) else { return false } + + let temporaryNames = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil))?.filter { url in + let name = url.lastPathComponent + return name == legacyFilename + || name.hasPrefix(".\(legacyFilename).") + || name.hasPrefix("\(legacyFilename).") + || name.hasPrefix("\(legacyFilename)-") + } ?? [legacyURL] + for url in temporaryNames { + try? FileManager.default.removeItem(at: url) + } + self.rebuildDatabase() + return true + } } // MARK: - Schema @@ -222,11 +307,18 @@ extension CostUsageStore { total_cached INTEGER, total_output INTEGER, total_reasoning INTEGER, - end_offset INTEGER NOT NULL, + end_offset INTEGER, PRIMARY KEY(file_id, event_index) ); CREATE INDEX token_snapshots_day_idx ON token_snapshots(day); CREATE INDEX token_snapshots_timestamp_idx ON token_snapshots(file_id, timestamp_ms, event_index); + CREATE TABLE usage_rows ( + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + row_index INTEGER NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY(file_id, row_index) + ); + CREATE INDEX usage_rows_file_idx ON usage_rows(file_id, row_index); CREATE TABLE file_day_aggregates ( file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, day TEXT NOT NULL, @@ -237,6 +329,7 @@ extension CostUsageStore { reasoning_tokens INTEGER NOT NULL, request_count INTEGER NOT NULL, known_cost_nanos INTEGER NOT NULL, + priority_surcharge_nanos INTEGER NOT NULL, unpriced_tokens INTEGER NOT NULL, standard_cost_nanos INTEGER NOT NULL, priority_cost_nanos INTEGER NOT NULL, @@ -255,6 +348,7 @@ extension CostUsageStore { reasoning_tokens INTEGER NOT NULL, request_count INTEGER NOT NULL, known_cost_nanos INTEGER NOT NULL, + priority_surcharge_nanos INTEGER NOT NULL, unpriced_tokens INTEGER NOT NULL, standard_cost_nanos INTEGER NOT NULL, priority_cost_nanos INTEGER NOT NULL, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStoreModels.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStoreModels.swift index 659fa0f531..b293fc6bc9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStoreModels.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStoreModels.swift @@ -23,6 +23,8 @@ struct CostUsageStoreScanState: Codable, Equatable, Sendable { var nextUsageRowIndex: Int? var lastModel: String? var lastTurnID: String? + var fileIdentity: String? + var detailsPayload: Data? } struct CostUsageStoreFile: Codable, Equatable, Sendable { @@ -47,7 +49,13 @@ struct CostUsageStoreTokenSnapshot: Codable, Equatable, Sendable { var day: String? var last: CostUsageStoreTotals? var total: CostUsageStoreTotals? - var endOffset: Int64 + var endOffset: Int64? +} + +struct CostUsageStoreUsageRow: Codable, Equatable, Sendable { + var path: String + var rowIndex: Int + var payload: Data } struct CostUsageStoreDayAggregate: Codable, Equatable, Sendable { @@ -59,6 +67,7 @@ struct CostUsageStoreDayAggregate: Codable, Equatable, Sendable { var reasoningTokens: Int64 var requestCount: Int64 var knownCostNanos: Int64 + var prioritySurchargeNanos: Int64 = 0 var unpricedTokens: Int64 var standardCostNanos: Int64 var priorityCostNanos: Int64 @@ -75,6 +84,7 @@ struct CostUsageStoreDayAggregate: Codable, Equatable, Sendable { reasoningTokens: 0, requestCount: 0, knownCostNanos: 0, + prioritySurchargeNanos: 0, unpricedTokens: 0, standardCostNanos: 0, priorityCostNanos: 0, @@ -194,6 +204,7 @@ struct CostUsageStoreSnapshot: Equatable, Sendable { var metadata: CostUsageStoreMetadata var files: [CostUsageStoreFile] var tokenSnapshots: [CostUsageStoreTokenSnapshot] + var usageRows: [CostUsageStoreUsageRow] = [] var fileDayAggregates: [CostUsageStoreFileDayAggregate] var dayAggregates: [CostUsageStoreDayAggregate] var forkLineage: [CostUsageStoreForkLineage] @@ -214,6 +225,7 @@ struct CostUsageStoreBudgetResult: Equatable, Sendable { var deletedRows: Int var rowCount: Int var fileBytes: Int64 + var catchUpRequired: Bool = false } struct CostUsageStoreConfiguration: Equatable, Sendable { diff --git a/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift index 29f9ac44ae..3b5be6b687 100644 --- a/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift +++ b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift @@ -79,7 +79,7 @@ struct CodexCompactSubagentAccountingTests { }?.totalTokens == 55) } - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let child = try #require(cache.files.values.first { $0.sessionId == "compact-child" }) #expect(child.days[CostUsageScanner.CostUsageDayRange.dayKey(from: day)]?[ CostUsagePricing.normalizeCodexModel(leafModel), @@ -139,7 +139,7 @@ struct CodexCompactSubagentAccountingTests { #expect(!(beforeDay.modelBreakdowns ?? []).contains { $0.modelName == CostUsagePricing.codexUnattributedModel }) - let beforeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let beforeCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let beforeChild = try #require(beforeCache.files.values.first { $0.sessionId == "cache-child" }) let beforeDependency = try #require(beforeChild.forkBaselineDependencyKey) #expect(beforeDependency == CostUsageScanner.codexForkDependencyNotRequiredKey) @@ -165,7 +165,7 @@ struct CodexCompactSubagentAccountingTests { #expect(!(afterDay.modelBreakdowns ?? []).contains { $0.modelName == CostUsagePricing.codexUnattributedModel }) - let afterCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let afterCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let afterChild = try #require(afterCache.files.values.first { $0.sessionId == "cache-child" }) #expect(afterChild.forkBaselineDependencyKey == beforeDependency) #expect(afterChild.days.values.allSatisfy { $0[CostUsagePricing.codexUnattributedModel] == nil }) diff --git a/Tests/CodexBarTests/CodexForkAppendResumeTests.swift b/Tests/CodexBarTests/CodexForkAppendResumeTests.swift index af8be3fb81..599e6f19a0 100644 --- a/Tests/CodexBarTests/CodexForkAppendResumeTests.swift +++ b/Tests/CodexBarTests/CodexForkAppendResumeTests.swift @@ -57,7 +57,7 @@ struct CodexForkAppendResumeTests { now: day, options: options) - let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let firstCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let firstUsage = try #require(firstCache.files.values.first { $0.sessionId == "redacted-child" }) let firstParsedBytes = try #require(firstUsage.parsedBytes) #expect(firstUsage.forkedFromId == "redacted-missing-parent") @@ -104,7 +104,7 @@ struct CodexForkAppendResumeTests { now: day.addingTimeInterval(1), options: options) - let secondCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let secondCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let secondUsage = try #require(secondCache.files.values.first { $0.sessionId == "redacted-child" }) #expect(secondUsage.parsedBytes == appendedSize) #expect((secondUsage.parsedBytes ?? 0) >= firstParsedBytes) @@ -168,7 +168,7 @@ struct CodexForkAppendResumeTests { now: day, options: options) - let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let firstCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let firstUsage = try #require(firstCache.files.values.first { $0.sessionId == "redacted-subagent" }) let firstParsedBytes = try #require(firstUsage.parsedBytes) #expect(firstUsage.forkedFromId == "redacted-missing-parent") @@ -231,7 +231,7 @@ struct CodexForkAppendResumeTests { now: day.addingTimeInterval(1), options: options) - let secondCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let secondCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let secondUsage = try #require(secondCache.files.values.first { $0.sessionId == "redacted-subagent" }) #expect((secondUsage.parsedBytes ?? 0) <= 512) #expect(secondUsage.codexScanComplete == false) diff --git a/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift b/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift index 2127cbe690..0e355498af 100644 --- a/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift +++ b/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift @@ -77,7 +77,7 @@ struct CodexLocalProjectUsageTests { } @Test - func `v10 cache remains untouched while v11 rebuilds and then refreshes incrementally`() throws { + func `predecessor cache remains untouched while SQLite builds and refreshes incrementally`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -87,8 +87,8 @@ struct CodexLocalProjectUsageTests { let v10URL = costCacheRoot.appendingPathComponent("codex-v10.json", isDirectory: false) let v10Bytes = Data("recoverable-v10-cursor".utf8) try v10Bytes.write(to: v10URL) - let v11URL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) - #expect(!FileManager.default.fileExists(atPath: v11URL.path)) + let databaseURL = CostUsageStore(cacheRoot: env.cacheRoot).databaseURL + #expect(!FileManager.default.fileExists(atPath: databaseURL.path)) try self.writeCodexUsageFile( env: env, @@ -113,8 +113,8 @@ struct CodexLocalProjectUsageTests { #expect(first.data.first?.totalTokens == 130) #expect(try Data(contentsOf: v10URL) == v10Bytes) - #expect(FileManager.default.fileExists(atPath: v11URL.path)) - #expect(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files.count == 1) + #expect(FileManager.default.fileExists(atPath: databaseURL.path)) + #expect(CostUsageStoreAccess.read(cacheRoot: env.cacheRoot).files.count == 1) try self.writeCodexUsageFile( env: env, @@ -135,7 +135,7 @@ struct CodexLocalProjectUsageTests { options: options) #expect(warm.data.first?.totalTokens == 180) - #expect(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files.count == 2) + #expect(CostUsageStoreAccess.read(cacheRoot: env.cacheRoot).files.count == 2) #expect(try Data(contentsOf: v10URL) == v10Bytes) } @@ -331,7 +331,7 @@ struct CodexLocalProjectUsageTests { dayKey: dayKey, fixture: fixture, costNanos: 1) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( now: day, @@ -393,7 +393,7 @@ struct CodexLocalProjectUsageTests { cached: 10, output: 25), costNanos: 1) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( now: day, @@ -665,7 +665,7 @@ struct CodexLocalProjectUsageTests { dayKey: dayKey, fixture: fixture, costNanos: 1) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( now: day, historyDays: 1, @@ -683,7 +683,7 @@ struct CodexLocalProjectUsageTests { scannerOptions: options)) != nil) cache.codexPricingKey = "pricing-b" - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) #expect(CodexLocalProjectUsageIndexer.cachedSnapshot(now: day, historyDays: 1, options: .init( scannerOptions: options))?.total.totalTokens == 130) @@ -741,7 +741,7 @@ struct CodexLocalProjectUsageTests { cached: 0, output: 20), costNanos: nil) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( now: day, @@ -814,7 +814,7 @@ struct CodexLocalProjectUsageTests { dayKey: dayKey, fixture: chatFixture, costNanos: 1) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( now: day, @@ -863,7 +863,7 @@ struct CodexLocalProjectUsageTests { cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) cache.files[firstFileURL.path] = self.makeCachedFileUsage(dayKey: dayKey, fixture: firstFixture, costNanos: 1) cache.files[secondFileURL.path] = self.makeCachedFileUsage(dayKey: dayKey, fixture: secondFixture, costNanos: 1) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let recorder = ProgressRecorder() _ = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( diff --git a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift index 932fda7740..6447beaffc 100644 --- a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift +++ b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift @@ -195,7 +195,7 @@ struct CodexSubagentAccountingIntegrationTests { options: options) #expect(report.data.first?.totalTokens == 165) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let childUsages = cache.files.values.filter { $0.sessionId?.hasPrefix("marker-child") == true } #expect(childUsages.count == 3) #expect(childUsages.allSatisfy { @@ -707,7 +707,7 @@ struct CodexSubagentAccountingIntegrationTests { options: options) #expect(second.data.first?.totalTokens == 55) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let usage = try #require(cache.files.values.first { $0.sessionId == "growing-child" }) #expect(usage.sessionId == "growing-child") #expect(usage.forkedFromId == "growing-parent") diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift deleted file mode 100644 index b297a4d2ca..0000000000 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ /dev/null @@ -1,1677 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -// swiftlint:disable:next type_body_length -struct CostUsageCacheTests { - @Test - func `legacy codex token cache decodes without reasoning while current rows round trip it`() throws { - let legacyTotals = try JSONDecoder().decode( - CostUsageCodexTotals.self, - from: Data(#"{"input":10,"cached":2,"output":4}"#.utf8)) - #expect(legacyTotals.reasoning == nil) - - let legacyRow = try JSONDecoder().decode( - CostUsageScanner.CodexUsageRow.self, - from: Data(#"{"day":"2026-07-17","model":"gpt-5.5","input":10,"cached":2,"output":4}"#.utf8)) - #expect(legacyRow.reasoning == nil) - - let currentRow = CostUsageScanner.CodexUsageRow( - day: "2026-07-17", - model: "gpt-5.5", - turnID: "turn", - eventIndex: 1, - input: 10, - cached: 2, - output: 4, - reasoning: 3) - let roundTripped = try JSONDecoder().decode( - CostUsageScanner.CodexUsageRow.self, - from: JSONEncoder().encode(currentRow)) - #expect(roundTripped.reasoning == 3) - } - - @Test - func `cache file URL uses provider artifact versions`() { - let root = URL(fileURLWithPath: "/tmp/codexbar-cost-cache", isDirectory: true) - - let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) - - #expect(codexURL.lastPathComponent == "codex-v11.json") - #expect(claudeURL.lastPathComponent == "claude-v6.json") - #expect(vertexURL.lastPathComponent == "vertexai-v6.json") - } - - @Test - func `cost cache ignores predecessor artifact with persisted offset`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let legacyURL = root - .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("codex-v9.json", isDirectory: false) - try FileManager.default.createDirectory( - at: legacyURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - let producerKey = try #require(CostUsageCacheIO.currentProducerKey(provider: .codex)) - let legacy = """ - { - "version": 1, - "producerKey": "\(producerKey)", - "lastScanUnixMs": 999, - "files": { - "/tmp/session.jsonl": { - "mtimeUnixMs": 1, - "size": 100, - "days": {}, - "parsedBytes": 100 - } - }, - "days": {} - } - """ - try legacy.write(to: legacyURL, atomically: false, encoding: .utf8) - - let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) - - #expect(loaded.lastScanUnixMs == 0) - #expect(loaded.files.isEmpty) - } - - @Test - func `Pi session cache ignores predecessor artifact with persisted offset`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let legacyURL = root - .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("pi-sessions-v7.json", isDirectory: false) - try FileManager.default.createDirectory( - at: legacyURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - var legacy = PiSessionCostCache(version: 7) - legacy.lastScanUnixMs = 999 - legacy.files = [ - "/tmp/session.jsonl": PiSessionFileUsage( - mtimeUnixMs: 1, - size: 100, - parsedBytes: 100, - lastModelContext: nil, - contributions: [:]), - ] - try JSONEncoder().encode(legacy).write(to: legacyURL) - - let loaded = PiSessionCostCacheIO.load(cacheRoot: root) - - #expect(loaded.version == 8) - #expect(loaded.lastScanUnixMs == 0) - #expect(loaded.files.isEmpty) - } - - @Test - func `cache load requires matching producer key`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.lastScanUnixMs = 123 - cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.producerKey == "codex:cu:p1111111111111111") - #expect(loaded.lastScanUnixMs == 123) - #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) - - let stale = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p2222222222222222") - #expect(stale.lastScanUnixMs == 0) - #expect(stale.files.isEmpty) - #expect(stale.days.isEmpty) - - let migration = CostUsageCacheIO.loadCodexForMigration( - cacheRoot: root, - producerKey: "codex:cu:p2222222222222222") - #expect(migration.cache.days.isEmpty) - #expect(migration.incompatibleCache?.producerKey == "codex:cu:p1111111111111111") - #expect(migration.incompatibleCache?.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) - } - - @Test - func `legacy cache without producer key is ignored`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - let legacy = """ - { - "version": 1, - "lastScanUnixMs": 999, - "files": {}, - "days": { - "2026-05-18": { - "gpt-5": [1, 0, 0] - } - } - } - """ - try legacy.write(to: url, atomically: false, encoding: .utf8) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - - #expect(loaded.lastScanUnixMs == 0) - #expect(loaded.days.isEmpty) - } - - @Test - func `current codex cache rejects pre interleave containment producers`() throws { - // Interleave containment (#2037) changed cumulative delta semantics, so caches from - // previously compatible parser hashes must be rebuilt instead of reused. - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - for legacyProducerKey in ["codex:cu:p3c27f997569eb3c5", "codex:cu:pc54070a94f6419ea"] { - var cache = CostUsageCache() - cache.lastScanUnixMs = 123 - cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: legacyProducerKey) - - let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) - - #expect(loaded.lastScanUnixMs == 0) - #expect(loaded.days.isEmpty) - } - } - - @Test - func `current codex cache accepts the append resume predecessor`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.lastScanUnixMs = 123 - cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p843ca061c36bbea1") - - let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) - let migration = CostUsageCacheIO.loadCodexForMigration(cacheRoot: root) - - #expect(loaded.lastScanUnixMs == 123) - #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) - #expect(migration.cache.lastScanUnixMs == 123) - #expect(migration.incompatibleCache == nil) - } - - @Test - func `current codex cache accepts calendar normalization predecessors`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - // Caches persisted by builds since the bounded-cost-cache work stay loadable: the - // catch-up report calendar normalization did not change stored totals or cache - // layout, so upgrading must not force a rebuild. - for (index, producerKey) in [ - "codex:cu:paa27d287348e79b5", - "codex:cu:p6c0f1fa950e63467", - "codex:cu:p37aedd661c4272a8", - "codex:cu:p1cd29792d9ca2b11", - ].enumerated() { - let root = root.appendingPathComponent("case-\(index)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - var cache = CostUsageCache() - cache.lastScanUnixMs = 456 - cache.days = ["2026-07-02": ["gpt-5.5": [1, 2, 3]]] - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: producerKey) - - let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) - let migration = CostUsageCacheIO.loadCodexForMigration(cacheRoot: root) - - #expect(loaded.lastScanUnixMs == 456) - #expect(loaded.days["2026-07-02"]?["gpt-5.5"] == [1, 2, 3]) - #expect(migration.cache.lastScanUnixMs == 456) - #expect(migration.incompatibleCache == nil) - } - } - - @Test - func `non codex cache does not require producer key`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - let legacy = """ - { - "version": 1, - "lastScanUnixMs": 999, - "files": {}, - "days": { - "2026-05-18": { - "claude-sonnet-4-5": [1, 0, 0] - } - } - } - """ - try legacy.write(to: url, atomically: false, encoding: .utf8) - - let loaded = CostUsageCacheIO.load(provider: .claude, cacheRoot: root) - - #expect(loaded.lastScanUnixMs == 999) - #expect(loaded.days["2026-05-18"]?["claude-sonnet-4-5"] == [1, 0, 0]) - } - - @Test - func `current producer key uses generated parser hash for codex only`() { - let codexKey = CostUsageCacheIO.currentProducerKey( - provider: .codex, - parserHash: "abc1234567890def") - let standaloneKey = CostUsageCacheIO.currentProducerKey( - provider: .claude, - parserHash: "abc1234567890def") - - #expect(codexKey == "codex:cu:pabc1234567890def") - #expect(standaloneKey == nil) - } - - @Test - func `generated parser hash is stable short lowercase hex`() { - let hash = CodexParserHash.value - - #expect(hash.range(of: #"^[0-9a-f]{16}$"#, options: .regularExpression) != nil) - #expect(CostUsageCacheIO.currentProducerKey(provider: .codex) == "codex:cu:p\(hash)") - } - - @Test - func `save prunes out-of-window files when over the entry budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - cache.days = [ - "2026-06-10": ["gpt-5.5": [10, 0, 0]], - "2026-06-20": ["gpt-5.5": [20, 0, 0]], - "2026-04-10": ["gpt-5.5": [99, 0, 0]], - ] - cache.files = [ - "/sessions/2026-06-10.jsonl": CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-10": ["gpt-5.5": [10, 0, 0]]]), - "/sessions/2026-06-20.jsonl": CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [20, 0, 0]]]), - "/sessions/2026-04-10.jsonl": CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [99, 0, 0]]]), - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 2) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files.keys.sorted() == [ - "/sessions/2026-06-10.jsonl", - "/sessions/2026-06-20.jsonl", - ]) - #expect(loaded.days["2026-04-10"] == nil) - #expect(loaded.days["2026-06-10"]?["gpt-5.5"] == [10, 0, 0]) - #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [20, 0, 0]) - } - - @Test - func `save prunes out-of-window files when the previous artifact exceeds the byte budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try Data(repeating: 0x20, count: 4096).write(to: url) - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - cache.days = [ - "2026-06-10": ["gpt-5.5": [1, 0, 0]], - "2026-04-10": ["gpt-5.5": [9, 0, 0]], - ] - cache.files = [ - "/sessions/2026-06-10.jsonl": CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-10": ["gpt-5.5": [1, 0, 0]]]), - "/sessions/2026-04-10.jsonl": CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [9, 0, 0]]]), - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheBytes: 1024, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(Array(loaded.files.keys) == ["/sessions/2026-06-10.jsonl"]) - #expect(loaded.days["2026-04-10"] == nil) - #expect(loaded.days["2026-06-10"]?["gpt-5.5"] == [1, 0, 0]) - } - - @Test - func `save never drops in-window files even when over the entry budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - cache.days = [ - "2026-06-10": ["gpt-5.5": [1, 0, 0]], - "2026-06-20": ["gpt-5.5": [2, 0, 0]], - "2026-06-28": ["gpt-5.5": [3, 0, 0]], - ] - for (index, day) in ["2026-06-10", "2026-06-20", "2026-06-28"].enumerated() { - cache.files["/sessions/\(day).jsonl"] = CostUsageFileUsage( - mtimeUnixMs: Int64(index), - size: 100, - days: [day: ["gpt-5.5": [index + 1, 0, 0]]]) - } - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 2) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files.count == 3) - #expect(loaded.days.count == 3) - } - - @Test - func `load refuses oversized cache artifacts`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - var payload = Data( - #"{"version":1,"producerKey":"codex:cu:p1111111111111111","files":{},"days":{}}"#.utf8) - payload.append(Data(repeating: 0x20, count: 2048)) - try payload.write(to: url) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheBytes: 1024) - - #expect(loaded.files.isEmpty) - #expect(loaded.days.isEmpty) - } - - @Test - func `save preserves out-of-window fork parents during budget pruning`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var parent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - parent.sessionId = "parent-session" - var unrelated = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) - unrelated.sessionId = "unrelated-session" - var child = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - child.forkedFromId = "parent-session" - cache.files = [ - "/sessions/parent.jsonl": parent, - "/sessions/unrelated.jsonl": unrelated, - "/sessions/child.jsonl": child, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-04-11": ["gpt-5.5": [1, 0, 0]], - "2026-06-20": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 2) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/parent.jsonl"] != nil) - #expect(loaded.files["/sessions/unrelated.jsonl"] == nil) - #expect(loaded.files["/sessions/child.jsonl"] != nil) - #expect(loaded.days["2026-04-10"]?["gpt-5.5"] == [1, 0, 0]) - #expect(loaded.days["2026-04-11"] == nil) - } - - @Test - func `save preserves out-of-window entries that are still resuming`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var incomplete = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - incomplete.codexScanComplete = false - var bufferedForkRetry = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) - bufferedForkRetry.codexBufferedSubagentLines = [ - CostUsageScanner.CodexBufferedFastLine( - lineIndex: 0, - ordinal: nil, - line: .taskStarted(turnID: nil)), - ] - var completedWithScanID = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-13": ["gpt-5.5": [1, 0, 0]]]) - completedWithScanID.codexScanFileId = "scan-id" - completedWithScanID.codexScanComplete = true - var settled = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-12": ["gpt-5.5": [1, 0, 0]]]) - cache.files = [ - "/sessions/incomplete.jsonl": incomplete, - "/sessions/buffered-fork-retry.jsonl": bufferedForkRetry, - "/sessions/completed-with-scan-id.jsonl": completedWithScanID, - "/sessions/settled.jsonl": settled, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-04-11": ["gpt-5.5": [1, 0, 0]], - "2026-04-13": ["gpt-5.5": [1, 0, 0]], - "2026-04-12": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 3) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/incomplete.jsonl"] != nil) - #expect(loaded.files["/sessions/buffered-fork-retry.jsonl"] != nil) - #expect(loaded.files["/sessions/completed-with-scan-id.jsonl"] == nil) - #expect(loaded.files["/sessions/settled.jsonl"] == nil) - #expect(loaded.days["2026-04-12"] == nil) - } - - @Test - func `save prunes against the requested window and narrows persisted coverage`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-01-01" - cache.scanUntilKey = "2026-07-01" - cache.days = [ - "2026-02-10": ["gpt-5.5": [1, 0, 0]], - "2026-06-20": ["gpt-5.5": [2, 0, 0]], - "2026-06-28": ["gpt-5.5": [3, 0, 0]], - ] - for (index, day) in ["2026-02-10", "2026-06-20", "2026-06-28"].enumerated() { - cache.files["/sessions/\(day).jsonl"] = CostUsageFileUsage( - mtimeUnixMs: Int64(index), - size: 100, - days: [day: ["gpt-5.5": [index + 1, 0, 0]]]) - } - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheEntries: 2) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(Array(loaded.files.keys).sorted() == [ - "/sessions/2026-06-20.jsonl", - "/sessions/2026-06-28.jsonl", - ]) - #expect(loaded.days["2026-02-10"] == nil) - #expect(loaded.scanSinceKey == "2026-06-01") - #expect(loaded.scanUntilKey == "2026-07-01") - } - - @Test - func `save prunes when the candidate artifact crosses the byte budget in one refresh`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var inWindow = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - var staleWithDetail = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - let snapshots = (0..<400).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-04-10T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - staleWithDetail.codexTokenSnapshots = snapshots - cache.files = [ - "/sessions/in-window.jsonl": inWindow, - "/sessions/stale-with-detail.jsonl": staleWithDetail, - ] - cache.days = [ - "2026-06-20": ["gpt-5.5": [1, 0, 0]], - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - ] - - // The entry count is within budget and no previous artifact exists, but the - // candidate payload exceeds the tiny byte budget; pruning must still happen. - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 1024, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(Array(loaded.files.keys) == ["/sessions/in-window.jsonl"]) - #expect(loaded.days["2026-04-10"] == nil) - #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) - } - - @Test - func `load cap applies only to codex`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - var payload = Data( - #"{"version":1,"lastScanUnixMs":999,"files":{},"days":{}}"#.utf8) - payload.append(Data(repeating: 0x20, count: 2048)) - try payload.write(to: url) - - // The load cap guards Codex's bounded-rebuild path only; Claude/Vertex caches are - // not pruned on save, so rejecting them would cause a rebuild loop. - let loaded = CostUsageCacheIO.load( - provider: .claude, - cacheRoot: root, - maxCacheBytes: 1024) - - #expect(loaded.lastScanUnixMs == 999) - } - - @Test - func `save drops stale parents referenced only by stale children`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var parent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - parent.sessionId = "parent-session" - var staleChild = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) - staleChild.sessionId = "child-session" - staleChild.forkedFromId = "parent-session" - cache.files = [ - "/sessions/parent.jsonl": parent, - "/sessions/stale-child.jsonl": staleChild, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-04-11": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 1) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files.isEmpty) - #expect(loaded.days.isEmpty) - } - - @Test - func `save retains recently active zero-day session entries`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone.current - var components = DateComponents() - components.calendar = calendar - components.timeZone = calendar.timeZone - components.year = 2026 - components.month = 6 - components.day = 25 - components.hour = 12 - let activeMtime = Int64( - (calendar.date(from: components) ?? Date()).timeIntervalSince1970 * 1000) - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var activeZeroDay = CostUsageFileUsage( - mtimeUnixMs: activeMtime, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - activeZeroDay.sessionId = "active-session" - var inactive = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-11": ["gpt-5.5": [1, 0, 0]]]) - inactive.sessionId = "inactive-session" - cache.files = [ - "/sessions/active-zero-day.jsonl": activeZeroDay, - "/sessions/inactive.jsonl": inactive, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-04-11": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - maxCacheEntries: 1) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/active-zero-day.jsonl"] != nil) - #expect(loaded.files["/sessions/inactive.jsonl"] == nil) - #expect(loaded.days["2026-04-10"]?["gpt-5.5"] == [1, 0, 0]) - } - - @Test - func `save drops oldest in-window entries when the window corpus exceeds the byte budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - let snapshots = (0..<300).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-0\(index % 9)T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var older = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) - older.codexTokenSnapshots = snapshots - var recent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - recent.codexTokenSnapshots = snapshots - cache.files = [ - "/sessions/older.jsonl": older, - "/sessions/recent.jsonl": recent, - ] - cache.days = [ - "2026-06-05": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/older.jsonl"] == nil) - #expect(loaded.files["/sessions/recent.jsonl"] != nil) - #expect(loaded.days["2026-06-05"] == nil) - #expect(loaded.days["2026-06-28"]?["gpt-5.5"] == [1, 0, 0]) - } - - @Test - func `save marks trimmed caches as needing catch up`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - cache.lastScanUnixMs = 123_456 - let snapshots = (0..<300).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-0\(index % 9)T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var older = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) - older.codexTokenSnapshots = snapshots - var recent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - recent.codexTokenSnapshots = snapshots - cache.files = [ - "/sessions/older.jsonl": older, - "/sessions/recent.jsonl": recent, - ] - cache.days = [ - "2026-06-05": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], - "2026-05-31": ["gpt-5.5": [1, 0, 0]], - "2026-07-02": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - reportWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.codexScanCatchUpPending == true) - #expect(loaded.lastScanUnixMs == 0) - #expect(loaded.codexPreviousReport != nil) - #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-05" } == true) - #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-06-28" } == true) - #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-05-31" } == false) - #expect(loaded.codexPreviousReport?.data.contains { $0.date == "2026-07-02" } == false) - #expect(loaded.codexPreviousReport?.scanSinceKey == "2026-06-01") - #expect(loaded.codexPreviousReport?.scanUntilKey == "2026-07-01") - } - - @Test - func `save prunes discovery records with removed sessions`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var stale = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - stale.sessionId = "stale-session" - var inWindow = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - inWindow.sessionId = "live-session" - cache.files = [ - "/sessions/stale.jsonl": stale, - "/sessions/in-window.jsonl": inWindow, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-06-20": ["gpt-5.5": [1, 0, 0]], - ] - cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( - roots: ["/sessions"], - generation: nil, - directoryStamps: [:], - directoryPaths: [], - nextDirectoryIndex: 0, - filePaths: ["/sessions/stale.jsonl", "/sessions/in-window.jsonl"], - nextFileIndex: 0, - fileStamps: [ - "/sessions/stale.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil), - "/sessions/in-window.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil), - ], - headScan: nil, - filePathBySessionId: [ - "stale-session": "/sessions/stale.jsonl", - "live-session": "/sessions/in-window.jsonl", - ], - missingSessionIds: ["stale-session"], - pendingSessionIds: [], - validationDirectoryIndex: 0, - isComplete: true) - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheEntries: 1) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let discovery = try #require(loaded.codexSessionDiscovery) - #expect(discovery.filePaths == ["/sessions/in-window.jsonl"]) - #expect(discovery.fileStamps["/sessions/stale.jsonl"] == nil) - #expect(discovery.filePathBySessionId["stale-session"] == nil) - #expect(discovery.filePathBySessionId["live-session"] != nil) - #expect(discovery.missingSessionIds == []) - #expect(discovery.isComplete == false) - #expect(discovery.nextFileIndex == 0) - #expect(discovery.nextDirectoryIndex == 0) - } - - @Test - func `save strips detail from a sole oversized in-window entry`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var huge = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - huge.sessionId = "huge-session" - huge.parsedBytes = 1_000_000 - huge.codexRows = [ - CostUsageScanner.CodexUsageRow( - day: "2026-06-20", - model: "gpt-5.5", - turnID: "turn", - eventIndex: 1, - input: 10, - cached: 2, - output: 4, - reasoning: nil), - ] - huge.codexTokenSnapshots = (0..<1000).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-20T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - cache.files = ["/sessions/huge.jsonl": huge] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let survivor = try #require(loaded.files["/sessions/huge.jsonl"]) - #expect(survivor.codexTokenSnapshots == nil) - #expect(survivor.codexRows == nil) - #expect(survivor.parsedBytes == 0) - #expect(survivor.codexScanComplete == false) - #expect(survivor.codexScanFileId == nil) - #expect(survivor.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) - #expect(loaded.codexScanCatchUpPending == true) - } - - @Test - func `save compacts a protected fork parent that alone exceeds the budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var parent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-06-10": ["gpt-5.5": [1, 0, 0]]]) - parent.sessionId = "parent-session" - parent.parsedBytes = 1_000_000 - parent.codexTokenSnapshots = (0..<1000).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-10T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var child = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - child.sessionId = "child-session" - child.forkedFromId = "parent-session" - child.codexScanComplete = false - cache.files = [ - "/sessions/parent.jsonl": parent, - "/sessions/child.jsonl": child, - ] - cache.days = [ - "2026-06-10": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let compacted = try #require(loaded.files["/sessions/parent.jsonl"]) - #expect(compacted.codexTokenSnapshots == nil) - #expect(compacted.parsedBytes == 0) - #expect(compacted.codexScanComplete == false) - #expect(loaded.files["/sessions/child.jsonl"] != nil) - #expect(loaded.codexScanCatchUpPending == true) - } - - @Test - func `save does not protect lineage only fork parents`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var parent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - parent.sessionId = "parent-session" - parent.codexTokenSnapshots = (0..<1000).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-04-10T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var lineageChild = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - lineageChild.sessionId = "lineage-child" - lineageChild.forkedFromId = "parent-session" - lineageChild.forkBaselineDependencyKey = CostUsageScanner.codexForkDependencyNotRequiredKey - cache.files = [ - "/sessions/parent.jsonl": parent, - "/sessions/lineage-child.jsonl": lineageChild, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheEntries: 1) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/parent.jsonl"] == nil) - #expect(loaded.files["/sessions/lineage-child.jsonl"] != nil) - #expect(loaded.days["2026-04-10"] == nil) - } - - @Test - func `save enforces the byte cap when the estimate underestimates the payload`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var entry = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - entry.sessionId = "big-session" - entry.parsedBytes = 1_000_000 - let longTimestamp = "2026-06-20T00:00:00.000000000Z-\(String(repeating: "x", count: 80))" - entry.codexTokenSnapshots = (0..<850).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "\(longTimestamp)-\(index)", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - cache.files = ["/sessions/big.jsonl": entry] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - let maxCacheBytes = 115_000 - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: maxCacheBytes, - maxCacheEntries: 100) - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? - .int64Value ?? 0 - #expect(artifactBytes <= maxCacheBytes) - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/big.jsonl"]?.codexTokenSnapshots == nil) - #expect(loaded.codexScanCatchUpPending == true) - #expect(loaded.days["2026-06-20"]?["gpt-5.5"] == [1, 0, 0]) - } - - @Test - func `save re-encodes after a forced prune removes out-of-window entries`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var stale = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-04-10": ["gpt-5.5": [1, 0, 0]]]) - stale.sessionId = "stale-session" - let longTimestamp = "2026-04-10T00:00:00.000000000Z-\(String(repeating: "x", count: 80))" - stale.codexTokenSnapshots = (0..<850).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "\(longTimestamp)-\(index)", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var inWindow = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - inWindow.sessionId = "live-session" - cache.files = [ - "/sessions/stale.jsonl": stale, - "/sessions/in-window.jsonl": inWindow, - ] - cache.days = [ - "2026-04-10": ["gpt-5.5": [1, 0, 0]], - "2026-06-20": ["gpt-5.5": [1, 0, 0]], - ] - let maxCacheBytes = 115_000 - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: maxCacheBytes, - maxCacheEntries: 100) - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? - .int64Value ?? 0 - #expect(artifactBytes <= maxCacheBytes) - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/stale.jsonl"] == nil) - #expect(loaded.files["/sessions/in-window.jsonl"] != nil) - #expect(loaded.days["2026-04-10"] == nil) - } - - @Test - func `save clears the active lookback queue when it keeps the artifact over budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var inWindow = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - inWindow.sessionId = "live-session" - cache.files = ["/sessions/in-window.jsonl": inWindow] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - cache.codexActiveLookbackState = CostUsageCodexActiveLookbackState( - scanSinceKey: "2026-06-01", - rootPaths: ["/sessions"], - nextDayKeyByRoot: ["/sessions": "2026-06-02"], - completedRootPaths: [], - pendingFilePaths: (0..<3000).map { "/sessions/pending-\($0).jsonl" }, - legacyRecursivePendingRootPaths: ["/sessions/archive"]) - let maxCacheBytes = 30000 - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: maxCacheBytes, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let lookback = try #require(loaded.codexActiveLookbackState) - #expect(lookback.pendingFilePaths.isEmpty) - #expect(lookback.legacyRecursivePendingRootPaths == ["/sessions/archive"]) - #expect(loaded.codexSessionDiscovery?.filePaths.contains("/sessions/pending-0.jsonl") == true) - #expect(loaded.codexSessionDiscovery?.directoryPaths.isEmpty == true) - #expect(loaded.files["/sessions/in-window.jsonl"] != nil) - } - - @Test - func `save compacts a dropped parent required by the kept survivor`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var parent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) - parent.sessionId = "parent-session" - parent.parsedBytes = 1_000_000 - parent.codexTokenSnapshots = (0..<1000).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-05T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var child = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - child.sessionId = "child-session" - child.forkedFromId = "parent-session" - cache.files = [ - "/sessions/parent.jsonl": parent, - "/sessions/child.jsonl": child, - ] - cache.days = [ - "2026-06-05": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], - ] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let compacted = try #require(loaded.files["/sessions/parent.jsonl"]) - #expect(compacted.codexTokenSnapshots == nil) - #expect(compacted.parsedBytes == 0) - #expect(compacted.codexScanComplete == false) - #expect(loaded.files["/sessions/child.jsonl"] != nil) - #expect(loaded.codexScanCatchUpPending == true) - } - - @Test - func `save prunes orphaned discovery mappings when they keep the artifact over budget`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var inWindow = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - inWindow.sessionId = "live-session" - cache.files = ["/sessions/in-window.jsonl": inWindow] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( - roots: ["/sessions"], - generation: nil, - directoryStamps: [:], - directoryPaths: [], - nextDirectoryIndex: 0, - filePaths: ["/sessions/in-window.jsonl"], - nextFileIndex: 0, - fileStamps: ["/sessions/in-window.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil)], - headScan: nil, - filePathBySessionId: Dictionary( - uniqueKeysWithValues: (0..<3000).map { index in - ("orphan-\(index)", "/sessions/deleted-\(index).jsonl") - }), - missingSessionIds: [], - pendingSessionIds: [], - validationDirectoryIndex: 0, - isComplete: true) - let maxCacheBytes = 30000 - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: maxCacheBytes, - maxCacheEntries: 100) - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? - .int64Value ?? 0 - #expect(artifactBytes <= maxCacheBytes) - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.codexSessionDiscovery?.filePathBySessionId.isEmpty == true) - #expect(loaded.files["/sessions/in-window.jsonl"] != nil) - } - - @Test - func `save shares discovery id capacity across missing and pending lists`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var inWindow = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - inWindow.sessionId = "live-session" - cache.files = ["/sessions/in-window.jsonl": inWindow] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery( - roots: ["/sessions"], - generation: nil, - directoryStamps: [:], - directoryPaths: [], - nextDirectoryIndex: 0, - filePaths: ["/sessions/in-window.jsonl"], - nextFileIndex: 0, - fileStamps: ["/sessions/in-window.jsonl": .init(mtimeUnixMs: 1, size: 100, fileId: nil)], - headScan: nil, - filePathBySessionId: ["live-session": "/sessions/in-window.jsonl"], - missingSessionIds: (0..<2000).map { "missing-\($0)" }, - pendingSessionIds: (0..<2000).map { "pending-\($0)" }, - validationDirectoryIndex: 0, - isComplete: true) - let maxCacheBytes = 30000 - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: maxCacheBytes, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let discovery = try #require(loaded.codexSessionDiscovery) - let combined = discovery.missingSessionIds.count + discovery.pendingSessionIds.count - #expect(combined <= maxCacheBytes / 48) - #expect(combined < 2000) - } - - @Test - func `save preserves an existing complete report across repeated trims`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var older = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) - older.sessionId = "older-session" - older.codexTokenSnapshots = (0..<1000).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-05T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - var recent = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-28": ["gpt-5.5": [1, 0, 0]]]) - recent.sessionId = "recent-session" - cache.files = [ - "/sessions/older.jsonl": older, - "/sessions/recent.jsonl": recent, - ] - cache.days = [ - "2026-06-05": ["gpt-5.5": [1, 0, 0]], - "2026-06-28": ["gpt-5.5": [1, 0, 0]], - ] - // Simulate an already pending catch-up pass with a complete previous report. - cache.codexScanCatchUpPending = true - cache.codexPreviousReport = CostUsageCodexPreviousReport( - report: CostUsageDailyReport(data: [ - CostUsageDailyReport.Entry( - date: "2026-06-05", - inputTokens: 1, - outputTokens: 0, - totalTokens: 1, - costUSD: nil, - modelsUsed: nil, - modelBreakdowns: nil), - ], summary: nil), - cache: cache) - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let preserved = try #require(loaded.codexPreviousReport) - #expect(preserved.data.count == 1) - #expect(preserved.data.first?.date == "2026-06-05") - #expect(preserved.data.contains { $0.date == "2026-06-28" } == false) - } - - @Test - func `codex load cap keeps headroom over the save budget with a real save load round trip`() throws { - // `save` bounds the artifact to `maxCacheFileBytes`; the load cap must stay above it - // (with slack for enforcement overshoot) or every persisted artifact near the budget - // would be refused and rebuilt on the next launch. - #expect(CostUsageCacheIO.maxCacheLoadBytes > CostUsageCacheIO.maxCacheFileBytes) - - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - // A resuming entry cannot be pruned, trimmed, or stripped, so the encoded payload - // stays above the save budget; the overshoot must still fit the load cap and the - // artifact must remain readable instead of entering a rebuild loop. - var resuming = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - resuming.codexScanComplete = false - resuming.codexBufferedSubagentLines = (0..<20).map { index in - CostUsageScanner.CodexBufferedFastLine( - lineIndex: index, - ordinal: nil, - line: .taskStarted(turnID: "turn-\(index)-\(String(repeating: "x", count: 300))")) - } - cache.files = ["/sessions/resuming.jsonl": resuming] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 1024, - maxCacheEntries: 100, - maxCacheLoadBytes: 50000) - - let artifactBytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? - .int64Value ?? 0 - #expect(artifactBytes > 1024) - #expect(artifactBytes <= 50000) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - #expect(loaded.files["/sessions/resuming.jsonl"]?.codexBufferedSubagentLines?.count == 20) - } - - @Test - func `catch up report honors a non-gregorian system calendar`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - var calendar = Calendar(identifier: .buddhist) - calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - var entry = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 1_000_000, - days: ["2026-06-05": ["gpt-5.5": [1, 0, 0]]]) - entry.sessionId = "in-window-session" - entry.codexTokenSnapshots = (0..<500).map { index in - CostUsageCodexTokenSnapshot( - timestamp: "2026-06-05T00:00:0\(index % 10)Z", - last: nil, - total: CostUsageCodexTotals(input: index, cached: 0, output: 0)) - } - cache.files = ["/sessions/in-window.jsonl": entry] - cache.days = ["2026-06-05": ["gpt-5.5": [1, 0, 0]]] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - calendar: calendar, - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - reportWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 30000, - maxCacheEntries: 100) - - let loaded = CostUsageCacheIO.load( - provider: .codex, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111") - let previous = try #require(loaded.codexPreviousReport) - #expect(previous.data.contains { $0.date == "2026-06-05" }) - #expect(previous.data.contains { $0.date == "1483-06-05" } == false) - } - - @Test - func `save removes the artifact when enforcement cannot fit the load cap`() throws { - let root = try self.makeTemporaryCacheRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let url = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try Data(repeating: 0x20, count: 128).write(to: url) - - var cache = CostUsageCache() - cache.scanSinceKey = "2026-06-01" - cache.scanUntilKey = "2026-07-01" - // An in-window entry that is still resuming keeps its buffered fork-retry lines: - // pruning, trimming, and detail stripping all skip it, so the payload cannot shrink. - var resuming = CostUsageFileUsage( - mtimeUnixMs: 1, - size: 100, - days: ["2026-06-20": ["gpt-5.5": [1, 0, 0]]]) - resuming.codexScanComplete = false - resuming.codexBufferedSubagentLines = (0..<200).map { index in - CostUsageScanner.CodexBufferedFastLine( - lineIndex: index, - ordinal: nil, - line: .taskStarted(turnID: "turn-\(index)-\(String(repeating: "x", count: 600))")) - } - cache.files = ["/sessions/resuming.jsonl": resuming] - cache.days = ["2026-06-20": ["gpt-5.5": [1, 0, 0]]] - - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: root, - producerKey: "codex:cu:p1111111111111111", - requestedScanWindow: (sinceKey: "2026-06-01", untilKey: "2026-07-01"), - maxCacheBytes: 1024, - maxCacheEntries: 100, - maxCacheLoadBytes: 50000) - - // Persisting an artifact the loader refuses would decode-and-discard it on every - // launch; the stale artifact must be gone so the bounded scanner rebuilds instead. - #expect(!FileManager.default.fileExists(atPath: url.path)) - } - - private func makeTemporaryCacheRoot() throws -> URL { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("codexbar-cost-cache-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root - } -} diff --git a/Tests/CodexBarTests/CostUsageCalendarTests.swift b/Tests/CodexBarTests/CostUsageCalendarTests.swift index 5ac618a7ff..f6154e2815 100644 --- a/Tests/CodexBarTests/CostUsageCalendarTests.swift +++ b/Tests/CodexBarTests/CostUsageCalendarTests.swift @@ -91,7 +91,7 @@ struct CostUsageCalendarTests { until: secondDay, now: secondDay, options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: buddhist) #expect(secondReport.data.map(\.date) == ["2026-07-23"]) #expect(secondReport.data.first?.totalTokens == 20) @@ -126,7 +126,7 @@ struct CostUsageCalendarTests { until: windowEnd, now: windowEnd, options: Self.codexOptions(env: env, calendar: utc)) - let utcCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let utcCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: utc) #expect(utcReport.data.map(\.date) == ["2026-07-22"]) #expect(utcCache.timeZoneIdentifier == utc.timeZone.identifier) #expect(utcCache.files.values.compactMap(\.sessionId) == ["calendar-time-zone-change.jsonl"]) @@ -137,7 +137,7 @@ struct CostUsageCalendarTests { until: windowEnd, now: windowEnd, options: Self.codexOptions(env: env, calendar: bangkok)) - let bangkokCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let bangkokCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: bangkok) #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) #expect(bangkokReport.data.first?.totalTokens == 10) #expect(bangkokCache.timeZoneIdentifier == "Asia/Bangkok") @@ -282,7 +282,7 @@ struct CostUsageCalendarTests { options: Self.claudeOptions(env: env, calendar: bangkok)) #expect(utcClaude.data.map(\.date) == ["2026-07-22"]) #expect(bangkokClaude.data.map(\.date) == ["2026-07-23"]) - #expect(CostUsageCacheIO.load( + #expect(CostUsageClaudeCacheIO.load( provider: .claude, cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") diff --git a/Tests/CodexBarTests/CostUsageCancellationTests.swift b/Tests/CodexBarTests/CostUsageCancellationTests.swift index 246603dbca..1bb7ce776d 100644 --- a/Tests/CodexBarTests/CostUsageCancellationTests.swift +++ b/Tests/CodexBarTests/CostUsageCancellationTests.swift @@ -46,8 +46,9 @@ struct CostUsageCancellationTests { options: options) #expect(report.data.count == 1) - let cacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) - let cacheBefore = try Data(contentsOf: cacheURL) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let cacheBefore = try encoder.encode(CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)) try self.codexSessionContents(iso: iso, tokenLineCount: 20000) .write(to: fileURL, atomically: true, encoding: .utf8) @@ -70,7 +71,7 @@ struct CostUsageCancellationTests { checkCancellation: checkCancellation) } #expect(checks >= 8) - #expect(try Data(contentsOf: cacheURL) == cacheBefore) + #expect(try encoder.encode(CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)) == cacheBefore) } @Test @@ -126,14 +127,18 @@ private actor AsyncCancellationGate { self.isBlocked = true self.blockedContinuation?.resume() self.blockedContinuation = nil - if self.isOpen { return } + if self.isOpen { + return + } await withCheckedContinuation { continuation in self.openContinuation = continuation } } func waitUntilBlocked() async { - if self.isBlocked { return } + if self.isBlocked { + return + } await withCheckedContinuation { continuation in self.blockedContinuation = continuation } diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 90ba4ae090..d80e999bf3 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -31,10 +31,9 @@ struct CostUsageFetcherCacheSnapshotTests { parsedBytes: 1, codexScanComplete: true) cache.days = fixtureDays - CostUsageCacheIO.save( - provider: .codex, - cache: cache, + CostUsageStoreAccess.replace( cacheRoot: env.cacheRoot, + cache: cache, calendar: options.calendar) let activity = await CostUsageFetcher.loadCachedCodexTokenActivity( @@ -61,10 +60,9 @@ struct CostUsageFetcherCacheSnapshotTests { cache.scanSinceKey = "2026-04-08" cache.scanUntilKey = "2026-04-08" cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) - CostUsageCacheIO.save( - provider: .codex, - cache: cache, + CostUsageStoreAccess.replace( cacheRoot: env.cacheRoot, + cache: cache, calendar: options.calendar) let activity = await CostUsageFetcher.loadCachedCodexTokenActivity( @@ -109,47 +107,6 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(cached?.daily.map(\.date) == ["2026-04-08"]) } - @Test - func `cached codex token snapshot exposes an incompatible producer as stale upgrade data`() 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: "cached.jsonl", - tokens: 42) - - let options = CostUsageScanner.Options( - codexSessionsRoot: env.codexSessionsRoot, - cacheRoot: env.cacheRoot) - _ = try await CostUsageFetcher.loadTokenSnapshot( - provider: .codex, - now: day, - historyDays: 1, - scannerOptions: options) - - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) - let scanTime = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) - CostUsageCacheIO.save( - provider: .codex, - cache: cache, - cacheRoot: env.cacheRoot, - producerKey: "codex:cu:pupgrade-fixture") - - let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( - now: day.addingTimeInterval(60), - historyDays: 1, - scannerOptions: options) - - #expect(cached?.snapshot.sessionTokens == 42) - #expect(cached?.snapshot.updatedAt == scanTime) - #expect(cached?.lastRefreshAt == nil) - #expect(cached?.staleSnapshotUpdatedAt == scanTime) - } - @Test func `cached codex token snapshot keeps the cache scan time as updatedAt`() async throws { let env = try CostUsageTestEnvironment() @@ -172,7 +129,7 @@ struct CostUsageFetcherCacheSnapshotTests { historyDays: 1, scannerOptions: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(cache.lastScanUnixMs > 0) let scanTime = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) @@ -216,7 +173,7 @@ struct CostUsageFetcherCacheSnapshotTests { scannerOptions: options, piScannerOptions: piOptions) - let nativeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let nativeCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) var piCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(nativeCache.lastScanUnixMs > 0) #expect(piCache.lastScanUnixMs > 0) @@ -306,7 +263,7 @@ struct CostUsageFetcherCacheSnapshotTests { piCache.lastScanUnixMs = 0 PiSessionCostCacheIO.save(cache: piCache, cacheRoot: env.cacheRoot) - let nativeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let nativeCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(nativeCache.lastScanUnixMs > 0) let nativeScanTime = Date( timeIntervalSince1970: TimeInterval(nativeCache.lastScanUnixMs) / 1000) @@ -386,9 +343,9 @@ struct CostUsageFetcherCacheSnapshotTests { scannerOptions: options) #expect(current?.projects.count == 1) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) cache.codexProjectMetadataVersion = nil - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let legacy = await CostUsageFetcher.loadCachedCodexTokenSnapshot( now: day, @@ -420,9 +377,9 @@ struct CostUsageFetcherCacheSnapshotTests { historyDays: 1, scannerOptions: options) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( now: day, @@ -530,9 +487,9 @@ struct CostUsageFetcherCacheSnapshotTests { scannerOptions: options, piScannerOptions: piOptions) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( now: day, diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index eb3244327b..bd0fe35aed 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -122,9 +122,9 @@ extension CostUsageFetcherTests { piScannerOptions: piOptions) #expect(ambient.sessionTokens == 100) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) cache.roots = nil - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let managed = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, @@ -168,10 +168,10 @@ extension CostUsageFetcherTests { #expect(narrow.daily.map(\.date) == ["2026-04-08"]) #expect(narrow.last30DaysTokens == 30) - var legacyCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var legacyCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) legacyCache.scanSinceKey = nil legacyCache.scanUntilKey = nil - CostUsageCacheIO.save(provider: .codex, cache: legacyCache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: legacyCache) let expanded = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, @@ -320,9 +320,9 @@ extension CostUsageFetcherTests { codexHomePath: env.codexHomeRoot.path, historyDays: 1, scannerOptions: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let cacheFileExists = FileManager.default.fileExists( - atPath: CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot).path) + atPath: CostUsageStore(cacheRoot: env.cacheRoot).databaseURL.path) #expect(snapshot.daily.map(\.date) == ["2026-04-08"]) #expect(snapshot.last30DaysTokens == 30) @@ -403,7 +403,7 @@ extension CostUsageFetcherTests { historyDays: 1, refreshPricingInBackground: false, scannerOptions: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(wide.last30DaysTokens == 45) #expect(narrow.last30DaysTokens == 30) @@ -484,7 +484,7 @@ extension CostUsageFetcherTests { until: newDay, now: newDay.addingTimeInterval(1), options: rescanOptions) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(cache.files.keys.map(URL.init(fileURLWithPath:)).map(\.lastPathComponent).sorted() == ["new.jsonl"]) #expect(cache.scanSinceKey == "2026-04-07") @@ -539,7 +539,7 @@ extension CostUsageFetcherTests { codexHomePath: env.codexHomeRoot.path, historyDays: 1, scannerOptions: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(first.last30DaysTokens == 30) #expect(second.last30DaysTokens == 30) @@ -1050,7 +1050,7 @@ extension CostUsageFetcherTests { #expect(first.modelBreakdowns.map(\.modelName) == ["gpt-5.4"]) #expect(first.costUSD != nil) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let unrelatedRoot = env.root.appendingPathComponent("unrelated/sessions", isDirectory: true) let filtered = CostUsageScanner.buildCodexSessionBreakdownsFromCache( diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index eeafac36f1..52bc7eba9a 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -133,7 +133,7 @@ struct CostUsagePerformanceGateTests { } """ let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(catalogJSON.utf8)) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let cachedUsage = try #require(cache.files.values.first { !($0.codexRows?.isEmpty ?? true) }) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) #expect(!CostUsageScanner.needsCodexCostCache(cachedUsage, range: range)) @@ -171,7 +171,7 @@ struct CostUsagePerformanceGateTests { now: day, options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) var catalogLoadCount = 0 let cached = CostUsageScanner.buildCodexReportFromCache( cache: cache, @@ -214,7 +214,7 @@ struct CostUsagePerformanceGateTests { now: day, options: options) - var legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var legacy = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) for path in legacy.files.keys { legacy.files[path]?.codexCostCacheComplete = nil legacy.files[path]?.codexCostNanos = nil @@ -228,7 +228,7 @@ struct CostUsagePerformanceGateTests { #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) - var mixed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var mixed = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let mixedPaths = mixed.files.keys.sorted() let legacyPath = try #require(mixedPaths.first) let rowlessPath = try #require(mixedPaths.last) @@ -270,7 +270,7 @@ struct CostUsagePerformanceGateTests { now: day, options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) var catalogLoadCount = 0 let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, @@ -327,8 +327,7 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) - let cached = try #require(CostUsageCacheIO.load( - provider: .codex, + let cached = try #require(CostUsageStoreAccess.read( cacheRoot: env.cacheRoot).files.values.first) offsets.append(cached.parsedBytes ?? 0) if cached.codexScanComplete == true { @@ -368,8 +367,7 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) - let cacheData = try Data(contentsOf: CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot)) - let roundTripped = try JSONDecoder().decode(CostUsageCache.self, from: cacheData) + let roundTripped = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let first = try #require(roundTripped.files.values.first) let firstOffset = try #require(first.parsedBytes) #expect(first.codexScanFileId == metadata.fileId) @@ -382,8 +380,7 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) - let second = try #require(CostUsageCacheIO.load( - provider: .codex, + let second = try #require(CostUsageStoreAccess.read( cacheRoot: env.cacheRoot).files.values.first) #expect((second.parsedBytes ?? 0) > firstOffset) #expect(second.codexScanFileId == metadata.fileId) @@ -415,8 +412,7 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) - let first = try #require(CostUsageCacheIO.load( - provider: .codex, + let first = try #require(CostUsageStoreAccess.read( cacheRoot: env.cacheRoot).files.values.first) #expect(first.parsedBytes == slice) #expect(first.codexScanComplete == false) @@ -435,8 +431,7 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) - let resumed = try #require(CostUsageCacheIO.load( - provider: .codex, + let resumed = try #require(CostUsageStoreAccess.read( cacheRoot: env.cacheRoot).files.values.first) #expect((resumed.parsedBytes ?? 0) > (first.parsedBytes ?? 0)) #expect(resumed.parsedBytes == min(changedMetadata.size, (first.parsedBytes ?? 0) + slice)) @@ -494,7 +489,7 @@ struct CostUsagePerformanceGateTests { progressKeys.append(status.progressKey) } - let completedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let completedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let completedUsage = try #require(completedCache.files.values.first) let completedReport = CostUsageScanner.buildCodexReportFromCache( cache: completedCache, @@ -508,7 +503,7 @@ struct CostUsagePerformanceGateTests { } @Test - func `incompatible populated cache stays visible until bounded fork rebuild converges`() async throws { + func `previous report stays visible until bounded fork rebuild converges`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) @@ -584,15 +579,17 @@ struct CostUsagePerformanceGateTests { priorCache.days = [ range.sinceKey: [CostUsagePricing.normalizeCodexModel(model): [777, 0, 0]], ] - CostUsageCacheIO.save( - provider: .codex, - cache: priorCache, - cacheRoot: env.cacheRoot, - producerKey: "codex:cu:pupgrade-fixture") - let priorReport = CostUsageScanner.buildCodexReportFromCache( cache: priorCache, range: range) + var rebuildingCache = CostUsageCache() + rebuildingCache.scanSinceKey = range.scanSinceKey + rebuildingCache.scanUntilKey = range.scanUntilKey + rebuildingCache.timeZoneIdentifier = options.calendar.timeZone.identifier + rebuildingCache.roots = priorCache.roots + rebuildingCache.codexScanCatchUpPending = true + rebuildingCache.codexPreviousReport = CostUsageCodexPreviousReport(report: priorReport, cache: priorCache) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: rebuildingCache) var report = CostUsageScanner.loadDailyReport( provider: .codex, since: day, @@ -606,8 +603,7 @@ struct CostUsagePerformanceGateTests { #expect(status.staleSnapshotUpdatedAt == priorScanAt) #expect(report.data == priorReport.data) #expect(report.summary == priorReport.summary) - #expect(CostUsageCacheIO.load( - provider: .codex, + #expect(CostUsageStoreAccess.read( cacheRoot: env.cacheRoot).codexPreviousReport != nil) for pass in 1...16 where status.pending { @@ -625,7 +621,7 @@ struct CostUsagePerformanceGateTests { } } - let completedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let completedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(!status.pending) #expect(status.staleSnapshotUpdatedAt == nil) #expect(completedCache.codexPreviousReport == nil) @@ -685,8 +681,7 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) - let cached = try #require(CostUsageCacheIO.load( - provider: .codex, + let cached = try #require(CostUsageStoreAccess.read( cacheRoot: env.cacheRoot).files.values.first) offsets.append(cached.parsedBytes ?? 0) sawPartialRecord = sawPartialRecord || cached.codexJSONLResumeState != nil @@ -856,7 +851,7 @@ extension CostUsagePerformanceGateTests { now: day, options: options) } - let coldCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let coldCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let coldChild = try #require(coldCache.files.values.first { $0.sessionId == "missing-child" }) let coldDiscovery = try #require(coldCache.codexSessionDiscovery) #expect(coldCounter.value >= 250) @@ -892,7 +887,7 @@ extension CostUsagePerformanceGateTests { until: day, now: day.addingTimeInterval(2), options: options) - let resolvedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let resolvedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let resolvedChild = try #require(resolvedCache.files.values.first { $0.sessionId == "missing-child" }) #expect(!resolvedChild.days.isEmpty) #expect(resolvedChild.forkBaselineDependencyKey?.hasPrefix("file|late-parent|") == true) @@ -903,7 +898,7 @@ extension CostUsagePerformanceGateTests { until: day, now: day.addingTimeInterval(3), options: options) - let stableCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let stableCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let stableChild = try #require(stableCache.files.values.first { $0.sessionId == "missing-child" }) #expect(stableChild.days == resolvedChild.days) #expect(stable.summary?.totalTokens == resolved.summary?.totalTokens) @@ -942,7 +937,7 @@ extension CostUsagePerformanceGateTests { until: day, now: day, options: options) - let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let firstCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let firstGeneration = try #require(firstCache.codexSessionDiscovery?.generation) #expect(firstCache.codexSessionDiscovery?.missingSessionIds.contains("inventory-missing") == true) @@ -965,7 +960,7 @@ extension CostUsagePerformanceGateTests { now: day.addingTimeInterval(1), options: options) } - let changedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let changedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let changedGeneration = try #require(changedCache.codexSessionDiscovery?.generation) #expect(changedGeneration != firstGeneration) #expect(changedCache.codexSessionDiscovery?.missingSessionIds.contains("inventory-missing") == true) @@ -1050,7 +1045,7 @@ extension CostUsagePerformanceGateTests { until: day, now: day, options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let cachedNames = Set(cache.files.keys.map { URL(fileURLWithPath: $0).lastPathComponent }) #expect(cachedNames.contains(newer.lastPathComponent)) @@ -1140,7 +1135,7 @@ extension CostUsagePerformanceGateTests { now: day, options: options) let elapsed = Date().timeIntervalSince(started) - let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let firstCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let firstParent = try #require(firstCache.files.values.first { $0.sessionId == "parent-giant" }) let firstChild = try #require(firstCache.files.values.first { $0.sessionId == "child-small" }) let firstChildDay = try #require( @@ -1167,7 +1162,7 @@ extension CostUsagePerformanceGateTests { until: day, now: day.addingTimeInterval(1), options: options) - let secondCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let secondCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let parent = try #require(secondCache.files.values.first { $0.sessionId == "parent-giant" }) let child = try #require(secondCache.files.values.first { $0.sessionId == "child-small" }) let childDay = try #require(child.days[CostUsageScanner.CostUsageDayRange.dayKey(from: day)]) @@ -1216,7 +1211,7 @@ extension CostUsagePerformanceGateTests { now: day, options: options) - let indexedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let indexedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let indexedParentEntry = try #require( indexedCache.files.first { $0.value.sessionId == "parent-append" }) let parentCachePath = indexedParentEntry.key @@ -1264,7 +1259,7 @@ extension CostUsagePerformanceGateTests { now: day.addingTimeInterval(120), options: options) - let refreshedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let refreshedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let deferredParent = try #require(refreshedCache.files[parentCachePath]) let child = try #require( refreshedCache.files.values.first { $0.sessionId == "child-append" }) @@ -1309,7 +1304,7 @@ extension CostUsagePerformanceGateTests { until: day, now: day, options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let usage = try #require(cache.files.values.first { $0.sessionId == "parent-rewrite" }) let anchor = try #require(usage.codexTokenIndexAnchor) diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 0b62a2482d..d5aebdae01 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -208,7 +208,7 @@ struct CostUsageScannerBreakdownTests { ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) - let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let firstCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(firstCache.codexPricingKey?.hasPrefix("builtin-") == true) let secondTokenCount: [String: Any] = [ @@ -342,7 +342,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(report.summary?.totalTokens == 66) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) var projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, range: CostUsageScanner.CostUsageDayRange(since: day, until: day), @@ -376,7 +376,7 @@ struct CostUsageScannerBreakdownTests { until: day, now: day, options: options) - cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, range: CostUsageScanner.CostUsageDayRange(since: day, until: day), @@ -560,7 +560,7 @@ struct CostUsageScannerBreakdownTests { until: day, now: day.addingTimeInterval(1), options: options) - let samePricingCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let samePricingCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(abs((samePricing.summary?.totalCostUSD ?? 0) - oldDailyCost) < costTolerance) #expect(samePricingCache.scanSinceKey == "2026-05-04") @@ -631,7 +631,7 @@ struct CostUsageScannerBreakdownTests { // Simulate a cache written by the previous formula. Its key hashed only the rates, so // derive that exact legacy key and verify the formula version makes the current key differ. - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let legacyPricingKey = "builtin-\(Self.sha256Hex(CostUsagePricing.codexBuiltInPricingFingerprint()))" let currentPricingKey = try #require(cache.codexPricingKey) #expect(currentPricingKey != legacyPricingKey) @@ -648,7 +648,7 @@ struct CostUsageScannerBreakdownTests { updated.codexCostNanos = inflated cache.files[path] = updated } - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) // A time-only refresh is suppressed (interval 60s), so repricing here is driven solely by // the pricing-key mismatch from the formula version bump. @@ -716,7 +716,7 @@ struct CostUsageScannerBreakdownTests { until: day, now: day.addingTimeInterval(1), options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let usage = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value @@ -769,7 +769,7 @@ struct CostUsageScannerBreakdownTests { now: day, options: options) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first) var cachedUsage = try #require(cache.files[path]) #expect(cachedUsage.sessionId == "legacy-cost-session") @@ -794,8 +794,8 @@ struct CostUsageScannerBreakdownTests { output: 0), ] cache.files[path] = cachedUsage - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) - let savedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) + let savedUsage = try #require(CostUsageStoreAccess.read(cacheRoot: env.cacheRoot).files[path]) #expect(savedUsage.codexRows?.map(\.day) == [olderDayKey, dayKey]) let secondTokenCount = self.codexTokenCount( @@ -819,7 +819,7 @@ struct CostUsageScannerBreakdownTests { #expect(report.data.first?.totalTokens == 15) #expect(abs((report.summary?.totalCostUSD ?? 0) - expectedCost) < 0.000_000_001) - var migratedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var migratedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let migratedUsage = try #require(migratedCache.files[path]) #expect(migratedUsage.codexRows?.map(\.day) == [olderDayKey, dayKey, dayKey]) #expect(migratedUsage.codexRows?.map(\.eventIndex) == [0, 1, 2]) @@ -833,7 +833,7 @@ struct CostUsageScannerBreakdownTests { until: day, now: day.addingTimeInterval(2), options: options) - migratedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + migratedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(repeated.data.first?.totalTokens == 15) #expect(migratedCache.files[path]?.parsedBytes == parsedBytes) } @@ -882,10 +882,10 @@ struct CostUsageScannerBreakdownTests { now: day, options: options) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first) cache.files[path]?.codexCostNanos = nil - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let appended = try "\n" + env.jsonl([secondTokenCount]) let handle = try FileHandle(forWritingTo: fileURL) @@ -901,7 +901,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(appendedReport.summary?.totalTokens == 15) - cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let activeRows = try #require(cache.files[path]?.codexRows) #expect(activeRows.map(\.eventIndex) == [0, 1]) @@ -959,7 +959,7 @@ struct CostUsageScannerBreakdownTests { now: day, options: options) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first) var cachedUsage = try #require(cache.files[path]) let originalCostNanos = try #require(cachedUsage.codexCostNanos?[dayKey]?[normalizedModel]) @@ -987,7 +987,7 @@ struct CostUsageScannerBreakdownTests { cachedUsage.codexStandardTokens = nil cachedUsage.codexPriorityTokens = nil cache.files[path] = cachedUsage - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) options.refreshMinIntervalSeconds = 60 let report = CostUsageScanner.loadDailyReport( @@ -999,7 +999,7 @@ struct CostUsageScannerBreakdownTests { let expectedCost = 10.0 * 2.5e-6 #expect(abs((report.summary?.totalCostUSD ?? 0) - expectedCost) < 0.000_000_001) - let migratedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) + let migratedUsage = try #require(CostUsageStoreAccess.read(cacheRoot: env.cacheRoot).files[path]) #expect(migratedUsage.codexRows?.map(\.eventIndex) == [0, 1]) #expect(migratedUsage.codexCostNanos?[dayKey]?[normalizedModel] == originalCostNanos) #expect(migratedUsage.codexCostNanos?[dayKey]?[addedModel] == Int64((10.0 * 5e-6 * 1_000_000_000).rounded())) @@ -1116,10 +1116,10 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(wide.summary?.totalTokens == 30) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first) cache.files[path]?.codexTurnIDs = nil - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) try env.jsonl([ self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: model), @@ -1142,7 +1142,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(narrow.summary?.totalTokens == 12) - cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(cache.scanSinceKey == "2026-05-17") #expect(cache.scanUntilKey == "2026-05-19") #expect(cache.files[path]?.days[olderDayKey] == nil) @@ -1207,13 +1207,13 @@ struct CostUsageScannerBreakdownTests { [.modificationDate: olderDay], ofItemAtPath: olderFile.path) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) cache.codexProjectMetadataVersion = nil for key in cache.files.keys { cache.files[key]?.projectPath = nil cache.files[key]?.canonicalProjectPath = nil } - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) options.refreshMinIntervalSeconds = 60 let narrow = CostUsageScanner.loadDailyReport( @@ -1224,7 +1224,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(narrow.summary?.totalTokens == 10) - cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(cache.codexProjectMetadataVersion == 1) #expect(cache.scanSinceKey == "2026-05-17") #expect(cache.scanUntilKey == "2026-05-19") @@ -1243,7 +1243,7 @@ struct CostUsageScannerBreakdownTests { now: day.addingTimeInterval(2), options: options) #expect(repeatedWide.summary?.totalTokens == 30) - cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let rescannedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day), @@ -1904,9 +1904,9 @@ struct CostUsageScannerBreakdownTests { #expect(first.data[0].modelBreakdowns?.map(\.modelName) == ["gpt-5.5"]) #expect(first.data[0].totalTokens == 132) - let newCacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) - #expect(newCacheURL.lastPathComponent == "codex-v11.json") - #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) + let databaseURL = CostUsageStore(cacheRoot: env.cacheRoot).databaseURL + #expect(databaseURL.lastPathComponent == "cost-usage.sqlite") + #expect(FileManager.default.fileExists(atPath: databaseURL.path)) #expect(FileManager.default.fileExists(atPath: oldCacheURL.path)) let second = CostUsageScanner.loadDailyReport( @@ -2810,7 +2810,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(second.data.first?.totalTokens == 101_000) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let usage = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value #expect(usage?.hasInterleavedTotals == true) @@ -2826,7 +2826,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(rescanned.data.first?.totalTokens == 101_000) - let rescannedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let rescannedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let rescannedUsage = rescannedCache.files .first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value @@ -2902,13 +2902,13 @@ struct CostUsageScannerBreakdownTests { #expect(baseline.data.first?.totalTokens == 100_000, "baseline failed for \(label)") options.forceRescan = false - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) for (path, usage) in cache.files { var stripped = usage mutate(&stripped) cache.files[path] = stripped } - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) try env.jsonl([sessionMeta, turnContext] + initialEvents + [replayedSnapshot]) .write(to: fileURL, atomically: true, encoding: .utf8) @@ -2921,7 +2921,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(second.data.first?.totalTokens == 100_000, "failed for missing \(label)") - let healed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let healed = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let usage = healed.files .first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value @@ -2976,7 +2976,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(first.data.first?.totalTokens == 100_000) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first { URL(fileURLWithPath: $0).lastPathComponent == fileURL.lastPathComponent }) @@ -2987,7 +2987,7 @@ struct CostUsageScannerBreakdownTests { // Optional precision only: stripping the seen-set must not block incremental resume. usage.seenRawTotals = nil cache.files[path] = usage - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let appendedEvents: [[String: Any]] = [ self.codexTokenCount( @@ -3012,7 +3012,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(second.data.first?.totalTokens == 101_000) - let after = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let after = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let afterUsage = try #require(after.files[path]) #expect(afterUsage.hasInterleavedTotals == true) #expect(afterUsage.lastRawTotalsWatermark?.input == 101_000) @@ -3067,7 +3067,7 @@ struct CostUsageScannerBreakdownTests { // Simulate a cache entry written before the interleave tracker existed: divergent totals // but no watermark. Resuming incrementally from it would be unsafe. - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) for (path, usage) in cache.files { var stripped = usage stripped.lastRawTotalsWatermark = nil @@ -3075,7 +3075,7 @@ struct CostUsageScannerBreakdownTests { stripped.hasInterleavedTotals = nil cache.files[path] = stripped } - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let replayedSnapshot = self.codexTokenCount( timestamp: env.isoString(for: day.addingTimeInterval(3)), @@ -3093,7 +3093,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(second.data.first?.totalTokens == 100_000) - let healed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let healed = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let usage = healed.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value #expect(usage?.lastRawTotalsWatermark != nil) @@ -3550,11 +3550,11 @@ struct CostUsageScannerBreakdownTests { #expect(second.data[0].outputTokens == 8) #expect(second.data[0].totalTokens == 43) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) for path in cache.files.keys where cache.files[path]?.sessionId == "sess-warm-cache-active-archive" { cache.files[path]?.codexRows = nil } - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let rowlessWarm = CostUsageScanner.loadDailyReport( provider: .codex, @@ -3660,7 +3660,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(narrow.summary?.totalTokens == 17) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let archiveEntry = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == archiveURL.lastPathComponent } @@ -3736,12 +3736,12 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(wide.summary?.totalTokens == 33) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let archivePath = try #require(cache.files.keys.first { URL(fileURLWithPath: $0).lastPathComponent == archiveURL.lastPathComponent }) cache.files[archivePath]?.codexRows = nil - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let narrow = CostUsageScanner.loadDailyReport( provider: .codex, @@ -3751,7 +3751,7 @@ struct CostUsageScannerBreakdownTests { options: options) #expect(narrow.summary?.totalTokens == 11) - cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let archiveUsage = try #require(cache.files[archivePath]) let olderDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: olderDay) let olderPacked = try #require(archiveUsage.days[olderDayKey]?.values.first) @@ -4995,7 +4995,7 @@ struct CostUsageScannerBreakdownTests { let expectedCost = parentCost + childCost #expect(abs((report.data[0].costUSD ?? 0) - expectedCost) < 0.000001) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) let childUsage = try #require(cache.files.values.first(where: { $0.sessionId == "child-session" })) #expect(childUsage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey) let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( @@ -6359,7 +6359,7 @@ struct CostUsageScannerBreakdownTests { now: reportDay, options: options) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(report.data.count == 1) #expect(cache.files.keys.contains { $0.hasSuffix("session-recent.jsonl") }) @@ -6479,7 +6479,7 @@ struct CostUsageScannerBreakdownTests { now: reportDay, options: secondOptions) - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) #expect(secondReport.data.count == 1) #expect(secondReport.data[0].inputTokens == 10) diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift index 0d8beb4b75..3ebb651d96 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -179,7 +179,7 @@ struct CostUsageScannerClaudeFableTests { options: options) #expect(unpriced.summary?.totalCostUSD == nil) - let cached = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + let cached = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) #expect(cached.days["2026-06-09"]?["claude-custom-cache-model"]?[safe: 7] == 20) try ModelsDevCache.save( diff --git a/Tests/CodexBarTests/CostUsageStoreCutoverTests.swift b/Tests/CodexBarTests/CostUsageStoreCutoverTests.swift new file mode 100644 index 0000000000..9558f28375 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageStoreCutoverTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CostUsageStoreCutoverTests { + @Test + func `legacy artifact cleanup deletes JSON and rebuilds SQLite from source`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 8) + let timestamp = env.isoString(for: day) + _ = try env.writeCodexSessionFile( + day: day, + filename: "current.jsonl", + contents: Self.session(timestamp: timestamp, sessionID: "current", input: 7)) + + let store = CostUsageStore(cacheRoot: env.cacheRoot) + #expect(await store.upsertFile(Self.staleStoreFile())) + let directory = store.databaseURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let legacy = directory.appendingPathComponent("codex-v11.json") + let temporary = directory.appendingPathComponent(".codex-v11.json.fixture.tmp") + try Data("legacy".utf8).write(to: legacy) + try Data("temporary".utf8).write(to: temporary) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.summary?.totalInputTokens == 7) + #expect(!FileManager.default.fileExists(atPath: legacy.path)) + #expect(!FileManager.default.fileExists(atPath: temporary.path)) + #expect(FileManager.default.fileExists(atPath: store.databaseURL.path)) + let snapshot = await CostUsageStore(cacheRoot: env.cacheRoot).readSnapshot() + #expect(snapshot.files.map(\.path).contains("/stale.jsonl") == false) + #expect(snapshot.files.contains { $0.path.hasSuffix("current.jsonl") }) + } + + @Test + func `fixture report remains identical through the store`() throws { + let fixture = try Issue2037FixtureHarness.load(named: "archived-fork-33ce-3869") + let sanitized = try SanitizedForkFamilyFixture.load(named: "archived-fork-33ce-3869") + let prefixLength = try #require(sanitized.manifest.copiedPrefixes.first).length + let expectedUnits = try sanitized.events(named: "parent").map(\.last.scannerUnits).reduce(0, +) + + sanitized.events(named: "child").dropFirst(prefixLength).map(\.last.scannerUnits).reduce(0, +) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + try Issue2037FixtureHarness.install(fixture, into: env) + + let since = try env.makeLocalNoon(year: 2030, month: 1, day: 1) + let until = try env.makeLocalNoon(year: 2030, month: 1, day: 2) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let scanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: since, + until: until, + now: until, + options: options) + let range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: options.calendar) + let stored = CostUsageScanner.buildCodexReportFromCache( + cache: CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: options.calendar), + range: range, + modelsDevCacheRoot: env.cacheRoot) + let storedUnits = stored.data.reduce(0) { + $0 + ($1.inputTokens ?? 0) + ($1.cacheReadTokens ?? 0) + ($1.outputTokens ?? 0) + } + + #expect(stored.data == scanned.data) + #expect(stored.summary == scanned.summary) + #expect(storedUnits == expectedUnits) + } + + @Test + func `one appended row does one row of work and stable EOF does none`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 8) + let timestamp = env.isoString(for: day) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "linear.jsonl", + contents: Self.session(timestamp: timestamp, sessionID: "linear", input: 1)) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + try Self.append(Self.tokenLine(timestamp: timestamp, input: 2) + "\n", to: fileURL) + let appendRecorder = CostUsageScanner.CodexScanWorkRecorder() + options.codexScanWorkRecorderForTesting = appendRecorder + let appended = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(appendRecorder.snapshot().usageRowsProcessed == 1) + #expect(appendRecorder.snapshot().usageRowsRepriced == 1) + + let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + let path = try #require(cache.files.keys.first { $0.hasSuffix("linear.jsonl") }) + let store = CostUsageStore(cacheRoot: env.cacheRoot) + #expect(await store.fetchUsageRows(path: path).count == 2) + #expect(await store.fetchAccumulator(path: path)?.eventCount == 2) + + let stableRecorder = CostUsageScanner.CodexScanWorkRecorder() + options.codexScanWorkRecorderForTesting = stableRecorder + let stable = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + #expect(stableRecorder.snapshot().usageRowsProcessed == 0) + #expect(stableRecorder.snapshot().usageRowsRepriced == 0) + #expect(stable.data == appended.data) + #expect(stable.summary == appended.summary) + } + + private static func staleStoreFile() -> CostUsageStoreFile { + CostUsageStoreFile( + path: "/stale.jsonl", + inode: 1, + mtimeUnixMs: 1, + size: 1, + parsedBytes: 1, + anchor: nil, + scanState: CostUsageStoreScanState( + targetSize: 1, + isComplete: true, + resumePayload: nil, + tokenTimestampsMonotonic: true, + nextUsageRowIndex: 0, + lastModel: nil, + lastTurnID: nil, + fileIdentity: "1:1", + detailsPayload: Data()), + sessionID: "stale", + coverageSinceDay: "2020-01-01", + coverageUntilDay: "2020-01-01", + updatedAtUnixMs: 1) + } + + private static func session(timestamp: String, sessionID: String, input: Int) -> String { + [ + #"{"type":"session_meta","timestamp":"\#(timestamp)","payload":{"session_id":"\#(sessionID)"}}"#, + #"{"type":"turn_context","timestamp":"\#(timestamp)","payload":{"model":"openai/gpt-5.4"}}"#, + self.tokenLine(timestamp: timestamp, input: input), + ].joined(separator: "\n") + "\n" + } + + private static func tokenLine(timestamp: String, input: Int) -> String { + #"{"type":"event_msg","timestamp":"\#(timestamp)","payload":{"# + + #""type":"token_count","info":{"total_token_usage":{"input_tokens":"# + + "\(input)" + + #", "cached_input_tokens":0,"output_tokens":0},"model":"openai/gpt-5.4"}}}"# + } + + private static func append(_ contents: String, to fileURL: URL) throws { + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(contents.utf8)) + } +} diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 37e3e120a3..976c764504 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -10,13 +10,13 @@ import CSQLite3 struct CostUsageStoreTests { @Test - func `database lives beside the legacy artifact directory`() async throws { + func `database lives beside the legacy artifact directory`() throws { let fixture = try StoreFixture() defer { fixture.remove() } let store = CostUsageStore(cacheRoot: fixture.root) - #expect(await store.databaseURL.lastPathComponent == "cost-usage.sqlite") - #expect(await store.databaseURL.deletingLastPathComponent().lastPathComponent == "cost-usage") + #expect(store.databaseURL.lastPathComponent == "cost-usage.sqlite") + #expect(store.databaseURL.deletingLastPathComponent().lastPathComponent == "cost-usage") } @Test @@ -73,7 +73,7 @@ struct CostUsageStoreTests { let store = CostUsageStore(cacheRoot: fixture.root) _ = await store.configuration() - let indexes = try await SQLiteTestConnection.indexNames(at: store.databaseURL) + let indexes = try SQLiteTestConnection.indexNames(at: store.databaseURL) #expect(indexes.contains("files_path_idx")) #expect(indexes.contains("file_day_aggregates_day_idx")) #expect(indexes.contains("file_day_aggregates_model_day_idx")) @@ -479,7 +479,7 @@ extension CostUsageStoreTests { defer { fixture.remove() } let store = CostUsageStore(cacheRoot: fixture.root) #expect(await store.mergeDayAggregates([Self.aggregate(day: "2026-08-01", model: "model-a", scale: 1)])) - try await SQLiteTestConnection.execute(at: store.databaseURL, sql: "DROP TABLE day_aggregates") + try SQLiteTestConnection.execute(at: store.databaseURL, sql: "DROP TABLE day_aggregates") #expect(await store.fetchDayAggregates(sinceDay: "2026-08-01", untilDay: "2026-08-01").isEmpty) #expect(await store.rebuildCount == 1) @@ -596,14 +596,11 @@ extension CostUsageStoreTests { for index in 0..<8 { let file = Self.file(path: "/rollouts/\(index).jsonl", day: "2026-08-01", updatedAt: Int64(index)) #expect(await store.upsertFile(file)) - let line = CostUsageStoreBufferedLine( + let row = CostUsageStoreUsageRow( path: file.path, - kind: .deferredReplay, - lineIndex: 0, - ordinal: nil, - endOffset: nil, + rowIndex: 0, payload: Data(repeating: UInt8(index), count: 256 * 1024)) - #expect(await store.replaceBufferedLines(path: file.path, kind: .deferredReplay, lines: [line])) + #expect(await store.replaceUsageRows(path: file.path, rows: [row])) } let before = await store.fileSizeBytes() let limit = max(1, before / 2) @@ -624,13 +621,90 @@ extension CostUsageStoreTests { #expect(await (store.readSnapshot()).files.isEmpty) } + @Test + func `budget pruning uses the requested window and narrows retained coverage`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + #expect(await store.upsertFile(Self.file(path: "/rollouts/stale.jsonl", day: "2026-06-01", updatedAt: 1))) + #expect(await store.upsertFile(Self.file(path: "/rollouts/current.jsonl", day: "2026-08-01", updatedAt: 2))) + + let result = await store.enforceBudgets( + maxRows: 1, + maxFileBytes: .max, + requestedSinceDay: "2026-07-31", + requestedUntilDay: "2026-08-02") + + #expect(result.rowCount == 1) + #expect(await store.fetchFile(path: "/rollouts/stale.jsonl") == nil) + #expect(await store.fetchFile(path: "/rollouts/current.jsonl") != nil) + let metadata = await store.fetchMetadata() + #expect(metadata.scanSinceDay == "2026-07-31") + #expect(metadata.scanUntilDay == "2026-08-02") + } + + @Test + func `budget keeps a recently active zero day entry`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + let activeDate = try #require(CostUsageScanner.parseDayKey("2026-08-01", calendar: calendar)) + var active = Self.file(path: "/rollouts/active.jsonl", day: "2026-08-01", updatedAt: 1) + active.coverageSinceDay = nil + active.coverageUntilDay = nil + active.mtimeUnixMs = Int64(activeDate.addingTimeInterval(3600).timeIntervalSince1970 * 1000) + var stale = Self.file(path: "/rollouts/stale-zero.jsonl", day: "2026-08-01", updatedAt: 0) + stale.coverageSinceDay = nil + stale.coverageUntilDay = nil + stale.mtimeUnixMs = 1 + #expect(await store.upsertFile(active)) + #expect(await store.upsertFile(stale)) + + _ = await store.enforceBudgets( + maxRows: 1, + maxFileBytes: .max, + requestedSinceDay: "2026-08-01", + requestedUntilDay: "2026-08-02", + calendar: calendar) + + #expect(await store.fetchFile(path: active.path) != nil) + #expect(await store.fetchFile(path: stale.path) == nil) + } + + @Test + func `in window budget trim marks catch up and preserves previous report`() async throws { + let fixture = try StoreFixture() + defer { fixture.remove() } + let store = CostUsageStore(cacheRoot: fixture.root) + let previous = Data("previous-report".utf8) + var metadata = CostUsageStoreMetadata.empty + metadata.lastScanUnixMs = 1234 + metadata.previousReportPayload = previous + #expect(await store.setMetadata(metadata)) + #expect(await store.upsertFile(Self.file(path: "/rollouts/one.jsonl", day: "2026-08-01", updatedAt: 1))) + #expect(await store.upsertFile(Self.file(path: "/rollouts/two.jsonl", day: "2026-08-01", updatedAt: 2))) + + let result = await store.enforceBudgets( + maxRows: 1, + maxFileBytes: .max, + requestedSinceDay: "2026-08-01", + requestedUntilDay: "2026-08-02") + let retained = await store.fetchMetadata() + + #expect(result.catchUpRequired) + #expect(retained.catchUpPending) + #expect(retained.lastScanUnixMs == 0) + #expect(retained.previousReportPayload == previous) + } + @Test func `read only WAL reader keeps a consistent snapshot during write`() async throws { let fixture = try StoreFixture() defer { fixture.remove() } let store = CostUsageStore(cacheRoot: fixture.root) #expect(await store.upsertFile(Self.file(path: "/rollouts/one.jsonl", day: "2026-08-01"))) - let reader = try await SQLiteTestConnection(url: store.databaseURL, readOnly: true) + let reader = try SQLiteTestConnection(url: store.databaseURL, readOnly: true) try reader.execute("BEGIN") #expect(try reader.scalarInt("SELECT COUNT(*) FROM files") == 1) @@ -663,7 +737,9 @@ extension CostUsageStoreTests { tokenTimestampsMonotonic: true, nextUsageRowIndex: 7, lastModel: "gpt-5.6-sol", - lastTurnID: "turn-1"), + lastTurnID: "turn-1", + fileIdentity: "1:42", + detailsPayload: Data([4, 5, 6])), sessionID: "session-\(path)", coverageSinceDay: day, coverageUntilDay: day, @@ -700,6 +776,7 @@ extension CostUsageStoreTests { reasoningTokens: 1 * scale, requestCount: 1 * scale, knownCostNanos: 1000 * scale, + prioritySurchargeNanos: 200 * scale, unpricedTokens: 4 * scale, standardCostNanos: 600 * scale, priorityCostNanos: 400 * scale, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index d0bf2bce5f..57f0db57c2 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1363,12 +1363,6 @@ struct ProviderArchitectureGatekeeperTests { 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: 556, - anchor: "CostUsageCacheIO.load(provider: .codex, cacheRoot: options.scanOptions.cacheRoot),", - expectedProviderIDs: ["codex"], - reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", line: 727, diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index c120e04079..e00047a1a6 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -164,9 +164,9 @@ struct UsageStoreCachedTokenHydrationTests { now: now, historyDays: 1, scannerOptions: options) - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) cache.lastScanUnixMs = Int64(now.addingTimeInterval(-2 * 60 * 60).timeIntervalSince1970 * 1000) - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache) let settings = Self.makeCodexOnlySettings(historyDays: 1) let store = UsageStore( diff --git a/TestsLinux/CodexWarmCacheResumeLinuxTests.swift b/TestsLinux/CodexWarmCacheResumeLinuxTests.swift index f33a0b9dca..edb2c13b99 100644 --- a/TestsLinux/CodexWarmCacheResumeLinuxTests.swift +++ b/TestsLinux/CodexWarmCacheResumeLinuxTests.swift @@ -100,8 +100,7 @@ struct CodexWarmCacheResumeLinuxTests { } private static func persistedCache(cacheRoot: URL) throws -> CostUsageCache { - let data = try Data(contentsOf: CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: cacheRoot)) - return try JSONDecoder().decode(CostUsageCache.self, from: data) + CostUsageStoreAccess.read(cacheRoot: cacheRoot) } @Test From 3167cd9d59b87ad479574372aaa9b35f21b24ed9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 00:53:06 -0700 Subject: [PATCH 2/4] docs: document SQLite cost history storage --- CHANGELOG.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7781b2734..cd0e2163a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - z.ai/GLM: parse `CREDIT_LIMIT` quota entries from credit-based Coding Plans (lite/standard/pro) so usage no longer sticks at 100% remaining / 0% used and the 5-hour credit window drives the primary percentage and reset time (#2724, #2712). Thanks @stuible! ### Changed +- Codex: cost history now lives in a single SQLite store — bounded memory at any corpus size, append-linear catch-up, and no more multi-hundred-MB JSON decode on refresh (#2760). Thanks @xx205 for the accumulator design! - CLI: dashboard snapshot identity now defaults to full; use `--identity redacted` to restore redacted emails. - Provider plugins: run the same bundled JavaScript providers and local plugin CLI on Linux through a sandboxed QuickJS engine, removing the cut-over providers' Linux-only Swift twins. diff --git a/README.md b/README.md index e007123aec..d282f562b5 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ show an incident indicator. - Provider-specific usage meters with reset countdowns. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock. -- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. +- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. Codex history uses a WAL-enabled SQLite store capped at 25,000 retained session entries and 256 MiB. - A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history. - Provider status polling with incident badges in the menu and icon overlay. - Merge Icons mode to combine providers into one status item + switcher. From 502c04a63586ac2c297b93da784b42186008fcf8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 01:26:56 -0700 Subject: [PATCH 3/4] test: reconcile provider gatekeeper for the SQLite cutover --- .../CostUsage/CostUsageClaudeCache.swift | 3 + .../ProviderArchitectureGatekeeperTests.swift | 68 +++---------------- 2 files changed, 12 insertions(+), 59 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index b053e86d42..826d1317c3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -8,6 +8,9 @@ enum CostUsageClaudeCacheIO { return root.appendingPathComponent("CodexBar", isDirectory: true) } + // Provider-specific by design: Claude/Vertex cost caching still uses the legacy JSON artifact pending its own + // migration (see #2760). + static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { precondition(provider == .claude || provider == .vertexai) let root = cacheRoot ?? self.defaultCacheRoot() diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 57f0db57c2..9b084da6e1 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1339,18 +1339,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift", - line: 73, - anchor: "provider: .codex,", - expectedProviderIDs: ["codex"], - reason: "This provider-owned integration passes its fixed identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift", - line: 162, - anchor: "provider: .codex,", - expectedProviderIDs: ["codex"], - reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", line: 278, @@ -1365,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: 727, + line: 715, 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: 802, + line: 790, 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: 896, + line: 865, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1519,12 +1507,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift", - line: 98, - anchor: "let url = self.cacheFileURL(provider: .codex, cacheRoot: cacheRoot)", - expectedProviderIDs: ["codex"], - reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/GeminiLoginRunner.swift", line: 7, @@ -3380,7 +3362,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: 553, + line: 539, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3388,7 +3370,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: 579, + line: 567, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, @@ -3396,7 +3378,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: 626, + line: 614, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3404,7 +3386,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: 653, + line: 641, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex", "openai"], expectedReferenceCount: 5, @@ -3412,7 +3394,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: 1145, + line: 1114, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3420,7 +3402,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: 1396, + line: 1365, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3605,38 +3587,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 2, expectedReferenceFingerprint: ["claude@0", "claude@8"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift", - line: 106, - anchor: "let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: .codex)", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift", - line: 169, - anchor: "if provider == .codex {", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift", - line: 197, - anchor: "if provider == .codex, data.count > maxCacheBytes {", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), - AllowedProviderConstruct( - path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift", - line: 226, - anchor: "if provider == .codex, data.count > maxCacheLoadBytes {", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", line: 222, From e48215beba0e8c1eb7848c45597e1e6065cc0ce8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 02:03:36 -0700 Subject: [PATCH 4/4] fix: share the cost store serial executor --- .../Vendored/CostUsage/CostUsageStore.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index ac016e79ee..dd296b089b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -60,10 +60,13 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" - private nonisolated let executor = StoreSerialExecutor( + /// Process-wide serialization keeps every writable store connection on the same queue. + /// This matches the scan pipeline's single-writer contract without multiplying executor + /// threads when tests or short-lived readers create several store actors. + private nonisolated static let sharedExecutor = StoreSerialExecutor( label: "com.steipete.codexbar.cost-usage-store") nonisolated var unownedExecutor: UnownedSerialExecutor { - self.executor.asUnownedSerialExecutor() + Self.sharedExecutor.asUnownedSerialExecutor() } nonisolated let databaseURL: URL @@ -99,7 +102,7 @@ actor CostUsageStore { extension CostUsageStore { nonisolated func syncLoadCodexCache(calendar: Calendar) -> CostUsageCache { - self.executor.sync { + Self.sharedExecutor.sync { self.assumeIsolated { store in store.loadCodexCache(calendar: calendar) } @@ -112,7 +115,7 @@ extension CostUsageStore { requestedScanWindow: (sinceKey: String, untilKey: String), reportWindow: (sinceKey: String, untilKey: String)? = nil) -> CostUsageStoreBudgetResult { - self.executor.sync { + Self.sharedExecutor.sync { self.assumeIsolated { store in store.saveCodexCache( cache,