Skip to content
Open
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
47 changes: 42 additions & 5 deletions Sources/CodexBar/SpendDashboardController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ final class SpendDashboardController {
private let publicationHandler: PublicationHandler?
private var loadTask: Task<Void, Never>?
private var loadedInputs: [SpendDashboardModel.ProviderInput] = []
private var loadedInputScopes: [String: SpendDashboardLoadedInputScope] = [:]
private var loadedAt = Date()
private var lastSuccessfulConfiguration: SpendDashboardConfiguration?
private var phase = LoadPhase.ordinary
Expand Down Expand Up @@ -1077,6 +1078,9 @@ final class SpendDashboardController {
self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) }
self.failedSourceIDs.subtract(invalidatedSourceIDs)
self.confirmedEmptySourceIDs.subtract(invalidatedSourceIDs)
for sourceID in invalidatedSourceIDs {
self.loadedInputScopes.removeValue(forKey: sourceID)
}
self.failedSourceCount = 0
self.rebuildModel()
}
Expand All @@ -1091,6 +1095,7 @@ final class SpendDashboardController {
!configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled
else {
self.loadedInputs = []
self.loadedInputScopes = [:]
self.failedSourceIDs = []
self.confirmedEmptySourceIDs = []
self.openCodexObservation = .disabled
Expand Down Expand Up @@ -1141,6 +1146,11 @@ final class SpendDashboardController {
self.loadedInputs.removeAll { cachedIDs.contains($0.id) }
self.loadedInputs.append(contentsOf: result.inputs)
self.loadedInputs = Self.stableUniqueInputs(self.loadedInputs)
for input in result.inputs {
self.loadedInputScopes[input.id] = SpendDashboardLoadedInputScope(
configuration: request.configuration,
input: input)
}
self.loadedAt = request.now
self.failedSourceCount = result.failedSourceCount
self.failedSourceIDs = result.failedSourceIDs
Expand Down Expand Up @@ -1271,19 +1281,46 @@ final class SpendDashboardController {
let codexDisplayNames = request.configuration.codexAccountDisplayNames
self.refreshRetainedCodexDisplayNames(codexDisplayNames)
var nextInputs = result.inputs
var nextInputScopes = Dictionary(uniqueKeysWithValues: nextInputs.map { input in
(input.id, SpendDashboardLoadedInputScope(configuration: request.configuration, input: input))
})
let unsafeSourceIDs = invalidatedSourceIDs
.union(result.invalidatedSourceIDs)
.union(confirmedEmptySourceIDs)
var incompleteCodexScopes: [String: SpendDashboardLoadedInputScope] = [:]
for input in nextInputs
where input.provider == .codex && !input.snapshot.historyCoverageIsEstablished
{
incompleteCodexScopes[input.id] = SpendDashboardLoadedInputScope(
configuration: request.configuration,
input: input)
}
if !incompleteCodexScopes.isEmpty {
let retainedInputs = self.loadedInputs.filter {
incompleteCodexScopes[$0.id] == self.loadedInputScopes[$0.id] &&
!unsafeSourceIDs.contains($0.id) &&
$0.provider == .codex &&
$0.snapshot.historyCoverageIsEstablished
}.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }
let retainedSourceIDs = Set(retainedInputs.map(\.id))
nextInputs.removeAll { retainedSourceIDs.contains($0.id) }
nextInputs.append(contentsOf: retainedInputs)
}
if !result.failedSourceIDs.isEmpty {
let freshIDs = Set(nextInputs.map(\.id))
let unsafeSourceIDs = invalidatedSourceIDs
.union(result.invalidatedSourceIDs)
.union(confirmedEmptySourceIDs)
nextInputs.append(contentsOf: self.loadedInputs.filter {
let retainedInputs = self.loadedInputs.filter {
result.failedSourceIDs.contains($0.id) &&
!unsafeSourceIDs.contains($0.id) &&
!freshIDs.contains($0.id)
}.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) })
}.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }
nextInputs.append(contentsOf: retainedInputs)
for input in retainedInputs {
nextInputScopes[input.id] = self.loadedInputScopes[input.id]
}
}
self.configuration = request.configuration
self.loadedInputs = Self.stableUniqueInputs(nextInputs)
self.loadedInputScopes = nextInputScopes
self.loadedAt = request.now
self.lastSuccessfulConfiguration = request.configuration
self.failedSourceCount = result.failedSourceCount
Expand Down
14 changes: 14 additions & 0 deletions Sources/CodexBar/SpendDashboardLoadedInputScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

