Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ extension UsageStore {
return
}

let scopeSignature = accounts
let historyDays = max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays)
let accountScopeSignature = accounts
.map { "\($0.id)|\($0.cacheIdentity)" }
.joined(separator: "\u{0}")
let scopeSignature = "\(historyDays)\u{0}\(accountScopeSignature)"
if self.spendDashboardCodexCostCatchUpTask != nil,
self.spendDashboardCodexCostCatchUpScopeSignature == scopeSignature
{
Expand All @@ -51,7 +53,7 @@ extension UsageStore {
let context = SpendDashboardCodexCostCatchUpContext(
token: token,
accounts: accounts,
historyDays: SpendDashboardSource.scanDays,
historyDays: historyDays,
scopeSignature: scopeSignature,
providerConfigRevision: self.settings.providerConfigRevision(for: .codex),
costUsageSettingsRevision: self.settings.costUsageSettingsRevision)
Expand Down Expand Up @@ -245,6 +247,7 @@ extension UsageStore {
&& self.spendDashboardCodexCostCatchUpScopeSignature == context.scopeSignature
&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision
&& self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision
&& max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) == context.historyDays
&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)
&& self.isEnabled(.codex)
&& context.accounts.allSatisfy(SpendDashboardSource.codexAuthFingerprintMatches)
Expand Down
113 changes: 104 additions & 9 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,26 +312,121 @@ public struct CostUsageFetcher: Sendable {
}

let scoped = CostUsageScanner.codexCache(cache, scopedTo: roots)
var progressHasher = Hasher()
for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) {
progressHasher.combine(path)
progressHasher.combine(usage.codexScanFileId)
progressHasher.combine(usage.parsedBytes)
progressHasher.combine(usage.size)
progressHasher.combine(usage.codexScanComplete)
}
let hasIncompleteFile = scoped.files.values.contains { $0.codexScanComplete == false }
let pending = cache.codexScanCatchUpPending == true || hasIncompleteFile
return CodexScanCatchUpStatus(
pending: pending,
progressKey: "\(scoped.files.count):\(progressHasher.finalize())",
progressKey: Self.codexScanCatchUpProgressKey(cache: cache, scoped: scoped),
processedBytes: cache.codexScanProcessedBytes ?? 0,
totalBytes: cache.codexScanTotalBytes ?? 0,
completedFiles: cache.codexScanCompletedFiles ?? 0,
totalFiles: cache.codexScanTotalFiles ?? 0,
staleSnapshotUpdatedAt: pending ? cache.codexPreviousReport?.updatedAt : nil)
}

private static func codexScanCatchUpProgressKey(
cache: CostUsageCache,
scoped: CostUsageCache) -> String
{
var hasher = Hasher()
hasher.combine(cache.codexScanProcessedBytes)
hasher.combine(cache.codexScanTotalBytes)
hasher.combine(cache.codexScanCompletedFiles)
hasher.combine(cache.codexScanTotalFiles)

for (path, usage) in scoped.files.sorted(by: { $0.key < $1.key }) {
hasher.combine(path)
hasher.combine(usage.codexScanFileId)
hasher.combine(usage.parsedBytes)
hasher.combine(usage.size)
hasher.combine(usage.codexScanTargetSize)
hasher.combine(usage.codexScanComplete)
hasher.combine(usage.codexJSONLResumeState?.offset)
hasher.combine(usage.forkBaselineDependencyKey)
Self.combineCodexBufferedProgress(usage.codexBufferedSubagentLines, into: &hasher)
Self.combineCodexBufferedProgress(usage.codexBufferedUnresolvedForkLines, into: &hasher)
}
Self.combineCodexDiscoveryProgress(cache.codexSessionDiscovery, into: &hasher)
Self.combineCodexActiveLookbackProgress(cache.codexActiveLookbackState, into: &hasher)
return "\(scoped.files.count):\(hasher.finalize())"
}

private static func combineCodexBufferedProgress(
_ lines: [CostUsageScanner.CodexBufferedFastLine]?,
into hasher: inout Hasher)
{
hasher.combine(lines?.count)
for line in lines ?? [] {
hasher.combine(line.lineIndex)
hasher.combine(line.ordinal)
hasher.combine(line.endOffset)
}
}

