diff --git a/EFFORT-LOG.md b/EFFORT-LOG.md index d4e1b26..7863688 100644 --- a/EFFORT-LOG.md +++ b/EFFORT-LOG.md @@ -1,5 +1,16 @@ # CodeCaps — Effort Log +## 2026-10-03 — Independent Provider and File Refresh [CODEX, PR #142] + +repo: CodeCaps; pre-work claim: posted to #agent-sync after reading AGENT-SYNC.md; Lane: `codex/independent-source-refresh`; Board `4bcf84f1`; GitHub #137; PR #142 open on this branch (depends on #139; refs #136). + +- Added `CodexSessionQuotaReader` for bounded passive Codex session JSONL reads with exact account identity, incremental append tracking, symlink-safe paths, and original event timestamps. Unchanged files do not invoke provider, Fleet, upload, or download work. +- Split Settings → Sources & Fleet into independent **Provider Checks** and **Codex Session File Checks** toggles with separate 1-, 3-, 5-, and 15-minute intervals (five-minute provider default, one-minute file default). Manual refresh runs both enabled paths; disabling one source preserves the other when possible. +- Integrated scheduling, merge rules, and cancellation in `MonitorModel` / `SourceRefreshSettings`; documented behavior in `docs/REFRESH.md`. +- Verification: `CodexSessionQuotaReaderTests`, `SourceRefreshTests`, and related refresh tests on the branch; hosted Swift CI green. Native Mac UI claims rely on code review and CI fixture rendering in `DocsScreenshotTests`, not supplied or manually captured screenshots. + +--- + ## 2026-10-03 — Audit 9 Residual Reconciliation [CODEX, in progress] Lane: `codex/audit-residuals`. Board `1ac04ba99f13478da0564f802d3af3e8`; GitHub #19. diff --git a/Sources/CodeCaps/ConsoleViews.swift b/Sources/CodeCaps/ConsoleViews.swift index 4c21752..694c6e1 100644 --- a/Sources/CodeCaps/ConsoleViews.swift +++ b/Sources/CodeCaps/ConsoleViews.swift @@ -231,7 +231,6 @@ final class ConsoleState: ObservableObject { unavailableAlert = nil pendingAlert = nil } - func clearHistoryFocus() { selectedWindowId = nil selectedTimestamp = nil diff --git a/Sources/CodeCaps/MonitorModel.swift b/Sources/CodeCaps/MonitorModel.swift index 103348c..4f33d57 100644 --- a/Sources/CodeCaps/MonitorModel.swift +++ b/Sources/CodeCaps/MonitorModel.swift @@ -251,6 +251,20 @@ final class MonitorModel: ObservableObject { // Local & Remote Reading @Published private(set) var localEnabled: Bool + @Published private(set) var providerChecksEnabled: Bool + @Published private(set) var sessionFileChecksEnabled: Bool + @Published var providerCheckCadence: SourceRefreshCadence { + didSet { + defaults.set(providerCheckCadence.rawValue, forKey: SourceRefreshPreference.providerMinutes) + scheduleSourceTimers() + } + } + @Published var sessionFileCadence: SourceRefreshCadence { + didSet { + defaults.set(sessionFileCadence.rawValue, forKey: SourceRefreshPreference.sessionMinutes) + scheduleSourceTimers() + } + } @Published private(set) var serverEnabled: Bool @Published private(set) var endpoint: String @Published private(set) var hasSavedToken: Bool @@ -331,10 +345,19 @@ final class MonitorModel: ObservableObject { private let defaults: UserDefaults private let burnRateHistoryURL: URL + /// Memoized historySamples(): the burn-rate file is parsed once per + /// on-disk change instead of once per view construction (UsageHistoryView + /// init runs on every SwiftUI body evaluation). Keyed on the file's + /// modification date + size; appends and trims both change those. + private var historySamplesCache: (modification: Date?, size: Int, samples: [AnomalyDetector.Sample])? private var lastRunawayAlertAt: [String: Double] = [:] private var hasCurrentLocalRead = false var localResultForTesting: LocalQuotaResult? var localReadForTesting: (@MainActor () async -> LocalQuotaResult?)? + var sessionFileReadForTesting: (@MainActor () async -> LocalQuotaResult)? + var sessionAccountIDForTesting: (@MainActor () async -> String?)? + var handoffWriteForTesting: (([QuotaWindow]) -> Void)? + var widgetWriteForTesting: (([QuotaWindow]) -> Void)? var serverFetchForTesting: (@MainActor () async throws -> QuotaResponse)? var syncTokenReadForTesting: (@MainActor () async -> String?)? var readTokenReadForTesting: (@MainActor () async -> String?)? @@ -343,11 +366,20 @@ final class MonitorModel: ObservableObject { var runawayNotificationForTesting: ((BurnRateNotification) -> Void)? var skipsSnapshotIOForTesting = false private var localWindows: [QuotaWindow] = [] + private var providerResult: LocalQuotaResult? + private var sessionFileResult: LocalQuotaResult? + private var currentCodexAccountID: String? + private let sessionFileReader = CodexSessionQuotaReader() private var serverWindows: [QuotaWindow] = [] @Published private(set) var fleetWindowGroups: [FleetWindowGroup] = [] private var refreshTimer: Timer? + private var sessionFileTimer: Timer? + private var hasStarted = false private var clockTimer: Timer? private var request: Task? + private var sessionFileRequest: Task? + var sessionFileTaskForTesting: Task? { sessionFileRequest } + private var sessionFileRevision = 0 var refreshTaskForTesting: Task? { request } private var revision = 0 private var pushRevision = 0 @@ -418,6 +450,12 @@ final class MonitorModel: ObservableObject { } disabledSources = Set((defaults.stringArray(forKey: "disabledSources") ?? [])) localEnabled = defaults.object(forKey: "localEnabled") as? Bool ?? true + providerChecksEnabled = SourceRefreshPreference.enabled(SourceRefreshPreference.providerEnabled, defaults: defaults) + sessionFileChecksEnabled = SourceRefreshPreference.enabled(SourceRefreshPreference.sessionEnabled, defaults: defaults) + providerCheckCadence = SourceRefreshPreference.cadence(SourceRefreshPreference.providerMinutes, + fallback: .five, defaults: defaults) + sessionFileCadence = SourceRefreshPreference.cadence(SourceRefreshPreference.sessionMinutes, + fallback: .one, defaults: defaults) serverEnabled = defaults.bool(forKey: "serverEnabled") hasSavedToken = defaults.bool(forKey: "hasSavedToken") syncEnabled = defaults.bool(forKey: "syncEnabled") @@ -467,7 +505,16 @@ final class MonitorModel: ObservableObject { } func historySamples() -> [AnomalyDetector.Sample] { - BurnRateMonitor.loadSamples(historyURL: burnRateHistoryURL) + let attrs = try? FileManager.default.attributesOfItem(atPath: burnRateHistoryURL.path) + let modification = attrs?[.modificationDate] as? Date + let size = (attrs?[.size] as? Int) ?? -1 + if let cache = historySamplesCache, + cache.modification == modification, cache.size == size { + return cache.samples + } + let samples = BurnRateMonitor.loadSamples(historyURL: burnRateHistoryURL) + historySamplesCache = (modification, size, samples) + return samples } func hasLocalHistorySource(for row: DisplaySection) -> Bool { @@ -860,10 +907,9 @@ final class MonitorModel: ObservableObject { } func start() { + hasStarted = true refresh() - refreshTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in - Task { @MainActor in self?.refresh() } - } + scheduleSourceTimers() clockTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in Task { @MainActor in guard let self else { return } @@ -886,15 +932,33 @@ final class MonitorModel: ObservableObject { } func stop() { + hasStarted = false revision += 1 request?.cancel() request = nil + invalidateSessionFileRefresh() cancelPendingPush() isRefreshing = false refreshTimer?.invalidate() + sessionFileTimer?.invalidate() clockTimer?.invalidate() } + private func scheduleSourceTimers() { + refreshTimer?.invalidate() + sessionFileTimer?.invalidate() + guard hasStarted else { return } + refreshTimer = Timer.scheduledTimer(withTimeInterval: providerCheckCadence.seconds, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, self.providerChecksEnabled || self.serverEnabled else { return } + self.refreshProviderChecks() + } + } + sessionFileTimer = Timer.scheduledTimer(withTimeInterval: sessionFileCadence.seconds, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refreshSessionFiles() } + } + } + func movePlatformUp(providerKey: String) { var current = platformOrder.isEmpty ? sections.map(\.providerKey) : platformOrder guard let idx = current.firstIndex(of: providerKey), idx > 0 else { return } @@ -1056,17 +1120,47 @@ final class MonitorModel: ObservableObject { func setLocalEnabled(_ value: Bool) { guard value != localEnabled else { return } invalidateRefresh() + invalidateSessionFileRefresh() localEnabled = value defaults.set(value, forKey: "localEnabled") if !value { hasCurrentLocalRead = false + providerResult = nil + sessionFileResult = nil + currentCodexAccountID = nil localWindows = [] activeRunawayAnomalies = [] if !skipsSnapshotIOForTesting { try? LocalQuotaSnapshot.remove() } + rebuildLocalState(recordSamples: false) } refresh() } + func setProviderChecksEnabled(_ value: Bool) { + guard value != providerChecksEnabled else { return } + invalidateRefresh() + providerChecksEnabled = value + defaults.set(value, forKey: SourceRefreshPreference.providerEnabled) + if !value { providerResult = nil } + rebuildLocalState(recordSamples: false) + if value || serverEnabled { refreshProviderChecks() } + } + + func setSessionFileChecksEnabled(_ value: Bool) { + guard value != sessionFileChecksEnabled else { return } + invalidateRefresh() + invalidateSessionFileRefresh() + sessionFileChecksEnabled = value + defaults.set(value, forKey: SourceRefreshPreference.sessionEnabled) + if value { + refreshSessionFiles() + } else { + sessionFileResult = nil + rebuildLocalState(recordSamples: false) + } + if providerChecksEnabled || serverEnabled { refreshProviderChecks() } + } + /// Turns push sharing off without needing a valid endpoint. Turning it on /// always goes through `saveSyncSettings`, which validates the endpoint. func disableSync() { @@ -1086,6 +1180,7 @@ final class MonitorModel: ObservableObject { serverWindows = [] fleetWindowGroups = [] serverError = nil + rebuildLocalState(recordSamples: false) refresh() } @@ -1200,6 +1295,7 @@ final class MonitorModel: ObservableObject { guard let url = URL(string: value), QuotaClient.isAllowedEndpoint(url) else { throw QuotaClientError.invalidEndpoint } let cleanToken = sanitizedToken(token) invalidateRefresh() + invalidateSessionFileRefresh() if !cleanToken.isEmpty { guard !cleanToken.contains("\n"), !cleanToken.contains("\r") else { throw QuotaClientError.invalidToken } try await TokenStore.save(cleanToken, server: value, service: TokenStore.readService) @@ -1226,6 +1322,7 @@ final class MonitorModel: ObservableObject { // A timer refresh can start while the token read above is suspended. // Invalidate it again before installing the new read modes. invalidateRefresh() + invalidateSessionFileRefresh() localEnabled = local serverEnabled = server endpoint = value @@ -1236,6 +1333,9 @@ final class MonitorModel: ObservableObject { defaults.set(server, forKey: "serverEnabled") defaults.set(value, forKey: "endpoint") localWindows = [] + providerResult = nil + sessionFileResult = nil + currentCodexAccountID = nil hasCurrentLocalRead = false activeRunawayAnomalies = [] serverWindows = [] @@ -1475,6 +1575,12 @@ final class MonitorModel: ObservableObject { isRefreshing = false } + private func invalidateSessionFileRefresh() { + sessionFileRevision += 1 + sessionFileRequest?.cancel() + sessionFileRequest = nil + } + private func cancelPendingPush() { pushRevision += 1 pushTask?.cancel() @@ -1485,10 +1591,15 @@ final class MonitorModel: ObservableObject { // MARK: - Refresh Loop func refresh() { + if providerChecksEnabled || serverEnabled { refreshProviderChecks() } + refreshSessionFiles() + } + + func refreshProviderChecks() { guard !isRefreshing else { return } isRefreshing = true let generation = revision - let useLocal = localEnabled + let useLocal = localEnabled && providerChecksEnabled let useServer = serverEnabled let currentEndpoint = endpoint request = Task { [weak self] in @@ -1528,7 +1639,10 @@ final class MonitorModel: ObservableObject { } } } - let local = await localRead + let providerRead = await localRead + let currentAccount: String? + if useLocal { currentAccount = await self?.codexAccountID() } + else { currentAccount = nil } guard !Task.isCancelled, let self, self.revision == generation else { return } if useServer { self.readTokenState = SavedTokenState.resolve(hasSavedFlag: self.hasSavedToken, @@ -1536,6 +1650,9 @@ final class MonitorModel: ObservableObject { } self.now = Date() self.lastChecked = self.now + if useLocal { self.currentCodexAccountID = currentAccount } + self.providerResult = providerRead + let local = self.currentLocalResult() if let local { self.issues = local.issues self.consentNeeded = local.consentNeeded @@ -1558,9 +1675,11 @@ final class MonitorModel: ObservableObject { // `issues` is still the local read's own map here — the server // failure below is merged in afterwards and must never reach a // file that promises local-only readings. - if self.skipsSnapshotIOForTesting { + if let writeForTesting = self.handoffWriteForTesting { + writeForTesting(self.localWindows) + } else if self.skipsSnapshotIOForTesting { self.handoffError = nil - } else if useLocal { + } else if self.localEnabled && local != nil { try LocalQuotaSnapshot.write(windows: self.localWindows, issues: self.issues, customMarks: exportedCustomMarks(), now: self.now) } else { try LocalQuotaSnapshot.remove() } @@ -1570,7 +1689,7 @@ final class MonitorModel: ObservableObject { } // Push to remote server if enabled - if self.syncEnabled && !self.localWindows.isEmpty { + if useLocal && self.syncEnabled && !self.localWindows.isEmpty { _ = await self.pushQuotasIfEnabled(windows: self.localWindows) } @@ -1610,28 +1729,7 @@ final class MonitorModel: ObservableObject { self.originByProvider = origins if newServer != nil { self.lastPullTime = self.now } self.response = QuotaResponse(generatedAt: ISO8601DateFormatter().string(from: self.now), windows: merged) - if !self.skipsSnapshotIOForTesting { - do { - let widgetCandidates = merged + split.groups.flatMap(\.windows) - let visibleProviderKeys = Set(QuotaResponse(generatedAt: "", windows: widgetCandidates) - .platformSections(now: self.now).map(\.providerKey)) - let widgetWindows = widgetCandidates.filter { - visibleProviderKeys.contains($0.canonicalProviderKey) - && !self.disabledSources.contains($0.source ?? "") - && !$0.isSupplementaryVideoQuota - } - try LocalQuotaSnapshot.writeWidgetSnapshot(windows: widgetWindows, - customMarks: self.exportedCustomMarks(), now: self.now) - self.widgetSharingError = nil - UserDefaults(suiteName: LocalQuotaSnapshot.appGroupId)?.set(self.platformOrder, forKey: "platformOrder") - #if canImport(WidgetKit) - WidgetCenter.shared.reloadAllTimelines() - #endif - } catch { - self.widgetSharingError = "Widgets cannot access the shared quota cache." + sentenceGap - + "Install a build with native widget sharing enabled." - } - } + self.publishWidgetSnapshot(candidates: merged + split.groups.flatMap(\.windows)) self.refreshRunawayUsageState(recordSamples: local != nil) self.alarmManager.evaluate(observations: self.resetAlarmObservationsForCurrentReadings(), now: self.now) self.isRefreshing = false @@ -1639,6 +1737,167 @@ final class MonitorModel: ObservableObject { } } + func refreshSessionFiles() { + guard localEnabled, sessionFileChecksEnabled, sessionFileRequest == nil else { return } + // An injected model read must not silently start a real auth/session + // file scan from the parallel timer in an offline test. + if sessionFileReadForTesting == nil, + skipsSnapshotIOForTesting || localReadForTesting != nil || localResultForTesting != nil + || serverFetchForTesting != nil { return } + let generation = sessionFileRevision + let reader = sessionFileReader + let readForTesting = sessionFileReadForTesting + sessionFileRequest = Task { [weak self] in + let result = if let readForTesting { + await readForTesting() + } else { + await reader.read() + } + let currentAccount = await self?.codexAccountID() + guard !Task.isCancelled, let self, self.sessionFileRevision == generation, + self.localEnabled, self.sessionFileChecksEnabled else { return } + self.sessionFileRequest = nil + let accountChanged = self.currentCodexAccountID != currentAccount + self.currentCodexAccountID = currentAccount + guard accountChanged || result != self.sessionFileResult else { return } + self.sessionFileResult = result + self.rebuildLocalState(recordSamples: true) + } + } + + private func codexAccountID() async -> String? { + if let sessionAccountIDForTesting { return await sessionAccountIDForTesting() } + if skipsSnapshotIOForTesting || localReadForTesting != nil || localResultForTesting != nil + || sessionFileReadForTesting != nil || serverFetchForTesting != nil { return nil } + return await sessionFileReader.currentAccountID() + } + + private func currentLocalResult() -> LocalQuotaResult? { + guard localEnabled else { return nil } + let provider = providerChecksEnabled ? providerResult : nil + let file = sessionFileChecksEnabled ? sessionFileResult : nil + guard provider != nil || file != nil else { return nil } + let providerWindows = (provider?.windows ?? []).filter { + $0.canonicalProviderKey != "openai" || (currentCodexAccountID != nil && $0.accountKey == currentCodexAccountID) + } + let fileWindows = (file?.windows ?? []).filter { + $0.canonicalProviderKey != "openai" || (currentCodexAccountID != nil && $0.accountKey == currentCodexAccountID) + } + let windows = Self.reconcileLocalWindows(provider: providerWindows, session: fileWindows) + var issues = provider?.issues ?? [:] + for (key, message) in file?.issues ?? [:] where issues[key] == nil { + issues[key] = message + } + if !fileWindows.filter({ $0.boundedRemainingPercent != nil }).isEmpty { + issues["openai"] = nil + } + if currentCodexAccountID == nil { + issues["openai"] = "Codex is not signed in locally." + } + return LocalQuotaResult(windows: windows, issues: issues, + consentNeeded: provider?.consentNeeded ?? []) + .droppingSupersededPlaceholders() + } + + static func reconcileLocalWindows(provider: [QuotaWindow], session: [QuotaWindow]) -> [QuotaWindow] { + let liveProviderCodex = provider.filter { + $0.canonicalProviderKey == "openai" && $0.boundedRemainingPercent != nil + } + let providerAccount = liveProviderCodex.first?.accountKey + var resolved = provider + for fileWindow in session { + guard fileWindow.canonicalProviderKey == "openai" else { continue } + // A provider reading with unknown or different account identity + // cannot be replaced by a session event from another login. + if !liveProviderCodex.isEmpty && (providerAccount == nil || fileWindow.accountKey != providerAccount) { + continue + } + if let index = resolved.firstIndex(where: { $0.id == fileWindow.id }) { + let current = resolved[index] + guard current.boundedRemainingPercent == nil + || (fileWindow.occurredDate ?? .distantPast) > (current.occurredDate ?? .distantPast) + else { continue } + resolved[index] = fileWindow + } else { + resolved.append(fileWindow) + } + } + return resolved + } + + /// File checks publish only changed local readings. Fleet pull and push + /// stay on the provider/manual path, so a one-minute file poll is passive. + private func rebuildLocalState(recordSamples: Bool) { + let local = currentLocalResult() + if recordSamples { + // Only a real read advances the check clock. Preference toggles + // rebuild state with no I/O; stamping lastChecked there made + // ConsoleState.reconcile treat the toggle as a completed read and + // abandon the saved-platform wait. (`now` itself is still kept + // fresh by the 30-second clock timer.) + now = Date() + lastChecked = now + } + issues = local?.issues ?? [:] + consentNeeded = local?.consentNeeded ?? [] + localWindows = AntigravityQuotaGroups.normalize(local?.windows ?? []) + hasCurrentLocalRead = local != nil + let localProviders = Set(localWindows.map(\.canonicalProviderKey)) + let split = FleetOrigin.split(serverWindows) + let ownPush = split.ownPush + let adopted = ownPush.filter { !localProviders.contains($0.canonicalProviderKey) } + let merged = localWindows + adopted + originByProvider = Dictionary(uniqueKeysWithValues: Set(merged.map(\.canonicalProviderKey)).map { ($0, .local) }) + response = QuotaResponse(generatedAt: ISO8601DateFormatter().string(from: now), windows: merged) + if let writeForTesting = handoffWriteForTesting { + writeForTesting(localWindows) + handoffError = nil + } else if !skipsSnapshotIOForTesting { + do { + if localEnabled { + try LocalQuotaSnapshot.write(windows: localWindows, issues: issues, + customMarks: exportedCustomMarks(), now: now) + } else { + try LocalQuotaSnapshot.remove() + } + handoffError = nil + } catch { + handoffError = "BotFleet quota sharing is unavailable." + } + } + publishWidgetSnapshot(candidates: merged + split.groups.flatMap(\.windows)) + refreshRunawayUsageState(recordSamples: recordSamples) + alarmManager.evaluate(observations: resetAlarmObservationsForCurrentReadings(), now: now) + } + + private func publishWidgetSnapshot(candidates: [QuotaWindow]) { + let visibleProviderKeys = Set(QuotaResponse(generatedAt: "", windows: candidates) + .platformSections(now: now).map(\.providerKey)) + let windows = candidates.filter { + visibleProviderKeys.contains($0.canonicalProviderKey) + && !disabledSources.contains($0.source ?? "") + && !$0.isSupplementaryVideoQuota + } + if let writeForTesting = widgetWriteForTesting { + writeForTesting(windows) + widgetSharingError = nil + return + } + guard !skipsSnapshotIOForTesting else { return } + do { + try LocalQuotaSnapshot.writeWidgetSnapshot(windows: windows, + customMarks: exportedCustomMarks(), now: now) + widgetSharingError = nil + UserDefaults(suiteName: LocalQuotaSnapshot.appGroupId)?.set(platformOrder, forKey: "platformOrder") + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } catch { + widgetSharingError = "Widgets cannot access the shared quota cache." + sentenceGap + + "Install a build with native widget sharing enabled." + } + } + private func refreshRunawayUsageState(recordSamples: Bool) { guard localEnabled, hasCurrentLocalRead else { activeRunawayAnomalies = [] diff --git a/Sources/CodeCaps/SettingsViews.swift b/Sources/CodeCaps/SettingsViews.swift index 6737c1c..e8efa17 100644 --- a/Sources/CodeCaps/SettingsViews.swift +++ b/Sources/CodeCaps/SettingsViews.swift @@ -295,6 +295,24 @@ struct SettingsSourcesFleetPage: View { Toggle("Read Quotas From This Mac", isOn: Binding(get: { model.localEnabled }, set: { model.setLocalEnabled($0) })) + Toggle("Provider Checks", isOn: Binding( + get: { model.providerChecksEnabled }, set: { model.setProviderChecksEnabled($0) })) + .disabled(!model.localEnabled) + Picker("Provider Check Interval", selection: $model.providerCheckCadence) { + ForEach(SourceRefreshCadence.allCases) { cadence in + Text(cadence.title).tag(cadence) + } + } + .disabled(!model.localEnabled || !model.providerChecksEnabled) + Toggle("Codex Session File Checks", isOn: Binding( + get: { model.sessionFileChecksEnabled }, set: { model.setSessionFileChecksEnabled($0) })) + .disabled(!model.localEnabled) + Picker("Session File Check Interval", selection: $model.sessionFileCadence) { + ForEach(SourceRefreshCadence.allCases) { cadence in + Text(cadence.title).tag(cadence) + } + } + .disabled(!model.localEnabled || !model.sessionFileChecksEnabled) ForEach(ReaderStatus.all, id: \.providerKey) { reader in readerRow(reader) } @@ -304,6 +322,10 @@ struct SettingsSourcesFleetPage: View { VStack(alignment: .leading, spacing: 4) { Text("CodeCaps reads each CLI's own saved credentials in place." + sentenceGap + "It never asks you for a provider API key.") + Text("Provider Checks: 7 HTTP paths and 3 local helpers across 8 AI plan families." + sentenceGap + + "These are source capabilities, not a request count per check.") + Text("Codex Session File Checks: 1 local quota source." + sentenceGap + + "File checks do not upload or download on their own.") Text("A snapshot is written to ~/Library/Application Support/Usage Monitor/quota-windows.json for BotFleet.") if let widgetSharingError = model.widgetSharingError { Text(widgetSharingError).foregroundStyle(Theme.warning) @@ -1136,6 +1158,15 @@ struct SettingsNotificationsPage: View { var body: some View { SettingsPage { + if let row = model.displaySections.first(where: { + $0.providerKey == model.runawayAlertHistory.first?.providerKey + }) ?? model.displaySections.first { + Section { + UsageHistoryView(model: model, state: state, row: row) + } header: { + Eyebrow("RECENT USAGE HISTORY") + } + } Section { Toggle("Reset Alarms For All Providers", isOn: $model.alarmsAll) .help("The same switch as the All bell at the top of the Docked Bar.") diff --git a/Sources/CodeCaps/SourceRefreshSettings.swift b/Sources/CodeCaps/SourceRefreshSettings.swift new file mode 100644 index 0000000..737244e --- /dev/null +++ b/Sources/CodeCaps/SourceRefreshSettings.swift @@ -0,0 +1,28 @@ +import Foundation + +enum SourceRefreshCadence: Int, CaseIterable, Identifiable { + case one = 1 + case three = 3 + case five = 5 + case fifteen = 15 + + var id: Int { rawValue } + var seconds: TimeInterval { TimeInterval(rawValue * 60) } + var title: String { "Every \(rawValue) minute\(rawValue == 1 ? "" : "s")" } +} + +enum SourceRefreshPreference { + static let providerEnabled = "providerChecksEnabled" + static let sessionEnabled = "sessionFileChecksEnabled" + static let providerMinutes = "providerCheckMinutes" + static let sessionMinutes = "sessionFileCheckMinutes" + + static func enabled(_ key: String, defaults: UserDefaults) -> Bool { + defaults.object(forKey: key) as? Bool ?? true + } + + static func cadence(_ key: String, fallback: SourceRefreshCadence, + defaults: UserDefaults) -> SourceRefreshCadence { + SourceRefreshCadence(rawValue: defaults.integer(forKey: key)) ?? fallback + } +} diff --git a/Sources/CodeCaps/UsageHistoryViews.swift b/Sources/CodeCaps/UsageHistoryViews.swift index ab7a720..d4e687d 100644 --- a/Sources/CodeCaps/UsageHistoryViews.swift +++ b/Sources/CodeCaps/UsageHistoryViews.swift @@ -33,8 +33,6 @@ struct UsageHistoryView: View { self.model = model self.state = state self.row = row - // Offscreen AppKit snapshots can draw before SwiftUI calls onAppear. - _samples = State(initialValue: model.historySamples()) } private var now: Date { model.now } @@ -128,7 +126,7 @@ struct UsageHistoryView: View { VStack(alignment: .leading, spacing: 2) { Text("Usage History") .font(.system(size: 16, weight: .semibold)) - Text("Quota remaining · local readings") + Text("\(row.title) · Quota remaining · local readings") .font(.system(size: 11)) .foregroundStyle(.secondary) } diff --git a/Sources/QuotaCore/AnomalyDetector.swift b/Sources/QuotaCore/AnomalyDetector.swift index c3e5587..3583708 100644 --- a/Sources/QuotaCore/AnomalyDetector.swift +++ b/Sources/QuotaCore/AnomalyDetector.swift @@ -243,10 +243,13 @@ public struct AnomalyDetector: Sendable { encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.withoutEscapingSlashes] let fm = FileManager.default - let existing = try load() - var seen = Set(existing.map { - SampleKey(pair: PairKey(provider: $0.providerKey, window: $0.windowId), time: $0.observedAt) - }) + // Dedup against a tail read only: decoding the whole file here ran + // a multi-megabyte parse on the main thread on every recorded + // refresh. Duplicates can only come from re-appending a recent + // batch (identical observedAt timestamps), so the tail is + // sufficient; load() dedups by key on read anyway, making a missed + // older duplicate harmless. + var seen = tailSampleKeys(maxBytes: 64 * 1024) let fresh = samples.filter { sample in guard sample.observedAt.timeIntervalSinceReferenceDate.isFinite, let percent = sample.remainingPercent, @@ -285,9 +288,30 @@ public struct AnomalyDetector: Sendable { } } + /// SampleKeys decoded from the last `maxBytes` of the history file, for + /// append-time dedup without a full parse. The first line of the + /// slice may be cut mid-line and is skipped. + private func tailSampleKeys(maxBytes: Int) -> Set { + guard let full = try? Data(contentsOf: url, options: .mappedIfSafe), !full.isEmpty else { return [] } + let sliced = full.count > maxBytes + let tail: Data = sliced ? full.suffix(maxBytes) : full + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var seen = Set() + var lines = tail.split(separator: 0x0A, omittingEmptySubsequences: true) + if sliced { lines = Array(lines.dropFirst()) } + for line in lines { + if let sample = try? decoder.decode(Sample.self, from: Data(line)) { + seen.insert(SampleKey(pair: PairKey(provider: sample.providerKey, + window: sample.windowId), + time: sample.observedAt)) + } + } + return seen + } + /// Load every sample currently on disk. - public func load() throws -> [Sample] { - guard FileManager.default.fileExists(atPath: url.path) else { return [] } + public func load() throws -> [Sample] { guard FileManager.default.fileExists(atPath: url.path) else { return [] } let data = try Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 diff --git a/Sources/QuotaCore/CodexSessionQuotaReader.swift b/Sources/QuotaCore/CodexSessionQuotaReader.swift new file mode 100644 index 0000000..c686f7f --- /dev/null +++ b/Sources/QuotaCore/CodexSessionQuotaReader.swift @@ -0,0 +1,342 @@ +import Darwin +import Foundation + +/// Reads quota events written by the signed-in Codex CLI. It never reads +/// conversation content beyond a bounded JSON line and never contacts Codex. +public actor CodexSessionQuotaReader { + private let homeDirectory: URL + private let now: @Sendable () -> Date + private var accountID: String? + private var cursors: [URL: Cursor] = [:] + private var latest: [String: Observation] = [:] + + private static let maxDays = 7 + private static let maxFiles = 64 + private static let maxPrefix = 16_384 + private static let maxRead = 262_144 + private static let maxTotalRead = 2_097_152 + private static let maxLine = 65_536 + private static let maxAuth = 1_048_576 + private static let maxAge: TimeInterval = 86_400 + private static let futureTolerance: TimeInterval = 120 + + public init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + now: @escaping @Sendable () -> Date = { Date() }) { + let path = Self.canonicalPath(homeDirectory.path) + self.homeDirectory = URL(fileURLWithPath: path, isDirectory: true) + self.now = now + } + + public func read() async -> LocalQuotaResult { + let instant = now() + guard let current = readAccountID() else { + accountID = nil + cursors.removeAll() + latest.removeAll() + return LocalQuotaResult(issues: ["openai": "Codex is not signed in locally."]) + } + if accountID != current { + accountID = current + cursors.removeAll() + latest.removeAll() + } + + var budget = Self.maxTotalRead + let files = recentFiles(at: instant) + for file in files where budget > 0 { + scan(file, accountID: current, at: instant, budget: &budget) + } + cursors = cursors.filter { files.contains($0.key) } + latest = latest.filter { _, item in isFresh(item, at: instant) } + let windows = latest.values.map(\.window).sorted { $0.id < $1.id } + return windows.isEmpty + ? LocalQuotaResult(issues: ["openai": "No recent Codex session quota is available."]) + : LocalQuotaResult(windows: windows) + } + + /// Recheck the bounded local auth identity after an asynchronous quota read. + /// A login switch during a read must not publish the previous account's quotas. + public func currentAccountID() -> String? { readAccountID() } + + private struct Cursor { + let device: UInt64 + let inode: UInt64 + var offset: Int64 + var modificationNanoseconds: Int64 + let matchesAccount: Bool + } + + private struct Observation { + let window: QuotaWindow + let date: Date + let reset: Date? + let file: URL + } + + private func readAccountID() -> String? { + let url = homeDirectory.appendingPathComponent(".codex/auth.json") + guard let data = boundedFile(url, maxBytes: Self.maxAuth), + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let tokens = root["tokens"] as? [String: Any], + let account = tokens["account_id"] as? String, + !account.isEmpty, account.utf8.count <= 256 else { return nil } + return account + } + + private func recentFiles(at instant: Date) -> [URL] { + let root = homeDirectory.appendingPathComponent(".codex/sessions") + guard isDirectoryWithoutSymlink(root) else { return [] } + let calendar = Calendar(identifier: .gregorian) + var days: [URL] = [] + for offset in 0.. $1.path }.prefix(Self.maxFiles)) + } + + private enum FileKind: Equatable { case regular, directory } + + private func secureStat(_ url: URL, required: FileKind) -> stat? { + let base = homeDirectory.path + let path = url.path + guard path.hasPrefix(base + "/") else { return nil } + var current = "/" + var info = stat() + for component in base.split(separator: "/") { + current += (current == "/" ? "" : "/") + String(component) + guard Darwin.lstat(current, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR else { return nil } + } + for component in path.dropFirst(base.count + 1).split(separator: "/") { + current += "/" + String(component) + guard Darwin.lstat(current, &info) == 0 else { return nil } + let kind = info.st_mode & S_IFMT + if current == path { + guard kind == (required == .regular ? S_IFREG : S_IFDIR) else { return nil } + } else if kind != S_IFDIR { return nil } + } + return info + } + + private func isDirectoryWithoutSymlink(_ url: URL) -> Bool { + secureStat(url, required: .directory) != nil + } + + private func boundedFile(_ url: URL, maxBytes: Int) -> Data? { + guard let info = secureStat(url, required: .regular), info.st_size >= 0, + info.st_size <= maxBytes else { return nil } + let fd = Darwin.open(url.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { return nil } + defer { Darwin.close(fd) } + var opened = stat() + guard Darwin.fstat(fd, &opened) == 0, + opened.st_dev == info.st_dev, opened.st_ino == info.st_ino, + opened.st_size <= maxBytes else { return nil } + return read(fd, from: 0, count: Int(opened.st_size)) + } + + private func read(_ fd: Int32, from offset: Int64, count: Int) -> Data? { + guard count >= 0 else { return nil } + var data = Data(count: count) + let received = data.withUnsafeMutableBytes { bytes in + Darwin.pread(fd, bytes.baseAddress, count, off_t(offset)) + } + guard received >= 0 else { return nil } + return Data(data.prefix(received)) + } + + private func scan(_ url: URL, accountID: String, at instant: Date, budget: inout Int) { + guard let info = secureStat(url, required: .regular), info.st_size >= 0 else { return } + let fd = Darwin.open(url.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { return } + defer { Darwin.close(fd) } + var opened = stat() + guard Darwin.fstat(fd, &opened) == 0, + opened.st_dev == info.st_dev, opened.st_ino == info.st_ino, + (opened.st_mode & S_IFMT) == S_IFREG else { return } + let size = Int64(opened.st_size) + let modification = Int64(opened.st_mtimespec.tv_sec) * 1_000_000_000 + + Int64(opened.st_mtimespec.tv_nsec) + var cursor = cursors[url] + if cursor?.device != UInt64(opened.st_dev) || cursor?.inode != UInt64(opened.st_ino) + || (cursor?.offset ?? 0) > size + || (cursor?.offset == size && cursor?.modificationNanoseconds != modification) { + cursor = nil + latest = latest.filter { $0.value.file != url } + } + if cursor == nil { + let count = min(Int(size), Self.maxPrefix, budget) + guard let prefix = read(fd, from: 0, count: count) else { return } + budget -= prefix.count + guard let newline = prefix.firstIndex(of: 10) else { + // Empty or still-flushing file: no complete first line to + // identify the session yet. Don't cache a rejection — a + // session observed before its first line is written would + // otherwise stay invisible for the life of the inode. + return + } + guard newline <= Self.maxLine else { + // First line exceeds the readable bound: not a session_meta + // header. The identity definitively does not match. + cursor = Cursor(device: UInt64(opened.st_dev), inode: UInt64(opened.st_ino), offset: 0, + modificationNanoseconds: modification, + matchesAccount: false) + cursors[url] = cursor + return + } + let matches = metadataAccount(in: Data(prefix[.. 0 else { return } + let isInitial = active.offset == 0 + let available = size - active.offset + guard available > 0 else { cursors[url] = active; return } + let length = min(Int(available), Self.maxRead, budget) + let start = isInitial ? max(0, size - Int64(length)) : max(active.offset, size - Int64(length)) + guard let data = read(fd, from: start, count: Int(size - start)) else { return } + budget -= data.count + var lineStart = 0 + // A retained cursor starts at a complete-line boundary (or the start + // of a partial line). Skip a fragment only when a bounded tail read + // actually jumped past that cursor. + if start > active.offset { + guard let boundary = data.firstIndex(of: 10) else { + active.offset = size + cursors[url] = active + return + } + lineStart = boundary + 1 + } + var consumed = lineStart + while lineStart < data.count, let end = data[lineStart...].firstIndex(of: 10) { + if end - lineStart <= Self.maxLine { + ingest(Data(data[lineStart.. Self.maxLine { active.offset = size } + cursors[url] = active + } + + private func metadataAccount(in data: Data) -> String? { + guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + root["type"] as? String == "session_meta", + let payload = root["payload"] as? [String: Any] else { return nil } + return payload["creator_account_id"] as? String + } + + private func ingest(_ data: Data, from file: URL, at instant: Date, accountID: String) { + guard !data.isEmpty, + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + root["type"] as? String == "event_msg", + let payload = root["payload"] as? [String: Any], + payload["type"] as? String == "token_count", + let timestamp = root["timestamp"] as? String, + let observed = Self.parseDate(timestamp), + instant.timeIntervalSince(observed) >= -Self.futureTolerance, + instant.timeIntervalSince(observed) <= Self.maxAge, + let limits = payload["rate_limits"] as? [String: Any], + (limits["limit_id"] == nil || limits["limit_id"] as? String == "codex") else { return } + for slot in ["primary", "secondary"] { + guard let values = limits[slot] as? [String: Any], + let percent = Self.percent(values) else { continue } + let seconds = Self.number(values["limit_window_seconds"]) + ?? Self.number(values["window_minutes"]).map { $0 * 60 } + let cadence = seconds.flatMap(Self.windowToken) + let reset = Self.reset(values, observed: observed) + let name = cadence.map { "\($0) window" } ?? "\(slot.capitalized) window" + let window = QuotaWindow( + id: "local-mac:openai:\(slot)", provider: "Codex", providerKey: "openai", + providerLabel: "Codex", sourceApp: "local-mac", label: name, + remainingPercent: percent, resetAt: reset.map(Self.iso), window: cadence, + occurredAt: Self.iso(observed), source: "Codex Session Files", accountKey: accountID + ).normalizedForExport() + let item = Observation(window: window, date: observed, reset: reset, file: file) + if let previous = latest[slot], previous.date > observed { continue } + latest[slot] = item + } + } + + private func isFresh(_ item: Observation, at instant: Date) -> Bool { + let age = instant.timeIntervalSince(item.date) + return age >= -Self.futureTolerance && age <= Self.maxAge + && (item.reset == nil || item.reset! > instant) + } + + private static func number(_ raw: Any?) -> Double? { + guard let value = raw as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID(), + value.doubleValue.isFinite else { return nil } + return value.doubleValue + } + + private static func percent(_ values: [String: Any]) -> Double? { + if values["remaining_percent"] != nil { + guard let direct = number(values["remaining_percent"]), (0...100).contains(direct) else { return nil } + return direct + } + if let used = number(values["used_percent"]), (0...100).contains(used) { return 100 - used } + return nil + } + + private static func reset(_ values: [String: Any], observed: Date) -> Date? { + if let timestamp = values["resets_at"] as? String, + let date = parseDate(timestamp), + date.timeIntervalSince(observed) <= 31_536_000 { return date } + if let epoch = number(values["resets_at"]) { + let seconds = epoch > 10_000_000_000 ? epoch / 1_000 : epoch + if seconds >= 0, seconds <= 4_102_444_800 { + let date = Date(timeIntervalSince1970: seconds) + if date.timeIntervalSince(observed) <= 31_536_000 { return date } + } + } + if let seconds = number(values["reset_after_seconds"]), + (0...31_536_000).contains(seconds) { return observed.addingTimeInterval(seconds) } + return nil + } + + private static func windowToken(_ seconds: Double) -> String? { + guard seconds.isFinite, seconds >= 60, seconds <= 31_536_000 else { return nil } + let rounded = Int(seconds.rounded()) + if rounded % 604_800 == 0 { return "\(rounded / 604_800)w" } + if rounded % 86_400 == 0 { return "\(rounded / 86_400)d" } + if rounded % 3_600 == 0 { return "\(rounded / 3_600)h" } + return nil + } + + private static func iso(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private static func parseDate(_ text: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: text) ?? ISO8601DateFormatter().date(from: text) + } + + private static func canonicalPath(_ path: String) -> String { + if let resolved = Darwin.realpath(path, nil) { + defer { free(resolved) } + return String(cString: resolved) + } + return path + } +} diff --git a/Sources/QuotaCore/LocalQuotaReader.swift b/Sources/QuotaCore/LocalQuotaReader.swift index d742999..2dd4222 100644 --- a/Sources/QuotaCore/LocalQuotaReader.swift +++ b/Sources/QuotaCore/LocalQuotaReader.swift @@ -232,7 +232,9 @@ public struct LocalQuotaReader: Sendable { request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") if let accountID = firstString(tokens, ["account_id", "accountId"]) { request.setValue(accountID, forHTTPHeaderField: "chatgpt-account-id") } let payload = try await requestJSON(request) - let windows = parseCodex(payload, planType: firstString(root, ["plan_type", "planType", "plan"]), observedAt: now()) + let accountID = firstString(tokens, ["account_id", "accountId"]).flatMap { $0.utf8.count <= 256 ? $0 : nil } + let windows = parseCodex(payload, planType: firstString(root, ["plan_type", "planType", "plan"]), + accountKey: accountID, observedAt: now()) guard !windows.isEmpty else { return ProviderRead(provider: provider, windows: [unknownWindow(provider: provider, label: "Codex quota", observedAt: now())], issue: "Codex returned no readable quota windows.") } @@ -463,6 +465,7 @@ private func window( quotaUnit: String? = nil, planName: String? = nil, periodStart: String? = nil, + accountKey: String? = nil, observedAt: Date ) -> QuotaWindow { let bounded = percentage(remaining) @@ -474,7 +477,7 @@ private func window( quotaUnit: quotaUnit, planName: planName, resetAt: resetAt, window: windowToken, occurredAt: isoFormatter.string(from: observedAt), source: provider.label, - periodStart: periodStart + periodStart: periodStart, accountKey: accountKey ).normalizedForExport() } @@ -512,7 +515,7 @@ private func claudeToken(_ value: String) -> String? { return count.map { "\($0)\(suffix)" } } -private func parseCodex(_ root: [String: Any], planType: String?, observedAt: Date) -> [QuotaWindow] { +private func parseCodex(_ root: [String: Any], planType: String?, accountKey: String?, observedAt: Date) -> [QuotaWindow] { let limits = record(root["rate_limit"] ?? root["rateLimit"] ?? root["rate_limits"] ?? root["rateLimits"] ?? root["limits"]) var result: [QuotaWindow] = [] func append(_ slot: String, _ value: [String: Any], modelId: String? = nil) { @@ -523,7 +526,7 @@ private func parseCodex(_ root: [String: Any], planType: String?, observedAt: Da let reset = firstTimestamp(value, ["resets_at", "resetsAt", "reset_at", "resetAt"]) ?? firstNumber(value, ["reset_after_seconds", "resetAfterSeconds", "resets_in_seconds"]).flatMap { seconds in seconds >= 0 && seconds.isFinite && seconds <= 31_536_000 ? isoFormatter.string(from: observedAt.addingTimeInterval(seconds)) : nil } let remaining = direct.map(percentage) ?? used.map { 100 - min(100, max(0, $0)) } let suffix = modelId.map { " (\($0))" } ?? "" - result.append(window(provider: .codex, id: slot, label: token.map { "\($0) window\(suffix)" } ?? "\(slot.capitalized) window\(suffix)", remaining: remaining, resetAt: reset, windowToken: token, modelId: modelId, planName: planType, observedAt: observedAt)) + result.append(window(provider: .codex, id: slot, label: token.map { "\($0) window\(suffix)" } ?? "\(slot.capitalized) window\(suffix)", remaining: remaining, resetAt: reset, windowToken: token, modelId: modelId, planName: planType, accountKey: accountKey, observedAt: observedAt)) } for (slot, names) in [("primary", ["primary_window", "primaryWindow", "primary"]), ("secondary", ["secondary_window", "secondaryWindow", "secondary"])] { var raw: Any? diff --git a/Tests/CodeCapsTests/DocsScreenshotTests.swift b/Tests/CodeCapsTests/DocsScreenshotTests.swift index ac3df59..039188f 100644 --- a/Tests/CodeCapsTests/DocsScreenshotTests.swift +++ b/Tests/CodeCapsTests/DocsScreenshotTests.swift @@ -31,7 +31,8 @@ final class DocsScreenshotTests: XCTestCase { return GlanceFixtures.png(of: popover, size: CGSize(width: Metrics.glanceWidth, height: height), dark: dark) } - private func console(page: ConsolePage, dark: Bool, selectedAlert: Bool = false) throws -> Data? { + private func console(page: ConsolePage, dark: Bool, selectedAlert: Bool = false, + settingsAlert: Bool = false) throws -> Data? { let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let historyURL = temporary.appendingPathComponent("history.jsonl") defer { try? FileManager.default.removeItem(at: temporary) } @@ -60,11 +61,21 @@ final class DocsScreenshotTests: XCTestCase { let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: true, fleet: false, localReadersOn: true, historyURL: historyURL, - alertHistory: selectedAlert ? [alert] : []) + alertHistory: (selectedAlert || settingsAlert) ? [alert] : []) defer { defaults.removePersistentDomain(forName: suite) } XCTAssertEqual(model.historySamples().count, samples.count, "the screenshot must use the saved local sample fixture") + let historyKey: String? if case .platform(let key) = page { + historyKey = key + } else if case .settingsNotifications = page, settingsAlert { + historyKey = "anthropic" + XCTAssertEqual(model.runawayAlertHistory.first?.windowId, alert.windowId, + "Alerts & Alarms screenshot must include the recent runaway alert") + } else { + historyKey = nil + } + if let key = historyKey { guard let row = model.displaySections.first(where: { $0.id == key }) else { XCTFail("the screenshot platform must exist") return nil @@ -78,9 +89,9 @@ final class DocsScreenshotTests: XCTestCase { let readingsByWindow = Dictionary(grouping: plotted, by: \.windowId) XCTAssertTrue(readingsByWindow.values.contains { $0.count >= 2 }, "the screenshot platform needs two readings of the same window to draw a line") - if selectedAlert { + if selectedAlert || settingsAlert { XCTAssertGreaterThanOrEqual(readingsByWindow[alert.windowId]?.count ?? 0, 2, - "the selected alert window needs its own visible history") + "the alert window needs its own visible history") } } let state = ConsoleState(defaults: defaults) @@ -109,6 +120,8 @@ final class DocsScreenshotTests: XCTestCase { "platform-alert-history.png", to: directory) try write(try console(page: .platform("google-antigravity:gemini"), dark: false), "platform-antigravity.png", to: directory) + try write(try console(page: .settingsNotifications, dark: false, settingsAlert: true), + "settings-alerts-history.png", to: directory) try write(try console(page: .settingsSourcesFleet, dark: false), "settings-sources-fleet.png", to: directory) } } diff --git a/Tests/CodeCapsTests/SourceRefreshTests.swift b/Tests/CodeCapsTests/SourceRefreshTests.swift new file mode 100644 index 0000000..eb9a10c --- /dev/null +++ b/Tests/CodeCapsTests/SourceRefreshTests.swift @@ -0,0 +1,200 @@ +import XCTest +@testable import CodeCaps +import QuotaCore + +@MainActor +final class SourceRefreshTests: XCTestCase { + private func defaults() -> UserDefaults { + let suite = "com.jays.codecaps.refresh.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + addTeardownBlock { defaults.removePersistentDomain(forName: suite) } + return defaults + } + + private func historyURL() -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codecaps-refresh-\(UUID().uuidString).jsonl") + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + private func codex(_ remaining: Double, at date: Date, account: String = "account-a", + source: String = "Codex Session Files") -> QuotaWindow { + QuotaWindow(id: "local-mac:openai:primary", provider: "Codex", providerKey: "openai", + label: "5h", remainingPercent: remaining, + occurredAt: ISO8601DateFormatter().string(from: date), source: source, + accountKey: account) + } + + func testDefaultCadencesAndIndependentPreferencesPersist() { + let settings = defaults() + let model = MonitorModel(defaults: settings) + model.skipsSnapshotIOForTesting = true + XCTAssertTrue(model.providerChecksEnabled) + XCTAssertTrue(model.sessionFileChecksEnabled) + XCTAssertEqual(model.providerCheckCadence, .five) + XCTAssertEqual(model.sessionFileCadence, .one) + + model.providerCheckCadence = .fifteen + model.sessionFileCadence = .three + model.setProviderChecksEnabled(false) + model.setSessionFileChecksEnabled(false) + let restored = MonitorModel(defaults: settings) + XCTAssertFalse(restored.providerChecksEnabled) + XCTAssertFalse(restored.sessionFileChecksEnabled) + XCTAssertEqual(restored.providerCheckCadence, .fifteen) + XCTAssertEqual(restored.sessionFileCadence, .three) + } + + func testUnchangedFilePollDoesNotInvokeProviderOrFleet() async { + let settings = defaults() + settings.set(false, forKey: SourceRefreshPreference.providerEnabled) + let savedHistory = historyURL() + let model = MonitorModel(defaults: settings, burnRateHistoryURL: savedHistory) + model.skipsSnapshotIOForTesting = true + let fixed = Date() + let result = LocalQuotaResult(windows: [codex(60, at: fixed)]) + var fileReads = 0 + var providerReads = 0 + var serverReads = 0 + var handoffWrites = 0 + var widgetWrites = 0 + model.sessionAccountIDForTesting = { "account-a" } + model.sessionFileReadForTesting = { fileReads += 1; return result } + model.localReadForTesting = { providerReads += 1; return nil } + model.serverFetchForTesting = { serverReads += 1; return QuotaResponse(generatedAt: "") } + model.handoffWriteForTesting = { _ in handoffWrites += 1 } + model.widgetWriteForTesting = { _ in widgetWrites += 1 } + + model.refreshSessionFiles() + await model.sessionFileTaskForTesting?.value + let first = model.response + let firstSampleCount = BurnRateMonitor.loadSamples(historyURL: savedHistory).count + model.refreshSessionFiles() + await model.sessionFileTaskForTesting?.value + + XCTAssertEqual(fileReads, 2) + XCTAssertEqual(providerReads, 0) + XCTAssertEqual(serverReads, 0) + XCTAssertEqual(model.response, first) + XCTAssertEqual(firstSampleCount, 1) + XCTAssertEqual(BurnRateMonitor.loadSamples(historyURL: savedHistory).count, firstSampleCount) + XCTAssertEqual(handoffWrites, 1) + XCTAssertEqual(widgetWrites, 1) + } + + func testDisablingSessionChecksRejectsInFlightResult() async { + let settings = defaults() + settings.set(false, forKey: SourceRefreshPreference.providerEnabled) + let model = MonitorModel(defaults: settings, burnRateHistoryURL: historyURL()) + model.skipsSnapshotIOForTesting = true + let gate = RefreshGate() + model.sessionAccountIDForTesting = { "account-a" } + model.sessionFileReadForTesting = { + await gate.pause() + return LocalQuotaResult(windows: [self.codex(50, at: Date())]) + } + model.refreshSessionFiles() + await gate.waitUntilEntered() + let pending = model.sessionFileTaskForTesting + model.setSessionFileChecksEnabled(false) + await gate.open() + await pending?.value + XCTAssertFalse(model.sessionFileChecksEnabled) + XCTAssertTrue(model.response.windows.isEmpty) + } + + func testAccountSwitchRejectsInFlightSessionReading() async { + let settings = defaults() + settings.set(false, forKey: SourceRefreshPreference.providerEnabled) + let model = MonitorModel(defaults: settings, burnRateHistoryURL: historyURL()) + model.skipsSnapshotIOForTesting = true + let gate = RefreshGate() + var currentAccount = "account-a" + model.sessionAccountIDForTesting = { currentAccount } + model.sessionFileReadForTesting = { + await gate.pause() + return LocalQuotaResult(windows: [self.codex(50, at: Date(), account: "account-a")]) + } + model.refreshSessionFiles() + await gate.waitUntilEntered() + currentAccount = "account-b" + await gate.open() + await model.sessionFileTaskForTesting?.value + XCTAssertTrue(model.response.windows.isEmpty) + } + + func testMasterLocalSwitchClearsFileOnlyDisplay() async { + let settings = defaults() + settings.set(false, forKey: SourceRefreshPreference.providerEnabled) + let model = MonitorModel(defaults: settings, burnRateHistoryURL: historyURL()) + model.skipsSnapshotIOForTesting = true + model.sessionAccountIDForTesting = { "account-a" } + model.sessionFileReadForTesting = { + LocalQuotaResult(windows: [self.codex(60, at: Date())]) + } + model.refreshSessionFiles() + await model.sessionFileTaskForTesting?.value + XCTAssertEqual(model.response.windows.count, 1) + model.setLocalEnabled(false) + XCTAssertTrue(model.response.windows.isEmpty) + } + + func testDisablingFleetPullClearsDisplayWhenProviderChecksOff() async { + let settings = defaults() + settings.set(false, forKey: SourceRefreshPreference.providerEnabled) + settings.set(false, forKey: SourceRefreshPreference.sessionEnabled) + settings.set(true, forKey: "serverEnabled") + let model = MonitorModel(defaults: settings) + model.skipsSnapshotIOForTesting = true + var accountReads = 0 + model.sessionAccountIDForTesting = { accountReads += 1; return "account-a" } + model.serverFetchForTesting = { + let own = QuotaWindow(id: "own:anthropic:5h", provider: "Claude", providerKey: "anthropic", + label: "5h", remainingPercent: 50, + occurredAt: ISO8601DateFormatter().string(from: Date()), source: "CodeCaps", + producerInstanceId: QuotaPublisher.producerInstanceId) + return QuotaResponse(generatedAt: "test", windows: [own]) + } + model.refresh() + await model.refreshTaskForTesting?.value + XCTAssertEqual(accountReads, 0, "server-only refresh must not read local Codex auth") + XCTAssertFalse(model.response.windows.isEmpty) + model.disableServerPull() + XCTAssertTrue(model.response.windows.isEmpty) + } + + func testCodexReconciliationRequiresAccountAndNewerEvent() { + let instant = Date(timeIntervalSince1970: 1_700_000_000) + let http = codex(60, at: instant, source: "Codex") + let tie = codex(50, at: instant) + let newer = codex(45, at: instant.addingTimeInterval(60)) + let wrongAccount = codex(25, at: instant.addingTimeInterval(120), account: "account-b") + XCTAssertEqual(MonitorModel.reconcileLocalWindows(provider: [http], session: [tie]).first, http) + XCTAssertEqual(MonitorModel.reconcileLocalWindows(provider: [http], session: [newer]).first, newer) + XCTAssertEqual(MonitorModel.reconcileLocalWindows(provider: [http], session: [wrongAccount]).first, http) + } +} + +private actor RefreshGate { + private var entered = false + private var entryWaiter: CheckedContinuation? + private var exitWaiter: CheckedContinuation? + + func pause() async { + entered = true + entryWaiter?.resume() + entryWaiter = nil + await withCheckedContinuation { exitWaiter = $0 } + } + + func waitUntilEntered() async { + if entered { return } + await withCheckedContinuation { entryWaiter = $0 } + } + + func open() { + exitWaiter?.resume() + exitWaiter = nil + } +} diff --git a/Tests/QuotaCoreTests/CodexSessionQuotaReaderTests.swift b/Tests/QuotaCoreTests/CodexSessionQuotaReaderTests.swift new file mode 100644 index 0000000..8157e1b --- /dev/null +++ b/Tests/QuotaCoreTests/CodexSessionQuotaReaderTests.swift @@ -0,0 +1,204 @@ +import Darwin +import Foundation +import XCTest +@testable import QuotaCore + +final class CodexSessionQuotaReaderTests: XCTestCase { + private let clock = ISO8601DateFormatter().date(from: "2026-10-03T12:00:00Z")! + + func testMatchingAccountAndStableObservationAcrossPolls() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let file = try fixture.session("a.jsonl", account: "account-a", lines: [ + fixture.event(at: "2026-10-03T11:55:00Z", used: 20, secondary: 35) + ]) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let first = await reader.read() + XCTAssertEqual(first.windows.map(\.id), ["local-mac:openai:primary", "local-mac:openai:secondary"]) + XCTAssertEqual(first.windows.first?.remainingPercent, 80) + XCTAssertEqual(first.windows.first?.source, "Codex Session Files") + XCTAssertEqual(first.windows.first?.accountKey, "account-a") + XCTAssertEqual(first.windows.first?.occurredAt, "2026-10-03T11:55:00Z") + let repeatRead = await reader.read() + XCTAssertEqual(repeatRead.windows, first.windows) + try fixture.append(fixture.event(at: "2026-10-03T11:58:00Z", used: 30) + "\n", to: file) + let updated = await reader.read() + XCTAssertEqual(updated.windows.first?.remainingPercent, 70) + XCTAssertEqual(updated.windows.first?.occurredAt, "2026-10-03T11:58:00Z") + XCTAssertEqual(updated.windows.last?.occurredAt, "2026-10-03T11:55:00Z") + } + + func testMissingAndMismatchedMetadataFailClosedAndAccountSwitchClearsCache() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + _ = try fixture.session("a.jsonl", account: "account-a", lines: [fixture.event(at: "2026-10-03T11:55:00Z", used: 20)]) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let first = await reader.read() + XCTAssertEqual(first.windows.count, 1) + try fixture.auth("account-b") + let switched = await reader.read() + XCTAssertTrue(switched.windows.isEmpty) + _ = try fixture.session("b.jsonl", account: nil, lines: [fixture.event(at: "2026-10-03T11:56:00Z", used: 10)]) + let missingMetadata = await reader.read() + XCTAssertTrue(missingMetadata.windows.isEmpty) + _ = try fixture.session("c.jsonl", account: "account-b", lines: [fixture.event(at: "2026-10-03T11:57:00Z", used: 40)]) + let result = await reader.read() + XCTAssertEqual(result.windows.first?.remainingPercent, 60) + XCTAssertEqual(result.windows.first?.accountKey, "account-b") + } + + func testPartialLineCompletesWithoutStampingPollTime() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let file = try fixture.session("a.jsonl", account: "account-a", lines: []) + let event = fixture.event(at: "2026-10-03T11:50:00Z", used: 25) + try fixture.append(String(event.dropLast()), to: file) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let partial = await reader.read() + XCTAssertTrue(partial.windows.isEmpty) + try fixture.append(String(event.suffix(1)) + "\n", to: file) + let completed = await reader.read() + XCTAssertEqual(completed.windows.first?.occurredAt, "2026-10-03T11:50:00Z") + } + + func testEmptyFileObservedBeforeFirstLineBecomesVisibleWhenItGrows() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let url = fixture.day.appendingPathComponent("growing.jsonl") + FileManager.default.createFile(atPath: url.path, contents: nil) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let empty = await reader.read() + XCTAssertTrue(empty.windows.isEmpty) + // The session_meta line lands after the first poll: the earlier + // not-yet-identifiable observation must not pin a rejection. + try fixture.replace(url, account: "account-a", + lines: [fixture.event(at: "2026-10-03T11:55:00Z", used: 20)]) + let grown = await reader.read() + XCTAssertEqual(grown.windows.first?.remainingPercent, 80) + XCTAssertEqual(grown.windows.first?.accountKey, "account-a") + } + + func testTruncationAndRotationRestartAtNewMetadata() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let file = try fixture.session("a.jsonl", account: "account-a", lines: [fixture.event(at: "2026-10-03T11:40:00Z", used: 10)]) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let first = await reader.read() + XCTAssertEqual(first.windows.first?.remainingPercent, 90) + try fixture.replace(file, account: "account-a", lines: [fixture.event(at: "2026-10-03T11:45:00Z", used: 20)]) + let truncated = await reader.read() + XCTAssertEqual(truncated.windows.first?.remainingPercent, 80) + try FileManager.default.removeItem(at: file) + try fixture.replace(file, account: "account-a", lines: [fixture.event(at: "2026-10-03T11:50:00Z", used: 30)]) + let rotated = await reader.read() + XCTAssertEqual(rotated.windows.first?.remainingPercent, 70) + } + + func testSymlinkAndUnknownPoolAreIgnored() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let outside = fixture.home.appendingPathComponent("outside.jsonl") + try fixture.replace(outside, account: "account-a", lines: [fixture.event(at: "2026-10-03T11:55:00Z", used: 5)]) + try FileManager.default.createSymbolicLink(at: fixture.day.appendingPathComponent("link.jsonl"), withDestinationURL: outside) + _ = try fixture.session("other.jsonl", account: "account-a", lines: [fixture.event(at: "2026-10-03T11:55:00Z", used: 5, limitID: "other-pool")]) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let result = await reader.read() + XCTAssertTrue(result.windows.isEmpty) + } + + func testBoundsAndInvalidEventsDoNotCreateQuota() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let oversized = String(repeating: "x", count: 70_000) + _ = try fixture.session("a.jsonl", account: "account-a", lines: [ + fixture.event(at: "2026-10-03T11:55:00Z", used: 10, extra: oversized), + fixture.event(at: "2026-10-03T12:10:00Z", used: 10), + fixture.event(at: "2026-10-01T11:55:00Z", used: 10), + fixture.event(at: "2026-10-03T11:55:00Z", used: 120), + fixture.event(at: "2026-10-03T11:55:00Z", used: -1) + ]) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let result = await reader.read() + XCTAssertTrue(result.windows.isEmpty) + XCTAssertNotNil(result.issues["openai"]) + } + + func testNoQuotaEventDoesNotReplaceLastObservation() async throws { + let fixture = try Fixture(now: clock) + defer { fixture.remove() } + try fixture.auth("account-a") + let file = try fixture.session("a.jsonl", account: "account-a", lines: [fixture.event(at: "2026-10-03T11:55:00Z", used: 20)]) + let reader = CodexSessionQuotaReader(homeDirectory: fixture.home, now: { self.clock }) + let original = await reader.read() + XCTAssertEqual(original.windows.count, 1) + XCTAssertEqual(original.windows.first?.remainingPercent, 80) + try fixture.append(#"{"timestamp":"2026-10-03T11:59:00Z","type":"event_msg","payload":{"type":"other"}}"# + "\n", to: file) + let unchanged = await reader.read() + XCTAssertEqual(unchanged.windows, original.windows) + } +} + +private struct Fixture { + let home: URL + let day: URL + + init(now: Date) throws { + // Foundation may shorten /private/var back to the /var symlink on macOS. + // Use the POSIX canonical path because the reader rejects symlink ancestors. + let temporaryPath = FileManager.default.temporaryDirectory.path + guard let resolved = Darwin.realpath(temporaryPath, nil) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + defer { free(resolved) } + home = URL(fileURLWithPath: String(cString: resolved), isDirectory: true) + .appendingPathComponent("CodexQuota-\(UUID().uuidString)") + day = home.appendingPathComponent(".codex/sessions/2026/10/03") + try FileManager.default.createDirectory(at: day, withIntermediateDirectories: true) + } + + func remove() { try? FileManager.default.removeItem(at: home) } + + func auth(_ account: String) throws { + let url = home.appendingPathComponent(".codex/auth.json") + let data = try JSONSerialization.data(withJSONObject: ["tokens": ["account_id": account]]) + try data.write(to: url) + } + + func session(_ name: String, account: String?, lines: [String]) throws -> URL { + let url = day.appendingPathComponent(name) + try replace(url, account: account, lines: lines) + return url + } + + func replace(_ url: URL, account: String?, lines: [String]) throws { + let meta = try JSONSerialization.data(withJSONObject: ["type": "session_meta", "payload": account.map { ["creator_account_id": $0] } ?? [:]]) + let content = String(data: meta, encoding: .utf8)! + "\n" + lines.map { $0 + "\n" }.joined() + try Data(content.utf8).write(to: url) + } + + func append(_ text: String, to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(text.utf8)) + } + + func event(at timestamp: String, used: Double, secondary: Double? = nil, + limitID: String = "codex", extra: String? = nil) -> String { + var limits: [String: Any] = ["limit_id": limitID, + "primary": ["used_percent": used, "window_minutes": 300, "reset_after_seconds": 3600]] + if let secondary { limits["secondary"] = ["used_percent": secondary, "window_minutes": 10080, "reset_after_seconds": 604800] } + var payload: [String: Any] = ["type": "token_count", "rate_limits": limits] + if let extra { payload["ignored"] = extra } + let root: [String: Any] = ["timestamp": timestamp, "type": "event_msg", "payload": payload] + let data = try! JSONSerialization.data(withJSONObject: root) + return String(data: data, encoding: .utf8)! + } +} diff --git a/docs/EFFORT-LOG.md b/docs/EFFORT-LOG.md index 571c304..4180395 100644 --- a/docs/EFFORT-LOG.md +++ b/docs/EFFORT-LOG.md @@ -502,6 +502,15 @@ without inspecting the underlying window. Board 42ae688ab3b84d9aa65e445aab072a15. Closes #37. +## 2026-10-03 — Independent Provider and File Refresh [CODEX, PR #142] + +repo: CodeCaps; pre-work claim: posted to #agent-sync after reading AGENT-SYNC.md; Lane: `codex/independent-source-refresh`; Board `4bcf84f1`; GitHub #137; PR #142 open on this branch (depends on #139; refs #136). + +- Added `CodexSessionQuotaReader` for bounded passive Codex session JSONL reads with exact account identity, incremental append tracking, symlink-safe paths, and original event timestamps. Unchanged files do not invoke provider, Fleet, upload, or download work. +- Split Settings → Sources & Fleet into independent **Provider Checks** and **Codex Session File Checks** toggles with separate 1-, 3-, 5-, and 15-minute intervals (five-minute provider default, one-minute file default). Manual refresh runs both enabled paths; disabling one source preserves the other when possible. +- Integrated scheduling, merge rules, and cancellation in `MonitorModel` / `SourceRefreshSettings`; documented behavior in `docs/REFRESH.md`. +- Verification: `CodexSessionQuotaReaderTests`, `SourceRefreshTests`, and related refresh tests on the branch; hosted Swift CI green. Native Mac UI claims rely on code review and CI fixture rendering in `DocsScreenshotTests`, not supplied or manually captured screenshots. + ## 2026-10-03 — Platform History and Alert Navigation [CODEX, in progress] - Board: `e574a7ee`; GitHub: #136. Branch: `codex/platform-usage-history`. diff --git a/docs/REFRESH.md b/docs/REFRESH.md new file mode 100644 index 0000000..2123b30 --- /dev/null +++ b/docs/REFRESH.md @@ -0,0 +1,33 @@ +# Refresh Sources and Intervals + +CodeCaps has two kinds of local quota input. Configure them separately in Settings → Sources & Fleet under Read Quotas From This Mac. + +| Input | Available Sources | Default | What a Check Does | +|---|---|---|---| +| Provider Checks | Seven direct HTTP reader paths and three helper paths, covering eight provider families | Every five minutes | Uses existing sign-ins and supported helpers to obtain quotas | +| Codex Session File Checks | One passive quota source | Every minute | Reads quota events already written by the signed-in Codex CLI | + +These are available reader paths, not a count of network requests. A missing sign-in can skip a request; retries, fallback paths, and helper behavior can change the number of requests. Reading a credential file and then calling a provider belongs to Provider Checks. + +Each group supports one-, three-, five-, and fifteen-minute intervals and has its own off switch. Read Quotas From This Mac turns both groups off. Faster checks do not make a provider or CLI publish fresher data sooner. + +| Interval | Scheduled Cycles per Hour | Scheduled Cycles per Day | +|---|---:|---:| +| One minute | 60 | 1,440 | +| Three minutes | 20 | 480 | +| Five minutes | 12 | 288 | +| Fifteen minutes | 4 | 96 | + +These figures assume the app is running and the Mac is awake for the entire period. Manual refreshes and other refresh triggers are additional; overlapping work can be coalesced. A one-minute interval produces five times as many scheduled cycles as five minutes, while three minutes produces about 1.7 times as many. + +## Passive File Reading + +The Codex reader reads bounded session files and accepts only quota events whose session metadata matches the current local account. It does not start the CLI, contact a provider, or read tokens from Keychain. It retains the event's original observation time; checking an unchanged file does not turn an old observation into a new one. + +A file-only check does not pull from a server or upload quota data. Changed readings update local app history and local sharing caches. A CLI must first write usable quota events; an inactive CLI may provide no recent reading. + +## Sharing and Widgets + +Upload and download remain separate, optional controls. Choosing a short file-check interval does not enable either. Widgets display the app's shared snapshots and follow the operating system's timeline scheduling; an app refresh interval is not a promise that a widget refreshes at that exact interval. + +History graphs show measured quota percentages and recorded observation times. Missing observations, account changes, and quota resets break the lines. Quota percentages do not identify which conversation or agent consumed them. diff --git a/docs/design/platform-usage-history.md b/docs/design/platform-usage-history.md index e8ca322..6afc861 100644 --- a/docs/design/platform-usage-history.md +++ b/docs/design/platform-usage-history.md @@ -4,7 +4,7 @@ Owner direction, October 3, 2026: remove All Platforms and make usage history vi ## Review Evidence -The UI review used the supplied macOS notification screenshot and the native source at `659e6f5`, including the alert identity/history work in PR #134. The screenshot showed a generic runaway banner with a 13.7× comparison. It did not show the full application window. These recommendations are a source-based design review, not a completed runtime visual audit. +The design review used a supplied macOS notification screenshot plus native source at `659e6f5`, including the alert identity/history work in PR #134. The screenshot showed a generic runaway banner with a 13.7× comparison and did not show the full application window. That input informed layout and copy only; it is not acceptance evidence for native Mac UI. Ship validation follows the Validation section below (code review and CI-based verification). ## Page Hierarchy @@ -32,4 +32,4 @@ The attached menu-bar popover is the Docked Bar. Floating Window describes a de ## Validation -Navigation migration, alert identity routing, legacy history decoding, finite quota values, duplicate timestamps, reset/gap segmentation, and sparse or flat comparison history need behavioral tests. Native Mac visual claims require appropriate review or captured evidence; iOS screenshots remain required for iOS UI changes. +Navigation migration, alert identity routing, legacy history decoding, finite quota values, duplicate timestamps, reset/gap segmentation, and sparse or flat comparison history need behavioral tests. Native Mac visual claims require code review and CI-based verification; iOS UI changes require CI-generated simulator screenshots.