Skip to content
Merged
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
14 changes: 8 additions & 6 deletions AIBattery/Services/FileWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ final class FileWatcher {

source.setEventHandler { [weak self] in
MainActor.assumeIsolated {
self?.debounceNotify()
self?.debounceNotify(invalidateStatsCache: true, invalidateSessionLog: false)
}
}

Expand Down Expand Up @@ -114,7 +114,7 @@ final class FileWatcher {
let box = Unmanaged<WeakBox<FileWatcher>>.fromOpaque(info).takeUnretainedValue()
guard let watcher = box.value else { return }
MainActor.assumeIsolated {
watcher.debounceNotify()
watcher.debounceNotify(invalidateStatsCache: false, invalidateSessionLog: true)
}
}

Expand Down Expand Up @@ -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()
}
}
Expand Down
84 changes: 84 additions & 0 deletions Tests/AIBatteryCoreTests/Models/ProjectTokenSummaryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion spec/DATA_LAYER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down