private static func combineCodexDiscoveryProgress(
_ discovery: CostUsageCodexSessionDiscovery?,
into hasher: inout Hasher)
{
hasher.combine(discovery != nil)
guard let discovery else { return }
for root in discovery.roots.sorted() {
hasher.combine(root)
}
hasher.combine(discovery.generation)
hasher.combine(discovery.directoryPaths.count)
hasher.combine(discovery.nextDirectoryIndex)
hasher.combine(discovery.filePaths.count)
hasher.combine(discovery.nextFileIndex)
hasher.combine(discovery.directoryStamps.count)
hasher.combine(discovery.fileStamps.count)
hasher.combine(discovery.validationDirectoryIndex)
hasher.combine(discovery.isComplete)
if discovery.directoryPaths.indices.contains(discovery.nextDirectoryIndex) {
hasher.combine(discovery.directoryPaths[discovery.nextDirectoryIndex])
}
if discovery.filePaths.indices.contains(discovery.nextFileIndex) {
hasher.combine(discovery.filePaths[discovery.nextFileIndex])
}
hasher.combine(discovery.headScan?.path)
hasher.combine(discovery.headScan?.offset)
hasher.combine(discovery.headScan?.resumeState?.offset)
for (sessionID, path) in discovery.filePathBySessionId.sorted(by: { $0.key < $1.key }) {
hasher.combine(sessionID)
hasher.combine(path)
}
for sessionID in discovery.missingSessionIds.sorted() {
hasher.combine(sessionID)
}
for sessionID in discovery.pendingSessionIds.sorted() {
hasher.combine(sessionID)
}
}

private static func combineCodexActiveLookbackProgress(
_ lookback: CostUsageCodexActiveLookbackState?,
into hasher: inout Hasher)
{
hasher.combine(lookback != nil)
guard let lookback else { return }
hasher.combine(lookback.scanSinceKey)
for root in lookback.rootPaths.sorted() {
hasher.combine(root)
}
for (root, day) in lookback.nextDayKeyByRoot.sorted(by: { $0.key < $1.key }) {
hasher.combine(root)
hasher.combine(day)
}
for root in lookback.completedRootPaths.sorted() {
hasher.combine(root)
}
for path in lookback.pendingFilePaths.sorted() {
hasher.combine(path)
}
for root in lookback.legacyRecursivePendingRootPaths.sorted() {
hasher.combine(root)
}
}

private static func codexHistoryCoverageIsEstablished(
options: CostUsageScanner.Options) -> Bool
{
Expand Down
113 changes: 113 additions & 0 deletions Tests/CodexBarTests/CostUsageFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,119 @@ extension CostUsageFetcherTests {
#expect(covered.historyCoverageIsEstablished)
}

@Test
func `codex catch-up progress includes discovery and active lookback cursors`() async throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }

let options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
cacheRoot: env.cacheRoot)
let rootPath = env.codexSessionsRoot.path
let firstPath = env.codexSessionsRoot.appendingPathComponent("first.jsonl").path
let secondPath = env.codexSessionsRoot.appendingPathComponent("second.jsonl").path
var cache = CostUsageCache()
cache.roots = CostUsageScanner.codexRootsFingerprint(options: options)
cache.codexScanCatchUpPending = true
cache.codexSessionDiscovery = CostUsageCodexSessionDiscovery(
roots: [rootPath],
generation: "generation",
directoryStamps: [:],
directoryPaths: [rootPath],
nextDirectoryIndex: 0,
filePaths: [firstPath, secondPath],
nextFileIndex: 0,
fileStamps: [:],
headScan: .init(path: firstPath, offset: 32, resumeState: nil),
filePathBySessionId: [:],
missingSessionIds: [],
pendingSessionIds: ["pending-session"],
validationDirectoryIndex: 0,
isComplete: false)
cache.codexActiveLookbackState = CostUsageCodexActiveLookbackState(
scanSinceKey: "2026-04-01",
rootPaths: [rootPath],
nextDayKeyByRoot: [rootPath: "2026-04-02"],
pendingFilePaths: [firstPath])
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)

let fetcher = CostUsageFetcher(scannerOptions: options)
let baseline = await fetcher.codexScanCatchUpStatus()
let unchanged = await fetcher.codexScanCatchUpStatus()
#expect(unchanged.progressKey == baseline.progressKey)

var discovery = try #require(cache.codexSessionDiscovery)
discovery.headScan = .init(path: firstPath, offset: 64, resumeState: nil)
cache.codexSessionDiscovery = discovery
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
let advancedHead = await fetcher.codexScanCatchUpStatus()
#expect(advancedHead.progressKey != baseline.progressKey)

discovery.nextFileIndex = 1
discovery.headScan = nil
cache.codexSessionDiscovery = discovery
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
let advancedFile = await fetcher.codexScanCatchUpStatus()
#expect(advancedFile.progressKey != advancedHead.progressKey)

var lookback = try #require(cache.codexActiveLookbackState)
lookback.nextDayKeyByRoot[rootPath] = "2026-04-03"
cache.codexActiveLookbackState = lookback
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
let advancedLookback = await fetcher.codexScanCatchUpStatus()
#expect(advancedLookback.progressKey != advancedFile.progressKey)
}