struct SpendDashboardLoadedInputScope: Equatable, Sendable {
let bucketTimeZoneIdentifier: String
let historyDays: Int

init(
configuration: SpendDashboardConfiguration,
input: SpendDashboardModel.ProviderInput)
{
self.bucketTimeZoneIdentifier = configuration.bucketCalendar.timeZone.identifier
self.historyDays = input.snapshot.historyDays
}
}
10 changes: 10 additions & 0 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ extension UsageStore {
}

func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) {
// A bounded Codex refresh can succeed with partial rows while catch-up remains pending.
// Keep the same-scope established snapshot until the scanner publishes another established
// result; account and history-window changes fail the current-publication lookup below.
if provider == .codex,
!snapshot.historyCoverageIsEstablished,
self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?
.snapshot?.historyCoverageIsEstablished == true
{
return
}
self.tokenSnapshots[provider.instanceID] = snapshot
self.publishTokenSnapshotState(snapshot, for: provider)
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,11 @@ public struct CostUsageFetcher: Sendable {
}

guard !reports.isEmpty else { return nil }
// `previous` is an exact report captured before the current bounded refresh became
// pending. Its rows remain established even though native catch-up is still active;
// `staleSnapshotUpdatedAt` keeps refresh scheduling and stale presentation explicit.
let displayedHistoryCoverageIsEstablished = nativeHistoryCoverageIsEstablished
|| staleSnapshotUpdatedAt != nil
// updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time
// would let stale token rows inherit app-start freshness (#1964). lastRefreshAt
// drives TTL suppression and stays native-only: a merged load must never delay a
Expand All @@ -978,7 +983,7 @@ public struct CostUsageFetcher: Sendable {
now: now,
historyDays: clampedHistoryDays,
calendar: options.calendar,
historyCoverageIsEstablished: Self.codexHistoryCoverageIsEstablished(options: options),
historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished,
costProvenance: .listPriceEstimate,
projects: Self.mergedProjectBreakdowns(projects),
sessions: sessions,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "2d17f4981b78d07f"
static let value = "6496ac5cffd5f58c"
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,18 @@ struct CostUsageCodexPreviousReport: Codable, Equatable {
var timeZoneIdentifier: String?
var roots: [String: Int64]?

init?(report: CostUsageDailyReport, cache: CostUsageCache) {
init?(
report: CostUsageDailyReport,
cache: CostUsageCache,
reportSinceKey: String,
reportUntilKey: String)
{
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.scanSinceKey = reportSinceKey
self.scanUntilKey = reportUntilKey
self.timeZoneIdentifier = cache.timeZoneIdentifier
self.roots = cache.roots
}
Expand Down
13 changes: 10 additions & 3 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5186,8 +5186,11 @@ enum CostUsageScanner {
return previous
}

let sourceCache: CostUsageCache? = if !currentScanIsPending,
options.forceRescan,
// A routine bounded refresh can turn an established cache back into pending while it
// validates a growing active tail. Snapshot the established report before any refresh,
// not only explicit rescans, so presentation can remain stable until catch-up converges.
let sourceCache: CostUsageCache? = if plan.shouldRefresh,
!currentScanIsPending,
!cache.days.isEmpty
Comment thread
mauriciopolvora marked this conversation as resolved.
{
cache
Expand All @@ -5207,7 +5210,11 @@ enum CostUsageScanner {
modelsDevCatalog: plan.modelsDevCatalog,
modelsDevCacheRoot: options.cacheRoot,
priorityTurns: plan.priorityTurns)
return CostUsageCodexPreviousReport(report: report, cache: sourceCache)
return CostUsageCodexPreviousReport(
report: report,
cache: sourceCache,
reportSinceKey: range.sinceKey,
reportUntilKey: range.untilKey)
}

static func codexPreviousReport(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -867,10 +867,11 @@ extension CostUsageStore {
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
return CostUsageCodexPreviousReport(
report: report,
cache: cache,
reportSinceKey: sinceKey,
reportUntilKey: untilKey)
}

private static func fileAggregates(_ usage: CostUsageFileUsage) -> [CostUsageStoreDayAggregate] {
Expand Down
31 changes: 27 additions & 4 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,17 @@ actor CostUsageStore {
parserHash: CodexParserHash.value)
static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)"
static let compatiblePredecessorParserHashes: Set<String> = [
"2d17f4981b78d07f", // Current main before retained-report persistence; parsed rows unchanged.
"8050a4faf4fddb96", // PR base before retained-report persistence; parsed rows unchanged.
"98da5914d2f6a9cd", // Pushed PR producer before retry signaling; persisted rows unchanged.
"43609cc56f76a003", // 0.49.3 request-tier pricing; persisted row shape unchanged.
"b975eb705f905b9a", // 0.49.0-0.49.2 SQLite producer with compatible rows.
"47144baa8daccf52", // This branch changes only scan scheduling, discovery, and persistence bookkeeping.
]
static let incompatibleRetainedReportPredecessorParserHashes: Set<String> = [
"2d17f4981b78d07f",
"8050a4faf4fddb96",
]

/// Test-only crash injection: invoked inside `saveCodexCache`'s transaction after each
/// persisted file with the running count, so a crash-safety harness can SIGKILL the
Expand Down Expand Up @@ -347,7 +353,7 @@ extension CostUsageStore {
}

private func validateExistingDatabase(_ database: OpaquePointer) throws {
let state: (isCurrent: Bool, canAdoptPredecessor: Bool)
let state: (storedHash: String, isCurrent: Bool, canAdoptPredecessor: Bool)
try Self.execute(database, "BEGIN")
do {
state = try self.databaseCompatibilityState(database)
Expand All @@ -374,7 +380,7 @@ extension CostUsageStore {
}
try Self.validateDatabaseIntegrity(database)
if lockedState.canAdoptPredecessor {
try self.adoptCompatiblePredecessor(database)
try self.adoptCompatiblePredecessor(database, storedHash: lockedState.storedHash)
}
try Self.execute(database, "COMMIT")
} catch {
Expand All @@ -384,6 +390,7 @@ extension CostUsageStore {
}

private func databaseCompatibilityState(_ database: OpaquePointer) throws -> (
storedHash: String,
isCurrent: Bool,
canAdoptPredecessor: Bool)
{
Expand All @@ -401,7 +408,7 @@ extension CostUsageStore {
&& self.expectedSchemaVersion == Self.schemaVersion
&& Self.compatiblePredecessorParserHashes.contains(storedHash)
&& actualVersion == Int64(predecessorVersion)
return (isCurrent, canAdoptPredecessor)
return (storedHash, isCurrent, canAdoptPredecessor)
}

private static func validateDatabaseIntegrity(_ database: OpaquePointer) throws {
Expand All @@ -413,7 +420,23 @@ extension CostUsageStore {
}
}

private func adoptCompatiblePredecessor(_ database: OpaquePointer) throws {
private func adoptCompatiblePredecessor(_ database: OpaquePointer, storedHash: String) throws {
if Self.incompatibleRetainedReportPredecessorParserHashes.contains(storedHash),
var metadata = try Self.readSingleton(
CostUsageStoreMetadata.self,
database: database,
table: "scan_metadata")
{
metadata.previousReportPayload = nil
let payload = try JSONEncoder().encode(metadata)
let metadataStatement = try Self.prepare(
database,
"UPDATE scan_metadata SET payload = ? WHERE id = 1")
defer { sqlite3_finalize(metadataStatement) }
Self.bind(payload, to: metadataStatement, at: 1)
try Self.stepDone(metadataStatement, database: database)
guard sqlite3_changes(database) == 1 else { throw StoreError.incompatibleSchema }
}
let statement = try Self.prepare(database, "UPDATE meta SET value = ? WHERE key = 'parser_hash'")
defer { sqlite3_finalize(statement) }
Self.bind(self.expectedParserHash, to: statement, at: 1)
Expand Down
Loading