diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fedc47b3cf..178f4c6275d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Settings: edit saved token-account labels and credentials in place and reorder accounts without changing the active account (#4612). Thanks @lghsigma597! ### Fixed +- Claude cost history: preserve the last valid cache when transcript roots or directories are unreadable (#4617). Thanks @dhruva77! - Codex: open the Usage Dashboard on the overview tab to avoid incorrect redirects (#4607). Thanks @Alex-Keyes! - Claude: try readable OAuth credentials before starting the CLI in Linux Auto mode, avoiding unnecessary interactive startup while preserving explicit source selection (#4613, #4615). Thanks @yeseoLee! - Claude: include Desktop Cowork audit transcripts in local token and cost history, using the existing response deduplication and ignoring result totals (#3209). Thanks @vinschger and @Akagilnc! diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index e2b86fad136..825abe2b9a1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -12,8 +12,17 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable { } static func read(at url: URL) -> Self? { + try? self.readIfPresent(at: url) + } + + /// Returns nil for a missing path, but throws when an existing source cannot be stat'ed. + static func readIfPresent(at url: URL) throws -> Self? { var info = stat() - guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return nil } + guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + let code = errno + if code == ENOENT || code == ENOTDIR { return nil } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: url.path]) + } guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { return nil } #if os(Linux) let modifiedTime = info.st_mtim @@ -26,6 +35,18 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable { modifiedSeconds: Int64(modifiedTime.tv_sec), modifiedNanoseconds: Int64(modifiedTime.tv_nsec)) } + + static func isMissingFileError(_ error: Error) -> Bool { + let error = error as NSError + if error.domain == NSPOSIXErrorDomain { return error.code == Int(ENOENT) || error.code == Int(ENOTDIR) } + if error.domain == NSCocoaErrorDomain, + error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError + { + return true + } + guard let underlying = error.userInfo[NSUnderlyingErrorKey] as? Error else { return false } + return self.isMissingFileError(underlying) + } } struct CostUsageClaudeReportMemoKey: Equatable, Sendable, Codable { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 935c3a2acfa..b5fe749157d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -109,26 +109,6 @@ extension CostUsageScanner { workingDirectory: workingDirectory) } - static func parseClaudeFile( - fileURL: URL, - range: CostUsageDayRange, - providerFilter: ClaudeLogProviderFilter, - startOffset: Int64 = 0, - modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult - { - let pricingResolver = modelsDevCatalog.map { CostUsagePricing.ClaudeResolver(catalog: $0) } - ?? CostUsagePricing.ClaudeResolver(now: Date(), cacheRoot: modelsDevCacheRoot) - return ( - try? self.parseClaudeFileCancellable( - fileURL: fileURL, - range: range, - providerFilter: providerFilter, - startOffset: startOffset, - pricingResolver: pricingResolver, - checkCancellation: nil)) ?? ClaudeParseResult(rows: [], parsedBytes: startOffset) - } - static func parseClaudeFileCancellable( fileURL: URL, range: CostUsageDayRange, @@ -580,6 +560,16 @@ extension CostUsageScanner { return lower.contains("vertex") || (includeGCP && lower.contains("gcp")) } + private static func claudeRootExists(atPath path: String) throws -> Bool { + var info = stat() + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + let code = errno + if code == ENOENT || code == ENOTDIR { return false } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: path]) + } + return true + } + private static func claudeRootCandidates(for rootPath: String) -> [String] { if rootPath.hasPrefix("/var/") { return ["/private" + rootPath, rootPath] @@ -591,19 +581,6 @@ extension CostUsageScanner { return [rootPath] } - private struct ClaudeSourceFile { - let url: URL - let stamp: CostUsageClaudeFileStamp - } - - private struct ClaudeSourceInventory { - var files: [String: ClaudeSourceFile] = [:] - - var stamps: [String: CostUsageClaudeFileStamp] { - self.files.mapValues(\.stamp) - } - } - private struct ClaudeScanState { var cache: CostUsageCache var sourceFileIDs: [String: String] @@ -616,12 +593,11 @@ extension CostUsageScanner { } private static func processClaudeFile( - source: ClaudeSourceFile, + path: String, + stamp: CostUsageClaudeFileStamp, state: inout ClaudeScanState) throws { try state.checkCancellation?() - let path = source.url.path - let stamp = source.stamp let cached = state.cache.files[path] let sameFile = state.sourceFileIDs[path] == stamp.fileID @@ -649,7 +625,7 @@ extension CostUsageScanner { Self.recordClaudeScanWork(.transcriptParse(startOffset: startOffset)) #endif let parsed = try Self.parseClaudeFileCancellable( - fileURL: source.url, + fileURL: URL(fileURLWithPath: path), range: state.range, providerFilter: state.providerFilter, startOffset: startOffset, @@ -670,33 +646,50 @@ extension CostUsageScanner { private static func inventoryClaudeRoots( _ roots: [URL], - checkCancellation: CancellationCheck?) throws -> ClaudeSourceInventory + checkCancellation: CancellationCheck?) throws -> [String: CostUsageClaudeFileStamp] { - var inventory = ClaudeSourceInventory() + var inventory: [String: CostUsageClaudeFileStamp] = [:] for root in roots { try checkCancellation?() let rootPath = root.path let rootCandidates = Self.claudeRootCandidates(for: rootPath) - guard let existingRootPath = rootCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) + // A root that exists but cannot be inspected must fail the scan, not silently drop its sources. + guard let existingRootPath = try rootCandidates.first(where: { try Self.claudeRootExists(atPath: $0) }) else { continue } let existingRoot = existingRootPath == rootPath ? root : URL(fileURLWithPath: existingRootPath) - if existingRoot.pathExtension == "jsonl", let stamp = CostUsageClaudeFileStamp.read(at: existingRoot) { - inventory.files[existingRoot.path] = ClaudeSourceFile(url: existingRoot, stamp: stamp) + if existingRoot.pathExtension == "jsonl", + let stamp = try CostUsageClaudeFileStamp.readIfPresent(at: existingRoot) + { + inventory[existingRoot.path] = stamp continue } + var enumerationError: Error? guard let enumerator = FileManager.default.enumerator( at: existingRoot, includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles, .skipsPackageDescendants]) - else { continue } + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, error in + // Files removed mid-walk are ordinary churn; unreadable directories would undercount. + if CostUsageClaudeFileStamp.isMissingFileError(error) { return true } + enumerationError = error + return false + }) + else { + guard try Self.claudeRootExists(atPath: existingRootPath) else { continue } + throw NSError( + domain: NSCocoaErrorDomain, + code: NSFileReadUnknownError, + userInfo: [NSFilePathErrorKey: existingRootPath]) + } for case let url as URL in enumerator { try checkCancellation?() guard url.pathExtension.lowercased() == "jsonl" else { continue } - guard let stamp = CostUsageClaudeFileStamp.read(at: url), stamp.size > 0 else { continue } - inventory.files[url.path] = ClaudeSourceFile(url: url, stamp: stamp) + guard let stamp = try CostUsageClaudeFileStamp.readIfPresent(at: url), stamp.size > 0 else { continue } + inventory[url.path] = stamp } + if let enumerationError { throw enumerationError } } return inventory } @@ -710,7 +703,7 @@ extension CostUsageScanner { checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { let roots = self.defaultClaudeProjectsRoots(options: options) - let inventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation) + let sourceInventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation) try checkCancellation?() let cacheContext = reportContext ?? .regular @@ -730,7 +723,6 @@ extension CostUsageScanner { artifactStamps: (cache: cacheArtifactStamp, pricing: pricingArtifactStamp)) let memo = CostUsageClaudeReportMemo.shared let priorMemo = memo.entry(provider: provider, canonicalCachePath: canonicalCachePath) - let sourceInventory = inventory.stamps if !options.forceRescan, let priorMemo, @@ -786,7 +778,7 @@ extension CostUsageScanner { artifact.sourceFileIDs = [:] } let changedPaths: Set = if let priorMemo { - Set(inventory.files.keys.filter { path in + Set(sourceInventory.keys.filter { path in priorMemo.sourceInventory[path] != sourceInventory[path] }) } else { @@ -802,9 +794,9 @@ extension CostUsageScanner { pricingResolver: pricingResolver, checkCancellation: checkCancellation) - for path in inventory.files.keys.sorted() { - guard let source = inventory.files[path] else { continue } - try Self.processClaudeFile(source: source, state: &scanState) + for path in sourceInventory.keys.sorted() { + guard let stamp = sourceInventory[path] else { continue } + try Self.processClaudeFile(path: path, stamp: stamp, state: &scanState) } try checkCancellation?() @@ -898,19 +890,6 @@ extension CostUsageScanner { } static func buildClaudeReportFromCache( - cache: CostUsageCache, - range: CostUsageDayRange, - now: Date = Date(), - modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport - { - self.buildClaudeReportFromCache( - cache: cache, - rows: self.reconciledClaudeRows(cache: cache), - range: range, - pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot)) - } - - private static func buildClaudeReportFromCache( cache: CostUsageCache, rows: [ClaudeUsageRow], range: CostUsageDayRange, diff --git a/Tests/CodexBarTests/CostUsageClaudeCacheTestSupport.swift b/Tests/CodexBarTests/CostUsageClaudeCacheTestSupport.swift index 43b4fef8c59..4d719e67082 100644 --- a/Tests/CodexBarTests/CostUsageClaudeCacheTestSupport.swift +++ b/Tests/CodexBarTests/CostUsageClaudeCacheTestSupport.swift @@ -112,3 +112,39 @@ extension CostUsageClaudeCacheIO { ArtifactMemo.shared.entries.removeObject(forKey: url.standardizedFileURL.resolvingSymlinksInPath() as NSURL) } } + +/// Convenience entry points used only by scanner tests. +extension CostUsageScanner { + static func parseClaudeFile( + fileURL: URL, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult + { + let pricingResolver = modelsDevCatalog.map { CostUsagePricing.ClaudeResolver(catalog: $0) } + ?? CostUsagePricing.ClaudeResolver(now: Date(), cacheRoot: modelsDevCacheRoot) + return ( + try? self.parseClaudeFileCancellable( + fileURL: fileURL, + range: range, + providerFilter: providerFilter, + startOffset: startOffset, + pricingResolver: pricingResolver, + checkCancellation: nil)) ?? ClaudeParseResult(rows: [], parsedBytes: startOffset) + } + + static func buildClaudeReportFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + now: Date = Date(), + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport + { + self.buildClaudeReportFromCache( + cache: cache, + rows: self.reconciledClaudeRows(cache: cache), + range: range, + pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot)) + } +} diff --git a/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift b/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift index f2cd343c2b6..3acd82a421c 100644 --- a/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift +++ b/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift @@ -227,10 +227,42 @@ struct CostUsageClaudeReportContextTests { } } + @Test(.enabled(if: getuid() != 0, "root ignores directory permissions"), arguments: [false, true]) + func `unreadable source inventory fails without replacing the cache or report memo`(lockRoot: Bool) throws { + var fixture = try Fixture() + defer { fixture.env.cleanup() } + let gate = fixture.env.root.appendingPathComponent("gate", isDirectory: true) + let gatedRoot = gate.appendingPathComponent("projects", isDirectory: true) + try FileManager.default.createDirectory(at: gatedRoot, withIntermediateDirectories: true) + fixture.options.claudeProjectsRoots = [fixture.env.claudeProjectsRoot, gatedRoot] + _ = try fixture.write(path: "project/session.jsonl", day: fixture.now, id: "first", input: 10) + let locked = lockRoot ? gate : fixture.env.claudeProjectsRoot.appendingPathComponent("locked") + if !lockRoot { + _ = try fixture.write(path: "locked/session.jsonl", day: fixture.now, id: "locked", input: 5) + } + let (original, _) = try fixture.load(context: .regular, days: 30) + #expect(original.summary?.totalInputTokens == (lockRoot ? 10 : 15)) + let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let memoURL = CostUsageClaudeReportMemo.reportMemoFileURL(cacheFileURL: cacheURL) + let cacheBefore = try Data(contentsOf: cacheURL) + let memoBefore = try Data(contentsOf: memoURL) + + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: locked.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) } + #expect(throws: (any Error).self) { try fixture.load(context: .regular, days: 30) } + #expect(try Data(contentsOf: cacheURL) == cacheBefore) + #expect(try Data(contentsOf: memoURL) == memoBefore) + + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) + let (recovered, work) = try fixture.load(context: .regular, days: 30) + #expect(recovered.data == original.data) + #expect(work == CostUsageScanner.ClaudeScanWorkMetrics()) + } + struct Fixture { let env: CostUsageTestEnvironment let now: Date - let options: CostUsageScanner.Options + var options: CostUsageScanner.Options init(now: Date? = nil) throws { self.env = try CostUsageTestEnvironment() diff --git a/TestsLinux/CostUsageHourlyReportLinuxTests.swift b/TestsLinux/CostUsageHourlyReportLinuxTests.swift index 450ba979e6f..7fca04f2138 100644 --- a/TestsLinux/CostUsageHourlyReportLinuxTests.swift +++ b/TestsLinux/CostUsageHourlyReportLinuxTests.swift @@ -346,3 +346,18 @@ struct CostUsageHourlyReportLinuxTests { return calendar } } + +extension CostUsageScanner { + static func buildClaudeReportFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + now: Date = Date(), + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport + { + self.buildClaudeReportFromCache( + cache: cache, + rows: self.reconciledClaudeRows(cache: cache), + range: range, + pricingResolver: CostUsagePricing.ClaudeResolver(now: now, cacheRoot: modelsDevCacheRoot)) + } +} diff --git a/docs/claude.md b/docs/claude.md index 7cb356dabb5..bfc28be4ed1 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -629,6 +629,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - GPT usage recorded through Claude Code uses the bundled OpenAI model's long-context boundary (272K for supported models), while retaining catalog rates. Uncached input and cache-read/create tokens all contribute to the prompt length. Saved reports are recalculated after pricing corrections without discarding retained Codex history. - Native provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v6.json` - Report memo: `~/Library/Caches/CodexBar/cost-usage/claude-v6.report-memo.json` stores source stamps and the daily report across launches. It is reused only while transcript inventory, cache/pricing artifacts, requested window, and report-semantics revision still match. + - An unreadable transcript root or directory fails the refresh without replacing the last valid cache or report memo. Restoring access allows the next refresh to reuse that history; genuinely missing paths still count as removed sources. - A pricing catalog replacement during a refresh preserves certification of the parsed transcript window. The next refresh applies the replacement prices to cached rows without reparsing unchanged transcripts, including after a restart. Externally replaced transcript caches still require window certification again. - Unchanged sources reuse the memo even when a menu refresh bypasses the scan debounce. Explicit rescans still reparse transcripts, but identical cache and report-memo content is not rewritten; an unchanged rebuild retains its previous scan timestamp. Existing artifacts may be rewritten once to establish deterministic key ordering. Changed transcripts or report metadata still replace the corresponding complete JSON artifacts. - Persistence keeps at most eight lightweight artifact identities independently of the decoded-row cache. An unchanged cache identity plus matching device/inode, size, and nanosecond mtime skips encoding; otherwise sorted JSON fingerprints avoid full-file read-back while preserving identical rebuilds. External replacement invalidates reuse, and changed files still use temporary-file rename. Evicted identities are re-established on load or the next save.