@Test
func `codex catch-up progress includes buffered retry cursors`() async throws {
let env = try CostUsageTestEnvironment()
defer { env.cleanup() }

let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
try Self.writeCodexSessionFile(
homeRoot: env.codexHomeRoot,
env: env,
day: day,
filename: "buffered.jsonl",
tokens: 42)
var options = CostUsageScanner.Options(
codexSessionsRoot: env.codexSessionsRoot,
cacheRoot: env.cacheRoot)
options.refreshMinIntervalSeconds = 0
_ = try await CostUsageFetcher.loadTokenSnapshot(
provider: .codex,
now: day,
historyDays: 1,
includePiSessions: false,
scannerOptions: options)

var cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
let path = try #require(cache.files.keys.first)
var usage = try #require(cache.files[path])
usage.codexBufferedSubagentLines = [CostUsageScanner.CodexBufferedFastLine(
lineIndex: 1,
ordinal: 1,
endOffset: 64,
line: .taskStarted(turnID: "synthetic-turn"))]
cache.files[path] = usage
cache.codexScanCatchUpPending = true
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)

let fetcher = CostUsageFetcher(scannerOptions: options)
let baseline = await fetcher.codexScanCatchUpStatus()

usage.codexBufferedSubagentLines = [CostUsageScanner.CodexBufferedFastLine(
lineIndex: 1,
ordinal: 1,
endOffset: 128,
line: .taskStarted(turnID: "synthetic-turn"))]
cache.files[path] = usage
CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: cache)
let advancedBuffer = await fetcher.codexScanCatchUpStatus()

#expect(advancedBuffer.progressKey != baseline.progressKey)
}

@Test
func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws {
let env = try CostUsageTestEnvironment()
Expand Down
26 changes: 13 additions & 13 deletions Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1118,19 +1118,19 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
SuppressedProviderReference(
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
line: 56,
line: 58,
anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),",
expectedProviderIDs: ["codex"],
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
SuppressedProviderReference(
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
line: 248,
line: 251,
anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)",
expectedProviderIDs: ["codex"],
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
SuppressedProviderReference(
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
line: 249,
line: 252,
anchor: "&& self.isEnabled(.codex)",
expectedProviderIDs: ["codex"],
reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."),
Expand Down Expand Up @@ -1353,19 +1353,19 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
SuppressedProviderReference(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 715,
line: 810,
anchor: "provider: .codex,",
expectedProviderIDs: ["codex"],
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
SuppressedProviderReference(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 790,
line: 885,
anchor: "provider: .codex,",
expectedProviderIDs: ["codex"],
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
SuppressedProviderReference(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 865,
line: 960,
anchor: "provider: .codex,",
expectedProviderIDs: ["codex"],
reason: "This provider-specific core branch passes its already-selected identity to a shared helper."),
Expand Down Expand Up @@ -2909,7 +2909,7 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift",
line: 246,
line: 248,
anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision",
expectedProviderIDs: ["codex"],
expectedReferenceCount: 1,
Expand Down Expand Up @@ -3379,47 +3379,47 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."),
AllowedProviderConstruct(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 539,
line: 634,
anchor: "if provider == .codex {",
expectedProviderIDs: ["codex"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["codex@0"],
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
AllowedProviderConstruct(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 567,
line: 662,
anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)",
expectedProviderIDs: ["claude", "codex"],
expectedReferenceCount: 5,
expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@15", "codex@27"],
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
AllowedProviderConstruct(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 614,
line: 709,
anchor: "options.provider == .codex || options.provider == .claude",
expectedProviderIDs: ["claude", "codex"],
expectedReferenceCount: 2,
expectedReferenceFingerprint: ["claude@0", "codex@0"],
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
AllowedProviderConstruct(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 641,
line: 736,
anchor: "guard provider == .codex || provider == .claude else { return nil }",
expectedProviderIDs: ["claude", "codex", "openai"],
expectedReferenceCount: 5,
expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@4", "codex@15", "openai@15"],
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
AllowedProviderConstruct(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 1114,
line: 1209,
anchor: "if provider == .vertexai {",
expectedProviderIDs: ["claude", "vertexai"],
expectedReferenceCount: 2,
expectedReferenceFingerprint: ["vertexai@0", "claude@2"],
reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."),
AllowedProviderConstruct(
path: "Sources/CodexBarCore/CostUsageFetcher.swift",
line: 1365,
line: 1460,
anchor: "if provider == .cursor {",
expectedProviderIDs: ["cursor"],
expectedReferenceCount: 1,
Expand Down
Loading