diff --git a/AIBattery/Services/FileWatcher.swift b/AIBattery/Services/FileWatcher.swift index 0faa839..2b58774 100644 --- a/AIBattery/Services/FileWatcher.swift +++ b/AIBattery/Services/FileWatcher.swift @@ -77,7 +77,7 @@ final class FileWatcher { source.setEventHandler { [weak self] in MainActor.assumeIsolated { - self?.debounceNotify() + self?.debounceNotify(invalidateStatsCache: true, invalidateSessionLog: false) } } @@ -114,7 +114,7 @@ final class FileWatcher { let box = Unmanaged>.fromOpaque(info).takeUnretainedValue() guard let watcher = box.value else { return } MainActor.assumeIsolated { - watcher.debounceNotify() + watcher.debounceNotify(invalidateStatsCache: false, invalidateSessionLog: true) } } @@ -163,15 +163,17 @@ final class FileWatcher { } } - private func debounceNotify() { + /// Selective invalidation — only clear the cache for the reader whose data actually changed. + /// Stats-cache changes don't require re-scanning JSONL files, and vice versa. + /// Fallback timer invalidates both (safe catch-all when FS events are unavailable). + private func debounceNotify(invalidateStatsCache: Bool = true, invalidateSessionLog: Bool = true) { guard !isStopped else { return } debounceWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in MainActor.assumeIsolated { guard let self, !self.isStopped else { return } - // Invalidate reader caches so the next refresh re-scans changed files - SessionLogReader.shared.invalidate() - StatsCacheReader.shared.invalidate() + if invalidateSessionLog { SessionLogReader.shared.invalidate() } + if invalidateStatsCache { StatsCacheReader.shared.invalidate() } self.onChange() } } diff --git a/Tests/AIBatteryCoreTests/Models/ProjectTokenSummaryTests.swift b/Tests/AIBatteryCoreTests/Models/ProjectTokenSummaryTests.swift index f43bfd8..09ac640 100644 --- a/Tests/AIBatteryCoreTests/Models/ProjectTokenSummaryTests.swift +++ b/Tests/AIBatteryCoreTests/Models/ProjectTokenSummaryTests.swift @@ -16,4 +16,88 @@ struct ProjectTokenSummaryTests { ) #expect(summary.totalTokens == 1000) } + + @Test func totalTokens_zeroWhenAllZero() { + let summary = ProjectTokenSummary( + id: "/workspace/empty", + projectName: "empty", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + estimatedCost: 0 + ) + #expect(summary.totalTokens == 0) + } + + @Test func id_isUsedAsIdentifiable() { + let summary = ProjectTokenSummary( + id: "/Users/kyle/projects/myapp", + projectName: "myapp", + inputTokens: 500, + outputTokens: 250, + cacheReadTokens: 0, + cacheWriteTokens: 0, + estimatedCost: 1.50 + ) + #expect(summary.id == "/Users/kyle/projects/myapp") + } + + @Test func projectName_displaysLastPathComponent() { + // Simulates what UsageAggregator.buildProjectTokens does + let cwd = "/Users/kyle/projects/my-app" + let displayName = URL(fileURLWithPath: cwd).lastPathComponent + let summary = ProjectTokenSummary( + id: cwd, + projectName: displayName, + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + estimatedCost: 0.30 + ) + #expect(summary.projectName == "my-app") + } + + @Test func otherProject_usedForMissingCwd() { + let summary = ProjectTokenSummary( + id: "Other", + projectName: "Other", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + estimatedCost: 0.10 + ) + #expect(summary.id == "Other") + #expect(summary.projectName == "Other") + #expect(summary.totalTokens == 150) + } + + @Test func estimatedCost_storedDirectly() { + let summary = ProjectTokenSummary( + id: "test", + projectName: "test", + inputTokens: 1_000_000, + outputTokens: 500_000, + cacheReadTokens: 2_000_000, + cacheWriteTokens: 100_000, + estimatedCost: 18.75 + ) + #expect(summary.estimatedCost == 18.75) + #expect(summary.totalTokens == 3_600_000) + } + + @Test func largeTokenCounts_noOverflow() { + let summary = ProjectTokenSummary( + id: "large", + projectName: "large", + inputTokens: 500_000_000, + outputTokens: 200_000_000, + cacheReadTokens: 800_000_000, + cacheWriteTokens: 100_000_000, + estimatedCost: 5000.0 + ) + #expect(summary.totalTokens == 1_600_000_000) + } } diff --git a/spec/DATA_LAYER.md b/spec/DATA_LAYER.md index b807f63..d5aa173 100644 --- a/spec/DATA_LAYER.md +++ b/spec/DATA_LAYER.md @@ -392,7 +392,7 @@ Pricing table (per million tokens): - FSEventStream flags: `kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes` - WeakBox wrapper for C callback to prevent retain cycles - Debounce: 2 seconds via `DispatchWorkItem` -- **Cache invalidation**: debounced handler calls `SessionLogReader.shared.invalidate()` and `StatsCacheReader.shared.invalidate()` before triggering refresh +- **Selective cache invalidation**: stats-cache watcher only invalidates `StatsCacheReader`, JSONL watcher only invalidates `SessionLogReader` — avoids unnecessary cache rebuilds when only one data source changed. Fallback timer invalidates both (safe catch-all) - Fallback timer: 60 seconds — starts if either DispatchSource or FSEventStream fails (ensures changes are picked up even if one watcher is unavailable) - Calls `onChange` closure → triggers `viewModel.refresh()` - **Stats-cache retry**: if `stats-cache.json` doesn't exist on launch (normal before first `/stats` run), retries with exponential backoff (60s base, doubles each retry, capped at 300s, max 10 retries ~30 min). Counter resets on success or `stopWatching()`