From 37092a31e31d63743a17239cfeaee25b81e3a20f Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Fri, 25 Sep 2026 21:09:03 +0530 Subject: [PATCH 1/2] refactor(macos): retire general Usage workspace --- .../CodeVetter.xcodeproj/project.pbxproj | 9 + .../xcshareddata/swiftpm/Package.resolved | 15 + apps/macos/CodeVetter/CodeVetterApp.swift | 20 +- .../CodeVetterFeature/EvidenceViews.swift | 9 +- .../PremiumCommandPaletteView.swift | 9 +- .../PremiumSettingsView.swift | 4 +- .../CodeVetterFeature/PremiumUsageView.swift | 1436 ----------------- .../CodeVetterFeature/PremiumWorkbench.swift | 17 +- .../Resources/provider-claude.svg | 5 - .../Resources/provider-codex.svg | 5 - .../CodeVetterFeature/SettingsModels.swift | 4 +- .../UsageAllowanceState.swift | 61 - .../UsageHistoryProjection.swift | 177 -- .../CodeVetterFeature/UsageHistoryView.swift | 291 ---- .../CodeVetterFeature/UsageModels.swift | 649 -------- .../UsageSnapshotStore.swift | 99 -- .../VerificationRunner.swift | 82 - .../CodeVetterFeature/WorkbenchModel.swift | 280 +--- .../CodeVetterFeatureTestSupport.swift | 96 +- .../CodeVetterFeatureTests.swift | 948 +---------- .../UsageDecodingTests.swift | 107 -- .../UsagePresentationTests.swift | 230 --- .../WorkbenchCoherenceTests.swift | 7 + .../CodeVetterUITests/CodeVetterUITests.swift | 58 +- scripts/run-native-checks.mjs | 1 - scripts/run-native-checks.test.mjs | 7 +- 26 files changed, 80 insertions(+), 4546 deletions(-) create mode 100644 apps/macos/CodeVetter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumUsageView.swift delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-claude.svg delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-codex.svg delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageAllowanceState.swift delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryProjection.swift delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryView.swift delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageModels.swift delete mode 100644 apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageSnapshotStore.swift delete mode 100644 apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/UsageDecodingTests.swift delete mode 100644 apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/UsagePresentationTests.swift diff --git a/apps/macos/CodeVetter.xcodeproj/project.pbxproj b/apps/macos/CodeVetter.xcodeproj/project.pbxproj index 43779810..a72a4686 100644 --- a/apps/macos/CodeVetter.xcodeproj/project.pbxproj +++ b/apps/macos/CodeVetter.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ preferredProjectObjectVersion = 77; productRefGroup = 8BC2655D2DEDD3F400F279A4 /* Products */; packageReferences = ( + 8B90D1492DEDD44A00FBD189 /* XCLocalSwiftPackageReference "CodeVetterPackage" */, 8C6A50152DF0000100A11CE5 /* XCRemoteSwiftPackageReference "Sparkle" */, ); projectDirPath = ""; @@ -347,6 +348,7 @@ /* Begin XCSwiftPackageProductDependency section */ 8B90D1472DEDD44A00FBD189 /* CodeVetterFeature */ = { isa = XCSwiftPackageProductDependency; + package = 8B90D1492DEDD44A00FBD189 /* XCLocalSwiftPackageReference "CodeVetterPackage" */; productName = CodeVetterFeature; }; 8C6A50162DF0000100A11CE5 /* Sparkle */ = { @@ -356,6 +358,13 @@ }; /* End XCSwiftPackageProductDependency section */ +/* Begin XCLocalSwiftPackageReference section */ + 8B90D1492DEDD44A00FBD189 /* XCLocalSwiftPackageReference "CodeVetterPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = CodeVetterPackage; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 8C6A50152DF0000100A11CE5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; diff --git a/apps/macos/CodeVetter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/macos/CodeVetter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..5ed508c3 --- /dev/null +++ b/apps/macos/CodeVetter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version" : "2.9.6" + } + } + ], + "version" : 3 +} diff --git a/apps/macos/CodeVetter/CodeVetterApp.swift b/apps/macos/CodeVetter/CodeVetterApp.swift index 384b6ab3..9374325e 100644 --- a/apps/macos/CodeVetter/CodeVetterApp.swift +++ b/apps/macos/CodeVetter/CodeVetterApp.swift @@ -6,21 +6,11 @@ import SwiftUI @MainActor final class CodeVetterAppDelegate: NSObject, NSApplicationDelegate { private static let retainedDelegate = CodeVetterAppDelegate() - private lazy var model = WorkbenchModel( - repositoryAccessStore: RepositoryAccessStore.standard, - usageSnapshotStore: usageSnapshotStore - ) + private lazy var model = WorkbenchModel(repositoryAccessStore: RepositoryAccessStore.standard) private let updater = NativeUpdaterController() private let appHealth = CodeVetterAppHealth() private var windowController: NSWindowController? - private var usageSnapshotStore: UsageSnapshotStore { - guard let path = launchValue(after: "--ui-test-usage-cache") else { - return UsageSnapshotStore() - } - return UsageSnapshotStore(directory: URL(fileURLWithPath: path, isDirectory: true)) - } - static func main() { let application = NSApplication.shared application.setActivationPolicy(.regular) @@ -70,7 +60,7 @@ final class CodeVetterAppDelegate: NSObject, NSApplicationDelegate { if let sectionName = launchValue(after: "--ui-test-section"), let section = WorkbenchSection.allCases.first(where: { $0.rawValue == sectionName }) { - model.section = section + model.section = section == .usage ? .repository : section } } @@ -188,7 +178,7 @@ final class CodeVetterAppDelegate: NSObject, NSApplicationDelegate { viewMenu.addItem( makeMenuItem("Command Palette…", action: #selector(showCommandPalette), key: "k")) viewMenu.addItem(.separator()) - for (index, section) in WorkbenchSection.allCases.enumerated() { + for (index, section) in WorkbenchSection.navigationSections.enumerated() { let item = makeMenuItem( section.rawValue, action: #selector(showSection(_:)), key: String(index + 1)) item.tag = index @@ -229,7 +219,7 @@ final class CodeVetterAppDelegate: NSObject, NSApplicationDelegate { } @objc private func showSection(_ sender: NSMenuItem) { - guard WorkbenchSection.allCases.indices.contains(sender.tag) else { return } - model.section = WorkbenchSection.allCases[sender.tag] + guard WorkbenchSection.navigationSections.indices.contains(sender.tag) else { return } + model.section = WorkbenchSection.navigationSections[sender.tag] } } diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/EvidenceViews.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/EvidenceViews.swift index 149b7da7..22675e8d 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/EvidenceViews.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/EvidenceViews.swift @@ -23,7 +23,7 @@ public struct EvidenceSidebarView: View { .padding(.top, 14) .padding(.bottom, 18) - navigationGroup("Workspace", sections: [.usage, .repository]) + navigationGroup("Workspace", sections: [.repository]) navigationGroup("Verification", sections: [.review, .testing, .performance]) .padding(.top, 12) navigationGroup("Evidence", sections: [.runs]) @@ -115,10 +115,9 @@ public struct EvidenceWorkbenchView: View { VerifyView(model: model) case .usage: EmptyWorkbenchView( - title: "Usage", - message: - "Local agent usage, cost, quota, model, and activity views will transfer here without changing their provider boundaries.", - icon: "chart.bar.xaxis" + title: "Agent usage moved to ContextDaddy", + message: "CodeVetter keeps token and cost evidence for verification runs. ContextDaddy owns the general agent usage dashboard.", + icon: "arrow.up.right.square" ) case .runs: EmptyWorkbenchView( diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumCommandPaletteView.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumCommandPaletteView.swift index 73008dad..1843d207 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumCommandPaletteView.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumCommandPaletteView.swift @@ -9,8 +9,9 @@ struct PremiumCommandPaletteView: View { private var matches: [WorkbenchSection] { let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !needle.isEmpty else { return WorkbenchSection.allCases } - return WorkbenchSection.allCases.filter { + let sections = WorkbenchSection.navigationSections + guard !needle.isEmpty else { return sections } + return sections.filter { $0.rawValue.localizedCaseInsensitiveContains(needle) } } @@ -55,7 +56,7 @@ struct PremiumCommandPaletteView: View { ContentUnavailableView( "No matching workspace", systemImage: "magnifyingglass", - description: Text("Try Usage, Review, Testing, Performance, Runs, or Settings.") + description: Text("Try Explore, Review, Testing, Performance, Runs, or Settings.") ) .frame(minHeight: 220) } else { @@ -144,7 +145,7 @@ struct PremiumCommandPaletteView: View { private func sectionPurpose(_ section: WorkbenchSection) -> String { switch section { - case .usage: "Inspect local provider usage without inventing quota truth" + case .usage: "Agent usage is now in ContextDaddy" case .repository: "Browse exact source, search code, and unpack repository structure" case .review: "Plan and inspect execution-backed change verification" case .testing: "Exercise changed behavior and preserve runtime evidence" diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumSettingsView.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumSettingsView.swift index ed4ff2c4..fe081b47 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumSettingsView.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumSettingsView.swift @@ -884,7 +884,7 @@ struct PremiumSettingsView: View { PremiumFieldLabel("CODEX HISTORY RECOVERY") Text("Additional Codex homes").font(.system(size: 15, weight: .semibold)) Text( - "Restore sessions outside the active CODEX_HOME. Rust normalizes, bounds, and deduplicates roots before Usage or reconciliation can consume them." + "Restore sessions outside the active CODEX_HOME. Rust normalizes, bounds, and deduplicates roots for local evidence recovery." ) .font(.system(size: 10)) .foregroundStyle(.secondary) @@ -973,7 +973,7 @@ struct PremiumSettingsView: View { } Label( - "Adding or removing a root never reads or deletes transcript content. Reconcile from Usage when ready.", + "Adding or removing a root never reads or deletes transcript content. Reconcile the local evidence archive separately when ready.", systemImage: "lock.shield.fill" ) .font(.system(size: 10)) diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumUsageView.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumUsageView.swift deleted file mode 100644 index 03145b09..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumUsageView.swift +++ /dev/null @@ -1,1436 +0,0 @@ -import AppKit -import SwiftUI - -struct UsageTrendPoint: Identifiable, Sendable { - let period: String - let generatedTokens: UInt64 - - var id: String { period } -} - -struct UsageSeriesPoint: Sendable { - var tokens: UInt64 = 0 - var costUSD: Double = 0 - var cacheReadTokens: UInt64 = 0 - - func value(for metric: UsageHistoryMetric) -> Double { - switch metric { - case .tokens: Double(tokens) - case .cost: costUSD - case .cache: Double(cacheReadTokens) - } - } -} - -/// One ranked entity (model or project) with per-period values aligned to the -/// chart's period keys, so stacked bars and breakdown rows always reconcile. -struct UsageTrendSeries: Identifiable, Sendable { - let name: String - let detail: String? - let points: [UsageSeriesPoint] - - var id: String { name } - var totalTokens: UInt64 { points.reduce(0) { $0 + $1.tokens } } - var totalCostUSD: Double { points.reduce(0) { $0 + $1.costUSD } } -} - -struct UsageBreakdownRow: Identifiable, Sendable { - let name: String - let detail: String? - let tokens: UInt64 - let costUSD: Double - - var id: String { name } -} - -struct UsageViewProjection: Sendable { - let history: UsageHistoryProjection - let totals: LocalUsageTotals - let activeDays: Int - let sessionCount: Int - let recentSessions: [LocalUsageSession] - let models: [LocalUsageModel] - let trend: [UsageTrendPoint] - /// Period keys aligned with `trend` and every series' `points`. - let trendPeriodKeys: [String] - let modelSeries: [UsageTrendSeries] - let projectSeries: [UsageTrendSeries] - let projectBreakdown: [UsageBreakdownRow] - /// Sessions with a recovered project; the rest render as Unattributed. - let attributedSessionCount: Int - - init( - report: LocalUsageReport, - selectedAgents: Set, - window: UsageWindow, - scale: UsageScale, - referenceDate: Date = Date(), - grouping: UsageGrouping = .model, - metric: UsageMetric = .tokens - ) { - let dayPeriods = report.periods(for: .day, window: window, referenceDate: referenceDate) - history = UsageHistoryProjection( - periods: dayPeriods, agents: selectedAgents, - scale: scale, grouping: grouping, metric: metric) - var selectedTotals = LocalUsageTotals.zero - var modelsByName: [String: LocalUsageModel] = [:] - for period in dayPeriods { - selectedTotals = selectedTotals.adding(period.totals(for: selectedAgents)) - for agent in period.agents where selectedAgents.contains(agent.agent) { - for item in agent.models { - if let current = modelsByName[item.model] { - modelsByName[item.model] = LocalUsageModel( - model: item.model, - totals: current.totals.adding(item.totals), - fallback: current.fallback || item.fallback, - priced: current.priced && item.priced - ) - } else { - modelsByName[item.model] = item - } - } - } - } - - let matchingSessions = report.sessions( - for: selectedAgents, - window: window, - referenceDate: referenceDate - ) - let trendLimit = - switch scale { - case .day: 180 - case .week: 24 - case .month: 18 - } - let trendPeriods = report.periods(for: scale, window: window, referenceDate: referenceDate) - .suffix(trendLimit) - - totals = selectedTotals - activeDays = dayPeriods.count - sessionCount = matchingSessions.count - recentSessions = Array(matchingSessions.prefix(8)) - models = modelsByName.values.sorted { - $0.totals.generatedTokens > $1.totals.generatedTokens - } - trend = trendPeriods.map { - UsageTrendPoint( - period: $0.period, - generatedTokens: $0.totals(for: selectedAgents).generatedTokens - ) - } - trendPeriodKeys = trendPeriods.map(\.period) - - // Model dimension: per-period model totals restricted to selected agents, - // taken from the same normalized report as the chart — the breakdown and - // the stacked segments therefore reconcile exactly. - var modelAcc: [String: [UsageSeriesPoint]] = [:] - for (index, period) in trendPeriods.enumerated() { - for agent in period.agents where selectedAgents.contains(agent.agent) { - for item in agent.models { - var points = modelAcc[item.model] - ?? Array(repeating: UsageSeriesPoint(), count: trendPeriods.count) - points[index].tokens &+= item.totals.generatedTokens - points[index].costUSD += item.totals.costUSD - points[index].cacheReadTokens &+= item.totals.cacheReadTokens - modelAcc[item.model] = points - } - } - } - modelSeries = modelAcc.map { name, points in - UsageTrendSeries(name: name, detail: nil, points: points) - }.sorted { $0.totalTokens > $1.totalTokens } - - // Project dimension: ccusage does not emit per-period project rows, so - // sessions — which carry recovered project paths — are bucketed by their - // last-activity day into the visible scale periods. Session totals count - // once on that day only; they are never spread across a range. The day→ - // period-key map is built once so 2.5k-session projections stay cheap. - var reportCalendar = Calendar(identifier: .gregorian) - reportCalendar.timeZone = TimeZone(identifier: report.provenance.timezone) ?? .current - var dayToPeriodIndex: [String: Int] = [:] - for (index, key) in trendPeriodKeys.enumerated() { - for day in Self.days(coveredBy: key, scale: scale, calendar: reportCalendar) { - dayToPeriodIndex[day] = index - } - } - let offsetSeconds = reportCalendar.timeZone.secondsFromGMT() - var projectAcc: [String: [UsageSeriesPoint]] = [:] - var projectBreakdownAcc: [String: (tokens: UInt64, costUSD: Double, detail: String?)] = [:] - var attributed = 0 - for session in matchingSessions { - let project = session.project - if project != nil { attributed += 1 } - let name = Self.projectName(project) - let detail = Self.projectDetail(project) - var row = projectBreakdownAcc[name] ?? (0, 0, detail) - row.tokens &+= session.totals.generatedTokens - row.costUSD += session.totals.costUSD - projectBreakdownAcc[name] = row - guard let activity = session.lastActivity, - let day = Self.activityDay(activity, offsetSeconds: offsetSeconds, calendar: reportCalendar), - let index = dayToPeriodIndex[day] else { - continue - } - var points = projectAcc[name] - ?? Array(repeating: UsageSeriesPoint(), count: trendPeriods.count) - points[index].tokens &+= session.totals.generatedTokens - points[index].costUSD += session.totals.costUSD - points[index].cacheReadTokens &+= session.totals.cacheReadTokens - projectAcc[name] = points - } - attributedSessionCount = attributed - projectSeries = projectAcc.map { name, points in - UsageTrendSeries(name: name, detail: projectBreakdownAcc[name]?.detail, points: points) - }.sorted { $0.totalTokens > $1.totalTokens } - projectBreakdown = projectBreakdownAcc.map { name, row in - UsageBreakdownRow(name: name, detail: row.detail, tokens: row.tokens, costUSD: row.costUSD) - }.sorted { $0.tokens > $1.tokens } - } - - private static func projectName(_ project: String?) -> String { - guard let project, let last = project.split(separator: "/").last else { - return "Unattributed" - } - return String(last) - } - - private static func projectDetail(_ project: String?) -> String? { - project - } - - /// Expands a ccusage period key into the report-timezone `YYYY-MM-DD` days - /// it covers: the key itself for day, the seven days opening on the - /// Monday-start key for week, every day of `YYYY-MM` for month. - private static func days(coveredBy key: String, scale: UsageScale, calendar: Calendar) -> [String] { - switch scale { - case .day: - return key.count == 10 ? [key] : [] - case .month: - guard key.count == 7 else { return [] } - return (1...31).compactMap { day in - let candidate = "\(key)-\(String(format: "%02d", day))" - return dayExists(candidate, calendar: calendar) ? candidate : nil - } - case .week: - guard key.count == 10, let start = date(forDay: key, calendar: calendar) else { return [] } - return (0..<7).compactMap { offset in - calendar.date(byAdding: .day, value: offset, to: start).map { - dayString($0, calendar: calendar) - } - } - } - } - - /// Extracts the report-timezone day for an ISO `lastActivity` without a - /// full date parse: UTC day/time fields are sliced out and shifted by the - /// timezone offset, crossing a day boundary only when the shift demands it. - private static func activityDay(_ activity: String, offsetSeconds: Int, calendar: Calendar) - -> String? - { - guard activity.count >= 16 else { return nil } - let day = String(activity.prefix(10)) - guard let utcDay = date(forDay: day, calendar: calendar), - let hour = Int(activity.dropFirst(11).prefix(2)), - let minute = Int(activity.dropFirst(14).prefix(2)) else { return nil } - let shifted = hour * 3600 + minute * 60 + offsetSeconds - let shiftDays = Int(floor(Double(shifted) / 86_400)) - guard shiftDays != 0 else { return day } - return calendar.date(byAdding: .day, value: shiftDays, to: utcDay).map { - dayString($0, calendar: calendar) - } - } - - private static func date(forDay day: String, calendar: Calendar) -> Date? { - var components = DateComponents() - components.year = Int(day.prefix(4)) - components.month = Int(day.dropFirst(5).prefix(2)) - components.day = Int(day.dropFirst(8).prefix(2)) - return calendar.date(from: components) - } - - private static func dayExists(_ day: String, calendar: Calendar) -> Bool { - guard let date = date(forDay: day, calendar: calendar) else { return false } - return dayString(date, calendar: calendar) == day - } - - private static func dayString(_ date: Date, calendar: Calendar) -> String { - let parts = calendar.dateComponents([.year, .month, .day], from: date) - return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) - } -} - -struct PremiumUsageView: View { - @Bindable var model: WorkbenchModel - private let startsUsageLifecycle: Bool - @State private var localDetailsExpanded = false - - init(model: WorkbenchModel, startsUsageLifecycle: Bool = true) { - self.model = model - self.startsUsageLifecycle = startsUsageLifecycle - } - - @ViewBuilder - var body: some View { - if startsUsageLifecycle { - content - .onAppear { model.setUsageAutoRefreshSuspended(!NSApp.isActive) } - .task { - await model.prepareUsage() - await model.runUsageAutoRefresh() - } - .onReceive( - NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) - ) { _ in - model.usageWindowBecameActive() - } - .onReceive( - NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification) - ) { _ in - model.setUsageAutoRefreshSuspended(true) - } - } else { - content - } - } - - private var content: some View { - VStack(spacing: 0) { - header - Rectangle().fill(EvidenceStyle.separator).frame(height: 1) - usageDesk - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .background(EvidenceStyle.canvas) - } - - private var header: some View { - PremiumPageHeader( - eyebrow: "Allowance and local history", - title: "Usage", - subtitle: "Live Claude and Codex allowance first, then bounded usage from local agent logs" - ) { - if let report = model.usageReport { - let showingSavedData = - model.usageShowingSavedSnapshot || model.providerQuotaShowingSavedSnapshot - StatusPill( - label: showingSavedData - ? (model.usageLoading || model.providerQuotaLoading - ? "Saved data · updating" : "Saved data") - : report.status.label, - color: showingSavedData ? EvidenceStyle.amber : report.status.color - ) - } - if let collectedAt = model.usageLastLoadedAt { - Text("Updated \(Text(collectedAt, style: .relative)) ago") - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .monospacedDigit() - .accessibilityLabel("Usage collected") - } - Button { - model.loadUsage(refresh: true) - model.loadProviderQuota() - } label: { - Label( - model.usageLoading || model.providerQuotaLoading ? "Refreshing" : "Refresh", - systemImage: "arrow.clockwise" - ) - } - .buttonStyle(.bordered) - .disabled(model.usageLoading || model.providerQuotaLoading) - .accessibilityLabel("Usage refresh") - } - } - - private var usageDesk: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - providerAllowance - historicalUsage - devinUsage - - Button { - withAnimation(.easeInOut(duration: 0.18)) { - localDetailsExpanded.toggle() - } - } label: { - HStack(spacing: 8) { - Image(systemName: "chart.bar.xaxis") - Text(localDetailsExpanded ? "Hide usage diagnostics" : "Show usage diagnostics") - Spacer() - Image(systemName: localDetailsExpanded ? "chevron.up" : "chevron.down") - } - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .premiumHitTarget(minHeight: 40) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel( - localDetailsExpanded ? "Hide usage diagnostics" : "Show usage diagnostics") - - if localDetailsExpanded { - localUsageDetails - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .padding(.horizontal, PremiumPageLayout.horizontalInset) - .padding(.vertical, 14) - } - } - - @ViewBuilder - private var historicalUsage: some View { - if let report = model.usageReport { - trendPanel(report, projection: model.usageProjection(for: report)) - } - } - - // Devin is indexed from its own SQLite history and is never folded into the - // ccusage totals above, so it reads as its own desk rather than a diagnostic. - @ViewBuilder - private var devinUsage: some View { - if let devin = model.usageReport?.devin { - devinPanel(devin) - } - } - - @ViewBuilder - private var providerAllowance: some View { - if let receipt = model.providerQuotaReceipt { - HStack(alignment: .top, spacing: 12) { - ForEach(receipt.providers) { provider in - ProviderAllowanceCard(provider: provider) - } - } - } else if model.providerQuotaLoading { - allowanceStatus( - icon: "arrow.triangle.2.circlepath", - title: "Checking Claude and Codex…", - detail: "Provider allowance loads independently from local activity.", - color: EvidenceStyle.amber - ) - } else { - allowanceStatus( - icon: "exclamationmark.triangle.fill", - title: "Usage allowance unavailable", - detail: model.providerQuotaIssue ?? "Press Refresh to check both providers.", - color: EvidenceStyle.warning - ) - } - } - - private func allowanceStatus(icon: String, title: String, detail: String, color: Color) - -> some View - { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(color) - .frame(width: 34, height: 34) - .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) - VStack(alignment: .leading, spacing: 3) { - Text(title).font(.system(size: 13, weight: .semibold)) - Text(detail).font(.system(size: 10)).foregroundStyle(.secondary) - } - Spacer() - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(EvidenceStyle.surface, in: RoundedRectangle(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).stroke(EvidenceStyle.separator) } - } - - @ViewBuilder - private var localUsageDetails: some View { - if let report = model.usageReport { - let projection = model.usageProjection(for: report) - VStack(alignment: .leading, spacing: 14) { - HStack { - VStack(alignment: .leading, spacing: 3) { - PremiumFieldLabel("LOCAL ACTIVITY · NOT PROVIDER ALLOWANCE") - Text("Token and session evidence") - .font(.system(size: 14, weight: .semibold)) - } - Spacer() - StatusPill(label: report.status.label, color: report.status.color) - } - metrics(report, projection: projection) - adapterHealth(report) - HStack(alignment: .top, spacing: 14) { - modelPanel(projection) - sessionsPanel(projection) - } - } - } else { - allowanceStatus( - icon: model.usageLoading ? "arrow.triangle.2.circlepath" : "chart.bar.xaxis", - title: model.usageLoading ? "Loading local activity…" : "Local activity unavailable", - detail: model.usageIssue ?? "Local activity is optional and does not affect allowance.", - color: model.usageLoading ? EvidenceStyle.amber : EvidenceStyle.warning - ) - } - } - - private func devinPanel(_ summary: DevinUsageSummary) -> some View { - let projection = summary.projection(for: model.usageWindow) - let availability = summary.availability(for: model.usageWindow) - let title = - switch availability { - case .unavailable: "Devin local history unavailable" - case .active: "Indexed Devin activity" - case .empty: "No Devin activity \(model.usageWindow.description)" - } - let pillLabel = - switch availability { - case .unavailable: "unavailable" - case .empty: "empty" - case .active: "\(model.usageWindow.rawValue) local history" - } - return VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 18) { - VStack(alignment: .leading, spacing: 4) { - PremiumFieldLabel("DEVIN · SEPARATE LOCAL SOURCE") - Text(title) - .font(.system(size: 15, weight: .semibold)) - Text(summary.source) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - } - .frame(width: 260, alignment: .leading) - Divider().frame(height: 48) - devinMetric( - value: compact(UInt64(max(projection.sessions, 0))), - label: "SESSIONS" - ) - devinMetric( - value: compact(UInt64(max(projection.generatedTokens, 0))), - label: "GENERATED" - ) - devinMetric( - value: compact(UInt64(max(projection.cacheReadTokens, 0))), - label: "CACHE READ" - ) - devinMetric( - value: currency(projection.costUSD), - label: "LOCAL COST" - ) - Spacer(minLength: 0) - StatusPill( - label: pillLabel, - color: availability == .active ? EvidenceStyle.success : EvidenceStyle.warning - ) - } - - HStack(spacing: 6) { - if !projection.models.isEmpty { - ForEach(projection.models.prefix(6)) { model in - HStack(spacing: 6) { - Text(model.model) - Text(compact(UInt64(max(model.generatedTokens, 0)))) - .foregroundStyle(.secondary) - } - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .padding(.horizontal, 9) - .frame(height: 27) - .background(EvidenceStyle.inspector, in: Capsule()) - .overlay { Capsule().stroke(EvidenceStyle.separator) } - } - } - Spacer(minLength: 10) - Text(summary.limitations.joined(separator: " · ")) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(2) - .multilineTextAlignment(.trailing) - } - } - .padding(18) - .background(EvidenceStyle.surface, in: RoundedRectangle(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).stroke(EvidenceStyle.separator) } - .accessibilityElement(children: .contain) - .accessibilityLabel("Separate Devin local usage") - } - - private func devinMetric(value: String, label: String) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(value) - .font(.system(size: 16, weight: .semibold, design: .monospaced)) - Text(label) - .font(.system(size: 10, weight: .bold, design: .monospaced)) - .tracking(0.8) - .foregroundStyle(.secondary) - } - .frame(minWidth: 88, alignment: .leading) - } - - private func metrics(_ report: LocalUsageReport, projection: UsageViewProjection) -> some View { - HStack(spacing: 8) { - UsageMetricCard( - value: compact(projection.totals.generatedTokens), - label: "GENERATED TOKENS", - detail: model.usageWindow.description - ) - UsageMetricCard( - value: compact(projection.totals.cacheReadTokens), - label: "CACHE READ", - detail: cacheShare(projection.totals) - ) - UsageMetricCard( - value: currency(projection.totals.costUSD), - label: "LOCAL LOG COST", - detail: report.provenance.pricingComplete ? "priced models complete" : "pricing has gaps" - ) - UsageMetricCard( - value: compact(UInt64(projection.sessionCount)), - label: "SESSIONS", - detail: "\(projection.activeDays) active days" - ) - } - } - - private func trendPanel(_ report: LocalUsageReport, projection: UsageViewProjection) -> some View - { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - PremiumFieldLabel("LOCAL HISTORY · NOT PROVIDER ALLOWANCE") - Text("Historical usage") - .font(.system(size: 15, weight: .semibold)) - Text( - model.usageMetric.caption - ) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - } - Spacer() - VStack(alignment: .trailing, spacing: 6) { - UsageWindowSwitch(selection: $model.usageWindow, scale: $model.usageScale) - UsageScaleSwitch(selection: $model.usageScale) - } - } - - HStack(spacing: 6) { - ForEach(report.provenance.detectedAgents, id: \.self) { agent in - let selected = model.usageSelectedAgents.contains(agent) - Button { - model.toggleUsageAgent(agent) - } label: { - HStack(spacing: 5) { - Circle().fill(agentColor(agent)).frame(width: 6, height: 6) - Text(agent.capitalized) - } - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(selected ? EvidenceStyle.amberForeground : Color.secondary) - .padding(.horizontal, 10) - .premiumHitTarget(minWidth: 70, minHeight: 36) - .overlay(alignment: .bottom) { - Rectangle() - .fill(selected ? EvidenceStyle.amberForeground : EvidenceStyle.separator) - .frame(height: selected ? 2 : 1) - } - } - .buttonStyle(.plain) - .accessibilityLabel("Filter \(agent.capitalized)") - .accessibilityValue(selected ? "Included" : "Excluded") - } - Spacer() - UsageOptionSwitch(selection: $model.usageDimension) - UsageOptionSwitch(selection: $model.usageMetric) - } - - UsageHistoryView( - history: projection.history, pricingComplete: report.provenance.pricingComplete) - } - .padding(16) - .background(EvidenceStyle.surface, in: RoundedRectangle(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).stroke(EvidenceStyle.separator) } - } - - private func adapterHealth(_ report: LocalUsageReport) -> some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - PremiumFieldLabel("ADAPTER HEALTH") - Spacer() - Circle().fill(report.status.color).frame(width: 7, height: 7) - } - UsageFact(label: "ENGINE", value: "\(report.provenance.engine) \(report.provenance.version)") - UsageFact(label: "TIMEZONE", value: report.provenance.timezone) - UsageFact(label: "WINDOW", value: report.provenance.window) - UsageFact( - label: "PRICING", - value: report.provenance.pricingComplete ? "Complete" : "Review gaps", - color: report.provenance.pricingComplete ? EvidenceStyle.success : EvidenceStyle.warning - ) - UsageFact( - label: "SOURCE", - value: report.provenance.sourceFingerprint.isEmpty - ? "Unavailable" : String(report.provenance.sourceFingerprint.prefix(22)) + "…" - ) - if !report.provenance.fallbackModels.isEmpty { - Divider() - PremiumFieldLabel("FALLBACK PRICING") - Text(report.provenance.fallbackModels.joined(separator: " · ")) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(EvidenceStyle.warning) - .lineLimit(3) - } - if let error = report.error { - Divider() - Label(error.message, systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 10)) - .foregroundStyle(EvidenceStyle.warning) - } - Spacer(minLength: 0) - Text("Read-only · offline · no quota inference") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(18) - .frame(minHeight: 286, alignment: .topLeading) - .background(EvidenceStyle.inspector, in: RoundedRectangle(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).stroke(EvidenceStyle.separator) } - } - - private func modelPanel(_ projection: UsageViewProjection) -> some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - VStack(alignment: .leading, spacing: 3) { - PremiumFieldLabel("MODEL MIX") - Text("Work by model").font(.system(size: 14, weight: .semibold)) - } - Spacer() - Text("generated") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(16) - Divider() - let rows = projection.models - if rows.isEmpty { - Text("No model activity in this local report.") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .padding(18) - } else { - ForEach(rows.prefix(8)) { row in - HStack(spacing: 10) { - Circle().fill(row.priced ? EvidenceStyle.success : EvidenceStyle.warning) - .frame(width: 6, height: 6) - Text(row.model) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .lineLimit(1) - if row.fallback { - Text("FALLBACK") - .font(.system(size: 10, weight: .bold, design: .monospaced)) - .foregroundStyle(EvidenceStyle.warning) - } - Spacer() - Text(compact(row.totals.generatedTokens)) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - } - .padding(.horizontal, 16) - .frame(height: 34) - .overlay(alignment: .bottom) { - Rectangle().fill(EvidenceStyle.separator).frame(height: 1) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .topLeading) - .background(EvidenceStyle.surface, in: RoundedRectangle(cornerRadius: 14)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).stroke(EvidenceStyle.separator) } - } - - private func sessionsPanel(_ projection: UsageViewProjection) -> some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - VStack(alignment: .leading, spacing: 3) { - PremiumFieldLabel("RECENT SESSIONS") - Text("Local agent activity").font(.system(size: 14, weight: .semibold)) - } - Spacer() - Text("showing \(projection.recentSessions.count) of \(projection.sessionCount)") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(16) - Divider() - let sessions = projection.recentSessions - if sessions.isEmpty { - Text("No sessions match the selected agents.") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .padding(18) - } else { - ForEach(sessions) { session in - HStack(spacing: 10) { - Circle().fill(agentColor(session.agent)).frame(width: 6, height: 6) - VStack(alignment: .leading, spacing: 2) { - Text(session.agent.capitalized) - .font(.system(size: 10, weight: .semibold)) - Text(session.lastActivity ?? session.sessionID) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Text(compact(session.totals.generatedTokens)) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - } - .padding(.horizontal, 16) - .frame(height: 42) - .overlay(alignment: .bottom) { - Rectangle().fill(EvidenceStyle.separator).frame(height: 1) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .topLeading) - .background(EvidenceStyle.surface, in: RoundedRectangle(cornerRadius: 14)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay { RoundedRectangle(cornerRadius: 14).stroke(EvidenceStyle.separator) } - } - - private var loadingDesk: some View { - VStack(spacing: 14) { - ProgressView().controlSize(.small) - Text("Reading local usage evidence…") - .font(.system(size: 12, weight: .medium)) - Text("The Rust adapter is normalizing pinned ccusage output offline.") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - } - } - - private var unavailableDesk: some View { - ContentUnavailableView( - "Local usage unavailable", - systemImage: "chart.bar.xaxis", - description: Text( - model.usageIssue - ?? "Refresh to read Claude, Codex, and Grok activity from the pinned local adapter." - ) - ) - } - -} - -/// Allowance health bands approved for the unified Usage card: red at or -/// under 20 percent remaining, yellow through 40, green above, with zero -/// called out as exhausted. Pace is reported separately and only when the -/// provider window's duration/reset metadata is trustworthy. -private func allowanceHealth(_ remaining: Double) -> (label: String, color: Color) { - switch remaining { - case .. some View { - VStack(alignment: .leading, spacing: 4) { - Text(window.label.replacingOccurrences(of: " window", with: "").uppercased()) - .font(.caption2.weight(.bold).monospaced()) - .tracking(0.55) - .foregroundStyle(.secondary) - .lineLimit(1) - Text("\(window.remainingPercent, specifier: "%.0f")%") - .font(.system(size: 32, weight: .semibold, design: .rounded)) - .foregroundStyle(allowanceHealth(window.remainingPercent).color) - HStack(spacing: 5) { - Text("remaining") - .font(.caption) - .foregroundStyle(.secondary) - Text(allowanceHealth(window.remainingPercent).label) - .font(.caption2.weight(.bold).monospaced()) - .foregroundStyle(allowanceHealth(window.remainingPercent).color) - } - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(Color.primary.opacity(0.07)) - Capsule() - .fill(allowanceHealth(window.remainingPercent).color) - .frame( - width: geometry.size.width * max(0, min(window.remainingPercent / 100, 1))) - } - } - .frame(height: 4) - if let pace = paceLabel(window) { - Text(pace) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - } - if let reset = resetLabel(window) { - Text(reset) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .frame(minWidth: 130, maxWidth: 190, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel( - [ - "\(window.label), \(Int(window.remainingPercent.rounded())) percent remaining", - resetLabel(window), - ].compactMap { $0 }.joined(separator: ". ")) - } - - private func creditText(_ credits: ProviderCreditBalance) -> String? { - if let limit = credits.limitAmount, let used = credits.usedAmount { - return "$\(Int(max(0, limit - used).rounded())) credits" - } - if let remaining = credits.remainingPercent { - return "\(Int(remaining.rounded()))% credits" - } - return nil - } - - /// Pace compares allowance remaining with the fraction of the provider - /// window still open — percentage points over or under an even-use rate, - /// never a spending forecast. Shown only when duration and reset metadata - /// are both trustworthy; otherwise pace stays silent rather than healthy. - private func paceLabel(_ window: ProviderQuotaWindow) -> String? { - guard let duration = window.windowDurationMinutes, duration > 0, - let resetsAt = window.resetsAtUnix else { return nil } - let now = Date().timeIntervalSince1970 - let windowSeconds = Double(duration) * 60 - let remaining = (Double(resetsAt) - now) / windowSeconds - guard remaining > 0, remaining <= 1 else { return nil } - let delta = window.remainingPercent - remaining * 100 - if abs(delta) < 1 { return "On even-use pace" } - return delta > 0 - ? "+\(Int(delta.rounded())) pts ahead of even pace" - : "\(Int(delta.rounded())) pts behind even pace" - } - - private func resetLabel(_ window: ProviderQuotaWindow) -> String? { - if let description = window.resetDescription { - return "Resets \(description)" - } - guard let timestamp = window.resetsAtUnix else { return nil } - let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) - return "Resets \(date.formatted(date: .abbreviated, time: .shortened))" - } -} - -enum ProviderBrandAsset { - private static let images: [String: NSImage] = Dictionary( - uniqueKeysWithValues: ["claude", "codex"].compactMap { provider in - guard let url = resourceURL(for: provider), let image = NSImage(contentsOf: url) else { - return nil - } - return (provider, image) - }) - - static func resourceURL(for provider: String) -> URL? { - Bundle.module.url(forResource: "provider-\(provider)", withExtension: "svg") - } - - static func image(for provider: String) -> NSImage? { - images[provider] - } -} - -private struct ProviderBrandMark: View { - let provider: String - let accent: Color - - var body: some View { - Group { - if let image = ProviderBrandAsset.image(for: provider) { - Image(nsImage: image) - .renderingMode(provider == "codex" ? .template : .original) - .resizable() - .scaledToFit() - .foregroundStyle(provider == "codex" ? Color.primary : accent) - } else { - Image(systemName: provider == "claude" ? "sparkles" : "terminal.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(accent) - } - } - .frame(width: 26, height: 26) - .padding(7) - .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) - .accessibilityHidden(true) - } -} - -private struct UsageMetricCard: View { - let value: String - let label: String - let detail: String - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(value).font(.system(size: 24, weight: .medium, design: .rounded)) - PremiumFieldLabel(label) - Text(detail).font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(EvidenceStyle.surface, in: RoundedRectangle(cornerRadius: 12)) - .overlay { RoundedRectangle(cornerRadius: 12).stroke(EvidenceStyle.separator) } - } -} - -private struct UsageFact: View { - let label: String - let value: String - var color: Color = .primary - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - PremiumFieldLabel(label) - Text(value) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(color) - .lineLimit(1) - } - } -} - -private struct UsageScaleSwitch: View { - @Binding var selection: UsageScale - - var body: some View { - HStack(spacing: 3) { - ForEach(UsageScale.allCases) { scale in - Button { - selection = scale - } label: { - Text(scale.rawValue) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle( - selection == scale ? EvidenceStyle.amberForeground : Color.secondary - ) - .premiumHitTarget(minWidth: 58, minHeight: 34) - .overlay(alignment: .bottom) { - Rectangle() - .fill(selection == scale ? EvidenceStyle.amberForeground : Color.clear) - .frame(height: 2) - } - } - .buttonStyle(.plain) - .accessibilityValue(selection == scale ? "Selected" : "") - .accessibilityAddTraits(selection == scale ? .isSelected : []) - } - } - .padding(3) - .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 9)) - .overlay { RoundedRectangle(cornerRadius: 9).stroke(EvidenceStyle.separator) } - .accessibilityElement(children: .contain) - .accessibilityLabel("Usage scale") - } -} - -private struct UsageWindowSwitch: View { - @Binding var selection: UsageWindow - @Binding var scale: UsageScale - - var body: some View { - HStack(spacing: 3) { - ForEach(UsageWindow.allCases) { window in - Button { - selection = window - if window == .oneWeek { scale = .day } - } label: { - Text(window.rawValue) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle( - selection == window ? EvidenceStyle.amberForeground : Color.secondary - ) - .premiumHitTarget(minWidth: 40, minHeight: 34) - .padding(.horizontal, 3) - .overlay(alignment: .bottom) { - Rectangle() - .fill(selection == window ? EvidenceStyle.amberForeground : Color.clear) - .frame(height: 2) - } - } - .buttonStyle(.plain) - .accessibilityLabel(window.description) - .accessibilityValue(selection == window ? "Selected" : "") - .accessibilityAddTraits(selection == window ? .isSelected : []) - } - } - .padding(3) - .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 9)) - .overlay { RoundedRectangle(cornerRadius: 9).stroke(EvidenceStyle.separator) } - .accessibilityElement(children: .contain) - .accessibilityLabel("Usage time range") - } -} - -/// Small segmented switch shared by the unified history card's dimension and -/// metric toggles, matching the window/scale switch styling. -private struct UsageOptionSwitch: View -where Option.RawValue == String -{ - @Binding var selection: Option - - var body: some View { - HStack(spacing: 3) { - ForEach(Array(Option.allCases), id: \.self) { option in - Button { - selection = option - } label: { - Text(option.rawValue) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle( - selection == option ? EvidenceStyle.amberForeground : Color.secondary - ) - .premiumHitTarget(minWidth: 52, minHeight: 34) - .overlay(alignment: .bottom) { - Rectangle() - .fill(selection == option ? EvidenceStyle.amberForeground : Color.clear) - .frame(height: 2) - } - } - .buttonStyle(.plain) - .accessibilityValue(selection == option ? "Selected" : "") - .accessibilityAddTraits(selection == option ? .isSelected : []) - } - } - .padding(3) - .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 9)) - .overlay { RoundedRectangle(cornerRadius: 9).stroke(EvidenceStyle.separator) } - } -} - -/// Restrained series palette for the unified history chart. Model identity -/// stays in blue/lavender/neutral territory per the approved direction; quota -/// semantics keep red/yellow/green exclusively for allowance. -private let usageSeriesPalette: [Color] = [ - Color(red: 0.43, green: 0.63, blue: 0.96), - Color(red: 0.62, green: 0.55, blue: 0.90), - Color(red: 0.36, green: 0.72, blue: 0.66), - Color(red: 0.85, green: 0.65, blue: 0.45), - Color(red: 0.60, green: 0.64, blue: 0.70), -] - -private func usageSeriesColor(_ index: Int) -> Color { - usageSeriesPalette[index % usageSeriesPalette.count] -} - -/// Stacked per-period bars sharing the exact series the ranked breakdown -/// lists, so segments and row totals always reconcile. The top five entities -/// render individually; the remainder collapse into Other. -private struct UsageStackedChart: View { - let series: [UsageTrendSeries] - let periodKeys: [String] - let metric: UsageHistoryMetric - let scale: UsageScale - - private var visibleSeries: [(name: String, points: [UsageSeriesPoint])] { - let head = series.prefix(5).map { ($0.name, $0.points) } - guard series.count > 5 else { return head } - var other = Array(repeating: UsageSeriesPoint(), count: periodKeys.count) - for tail in series.dropFirst(5) { - for (index, point) in tail.points.enumerated() { - other[index].tokens &+= point.tokens - other[index].costUSD += point.costUSD - } - } - return head + [("Other", other)] - } - - private func color(for name: String, index: Int) -> Color { - if name == "Unattributed" || name == "Other" { return Color.secondary.opacity(0.35) } - return usageSeriesColor(index) - } - - private func value(_ point: UsageSeriesPoint) -> Double { - point.value(for: metric) - } - - private func format(_ value: Double) -> String { - metric == .tokens ? compact(UInt64(max(value, 0))) : currency(value) - } - - var body: some View { - let visible = visibleSeries - if visible.isEmpty || periodKeys.isEmpty { - ContentUnavailableView("No local activity", systemImage: "chart.bar") - } else { - GeometryReader { geometry in - let periodTotals = periodKeys.indices.map { index in - visible.reduce(0.0) { $0 + value($1.points[index]) } - } - let maximum = max(periodTotals.max() ?? 0, 1e-9) - ZStack(alignment: .bottom) { - HStack(alignment: .bottom, spacing: scale == .day ? 3 : 7) { - ForEach(periodKeys.indices, id: \.self) { periodIndex in - VStack(spacing: 0) { - ForEach(visible.indices.reversed(), id: \.self) { seriesIndex in - let share = value(visible[seriesIndex].points[periodIndex]) / maximum - Rectangle() - .fill(color(for: visible[seriesIndex].name, index: seriesIndex)) - .frame( - height: max( - share > 0 ? 2 : 0, - (geometry.size.height - 22) * CGFloat(share) - ) - ) - } - } - .clipShape(RoundedRectangle(cornerRadius: 3)) - .help( - "\(periodKeys[periodIndex]): " - + visible - .filter { value($0.points[periodIndex]) > 0 } - .map { "\($0.name) \(format(value($0.points[periodIndex])))" } - .joined(separator: " · ") - ) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .ignore) - .accessibilityLabel(periodKeys[periodIndex]) - .accessibilityValue(format(periodTotals[periodIndex])) - } - } - .padding(.bottom, 18) - .overlay(alignment: .bottom) { - Rectangle().fill(EvidenceStyle.separator).frame(height: 1).offset(y: -17) - } - HStack { - Text(shortLabel(periodKeys.first ?? "")) - Spacer() - Text(shortLabel(periodKeys.last ?? "")) - } - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - - private func shortLabel(_ value: String) -> String { - if value.count > 7 { return String(value.suffix(5)) } - return value - } -} - -/// Ranked exact-value rows for the same series the chart stacks, with a share -/// bar relative to the window total. Unknown project sessions stay visible as -/// Unattributed rather than disappearing. -private struct UsageBreakdownList: View { - let series: [UsageTrendSeries] - let metric: UsageHistoryMetric - - private var grandTotal: Double { - series.reduce(0.0) { $0 + value($1) } - } - - private func value(_ series: UsageTrendSeries) -> Double { - metric == .tokens ? Double(series.totalTokens) : series.totalCostUSD - } - - private func format(_ value: Double) -> String { - metric == .tokens ? compact(UInt64(max(value, 0))) : currency(value) - } - - var body: some View { - let total = max(grandTotal, 1e-9) - VStack(spacing: 0) { - ForEach(Array(series.prefix(8).enumerated()), id: \.element.id) { index, row in - HStack(spacing: 10) { - Circle() - .fill(row.name == "Unattributed" ? Color.secondary.opacity(0.35) : usageSeriesColor(index)) - .frame(width: 6, height: 6) - VStack(alignment: .leading, spacing: 1) { - Text(row.name) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .lineLimit(1) - if let detail = row.detail { - Text(detail) - .font(.system(size: 9, design: .monospaced)) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) - } - } - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(Color.primary.opacity(0.05)) - Capsule() - .fill(row.name == "Unattributed" ? Color.secondary.opacity(0.3) : usageSeriesColor(index)) - .frame(width: geometry.size.width * min(value(row) / total, 1)) - } - } - .frame(width: 90, height: 3) - Text(String(format: "%.0f%%", value(row) / total * 100)) - .font(.system(size: 9, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 32, alignment: .trailing) - Text(format(value(row))) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .frame(minWidth: 52, alignment: .trailing) - } - .frame(height: row.detail == nil ? 30 : 38) - .overlay(alignment: .bottom) { - Rectangle().fill(EvidenceStyle.separator.opacity(0.6)).frame(height: 1) - } - } - if series.count > 8 { - Text("+ \(series.count - 8) more \(series.count - 8 == 1 ? "entry" : "entries")") - .font(.system(size: 9, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(height: 24) - } - } - } -} - -extension LocalUsageStatus { - fileprivate var label: String { - switch self { - case .ready: "Local data ready" - case .stale: "Showing stale data" - case .unavailable: "Adapter unavailable" - } - } - - fileprivate var color: Color { - switch self { - case .ready: EvidenceStyle.success - case .stale: EvidenceStyle.warning - case .unavailable: EvidenceStyle.failure - } - } -} - -private func compact(_ value: UInt64) -> String { - switch value { - case 1_000_000_000...: String(format: "%.1fB", Double(value) / 1_000_000_000) - case 1_000_000...: String(format: "%.1fM", Double(value) / 1_000_000) - case 1_000...: String(format: "%.1fK", Double(value) / 1_000) - default: String(value) - } -} - -private func currency(_ value: Double) -> String { - if value >= 1_000 { - return String(format: "$%.1fK", value / 1_000) - } - return String(format: "$%.2f", value) -} - -private func cacheShare(_ totals: LocalUsageTotals) -> String { - guard totals.totalTokens > 0 else { return "no token activity" } - return String( - format: "%.0f%% of all tokens", - Double(totals.cacheReadTokens) / Double(totals.totalTokens) * 100 - ) -} - -private func agentColor(_ agent: String) -> Color { - switch agent { - case "claude": Color(red: 0.91, green: 0.47, blue: 0.28) - case "codex": EvidenceStyle.success - case "grok": Color(red: 0.43, green: 0.63, blue: 0.96) - default: EvidenceStyle.amber - } -} diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumWorkbench.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumWorkbench.swift index f41231cd..174efaa1 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumWorkbench.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/PremiumWorkbench.swift @@ -26,7 +26,11 @@ public struct PremiumWorkbenchRootView: View { PremiumTopBar(model: model) switch model.section { case .usage: - PremiumUsageView(model: model) + ContentUnavailableView( + "Agent usage moved to ContextDaddy", + systemImage: "arrow.up.right.square", + description: Text("CodeVetter keeps usage evidence for verification runs. Open ContextDaddy for general agent usage and allowances.") + ) case .repository: NavigatorWorkspaceView(model: model, mode: .explore) case .review: @@ -63,12 +67,7 @@ public struct PremiumWorkbenchRootView: View { .task { model.loadOnboarding() } - .task { - await model.warmUsage() - } .task(id: model.section) { - // Usage owns its own preparation and revalidation inside PremiumUsageView so the - // poll lives exactly as long as the visible section. if model.section == .repository, model.unpackSnapshots.isEmpty { model.loadUnpackSnapshots() } else if model.section == .settings, model.settingsReceipt == nil { @@ -604,7 +603,7 @@ private enum PremiumTopBarMetrics { static let fullNavigationWidth = navigationWidth(showLabels: true) private static func navigationWidth(showLabels: Bool) -> CGFloat { - WorkbenchSection.allCases.reduce(0) { width, section in + WorkbenchSection.navigationSections.reduce(0) { width, section in let iconWidth = NSImage( systemSymbolName: section.systemImage, accessibilityDescription: nil )?.withSymbolConfiguration(symbolConfiguration)?.size.width ?? 11 @@ -616,7 +615,7 @@ private enum PremiumTopBarMetrics { let buttonWidth = max( PremiumPageLayout.navigationControlHeight, contentWidth + horizontalPadding) return width + buttonWidth - } + CGFloat(max(WorkbenchSection.allCases.count - 1, 0)) * 3 + } + CGFloat(max(WorkbenchSection.navigationSections.count - 1, 0)) * 3 } } @@ -669,7 +668,7 @@ private struct PremiumTopBar: View { private func navigation(showLabels: Bool) -> some View { HStack(spacing: 3) { - ForEach(WorkbenchSection.allCases) { section in + ForEach(WorkbenchSection.navigationSections) { section in let showsLabel = workbenchNavigationShowsLabel( for: section, selectedSection: model.section, diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-claude.svg b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-claude.svg deleted file mode 100644 index d8db7a19..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-claude.svg +++ /dev/null @@ -1,5 +0,0 @@ - - Claude - Claude mark from Simple Icons, sourced from the Claude product identity. - - diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-codex.svg b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-codex.svg deleted file mode 100644 index 99b7a882..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/provider-codex.svg +++ /dev/null @@ -1,5 +0,0 @@ - - Codex by OpenAI - OpenAI Blossom used as a compact service identifier for Codex. - - diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/SettingsModels.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/SettingsModels.swift index 3c04afaf..75f629cb 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/SettingsModels.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/SettingsModels.swift @@ -355,7 +355,7 @@ public enum NativeSettingsSection: String, CaseIterable, Identifiable, Sendable case .agentIsland: "Agent Island" case .mcp: "Agent MCP" case .notifications: "Notifications" - case .usage: "Usage" + case .usage: "History" case .rubrics: "Rubrics" case .ops: "Ops" case .memories: "Memories" @@ -373,7 +373,7 @@ public enum NativeSettingsSection: String, CaseIterable, Identifiable, Sendable case .agentIsland: "rectangle.topthird.inset.filled" case .mcp: "arrow.trianglehead.branch" case .notifications: "bell" - case .usage: "chart.bar" + case .usage: "clock.arrow.circlepath" case .rubrics: "checklist" case .ops: "bolt" case .memories: "memorychip" diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageAllowanceState.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageAllowanceState.swift deleted file mode 100644 index 64cf9a87..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageAllowanceState.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -struct UsageAllowanceState: Equatable { - enum Level { case healthy, watch, low, exhausted, unknown } - let level: Level - let paceDelta: Double? - - init( - window: ProviderQuotaWindow, checkedAt: String, available: Bool, - now: Date = Date() - ) { - let parser = ISO8601DateFormatter() - let checked = - parser.date(from: checkedAt) - ?? { - parser.formatOptions.insert(.withFractionalSeconds) - return parser.date(from: checkedAt) - }() - let age = checked.map { now.timeIntervalSince($0) } - let isCurrent = available && age.map { $0 >= -60 && $0 <= 300 } == true - let remaining = window.remainingPercent - let expired = window.resetsAtUnix.map { Double($0) <= now.timeIntervalSince1970 } ?? false - if !isCurrent || expired || !remaining.isFinite || !(0...100).contains(remaining) { - level = .unknown - } else if remaining == 0 { - level = .exhausted - } else if remaining <= 20 { - level = .low - } else if remaining <= 40 { - level = .watch - } else { - level = .healthy - } - if level != .unknown, let minutes = window.windowDurationMinutes, minutes > 0, - let reset = window.resetsAtUnix - { - let duration = Double(minutes) * 60 - let untilReset = Double(reset) - now.timeIntervalSince1970 - paceDelta = - untilReset > 0 && untilReset <= duration - ? remaining - untilReset / duration * 100 : nil - } else { - paceDelta = nil - } - } - - var label: String { - switch level { - case .healthy: "Healthy allowance" - case .watch: "Watch allowance" - case .low: "Low allowance" - case .exhausted: "Allowance exhausted" - case .unknown: "Allowance not current" - } - } - var paceLabel: String { - guard let delta = paceDelta else { return "Pace unavailable" } - if abs(delta) < 1 { return "On even-use pace" } - return String(format: "%.0f pp %@ even-use pace", abs(delta), delta < 0 ? "over" : "under") - } -} diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryProjection.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryProjection.swift deleted file mode 100644 index 962c2e74..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryProjection.swift +++ /dev/null @@ -1,177 +0,0 @@ -import Foundation - -public enum UsageGrouping: String, CaseIterable, Identifiable, Sendable { - case model = "Model" - case project = "Project" - public var id: String { rawValue } -} - -public enum UsageMetric: String, CaseIterable, Identifiable, Sendable { - case tokens = "Tokens" - case cost = "Cost" - case cache = "Cache reads" - public var id: String { rawValue } - - var caption: String { - switch self { - case .tokens: "Generated tokens · input + cache creation + output" - case .cost: "Reported / estimated USD · not subscription spend" - case .cache: "Cache-read tokens · separate from generated tokens" - } - } - - func value(_ totals: LocalUsageTotals) -> Double { - switch self { - case .tokens: Double(totals.generatedTokens) - case .cost: totals.costUSD.isFinite ? max(0, totals.costUSD) : 0 - case .cache: Double(totals.cacheReadTokens) - } - } - - func formatted(_ value: Double, exact: Bool = false) -> String { - if self == .cost { return String(format: exact ? "$%.4f" : "$%.2f", value) } - if exact { return value.formatted(.number.precision(.fractionLength(0))) } - if value >= 1_000_000_000 { return String(format: "%.1fB", value / 1_000_000_000) } - if value >= 1_000_000 { return String(format: "%.1fM", value / 1_000_000) } - if value >= 1_000 { return String(format: "%.1fK", value / 1_000) } - return String(format: "%.0f", value) - } -} - -struct UsageHistorySeries: Identifiable, Sendable { - let id: String - let label: String - let value: Double -} - -struct UsageHistoryBucket: Identifiable, Sendable { - let period: String - let values: [String: Double] - var id: String { period } - var total: Double { values.values.reduce(0, +) } -} - -struct UsageHistoryProjection: Sendable { - let series: [UsageHistorySeries] - let buckets: [UsageHistoryBucket] - /// Values aligned with `buckets` and `visibleSeries` so the Canvas renderer - /// does not repeat dictionary aggregation while drawing the same projection. - let chartValues: [[Double]] - let total: Double - let unattributed: Double - let grouping: UsageGrouping - let metric: UsageMetric - static let unknown = "unattributed" - static let other = "other" - - init( - periods: [LocalUsagePeriod], agents: Set, scale: UsageScale, - grouping: UsageGrouping, metric: UsageMetric - ) { - self.grouping = grouping - self.metric = metric - var grouped: [String: [String: Double]] = [:] - var totals: [String: Double] = [:] - var labels = [Self.unknown: "Unattributed", Self.other: "Other"] - for period in periods { - let key = Self.periodKey(period.period, scale: scale) - for agent in period.agents where agents.isEmpty || agents.contains(agent.agent) { - let amount = metric.value(agent.totals) - var attributed: [String: Double] = [:] - if grouping == .model { - for model in agent.models { - let id = "model:\(model.model)" - labels[id] = model.model - attributed[id, default: 0] += metric.value(model.totals) - } - } else { - for project in period.projects ?? [] where project.agent == agent.agent { - let id = "project:\(project.project)" - labels[id] = project.project - attributed[id, default: 0] += metric.value(project.totals) - } - } - let sum = attributed.values.reduce(0, +) - // Never let inconsistent breakdowns inflate the canonical daily ledger. - if sum > amount + 0.000_001 { attributed.removeAll() } - let remainder = max(0, amount - attributed.values.reduce(0, +)) - if remainder > 0 { attributed[Self.unknown] = remainder } - for (id, value) in attributed where value > 0 { - grouped[key, default: [:]][id, default: 0] += value - totals[id, default: 0] += value - } - if grouped[key] == nil { grouped[key] = [:] } - } - } - total = totals.values.reduce(0, +) - unattributed = totals[Self.unknown] ?? 0 - var sortedSeries = [UsageHistorySeries]() - sortedSeries.reserveCapacity(totals.count) - for (id, value) in totals { - sortedSeries.append(UsageHistorySeries(id: id, label: labels[id] ?? id, value: value)) - } - sortedSeries.sort { $0.value == $1.value ? $0.id < $1.id : $0.value > $1.value } - series = sortedSeries - var points = grouped.keys.sorted().map { UsageHistoryBucket(period: $0, values: grouped[$0]!) } - // Keep all totals while bounding render work, even for all-time daily history. - if points.count > 180 { - var earlier: [String: Double] = [:] - for point in points.prefix(points.count - 179) { - for (id, value) in point.values { earlier[id, default: 0] += value } - } - points = [UsageHistoryBucket(period: "Earlier", values: earlier)] + points.suffix(179) - } - buckets = points - - let renderedSeries = Self.visibleSeries(from: sortedSeries) - let primaryIDs = Set(Array(sortedSeries.prefix(4)).map { $0.id }) - chartValues = points.map { bucket in - renderedSeries.map { item in - if item.id != Self.other { - return bucket.values[item.id] ?? 0 - } - return bucket.values - .filter { !primaryIDs.contains($0.key) } - .values - .reduce(0, +) - } - } - } - - var visibleSeries: [UsageHistorySeries] { - Self.visibleSeries(from: series) - } - - private static func visibleSeries(from series: [UsageHistorySeries]) -> [UsageHistorySeries] { - guard series.count > 5 else { return series } - let first = Array(series.prefix(4)) - return first + [ - UsageHistorySeries( - id: Self.other, label: "Other (\(series.count - 4))", - value: series.dropFirst(4).reduce(0) { $0 + $1.value }) - ] - } - - func value(in bucket: UsageHistoryBucket, series item: UsageHistorySeries) -> Double { - if item.id != Self.other { return bucket.values[item.id] ?? 0 } - let visible = Set(series.prefix(4).map(\.id)) - return bucket.values.filter { !visible.contains($0.key) }.values.reduce(0, +) - } - - private static func periodKey(_ day: String, scale: UsageScale) -> String { - if scale == .day { return day } - if scale == .month { return String(day.prefix(7)) } - let parts = day.split(separator: "-").compactMap { Int($0) } - guard parts.count == 3 else { return day } - var calendar = Calendar(identifier: .iso8601) - calendar.timeZone = TimeZone(secondsFromGMT: 0)! - guard - let date = calendar.date( - from: DateComponents(year: parts[0], month: parts[1], day: parts[2])), - let monday = calendar.date( - byAdding: .day, value: -((calendar.component(.weekday, from: date) + 5) % 7), to: date) - else { return day } - let c = calendar.dateComponents([.year, .month, .day], from: monday) - return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) - } -} diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryView.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryView.swift deleted file mode 100644 index 1ceef6f4..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageHistoryView.swift +++ /dev/null @@ -1,291 +0,0 @@ -import SwiftUI - -/// One shared projection powers both the chart and its exact-value breakdown. -struct UsageHistoryView: View { - let history: UsageHistoryProjection - let pricingComplete: Bool - @State private var selectedPeriod: String? - @State private var expanded = false - @Environment(\.colorScheme) private var colorScheme - - private var selected: UsageHistoryBucket? { - history.buckets.first { $0.period == selectedPeriod } - } - private var rows: [UsageHistorySeries] { - guard let selected else { return history.series } - return history.series.map { - UsageHistorySeries(id: $0.id, label: $0.label, value: selected.values[$0.id] ?? 0) - }.filter { $0.value > 0 }.sorted { $0.value == $1.value ? $0.id < $1.id : $0.value > $1.value } - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(history.metric.caption) - .font(.system(size: 10)).foregroundStyle(.secondary) - if history.buckets.isEmpty { - Text("No local activity in this time range.") - .font(.callout).foregroundStyle(.secondary).frame(maxWidth: .infinity, minHeight: 145) - } else { - // Keep one chart/picker/breakdown tree. ViewThatFits instantiated both - // alternatives on every render, doubling the expensive native controls. - UsageHistoryLayout { - chart - Divider() - breakdown - } - if history.grouping == .project { - Text( - history.unattributed > 0 - ? "Partial project coverage · \(history.metric.formatted(history.unattributed)) unattributed. Claude project records only; other activity stays visible." - : "Project attribution from ccusage Claude records · no repository identity is inferred." - ) - .font(.system(size: 10)).foregroundStyle(.secondary) - } - if history.metric == .cost && !pricingComplete { - Label("Pricing incomplete · known costs only", systemImage: "exclamationmark.triangle") - .font(.system(size: 10)).foregroundStyle(EvidenceStyle.warning) - } - } - } - .onChange(of: history.grouping) { - selectedPeriod = nil - expanded = false - } - .onChange(of: history.metric) { selectedPeriod = nil } - .onChange(of: history.buckets.map(\.period)) { - if selected == nil { selectedPeriod = nil } - } - } - - private var chart: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 12) { - ForEach(Array(history.visibleSeries.enumerated()), id: \.element.id) { index, series in - HStack(spacing: 4) { - RoundedRectangle(cornerRadius: 1).fill(tone(index)).frame(width: 6, height: 6) - Text(series.label).lineLimit(1).help(series.label) - }.font(.system(size: 9)).foregroundStyle(.secondary) - } - } - GeometryReader { geometry in - let buckets = history.buckets - let visibleSeries = history.visibleSeries - let chartValues = history.chartValues - let maximum = max(buckets.map(\.total).max() ?? 0, 1) - let step = geometry.size.width / CGFloat(max(buckets.count, 1)) - Canvas { context, size in - // Batch disjoint bar segments by series instead of submitting a - // separate graphics operation for every period/series pair. - let seriesPaths = (0.. 60 ? 1 : 5))) - let x = CGFloat(index) * step + (step - width) / 2 - var y = size.height - 18 - for seriesIndex in visibleSeries.indices { - let height = - CGFloat(chartValues[index][seriesIndex] / maximum) * (size.height - 38) - y -= height - seriesPaths[seriesIndex].addRect( - CGRect(x: x, y: y, width: width, height: height)) - } - if buckets.count <= 12 { - labels.append((history.metric.formatted(bucket.total), - CGPoint(x: x + width / 2, y: y - 9))) - } - if selectedPeriod == bucket.period { - selectionOutline = Path(CGRect( - x: x - 2, y: y - 2, width: width + 4, - height: size.height - 18 - y + 4)) - } - } - for (index, path) in seriesPaths.enumerated() { - context.fill(Path(path), with: .color(tone(index))) - } - // Keep annotations above the opaque fills, including the inner edge - // of the selected-period outline. - for (label, point) in labels { - context.draw(Text(label).font(.system(size: 9)).foregroundColor(.secondary), at: point) - } - if let selectionOutline { - context.stroke(selectionOutline, with: .color(EvidenceStyle.amberForeground), lineWidth: 1) - } - } - .contentShape(Rectangle()) - .gesture( - SpatialTapGesture().onEnded { value in - let index = min(max(Int(value.location.x / max(step, 1)), 0), buckets.count - 1) - selectedPeriod = selectedPeriod == buckets[index].period ? nil : buckets[index].period - } - ) - .onContinuousHover { phase in - if case .active(let location) = phase { - let index = min(max(Int(location.x / max(step, 1)), 0), buckets.count - 1) - hoverDetail = detail(buckets[index]) - } - } - .help(hoverDetail.isEmpty ? "Select a period to inspect its exact values" : hoverDetail) - .accessibilityLabel("Historical usage chart") - .accessibilityValue( - "\(buckets.count) periods, \(history.metric.formatted(history.total, exact: true)) \(history.metric.rawValue)" - ) - .overlay(alignment: .bottom) { - HStack { - Text(buckets.first?.period ?? "") - Spacer() - Text(buckets.last?.period ?? "") - }.font(.system(size: 9, design: .monospaced)).foregroundStyle(.secondary) - } - }.frame(height: 156) - HStack(spacing: 8) { - // Menu contents are built on demand; the native Picker eagerly creates - // every period item even when the inspection control is never opened. - Menu { - Button("Entire selected range") { selectedPeriod = nil } - ForEach(history.buckets) { bucket in - Button(bucket.period) { selectedPeriod = bucket.period } - } - } label: { - Text(selectedPeriod ?? "Entire selected range") - } - .frame(maxWidth: 210).accessibilityLabel("Inspect usage period") - .accessibilityValue(selectedPeriod ?? "Entire selected range") - Spacer(minLength: 0) - Text("Select a bar for detail").font(.system(size: 9)).foregroundStyle(.secondary) - }.controlSize(.mini) - } - } - - @State private var hoverDetail = "" - - private func detail(_ bucket: UsageHistoryBucket) -> String { - ([bucket.period] - + history.series.compactMap { series in - guard let value = bucket.values[series.id], value > 0 else { return nil } - return "\(series.label): \(history.metric.formatted(value, exact: true))" - }).joined(separator: "\n") - } - - private var breakdown: some View { - VStack(alignment: .leading, spacing: 10) { - Text(selected?.period ?? "SELECTED RANGE") - .font(.system(size: 9, weight: .medium, design: .monospaced)).foregroundStyle(.secondary) - Text(history.metric.formatted(selected?.total ?? history.total)) - .font(.system(size: 25, weight: .semibold)).monospacedDigit() - .help(history.metric.formatted(selected?.total ?? history.total, exact: true)) - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(expanded ? rows : Array(rows.prefix(6))) { row in - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 6) { - Rectangle().fill(seriesTone(row.id)).frame(width: 6, height: 6) - Text(row.label).lineLimit(1).truncationMode(.middle) - Spacer(minLength: 4) - Text(history.metric.formatted(row.value)).monospacedDigit() - }.font(.system(size: 10)) - GeometryReader { geometry in - Rectangle().fill(seriesTone(row.id)) - .frame( - width: geometry.size.width - * min(1, row.value / max(selected?.total ?? history.total, 1))) - }.frame(height: 3).background(EvidenceStyle.separator) - } - .help("\(row.label): \(history.metric.formatted(row.value, exact: true))") - .accessibilityElement(children: .ignore) - .accessibilityLabel(row.label) - .accessibilityValue( - "\(history.metric.formatted(row.value, exact: true)) \(history.metric.rawValue)") - } - if rows.count > 6 { - Button( - expanded - ? "Show fewer" : "Show all \(rows.count) \(history.grouping.rawValue.lowercased())s" - ) { - expanded.toggle() - }.buttonStyle(.plain).font(.system(size: 10)).foregroundStyle( - EvidenceStyle.amberForeground) - } - } - }.frame(height: 153) - } - } - - private func seriesTone(_ id: String) -> Color { - let index = history.visibleSeries.firstIndex { $0.id == id } ?? 4 - return tone(index) - } - - private func tone(_ index: Int) -> Color { - let dark: [Double] = [0.69, 0.55, 0.43, 0.34, 0.27] - let light: [Double] = [0.30, 0.42, 0.53, 0.63, 0.72] - return Color(white: (colorScheme == .dark ? dark : light)[min(index, 4)]) - } -} - -/// A single subview tree responds to the actual proposal, including the first -/// offscreen render. Geometry state updates arrive too late for first-frame layout. -private struct UsageHistoryLayout: Layout { - struct Cache { - struct Measurement { - let horizontal: Bool - let chart: CGSize - let breakdown: CGSize - } - - var measurements: [CGFloat: Measurement] = [:] - } - - func makeCache(subviews: Subviews) -> Cache { - Cache() - } - - func updateCache(_ cache: inout Cache, subviews: Subviews) { - cache = Cache() - } - - func measurement( - for width: CGFloat, subviews: Subviews, cache: inout Cache - ) -> Cache.Measurement { - if let measurement = cache.measurements[width] { - return measurement - } - let horizontal = width >= 565 - let chartWidth = horizontal ? width - 246 : width - let breakdownWidth = horizontal ? 205 : width - let measurement = Cache.Measurement( - horizontal: horizontal, - chart: subviews[0].sizeThatFits(.init(width: chartWidth, height: nil)), - breakdown: subviews[2].sizeThatFits(.init(width: breakdownWidth, height: nil))) - cache.measurements[width] = measurement - return measurement - } - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { - let width = proposal.width ?? 565 - let measurement = measurement(for: width, subviews: subviews, cache: &cache) - return CGSize(width: width, height: measurement.horizontal - ? max(measurement.chart.height, measurement.breakdown.height) - : measurement.chart.height + 16 + measurement.breakdown.height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { - let measurement = measurement(for: bounds.width, subviews: subviews, cache: &cache) - let chartWidth = measurement.horizontal ? bounds.width - 246 : bounds.width - subviews[0].place(at: bounds.origin, anchor: .topLeading, - proposal: .init(width: chartWidth, height: measurement.chart.height)) - subviews[1].place(at: CGPoint(x: bounds.minX + chartWidth + 20, y: bounds.minY), - anchor: .topLeading, proposal: .init(width: measurement.horizontal ? 1 : 0, - height: measurement.horizontal ? bounds.height : 0)) - subviews[2].place(at: CGPoint(x: measurement.horizontal ? bounds.minX + chartWidth + 41 : bounds.minX, - y: measurement.horizontal ? bounds.minY : bounds.minY + measurement.chart.height + 16), anchor: .topLeading, - proposal: .init(width: measurement.horizontal ? 205 : bounds.width, height: nil)) - } -} diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageModels.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageModels.swift deleted file mode 100644 index 4cb4636e..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageModels.swift +++ /dev/null @@ -1,649 +0,0 @@ -import Foundation - -public enum LocalUsageStatus: String, Codable, Sendable { - case ready - case stale - case unavailable -} - -public enum UsageScale: String, CaseIterable, Identifiable, Sendable { - case day = "Day" - case week = "Week" - case month = "Month" - - public var id: String { rawValue } -} - -/// Unified history-card breakdown dimension. Model comes from ccusage -/// period breakdowns; Project comes from session working-directory -/// attribution and keeps unreconciled activity visible as Unattributed. -public enum UsageDimension: String, CaseIterable, Identifiable, Sendable { - case model = "Model" - case project = "Project" - - public var id: String { rawValue } -} - -/// Unified history-card metric. Cost is locally estimated from pinned -/// pricing, never subscription spend. -public typealias UsageHistoryMetric = UsageMetric - -public enum UsageWindow: String, CaseIterable, Identifiable, Sendable { - case oneWeek = "1w" - case thirtyDays = "30d" - case ninetyDays = "90d" - case allTime = "All" - - public var id: String { rawValue } - - fileprivate var dayCount: Int? { - switch self { - case .oneWeek: 7 - case .thirtyDays: 30 - case .ninetyDays: 90 - case .allTime: nil - } - } - - public var description: String { - switch self { - case .oneWeek: "last week" - case .thirtyDays: "last 30 days" - case .ninetyDays: "last 90 days" - case .allTime: "all time" - } - } - - var receiptKey: String { - switch self { - case .oneWeek: "1w" - case .thirtyDays: "30d" - case .ninetyDays: "90d" - case .allTime: "all" - } - } -} - -public struct LocalUsageTotals: Codable, Equatable, Sendable { - public let inputTokens: UInt64 - public let cacheCreationTokens: UInt64 - public let cacheReadTokens: UInt64 - public let outputTokens: UInt64 - public let totalTokens: UInt64 - public let costUSD: Double - - enum CodingKeys: String, CodingKey { - case inputTokens = "input_tokens" - case cacheCreationTokens = "cache_creation_tokens" - case cacheReadTokens = "cache_read_tokens" - case outputTokens = "output_tokens" - case totalTokens = "total_tokens" - case costUSD = "cost_usd" - } - - public static let zero = LocalUsageTotals( - inputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - outputTokens: 0, - totalTokens: 0, - costUSD: 0 - ) - - public init( - inputTokens: UInt64, - cacheCreationTokens: UInt64, - cacheReadTokens: UInt64, - outputTokens: UInt64, - totalTokens: UInt64, - costUSD: Double - ) { - self.inputTokens = inputTokens - self.cacheCreationTokens = cacheCreationTokens - self.cacheReadTokens = cacheReadTokens - self.outputTokens = outputTokens - self.totalTokens = totalTokens - self.costUSD = costUSD - } - - public var generatedTokens: UInt64 { - inputTokens &+ cacheCreationTokens &+ outputTokens - } - - public func adding(_ other: LocalUsageTotals) -> LocalUsageTotals { - LocalUsageTotals( - inputTokens: inputTokens &+ other.inputTokens, - cacheCreationTokens: cacheCreationTokens &+ other.cacheCreationTokens, - cacheReadTokens: cacheReadTokens &+ other.cacheReadTokens, - outputTokens: outputTokens &+ other.outputTokens, - totalTokens: totalTokens &+ other.totalTokens, - costUSD: costUSD + other.costUSD - ) - } -} - -public struct LocalUsageModel: Codable, Identifiable, Sendable { - public let model: String - public let totals: LocalUsageTotals - public let fallback: Bool - public let priced: Bool - - public var id: String { model } -} - -public struct LocalUsageAgent: Codable, Identifiable, Sendable { - public let agent: String - public let totals: LocalUsageTotals - public let models: [LocalUsageModel] - - public var id: String { agent } -} - -public struct LocalUsagePeriod: Codable, Identifiable, Sendable { - public let period: String - public let totals: LocalUsageTotals - public let agents: [LocalUsageAgent] - public let models: [LocalUsageModel] - public var projects: [LocalUsageProject]? = nil - - public var id: String { period } - - public func totals(for selectedAgents: Set) -> LocalUsageTotals { - guard !selectedAgents.isEmpty else { return totals } - return agents.lazy - .filter { selectedAgents.contains($0.agent) } - .reduce(.zero) { $0.adding($1.totals) } - } -} - -public struct LocalUsageProject: Codable, Sendable { - public let project: String - public let agent: String - public let totals: LocalUsageTotals -} - -public struct LocalUsageSession: Codable, Identifiable, Sendable { - public let sessionID: String - public let agent: String - public let lastActivity: String? - /// Working directory recovered from the agent's own session storage. - /// `nil` means unsupported or unreconciled — render as Unattributed. - public let project: String? - public let reasoningOutputTokens: UInt64 - public let totals: LocalUsageTotals - public let models: [LocalUsageModel] - - public var id: String { "\(agent)\u{0}\(sessionID)" } - - enum CodingKeys: String, CodingKey { - case agent, totals, models, project - case sessionID = "session_id" - case lastActivity = "last_activity" - case reasoningOutputTokens = "reasoning_output_tokens" - } -} - -// Reports contain thousands of totals objects. Decode their primitive fields -// directly from nested containers instead of boxing another Decoder per object. -// The primitive decoders and required/optional wire fields remain unchanged. -extension LocalUsageTotals { - fileprivate init(container: KeyedDecodingContainer) throws { - self.init( - inputTokens: try container.decode(UInt64.self, forKey: .inputTokens), - cacheCreationTokens: try container.decode(UInt64.self, forKey: .cacheCreationTokens), - cacheReadTokens: try container.decode(UInt64.self, forKey: .cacheReadTokens), - outputTokens: try container.decode(UInt64.self, forKey: .outputTokens), - totalTokens: try container.decode(UInt64.self, forKey: .totalTokens), - costUSD: try container.decode(Double.self, forKey: .costUSD)) - } -} - -extension LocalUsageModel { - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - model = try c.decode(String.self, forKey: .model) - totals = try LocalUsageTotals(container: c.nestedContainer(keyedBy: LocalUsageTotals.CodingKeys.self, forKey: .totals)) - fallback = try c.decode(Bool.self, forKey: .fallback) - priced = try c.decode(Bool.self, forKey: .priced) - } -} - -extension LocalUsageAgent { - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - agent = try c.decode(String.self, forKey: .agent) - totals = try LocalUsageTotals(container: c.nestedContainer(keyedBy: LocalUsageTotals.CodingKeys.self, forKey: .totals)) - models = try c.decode([LocalUsageModel].self, forKey: .models) - } -} - -extension LocalUsagePeriod { - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - period = try c.decode(String.self, forKey: .period) - totals = try LocalUsageTotals(container: c.nestedContainer(keyedBy: LocalUsageTotals.CodingKeys.self, forKey: .totals)) - agents = try c.decode([LocalUsageAgent].self, forKey: .agents) - models = try c.decode([LocalUsageModel].self, forKey: .models) - projects = try c.decodeIfPresent([LocalUsageProject].self, forKey: .projects) - } -} - -extension LocalUsageProject { - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - project = try c.decode(String.self, forKey: .project) - agent = try c.decode(String.self, forKey: .agent) - totals = try LocalUsageTotals(container: c.nestedContainer(keyedBy: LocalUsageTotals.CodingKeys.self, forKey: .totals)) - } -} - -extension LocalUsageSession { - public init(from decoder: any Decoder) throws { - try self.init(container: decoder.container(keyedBy: CodingKeys.self)) - } - - fileprivate init(container c: KeyedDecodingContainer) throws { - sessionID = try c.decode(String.self, forKey: .sessionID) - agent = try c.decode(String.self, forKey: .agent) - lastActivity = try c.decodeIfPresent(String.self, forKey: .lastActivity) - project = try c.decodeIfPresent(String.self, forKey: .project) - reasoningOutputTokens = try c.decode(UInt64.self, forKey: .reasoningOutputTokens) - totals = try LocalUsageTotals(container: c.nestedContainer(keyedBy: LocalUsageTotals.CodingKeys.self, forKey: .totals)) - models = try c.decode([LocalUsageModel].self, forKey: .models) - } -} - -public struct LocalUsageProvenance: Codable, Sendable { - public let engine: String - public let version: String - public let generatedAt: String - public let timezone: String - public let window: String - public let detectedAgents: [String] - public let excludedAgents: [String] - public let codexRoots: [String] - public let sourceFingerprint: String - public let pricingComplete: Bool - public let fallbackModels: [String] - public let unpricedModels: [String] - - enum CodingKeys: String, CodingKey { - case engine, version, window - case generatedAt = "generated_at" - case detectedAgents = "detected_agents" - case excludedAgents = "excluded_agents" - case codexRoots = "codex_roots" - case sourceFingerprint = "source_fingerprint" - case pricingComplete = "pricing_complete" - case fallbackModels = "fallback_models" - case unpricedModels = "unpriced_models" - case timezone - } -} - -public struct LocalUsageFailure: Codable, Sendable { - public let category: String - public let message: String -} - -public struct DevinUsageModel: Codable, Identifiable, Sendable { - public let model: String - public let sessions: Int64 - public let generatedTokens: Int64 - public let cacheReadTokens: Int64 - public let costUSD: Double - - public var id: String { model } - - enum CodingKeys: String, CodingKey { - case model, sessions - case generatedTokens = "generated_tokens" - case cacheReadTokens = "cache_read_tokens" - case costUSD = "cost_usd" - } -} - -public struct DevinUsageWindow: Codable, Identifiable, Sendable { - public let window: String - public let since: String? - public let sessions: Int64 - public let generatedTokens: Int64 - public let cacheReadTokens: Int64 - public let costUSD: Double - public let models: [DevinUsageModel] - - public var id: String { window } - - enum CodingKeys: String, CodingKey { - case window, since, sessions, models - case generatedTokens = "generated_tokens" - case cacheReadTokens = "cache_read_tokens" - case costUSD = "cost_usd" - } -} - -public struct DevinUsageSummary: Codable, Sendable { - public let status: String - public let source: String - public let sessions: Int64 - public let generatedTokens: Int64 - public let cacheReadTokens: Int64 - public let outputTokens: Int64 - public let costUSD: Double - public let models: [DevinUsageModel] - public let windows: [DevinUsageWindow]? - public let limitations: [String] - - enum CodingKeys: String, CodingKey { - case status, source, sessions, models, windows, limitations - case generatedTokens = "generated_tokens" - case cacheReadTokens = "cache_read_tokens" - case outputTokens = "output_tokens" - case costUSD = "cost_usd" - } - - public func projection(for window: UsageWindow) -> DevinUsageWindow { - if let projection = windows?.first(where: { $0.window == window.receiptKey }) { - return projection - } - return DevinUsageWindow( - window: "all", - since: nil, - sessions: sessions, - generatedTokens: generatedTokens, - cacheReadTokens: cacheReadTokens, - costUSD: costUSD, - models: models - ) - } - - /// An unreadable Devin history is never the same claim as a quiet window, so the - /// desk distinguishes the two instead of rendering both as zero activity. - public func availability(for window: UsageWindow) -> DevinUsageAvailability { - guard status == "ready" else { return .unavailable } - return projection(for: window).sessions > 0 ? .active : .empty - } -} - -public enum DevinUsageAvailability: Equatable, Sendable { - case unavailable - case empty - case active -} - -public struct LocalUsageReport: Codable, Sendable { - public let status: LocalUsageStatus - public let stale: Bool - public let error: LocalUsageFailure? - public let provenance: LocalUsageProvenance - public let daily: [LocalUsagePeriod] - public let weekly: [LocalUsagePeriod] - public let monthly: [LocalUsagePeriod] - public let sessions: [LocalUsageSession] - public let totals: LocalUsageTotals - public let devin: DevinUsageSummary? - - public func periods(for scale: UsageScale) -> [LocalUsagePeriod] { - switch scale { - case .day: daily - case .week: weekly - case .month: monthly - } - } - - public func periods( - for scale: UsageScale, - window: UsageWindow, - referenceDate: Date = Date() - ) -> [LocalUsagePeriod] { - guard let cutoff = usageWindowCutoff(window, referenceDate: referenceDate) else { - return periods(for: scale) - } - let calendar = usageCalendar() - let referenceDay = usageDayKey(referenceDate) - let cutoffDay = usageDayKey(cutoff) - let lowerWeekDay = usageDayKey( - calendar.date(byAdding: .day, value: -6, to: cutoff) ?? cutoff) - let referenceMonth = String(referenceDay.prefix(7)) - let cutoffMonth = String(cutoffDay.prefix(7)) - return periods(for: scale).filter { period in - switch scale { - case .day: - guard usagePeriodKeyIsValid(period.period, length: 10) else { return false } - return period.period >= cutoffDay && period.period <= referenceDay - case .week: - guard usagePeriodKeyIsValid(period.period, length: 10) else { return false } - return period.period >= lowerWeekDay && period.period <= referenceDay - case .month: - guard usagePeriodKeyIsValid(period.period, length: 7) else { return false } - return period.period >= cutoffMonth && period.period <= referenceMonth - } - } - } - - public func totals( - for selectedAgents: Set, - window: UsageWindow, - referenceDate: Date = Date() - ) -> LocalUsageTotals { - periods(for: .day, window: window, referenceDate: referenceDate) - .reduce(.zero) { $0.adding($1.totals(for: selectedAgents)) } - } - - public func sessions( - for selectedAgents: Set, - window: UsageWindow, - referenceDate: Date = Date() - ) -> [LocalUsageSession] { - let cutoff = usageWindowCutoff(window, referenceDate: referenceDate) - let cutoffKey = cutoff.map(usageUTCInstantKey) - let referenceKey = usageUTCInstantKey(referenceDate) - return sessions.filter { session in - guard selectedAgents.contains(session.agent) else { return false } - guard let cutoff else { return true } - guard let raw = session.lastActivity else { - return false - } - if let rawKey = usageUTCInstantKey(raw), let cutoffKey { - return rawKey >= cutoffKey && rawKey <= referenceKey - } - guard let activity = usageTimestamp(raw) else { return false } - return activity >= cutoff && activity <= referenceDate - } - } -} - -extension LocalUsageReport { - public init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - status = try c.decode(LocalUsageStatus.self, forKey: .status) - stale = try c.decode(Bool.self, forKey: .stale) - error = try c.decodeIfPresent(LocalUsageFailure.self, forKey: .error) - provenance = try c.decode(LocalUsageProvenance.self, forKey: .provenance) - daily = try c.decode([LocalUsagePeriod].self, forKey: .daily) - weekly = try c.decode([LocalUsagePeriod].self, forKey: .weekly) - monthly = try c.decode([LocalUsagePeriod].self, forKey: .monthly) - var items = try c.nestedUnkeyedContainer(forKey: .sessions) - var decoded = [LocalUsageSession]() - decoded.reserveCapacity(items.count ?? 0) - while !items.isAtEnd { - decoded.append(try LocalUsageSession( - container: items.nestedContainer(keyedBy: LocalUsageSession.CodingKeys.self))) - } - sessions = decoded - totals = try LocalUsageTotals(container: c.nestedContainer(keyedBy: LocalUsageTotals.CodingKeys.self, forKey: .totals)) - devin = try c.decodeIfPresent(DevinUsageSummary.self, forKey: .devin) - } -} - -public struct ProviderQuotaReceipt: Codable, Sendable { - public let schemaVersion: String - public let generatedAt: String - public let providers: [ProviderQuotaStatus] - public let limitations: [String] - - enum CodingKeys: String, CodingKey { - case providers, limitations - case schemaVersion = "schema_version" - case generatedAt = "generated_at" - } -} - -public struct ProviderQuotaStatus: Codable, Identifiable, Sendable { - public let provider: String - public let status: String - public let source: String - public let checkedAt: String - public let plan: String? - public let windows: [ProviderQuotaWindow] - public let credits: ProviderCreditBalance? - public let resetCredits: UInt64? - public let message: String? - - public var id: String { provider } - public var isReady: Bool { status == "ready" } - - enum CodingKeys: String, CodingKey { - case provider, status, source, plan, windows, credits, message - case checkedAt = "checked_at" - case resetCredits = "reset_credits" - } -} - -public struct ProviderQuotaWindow: Codable, Identifiable, Sendable { - public let id: String - public let label: String - public let usedPercent: Double - public let remainingPercent: Double - public let windowDurationMinutes: UInt64? - public let resetsAtUnix: Int64? - public let resetDescription: String? - - enum CodingKeys: String, CodingKey { - case id, label - case usedPercent = "used_percent" - case remainingPercent = "remaining_percent" - case windowDurationMinutes = "window_duration_minutes" - case resetsAtUnix = "resets_at_unix" - case resetDescription = "reset_description" - } -} - -public struct ProviderCreditBalance: Codable, Sendable { - public let usedPercent: Double? - public let remainingPercent: Double? - public let usedAmount: Double? - public let limitAmount: Double? - public let currency: String? - public let resetDescription: String? - - enum CodingKeys: String, CodingKey { - case currency - case usedPercent = "used_percent" - case remainingPercent = "remaining_percent" - case usedAmount = "used_amount" - case limitAmount = "limit_amount" - case resetDescription = "reset_description" - } -} - -private func usageCalendar() -> Calendar { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0)! - return calendar -} - -private func usageWindowCutoff(_ window: UsageWindow, referenceDate: Date) -> Date? { - guard let dayCount = window.dayCount else { return nil } - let calendar = usageCalendar() - let today = calendar.startOfDay(for: referenceDate) - return calendar.date(byAdding: .day, value: -(dayCount - 1), to: today) -} - -private nonisolated(unsafe) let usageTimestampFormatter = ISO8601DateFormatter() - -private func usageTimestamp(_ raw: String) -> Date? { - usageTimestampFormatter.date(from: raw) -} - -private struct UsageUTCInstantKey: Comparable { - let second: UInt64 - let nanosecond: UInt32 - - static func < (left: Self, right: Self) -> Bool { - left.second < right.second - || (left.second == right.second && left.nanosecond < right.nanosecond) - } -} - -private func usageUTCInstantKey(_ date: Date) -> UsageUTCInstantKey { - let seconds = floor(date.timeIntervalSince1970) - let fraction = max(0, date.timeIntervalSince1970 - seconds) - return UsageUTCInstantKey( - second: usageUTCInstantKey(usageTimestampFormatter.string(from: date))?.second ?? 0, - nanosecond: UInt32(min(fraction * 1_000_000_000, 999_999_999)) - ) -} - -private func usageUTCInstantKey(_ raw: String) -> UsageUTCInstantKey? { - let bytes = Array(raw.utf8) - guard bytes.count >= 20, bytes.last == 90, - bytes[4] == 45, bytes[7] == 45, bytes[10] == 84, - bytes[13] == 58, bytes[16] == 58 - else { return nil } - var second: UInt64 = 0 - for index in [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] { - guard bytes[index] >= 48, bytes[index] <= 57 else { return nil } - second = second * 10 + UInt64(bytes[index] - 48) - } - var nanosecond: UInt32 = 0 - if bytes.count > 20, bytes[19] == 46 { - var digits = 0 - var index = 20 - while index < bytes.count - 1, digits < 9 { - guard bytes[index] >= 48, bytes[index] <= 57 else { return nil } - nanosecond = nanosecond * 10 + UInt32(bytes[index] - 48) - digits += 1 - index += 1 - } - while digits < 9 { - nanosecond *= 10 - digits += 1 - } - } else if bytes.count != 20 { - return nil - } - return UsageUTCInstantKey(second: second, nanosecond: nanosecond) -} - -private func usageDayKey(_ date: Date) -> String { - String(usageTimestampFormatter.string(from: date).prefix(10)) -} - -private func usagePeriodKeyIsValid(_ raw: String, length: Int) -> Bool { - guard raw.count == length else { return false } - let characters = Array(raw) - guard characters.count == length, characters[4] == "-" else { return false } - if length == 10, characters[7] != "-" { return false } - return characters.enumerated().allSatisfy { index, character in - index == 4 || (length == 10 && index == 7) || character.isNumber - } -} - -private func usageUTCSecondKey(_ raw: String) -> String? { - guard raw.hasSuffix("Z"), raw.count >= 20 else { return nil } - let key = raw.prefix(19) - guard key[key.index(key.startIndex, offsetBy: 4)] == "-", - key[key.index(key.startIndex, offsetBy: 7)] == "-", - key[key.index(key.startIndex, offsetBy: 10)] == "T", - key[key.index(key.startIndex, offsetBy: 13)] == ":", - key[key.index(key.startIndex, offsetBy: 16)] == ":" - else { return nil } - return String(key) -} - -public struct LocalUsageRunResult: Sendable { - public let report: LocalUsageReport - public let rawJSON: String - public let processStatus: Int32 -} diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageSnapshotStore.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageSnapshotStore.swift deleted file mode 100644 index a6fb18f3..00000000 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/UsageSnapshotStore.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation - -struct RestoredUsageSnapshots: Sendable { - let usageReport: LocalUsageReport? - let usageReportJSON: String - let usageSavedAt: Date? - let providerQuota: ProviderQuotaReceipt? - let providerQuotaSavedAt: Date? -} - -public struct UsageSnapshotStore: Sendable { - private static let maximumUsageBytes = 32 * 1_024 * 1_024 - private static let maximumProviderQuotaBytes = 1_024 * 1_024 - - private let directory: URL - - public init(directory: URL? = nil) { - self.directory = directory ?? Self.defaultDirectory() - } - - func restore() -> RestoredUsageSnapshots { - let usage = read( - file: "local-usage.json", - maximumBytes: Self.maximumUsageBytes, - as: LocalUsageReport.self - ) - let quota = read( - file: "provider-quota.json", - maximumBytes: Self.maximumProviderQuotaBytes, - as: ProviderQuotaReceipt.self - ) - return RestoredUsageSnapshots( - usageReport: usage?.value, - usageReportJSON: usage.map { String(decoding: $0.data, as: UTF8.self) } ?? "", - usageSavedAt: usage?.savedAt, - providerQuota: quota?.value, - providerQuotaSavedAt: quota?.savedAt - ) - } - - func saveUsage(rawJSON: String) throws { - let data = Data(rawJSON.utf8) - guard data.count <= Self.maximumUsageBytes else { return } - _ = try JSONDecoder().decode(LocalUsageReport.self, from: data) - try write(data, file: "local-usage.json") - } - - func saveProviderQuota(_ receipt: ProviderQuotaReceipt) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(receipt) - guard data.count <= Self.maximumProviderQuotaBytes else { return } - try write(data, file: "provider-quota.json") - } - - private func read( - file: String, - maximumBytes: Int, - as type: Value.Type - ) -> (value: Value, data: Data, savedAt: Date)? { - let url = directory.appending(path: file) - guard - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attributes[.size] as? NSNumber, - size.intValue > 0, - size.intValue <= maximumBytes, - let data = try? Data(contentsOf: url, options: .mappedIfSafe), - data.count <= maximumBytes, - let value = try? JSONDecoder().decode(type, from: data) - else { return nil } - return ( - value, - data, - attributes[.modificationDate] as? Date ?? .distantPast - ) - } - - private func write(_ data: Data, file: String) throws { - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - let destination = directory.appending(path: file) - try data.write(to: destination, options: .atomic) - try FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: destination.path - ) - } - - private static func defaultDirectory() -> URL { - let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - ?? FileManager.default.temporaryDirectory - return root - .appending(path: "com.codevetter.desktop", directoryHint: .isDirectory) - .appending(path: "usage-snapshots", directoryHint: .isDirectory) - } -} diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/VerificationRunner.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/VerificationRunner.swift index 4170ce6d..89401983 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/VerificationRunner.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/VerificationRunner.swift @@ -2500,88 +2500,6 @@ public final class CodeVetterProcessRunner: @unchecked Sendable { } } - public func runUsage(timezone: String? = nil, refresh: Bool = false) async throws - -> LocalUsageRunResult - { - let executable = try resolveExecutable() - var arguments = ["usage"] - if let timezone, !timezone.isEmpty { - arguments += ["--timezone", timezone] - } - if refresh { - arguments.append("--refresh") - } - arguments.append("--json") - - let execution = try await runTrackedProcess(executable: executable, arguments: arguments) - let output = execution.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) - let errors = execution.stderrString.trimmingCharacters(in: .whitespacesAndNewlines) - guard !output.isEmpty else { - throw VerificationRunnerError.emptyReceipt(errors) - } - do { - let report = try JSONDecoder().decode(LocalUsageReport.self, from: Data(output.utf8)) - let expectedStatus: Int32 = - switch report.status { - case .ready: 0 - case .stale: 1 - case .unavailable: 2 - } - guard execution.status == expectedStatus else { - throw VerificationRunnerError.invalidReceipt( - "Usage state \(report.status.rawValue) conflicts with process status \(execution.status)." - ) - } - return LocalUsageRunResult( - report: report, - rawJSON: output, - processStatus: execution.status - ) - } catch let error as VerificationRunnerError { - throw error - } catch { - throw VerificationRunnerError.invalidReceipt(error.localizedDescription) - } - } - - public func runProviderQuota() async throws -> ProviderQuotaReceipt { - let executable = try resolveExecutable() - let execution = try await runTrackedProcess( - executable: executable, - arguments: ["quota", "--provider", "all", "--json"] - ) - let output = execution.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) - let errors = execution.stderrString.trimmingCharacters(in: .whitespacesAndNewlines) - guard !output.isEmpty else { - throw VerificationRunnerError.emptyReceipt(errors) - } - do { - let receipt = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(output.utf8)) - guard receipt.schemaVersion == "codevetter.provider-quota/v1" else { - throw VerificationRunnerError.invalidReceipt( - "Unsupported provider quota schema \(receipt.schemaVersion).") - } - let ready = receipt.providers.filter(\.isReady).count - let expectedStatus: Int32 = - if ready == receipt.providers.count { - 0 - } else if ready > 0 { - 1 - } else { - 2 - } - guard execution.status == expectedStatus else { - throw VerificationRunnerError.invalidReceipt( - "Provider quota availability conflicts with process status \(execution.status).") - } - return receipt - } catch let error as VerificationRunnerError { - throw error - } catch { - throw VerificationRunnerError.invalidReceipt(error.localizedDescription) - } - } - public func listUnpackSnapshots(repositoryPath: String? = nil, limit: Int = 50) async throws -> UnpackHistoryReceipt { diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/WorkbenchModel.swift b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/WorkbenchModel.swift index 6dfcd1c6..ddb8767e 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/WorkbenchModel.swift +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/WorkbenchModel.swift @@ -1,14 +1,6 @@ import Foundation import Observation -private struct UsageProjectionCacheKey: Hashable { - let agents: String - let window: String - let scale: String - let grouping: UsageGrouping - let metric: UsageMetric -} - public enum WorkbenchSection: String, CaseIterable, Hashable, Identifiable, Sendable { case usage = "Usage" case repository = "Explore" @@ -20,6 +12,9 @@ public enum WorkbenchSection: String, CaseIterable, Hashable, Identifiable, Send public var id: String { rawValue } + /// The retired Usage case remains decodable for legacy deep links, but is not a destination. + public static var navigationSections: [Self] { allCases.filter { $0 != .usage } } + public var systemImage: String { switch self { case .usage: "chart.bar.xaxis" @@ -190,32 +185,6 @@ public final class WorkbenchModel { public var performanceScopeLoading = false public var performanceScopeIssue: String? var performancePlanScopeFingerprint: String? - public var usageReport: LocalUsageReport? { - didSet { - usageProjectionCache.removeAll(keepingCapacity: true) - usageProjectionReferenceDate = Date() - } - } - public var usageReportJSON = "" - public var providerQuotaReceipt: ProviderQuotaReceipt? - public var providerQuotaLoading = false - public var providerQuotaIssue: String? - public var usageScale: UsageScale = .day - public var usageGrouping: UsageGrouping { - get { usageDimension == .model ? .model : .project } - set { usageDimension = newValue == .model ? .model : .project } - } - public var usageWindow: UsageWindow = .thirtyDays - /// Unified history card controls — shared by the chart and the ranked - /// breakdown so both always describe the same filtered evidence. - public var usageDimension: UsageDimension = .model - public var usageMetric: UsageHistoryMetric = .tokens - public var usageSelectedAgents: Set = [] - public var usageTimezone = TimeZone.current.identifier - public var usageLoading = false - public var usageIssue: String? - public private(set) var usageShowingSavedSnapshot = false - public private(set) var providerQuotaShowingSavedSnapshot = false public var unpackSnapshots: [UnpackSnapshotSummary] = [] public var selectedUnpackSnapshotID: String? public var unpackSnapshot: UnpackSnapshotRecord? @@ -281,7 +250,6 @@ public final class WorkbenchModel { private let runner: CodeVetterProcessRunner private let repositoryAccessStore: RepositoryAccessStore? - private let usageSnapshotStore: UsageSnapshotStore private var runTask: Task? private var activeReviewRequestID: String? private var reviewPlanFingerprint: String? @@ -301,24 +269,6 @@ public final class WorkbenchModel { private var performanceTask: Task? private var testingScopeTask: Task? private var performanceScopeTask: Task? - private var usageTask: Task? - private var providerQuotaTask: Task? - private var usageSnapshotRestoreTask: Task? - private var usageSnapshotsRestored = false - public private(set) var usageLastLoadedAt: Date? - private var providerQuotaLastLoadedAt: Date? - /// Local agent-log usage is an offline `ccusage` scan, so it can revalidate often. - @ObservationIgnored public var usageAutoRefreshInterval: TimeInterval = 60 - /// Provider allowance spawns supervised `claude` and `codex` sessions that can take - /// twenty seconds and reach the provider. The cadence is measured from the end of the - /// previous collection, and polling suspends while the app is not frontmost, so this - /// only runs while an operator is actually reading the page. - @ObservationIgnored public var providerQuotaAutoRefreshInterval: TimeInterval = 60 - @ObservationIgnored public var usageAutoRefreshTick: TimeInterval = 5 - @ObservationIgnored private var usageAutoRefreshSuspended = false - @ObservationIgnored private var usageProjectionCache: - [UsageProjectionCacheKey: UsageViewProjection] = [:] - @ObservationIgnored private var usageProjectionReferenceDate = Date() private var unpackTask: Task? private var repositoryQueryWarmTask: Task? private var repositoryQueryTask: Task? @@ -336,12 +286,10 @@ public final class WorkbenchModel { public init( runner: CodeVetterProcessRunner = CodeVetterProcessRunner(), - repositoryAccessStore: RepositoryAccessStore? = nil, - usageSnapshotStore: UsageSnapshotStore = UsageSnapshotStore() + repositoryAccessStore: RepositoryAccessStore? = nil ) { self.runner = runner self.repositoryAccessStore = repositoryAccessStore - self.usageSnapshotStore = usageSnapshotStore do { registry = try CapabilityRegistry.bundled() registryIssue = nil @@ -396,7 +344,7 @@ public final class WorkbenchModel { public var isBusy: Bool { verificationState == .planning || verificationState == .running || testingState == .running - || performanceState == .planning || performanceState == .running || usageLoading + || performanceState == .planning || performanceState == .running || unpackLoading || repositoryQueryLoading || repositoryQueryDetailLoading || onboardingLoading || settingsLoading || !settingsSavingKeys.isEmpty @@ -2040,224 +1988,6 @@ public final class WorkbenchModel { "Ready to inspect another exact workload before project code executes." } - public func restoreUsageSnapshots() async { - guard !usageSnapshotsRestored else { return } - let restoreTask: Task - if let usageSnapshotRestoreTask { - restoreTask = usageSnapshotRestoreTask - } else { - let store = usageSnapshotStore - let task = Task.detached(priority: .utility) { - store.restore() - } - usageSnapshotRestoreTask = task - restoreTask = task - } - - let snapshots = await restoreTask.value - guard !Task.isCancelled else { return } - if usageReport == nil, let report = snapshots.usageReport { - usageReport = report - usageReportJSON = snapshots.usageReportJSON - usageLastLoadedAt = snapshots.usageSavedAt - usageShowingSavedSnapshot = true - let detected = Set(report.provenance.detectedAgents) - usageSelectedAgents = detected - } - if providerQuotaReceipt == nil, let receipt = snapshots.providerQuota { - providerQuotaReceipt = receipt - providerQuotaLastLoadedAt = snapshots.providerQuotaSavedAt - providerQuotaShowingSavedSnapshot = true - } - usageSnapshotsRestored = true - usageSnapshotRestoreTask = nil - } - - public func prepareUsage() async { - await restoreUsageSnapshots() - refreshUsageIfStale() - } - - /// Collects whichever usage surface has outlived its cadence. Both reads are - /// independently gated, so the slow provider allowance never delays local history. - public func refreshUsageIfStale(now: Date = Date()) { - if usageReport == nil - || usageReport?.provenance.timezone != usageTimezone - || needsUsageRefresh(loadedAt: usageLastLoadedAt, now: now, interval: usageAutoRefreshInterval) - { - loadUsage() - } - if providerQuotaReceipt == nil - || needsUsageRefresh( - loadedAt: providerQuotaLastLoadedAt, - now: now, - interval: providerQuotaAutoRefreshInterval - ) - { - loadProviderQuota() - } - } - - /// Drives the Usage section's automatic revalidation. The caller owns the lifetime: - /// SwiftUI cancels this with the view, so the poll never outlives the visible section. - public func runUsageAutoRefresh() async { - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(usageAutoRefreshTick)) - } catch { - return - } - guard !Task.isCancelled else { return } - guard !usageAutoRefreshSuspended else { continue } - refreshUsageIfStale() - } - } - - /// Suspends polling while the app is not frontmost so background windows never - /// spawn provider sessions the operator cannot see. - public func setUsageAutoRefreshSuspended(_ suspended: Bool) { - usageAutoRefreshSuspended = suspended - } - - public func usageWindowBecameActive() { - usageAutoRefreshSuspended = false - refreshUsageIfStale() - } - - public func warmUsage() async { - await restoreUsageSnapshots() - if usageReport == nil { - loadUsage() - } - if providerQuotaReceipt == nil { - loadProviderQuota() - } - } - - public func loadUsage(refresh: Bool = false) { - guard !usageLoading else { return } - usageLoading = true - usageIssue = nil - usageTask = Task { [weak self] in - guard let self else { return } - defer { - usageLoading = false - usageTask = nil - } - do { - let result = try await runner.runUsage(timezone: usageTimezone, refresh: refresh) - guard !Task.isCancelled else { return } - usageLastLoadedAt = Date() - usageShowingSavedSnapshot = false - // A repeat collection over unchanged agent logs carries the same source - // fingerprint. Keeping the accepted report avoids discarding the projection - // cache and re-rendering the whole section underneath the reader. - let fingerprint = result.report.provenance.sourceFingerprint - if !fingerprint.isEmpty, - fingerprint == usageReport?.provenance.sourceFingerprint, - result.report.provenance.timezone == usageReport?.provenance.timezone, - result.report.status == usageReport?.status - { - return - } - usageReport = result.report - usageReportJSON = result.rawJSON - let detected = Set(result.report.provenance.detectedAgents) - usageSelectedAgents.formIntersection(detected) - if usageSelectedAgents.isEmpty { - usageSelectedAgents = detected - } - let store = usageSnapshotStore - let rawJSON = result.rawJSON - Task.detached(priority: .utility) { - try? store.saveUsage(rawJSON: rawJSON) - } - } catch is CancellationError { - // Navigation may cancel this bounded read without changing the last accepted report. - } catch { - usageIssue = error.localizedDescription - } - } - } - - public func loadProviderQuota() { - guard !providerQuotaLoading else { return } - providerQuotaLoading = true - providerQuotaIssue = nil - providerQuotaTask = Task { [weak self] in - guard let self else { return } - defer { - providerQuotaLoading = false - providerQuotaTask = nil - } - do { - let runner = self.runner - let collection = Task.detached(priority: .utility) { - try await runner.runProviderQuota() - } - let receipt = try await withTaskCancellationHandler { - try await collection.value - } onCancel: { - collection.cancel() - } - guard !Task.isCancelled else { return } - providerQuotaReceipt = receipt - providerQuotaLastLoadedAt = Date() - providerQuotaShowingSavedSnapshot = false - let store = usageSnapshotStore - Task.detached(priority: .utility) { - try? store.saveProviderQuota(receipt) - } - } catch is CancellationError { - // Navigation may cancel this bounded read without changing the last accepted receipt. - } catch { - providerQuotaIssue = error.localizedDescription - } - } - } - - private func needsUsageRefresh(loadedAt: Date?, now: Date, interval: TimeInterval) -> Bool { - guard let loadedAt else { return true } - let age = now.timeIntervalSince(loadedAt) - return age < 0 || age >= interval - } - - public func toggleUsageAgent(_ agent: String) { - if usageSelectedAgents.contains(agent) { - guard usageSelectedAgents.count > 1 else { return } - usageSelectedAgents.remove(agent) - } else { - usageSelectedAgents.insert(agent) - } - } - - func usageProjection(for report: LocalUsageReport) -> UsageViewProjection { - let key = UsageProjectionCacheKey( - agents: usageSelectedAgents.sorted().joined(separator: "\u{0}"), - window: usageWindow.rawValue, - scale: usageScale.rawValue, - grouping: usageGrouping, - metric: usageMetric - ) - if let cached = usageProjectionCache[key] { - return cached - } - let projection = UsageViewProjection( - report: report, - selectedAgents: usageSelectedAgents, - window: usageWindow, - scale: usageScale, - referenceDate: usageProjectionReferenceDate, - grouping: usageGrouping, - metric: usageMetric - ) - if usageProjectionCache.count >= 32 { - usageProjectionCache.removeAll(keepingCapacity: true) - } - usageProjectionCache[key] = projection - return projection - } - public func loadUnpackSnapshots() { guard !unpackLoading else { return } repositoryQueryWarmTask?.cancel() diff --git a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift index 5c28bdc4..c1335713 100644 --- a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift +++ b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTestSupport.swift @@ -503,7 +503,7 @@ func renderTrexWatcher(_ model: WorkbenchModel) { } @MainActor -func renderPerformance(_ model: WorkbenchModel) { +func renderWorkbench(_ model: WorkbenchModel) { autoreleasepool { let host = NSHostingView(rootView: PremiumWorkbenchRootView(model: model)) host.appearance = NSAppearance(named: .darkAqua) @@ -513,20 +513,6 @@ func renderPerformance(_ model: WorkbenchModel) { } } -@MainActor -func renderUsage(_ model: WorkbenchModel) { - autoreleasepool { - // The gate measures the already-loaded Usage surface. Repeatedly mounting - // the production lifecycle would start collectors and auto-refresh tasks in - // every sample, contradicting the gate's documented render-only boundary. - let host = NSHostingView(rootView: PremiumUsageView(model: model, startsUsageLifecycle: false)) - host.appearance = NSAppearance(named: .darkAqua) - host.frame = NSRect(x: 0, y: 0, width: 980, height: 640) - host.layoutSubtreeIfNeeded() - host.displayIfNeeded() - } -} - @MainActor func renderUnpack(_ model: WorkbenchModel) { autoreleasepool { @@ -639,15 +625,6 @@ func captureUnpackQuery( try data.write(to: destination, options: .atomic) } -@MainActor -func captureUsage( - _ model: WorkbenchModel, - at destination: URL, - appearance: NSAppearance.Name -) throws { - try captureWorkbench(model, at: destination, appearance: appearance) -} - @MainActor func captureWorkbench( _ model: WorkbenchModel, @@ -1189,26 +1166,10 @@ func nativeSettingsFixtureReceipt( "notify_task_complete", "notifications", "Task Completed", "Notify on task completion.", "toggle", "false", [] ), - ( - "notify_quota_thresholds", "notifications", "Provider Quota Thresholds", - "Observed provider-window telemetry only.", "toggle", "true", [] - ), - ( - "notify_session_usage_thresholds", "notifications", "Session Usage Thresholds", - "Indexed session estimates when enabled.", "toggle", "false", [] - ), ( "notification_sound", "notifications", "Notification Sounds", "Play a local tone.", "toggle", "true", [] ), - ( - "tray_refresh_cadence_secs", "notifications", "Menu Bar Refresh Cadence", - "Polling cadence for observed live usage.", "choice", "300", - [ - ["value": "manual", "label": "Manual only"], - ["value": "300", "label": "Every 5 minutes"], - ] - ), ( "native_agent_island_enabled", "agent_island", "Native Agent Island", "Retain the opt-in native presentation preference.", "toggle", "false", [] @@ -1672,61 +1633,6 @@ func trexWatcherFixture(operation: String) -> String { """ } -func usagePeriod( - _ period: String, - agent: String, - generated: UInt64, - model: String -) -> LocalUsagePeriod { - let totals = localUsageTotals(generated) - let modelUsage = LocalUsageModel(model: model, totals: totals, fallback: false, priced: true) - return LocalUsagePeriod( - period: period, - totals: totals, - agents: [LocalUsageAgent(agent: agent, totals: totals, models: [modelUsage])], - models: [modelUsage] - ) -} - -func usageSession( - _ id: String, - agent: String, - activity: String?, - project: String? = nil -) -> LocalUsageSession { - LocalUsageSession( - sessionID: id, - agent: agent, - lastActivity: activity, - project: project, - reasoningOutputTokens: 0, - totals: localUsageTotals(10), - models: [] - ) -} - -func localUsageTotals(_ generated: UInt64) -> LocalUsageTotals { - LocalUsageTotals( - inputTokens: generated / 2, - cacheCreationTokens: generated / 4, - cacheReadTokens: generated * 2, - outputTokens: generated - generated / 2 - generated / 4, - totalTokens: generated * 3, - costUSD: Double(generated) / 100_000 - ) -} - -func usageTotals(_ generated: UInt64) -> [String: Any] { - [ - "input_tokens": generated / 2, - "cache_creation_tokens": generated / 4, - "cache_read_tokens": generated * 2, - "output_tokens": generated / 4, - "total_tokens": generated * 3, - "cost_usd": Double(generated) / 100_000, - ] -} - @Test func supervisedRunnerPassesTheRepositoryFilterToRust() async throws { let fixtureDirectory = FileManager.default.temporaryDirectory diff --git a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift index bb83a16c..36c81570 100644 --- a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift +++ b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/CodeVetterFeatureTests.swift @@ -25,276 +25,6 @@ import Testing } } -@Test func providerBrandMarksAreBundledAndDecodable() throws { - for provider in ["claude", "codex"] { - #expect(ProviderBrandAsset.resourceURL(for: provider) != nil) - let image = try #require(ProviderBrandAsset.image(for: provider)) - #expect(image.size.width > 0) - #expect(image.size.height > 0) - } -} - -@MainActor -@Test func usageSnapshotsRestoreBeforeLiveCollectionFinishes() async throws { - let directory = FileManager.default.temporaryDirectory - .appending(path: "codevetter-usage-snapshots-\(UUID().uuidString)", directoryHint: .isDirectory) - defer { try? FileManager.default.removeItem(at: directory) } - let store = UsageSnapshotStore(directory: directory) - let usageJSON = - #"{"status":"ready","stale":false,"error":null,"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-09-04T00:00:00Z","timezone":"UTC","window":"all","detected_agents":["claude","codex"],"excluded_agents":[],"codex_roots":[],"source_fingerprint":"sha256:snapshot","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"sessions":[],"totals":{"input_tokens":1,"cache_creation_tokens":2,"cache_read_tokens":3,"output_tokens":4,"total_tokens":10,"cost_usd":0.25}}"# - let quotaJSON = - #"{"schema_version":"codevetter.provider-quota/v1","generated_at":"2026-09-04T00:00:00Z","providers":[{"provider":"codex","status":"ready","source":"fixture","checked_at":"2026-09-04T00:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"Weekly window","used_percent":25,"remaining_percent":75,"window_duration_minutes":10080,"resets_at_unix":null,"reset_description":null}],"credits":null,"reset_credits":null,"message":null}],"limitations":[]}"# - let quota = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(quotaJSON.utf8)) - try store.saveUsage(rawJSON: usageJSON) - try store.saveProviderQuota(quota) - let usagePermissions = try FileManager.default.attributesOfItem( - atPath: directory.appending(path: "local-usage.json").path - )[.posixPermissions] as? NSNumber - - let model = WorkbenchModel(usageSnapshotStore: store) - await model.restoreUsageSnapshots() - - #expect(model.usageReport?.totals.totalTokens == 10) - #expect(model.usageReportJSON == usageJSON) - #expect(model.usageSelectedAgents == ["claude", "codex"]) - #expect(model.providerQuotaReceipt?.providers.first?.provider == "codex") - #expect(model.usageShowingSavedSnapshot) - #expect(model.providerQuotaShowingSavedSnapshot) - #expect(!model.usageLoading) - #expect(!model.providerQuotaLoading) - #expect(usagePermissions?.intValue == 0o600) -} - -@Test func usageSnapshotStoreIgnoresMalformedFiles() throws { - let directory = FileManager.default.temporaryDirectory - .appending(path: "codevetter-invalid-usage-snapshots-\(UUID().uuidString)", directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - try Data("not-json".utf8).write(to: directory.appending(path: "local-usage.json")) - try Data("[]".utf8).write(to: directory.appending(path: "provider-quota.json")) - - let restored = UsageSnapshotStore(directory: directory).restore() - - #expect(restored.usageReport == nil) - #expect(restored.usageReportJSON.isEmpty) - #expect(restored.providerQuota == nil) -} - -@MainActor -@Test func staleUsageSnapshotsStayVisibleWhileCollectorsRevalidate() async throws { - let root = FileManager.default.temporaryDirectory - .appending(path: "codevetter-stale-usage-\(UUID().uuidString)", directoryHint: .isDirectory) - let snapshotDirectory = root.appending(path: "snapshots", directoryHint: .isDirectory) - let executable = root.appending(path: "codevetter") - defer { try? FileManager.default.removeItem(at: root) } - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let usageJSON = - #"{"status":"ready","stale":false,"error":null,"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-09-04T00:00:00Z","timezone":"UTC","window":"all","detected_agents":["codex"],"excluded_agents":[],"codex_roots":[],"source_fingerprint":"sha256:snapshot","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"sessions":[],"totals":{"input_tokens":1,"cache_creation_tokens":2,"cache_read_tokens":3,"output_tokens":4,"total_tokens":10,"cost_usd":0.25}}"# - let quotaJSON = - #"{"schema_version":"codevetter.provider-quota/v1","generated_at":"2026-09-04T00:00:00Z","providers":[{"provider":"codex","status":"ready","source":"fixture","checked_at":"2026-09-04T00:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"Weekly window","used_percent":25,"remaining_percent":75,"window_duration_minutes":10080,"resets_at_unix":null,"reset_description":null}],"credits":null,"reset_credits":null,"message":null}],"limitations":[]}"# - let store = UsageSnapshotStore(directory: snapshotDirectory) - let quota = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(quotaJSON.utf8)) - try store.saveUsage(rawJSON: usageJSON) - try store.saveProviderQuota(quota) - let staleDate = Date().addingTimeInterval(-10 * 60) - for file in ["local-usage.json", "provider-quota.json"] { - try FileManager.default.setAttributes( - [.modificationDate: staleDate], - ofItemAtPath: snapshotDirectory.appending(path: file).path - ) - } - try """ - #!/bin/sh - sleep 0.25 - if [ "$1" = "usage" ]; then - printf '%s' '\(usageJSON)' - else - printf '%s' '\(quotaJSON)' - fi - """.write(to: executable, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - - let model = WorkbenchModel( - runner: CodeVetterProcessRunner(executableURL: executable), - usageSnapshotStore: store - ) - model.usageTimezone = "UTC" - await model.prepareUsage() - - #expect(model.usageReport?.totals.totalTokens == 10) - #expect(model.providerQuotaReceipt?.providers.first?.provider == "codex") - #expect(model.usageShowingSavedSnapshot) - #expect(model.providerQuotaShowingSavedSnapshot) - #expect(model.usageLoading) - #expect(model.providerQuotaLoading) - - while model.usageLoading || model.providerQuotaLoading { - try await Task.sleep(for: .milliseconds(20)) - } - #expect(!model.usageShowingSavedSnapshot) - #expect(!model.providerQuotaShowingSavedSnapshot) -} - -@MainActor -private struct UsageAutoRefreshHarness { - let root: URL - let model: WorkbenchModel - - init(sameFingerprintOnEveryCall: Bool) throws { - root = FileManager.default.temporaryDirectory - .appending(path: "codevetter-usage-poll-\(UUID().uuidString)", directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let executable = root.appending(path: "codevetter") - let usageCalls = root.appending(path: "usage-calls").path - let quotaCalls = root.appending(path: "quota-calls").path - let fingerprint = - sameFingerprintOnEveryCall ? "sha256:constant" : #"sha256:call-'"$count"'"# - let quotaJSON = - #"{"schema_version":"codevetter.provider-quota/v1","generated_at":"2026-09-04T00:00:00Z","providers":[{"provider":"codex","status":"ready","source":"fixture","checked_at":"2026-09-04T00:00:00Z","plan":"pro","windows":[],"credits":null,"reset_credits":null,"message":null}],"limitations":[]}"# - try """ - #!/bin/sh - if [ "$1" = "usage" ]; then - printf 'x' >> "\(usageCalls)" - count=$(wc -c < "\(usageCalls)" | tr -d ' ') - printf '{"status":"ready","stale":false,"error":null,"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-09-04T00:00:0'"$count"'Z","timezone":"UTC","window":"all","detected_agents":["codex"],"excluded_agents":[],"codex_roots":[],"source_fingerprint":"\(fingerprint)","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"sessions":[],"totals":{"input_tokens":1,"cache_creation_tokens":2,"cache_read_tokens":3,"output_tokens":4,"total_tokens":10,"cost_usd":0.25}}' - else - printf 'x' >> "\(quotaCalls)" - printf '%s' '\(quotaJSON)' - fi - """.write(to: executable, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], ofItemAtPath: executable.path) - - model = WorkbenchModel( - runner: CodeVetterProcessRunner(executableURL: executable), - usageSnapshotStore: UsageSnapshotStore( - directory: root.appending(path: "snapshots", directoryHint: .isDirectory)) - ) - model.usageTimezone = "UTC" - model.usageAutoRefreshTick = 0.05 - model.usageAutoRefreshInterval = 0.1 - model.providerQuotaAutoRefreshInterval = 600 - } - - func usageCollectionCount() -> Int { - let data = try? Data(contentsOf: root.appending(path: "usage-calls")) - return data?.count ?? 0 - } - - func cleanUp() { - try? FileManager.default.removeItem(at: root) - } - - func settle() async throws { - while model.usageLoading || model.providerQuotaLoading { - try await Task.sleep(for: .milliseconds(20)) - } - } - - func waitForUsageCollections(atLeast target: Int) async throws -> Bool { - let deadline = Date().addingTimeInterval(10) - while Date() < deadline { - if usageCollectionCount() >= target { return true } - try await Task.sleep(for: .milliseconds(20)) - } - return false - } -} - -@MainActor -@Test func usageRecollectsOnItsOwnAfterTheCadenceElapses() async throws { - let harness = try UsageAutoRefreshHarness(sameFingerprintOnEveryCall: false) - defer { harness.cleanUp() } - let model = harness.model - - await model.prepareUsage() - try await harness.settle() - #expect(harness.usageCollectionCount() == 1) - let firstCollectedAt = try #require(model.usageLastLoadedAt) - - let poll = Task { await model.runUsageAutoRefresh() } - let recollected = try await harness.waitForUsageCollections(atLeast: 3) - try await harness.settle() - poll.cancel() - - #expect(recollected) - #expect(!model.usageShowingSavedSnapshot) - #expect((model.usageLastLoadedAt ?? firstCollectedAt) > firstCollectedAt) -} - -@MainActor -@Test func usageAutoRefreshStaysQuietWhileTheAppIsNotFrontmost() async throws { - let harness = try UsageAutoRefreshHarness(sameFingerprintOnEveryCall: false) - defer { harness.cleanUp() } - let model = harness.model - - await model.prepareUsage() - try await harness.settle() - model.setUsageAutoRefreshSuspended(true) - - let poll = Task { await model.runUsageAutoRefresh() } - try await Task.sleep(for: .milliseconds(500)) - let suspendedCount = harness.usageCollectionCount() - model.usageWindowBecameActive() - let resumed = try await harness.waitForUsageCollections(atLeast: suspendedCount + 1) - poll.cancel() - - #expect(suspendedCount == 1) - #expect(resumed) -} - -@MainActor -@Test func repeatUsageCollectionKeepsTheAcceptedReportWhenSourcesAreUnchanged() async throws { - let harness = try UsageAutoRefreshHarness(sameFingerprintOnEveryCall: true) - defer { harness.cleanUp() } - let model = harness.model - - model.loadUsage() - try await harness.settle() - let firstGeneratedAt = model.usageReport?.provenance.generatedAt - #expect(firstGeneratedAt == "2026-09-04T00:00:01Z") - - model.loadUsage() - try await harness.settle() - - #expect(harness.usageCollectionCount() == 2) - #expect(model.usageReport?.provenance.generatedAt == firstGeneratedAt) - #expect(model.usageReportJSON.contains("2026-09-04T00:00:01Z")) - #expect(!model.usageShowingSavedSnapshot) -} - -private func devinSummary(status: String, sessions: Int64) -> DevinUsageSummary { - let json = """ - {"status":"\(status)","source":"CodeVetter SQLite · indexed Devin sessions.db", - "sessions":\(sessions),"generated_tokens":1200,"cache_read_tokens":200, - "output_tokens":300,"cost_usd":0.42, - "models":[{"model":"glm-5.2","sessions":\(sessions),"generated_tokens":1200, - "cache_read_tokens":200,"cost_usd":0.42}], - "windows":[{"window":"30d","since":"2026-08-06","sessions":\(sessions), - "generated_tokens":1200,"cache_read_tokens":200,"cost_usd":0.42, - "models":[]}], - "limitations":["This local history is not live quota telemetry."]} - """ - return try! JSONDecoder().decode(DevinUsageSummary.self, from: Data(json.utf8)) -} - -@Test func devinDistinguishesAnUnreadableHistoryFromAQuietWindow() throws { - let active = devinSummary(status: "ready", sessions: 3) - #expect(active.availability(for: .thirtyDays) == .active) - - let quiet = devinSummary(status: "ready", sessions: 0) - #expect(quiet.availability(for: .thirtyDays) == .empty) - - // A source CodeVetter could not read must never be reported as zero activity, - // even when the projected counters are zero. - let unreadable = devinSummary(status: "unavailable", sessions: 0) - #expect(unreadable.availability(for: .thirtyDays) == .unavailable) - - // Nor when stale counters survive alongside a failed status. - let staleCounters = devinSummary(status: "unavailable", sessions: 3) - #expect(staleCounters.availability(for: .thirtyDays) == .unavailable) -} - @Test func nativeUpdaterConfigurationFailsClosedUntilEverySigningInputExists() throws { let preview = NativeUpdaterConfiguration( feedURL: URL(string: "https://updates.example.test/appcast.xml"), @@ -1979,122 +1709,6 @@ func supervisedEvidenceScopeRunnerPreservesTheSharedDiscoveryContract() async th ) } -@Test -func supervisedUsageRunnerPreservesArgumentsAndInspectableNonzeroStates() async throws { - let fixtureDirectory = FileManager.default.temporaryDirectory - .appending(path: "codevetter-usage-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: fixtureDirectory) } - - let executable = fixtureDirectory.appending(path: "codevetter") - let argumentsFile = fixtureDirectory.appending(path: "arguments.txt") - let report = - #"{"status":"stale","stale":true,"error":{"category":"timeout","message":"Using the last accepted local snapshot."},"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-08-31T00:00:00Z","timezone":"Asia/Kolkata","window":"all","detected_agents":["claude","codex","grok"],"excluded_agents":["devin"],"codex_roots":["/fixture/codex"],"source_fingerprint":"sha256:fixture","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"sessions":[],"totals":{"input_tokens":1,"cache_creation_tokens":2,"cache_read_tokens":3,"output_tokens":4,"total_tokens":10,"cost_usd":0.25},"devin":{"status":"ready","source":"CodeVetter SQLite · indexed Devin sessions.db","sessions":3,"generated_tokens":1200,"cache_read_tokens":200,"output_tokens":300,"cost_usd":0.42,"models":[{"model":"glm-5.2","sessions":3,"generated_tokens":1200,"cache_read_tokens":200,"cost_usd":0.42}],"limitations":["This local history is not live quota telemetry."]}}"# - let script = """ - #!/bin/sh - printf '%s' "$*" > '\(argumentsFile.path)' - printf '%s\n' '\(report)' - exit 1 - """ - try script.write(to: executable, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) - - let result = try await CodeVetterProcessRunner(executableURL: executable).runUsage( - timezone: "Asia/Kolkata", - refresh: true - ) - #expect(result.processStatus == 1) - #expect(result.report.status == .stale) - #expect(result.report.totals.generatedTokens == 7) - #expect(result.report.provenance.excludedAgents == ["devin"]) - #expect(result.report.devin?.sessions == 3) - #expect(result.report.devin?.models.first?.model == "glm-5.2") - #expect( - try String(contentsOf: argumentsFile, encoding: .utf8) - == "usage --timezone Asia/Kolkata --refresh --json" - ) -} - -@Test -func supervisedUsageRunnerRejectsStatusExitMismatch() async throws { - let fixtureDirectory = FileManager.default.temporaryDirectory - .appending(path: "codevetter-usage-mismatch-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: fixtureDirectory) } - - let executable = fixtureDirectory.appending(path: "codevetter") - let report = - #"{"status":"ready","stale":false,"error":null,"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-08-31T00:00:00Z","timezone":"UTC","window":"all","detected_agents":[],"excluded_agents":[],"codex_roots":[],"source_fingerprint":"sha256:fixture","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"sessions":[],"totals":{"input_tokens":0,"cache_creation_tokens":0,"cache_read_tokens":0,"output_tokens":0,"total_tokens":0,"cost_usd":0}}"# - try "#!/bin/sh\nprintf '%s\\n' '\(report)'\nexit 2\n".write( - to: executable, - atomically: true, - encoding: .utf8 - ) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) - - do { - _ = try await CodeVetterProcessRunner(executableURL: executable).runUsage() - Issue.record("A ready report must not survive a nonzero process status") - } catch let error as VerificationRunnerError { - #expect(error.localizedDescription.contains("conflicts with process status")) - } -} - -@Test -func supervisedProviderQuotaRunnerPreservesRemainingWindowsAndPartialAvailability() async throws { - let fixtureDirectory = FileManager.default.temporaryDirectory - .appending(path: "codevetter-quota-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: fixtureDirectory) } - - let executable = fixtureDirectory.appending(path: "codevetter") - let argumentsFile = fixtureDirectory.appending(path: "arguments.txt") - let receipt = - #"{"schema_version":"codevetter.provider-quota/v1","generated_at":"2026-09-03T00:00:00Z","providers":[{"provider":"claude","status":"ready","source":"Claude Code /usage","checked_at":"2026-09-03T00:00:00Z","plan":"Claude Team","windows":[{"id":"current","label":"Current window","used_percent":3,"remaining_percent":97,"window_duration_minutes":null,"resets_at_unix":null,"reset_description":"2:20am (Asia/Calcutta)"},{"id":"weekly","label":"Weekly window","used_percent":32,"remaining_percent":68,"window_duration_minutes":null,"resets_at_unix":null,"reset_description":"Sep 6 at 5:30pm (Asia/Calcutta)"}],"credits":{"used_percent":0,"remaining_percent":100,"used_amount":0,"limit_amount":150,"currency":"USD","reset_description":"Oct 1 (Asia/Calcutta)"},"reset_credits":null,"message":null},{"provider":"codex","status":"unavailable","source":"codex app-server account/rateLimits/read","checked_at":"2026-09-03T00:00:00Z","plan":null,"windows":[],"credits":null,"reset_credits":null,"message":"Sign in with Codex."}],"limitations":["Unavailable provider telemetry is never represented as zero usage."]}"# - let script = """ - #!/bin/sh - printf '%s' "$*" > '\(argumentsFile.path)' - printf '%s\n' '\(receipt)' - exit 1 - """ - try script.write(to: executable, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) - - let result = try await CodeVetterProcessRunner(executableURL: executable).runProviderQuota() - #expect(result.providers.count == 2) - #expect(result.providers[0].windows[0].remainingPercent == 97) - #expect(result.providers[0].credits?.limitAmount == 150) - #expect(result.providers[1].status == "unavailable") - #expect( - try String(contentsOf: argumentsFile, encoding: .utf8) - == "quota --provider all --json" - ) -} - -@Test -func supervisedProviderQuotaRunnerRejectsAvailabilityExitMismatch() async throws { - let fixtureDirectory = FileManager.default.temporaryDirectory - .appending(path: "codevetter-quota-mismatch-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: fixtureDirectory) } - - let executable = fixtureDirectory.appending(path: "codevetter") - let receipt = - #"{"schema_version":"codevetter.provider-quota/v1","generated_at":"2026-09-03T00:00:00Z","providers":[{"provider":"codex","status":"ready","source":"codex app-server account/rateLimits/read","checked_at":"2026-09-03T00:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"Weekly window","used_percent":92,"remaining_percent":8,"window_duration_minutes":10080,"resets_at_unix":1788750854,"reset_description":null}],"credits":null,"reset_credits":1,"message":null}],"limitations":[]}"# - try "#!/bin/sh\nprintf '%s\\n' '\(receipt)'\nexit 2\n".write( - to: executable, - atomically: true, - encoding: .utf8 - ) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) - - do { - _ = try await CodeVetterProcessRunner(executableURL: executable).runProviderQuota() - Issue.record("A ready provider quota receipt must not survive exit 2") - } catch let error as VerificationRunnerError { - #expect(error.localizedDescription.contains("conflicts with process status")) - } -} @Test func supervisedUnpackRunnerPreservesScanComparisonAndExportContracts() async throws { @@ -2424,7 +2038,7 @@ func supervisedSettingsRunnerPreservesOneValidatedAssignmentAndSchema() async th .split(separator: "\n").map(String.init) #expect(listed.schemaVersion == "codevetter.native-settings/v1") - #expect(listed.settings.count == 28) + #expect(listed.settings.count == 25) #expect(listed.settings.filter { $0.section == "agent_island" }.count == 12) #expect(listed.settings.allSatisfy { $0.key != "github_token" }) #expect(saved.savedKey == "review_tone") @@ -3148,543 +2762,6 @@ func repositoryQueryDeskRendersCanonicalFreshnessTrustAndSources() throws { } } -@MainActor -@Test -func usageWindowsKeepChartTotalsModelsAndSessionsOnOneBoundary() throws { - let reference = try #require(ISO8601DateFormatter().date(from: "2026-09-01T12:00:00Z")) - let claude = "claude" - let daily = [ - usagePeriod("2026-09-01", agent: claude, generated: 10, model: "sonnet"), - usagePeriod("2026-08-26", agent: claude, generated: 20, model: "sonnet"), - usagePeriod("2026-08-25", agent: claude, generated: 30, model: "opus"), - usagePeriod("2026-07-15", agent: claude, generated: 40, model: "opus"), - ] - let report = LocalUsageReport( - status: .ready, - stale: false, - error: nil, - provenance: LocalUsageProvenance( - engine: "ccusage", - version: "20.0.20", - generatedAt: "2026-09-01T12:00:00Z", - timezone: "UTC", - window: "all", - detectedAgents: [claude], - excludedAgents: ["devin"], - codexRoots: [], - sourceFingerprint: "sha256:fixture", - pricingComplete: true, - fallbackModels: [], - unpricedModels: [] - ), - daily: daily, - weekly: [usagePeriod("2026-08-24", agent: claude, generated: 60, model: "sonnet")], - monthly: [usagePeriod("2026-08", agent: claude, generated: 60, model: "sonnet")], - sessions: [ - usageSession("current", agent: claude, activity: "2026-09-01T10:00:00Z"), - usageSession("fraction", agent: claude, activity: "2026-09-01T11:59:59.999Z"), - usageSession("offset", agent: claude, activity: "2026-09-01T17:29:59+05:30"), - usageSession("future", agent: claude, activity: "2026-09-01T12:00:00.900Z"), - usageSession("month", agent: claude, activity: "2026-08-10T10:00:00Z"), - usageSession("old", agent: claude, activity: "2026-05-01T10:00:00Z"), - usageSession("unknown", agent: claude, activity: nil), - ], - totals: daily.reduce(.zero) { $0.adding($1.totals) }, - devin: nil - ) - let selected = Set([claude]) - - #expect(report.periods(for: .day, window: .oneWeek, referenceDate: reference).count == 2) - #expect( - report.totals(for: selected, window: .oneWeek, referenceDate: reference).generatedTokens == 30) - #expect( - report.totals(for: selected, window: .thirtyDays, referenceDate: reference).generatedTokens - == 60) - #expect( - report.totals(for: selected, window: .ninetyDays, referenceDate: reference).generatedTokens - == 100) - #expect( - report.totals(for: selected, window: .allTime, referenceDate: reference).generatedTokens == 100) - #expect(report.periods(for: .week, window: .oneWeek, referenceDate: reference).count == 1) - #expect(report.periods(for: .month, window: .oneWeek, referenceDate: reference).count == 1) - #expect( - report.sessions(for: selected, window: .oneWeek, referenceDate: reference).map(\.sessionID) == [ - "current", "fraction", "offset", - ]) - #expect( - report.sessions(for: selected, window: .thirtyDays, referenceDate: reference).map(\.sessionID) - == ["current", "fraction", "offset", "month"]) - #expect(report.sessions(for: selected, window: .allTime, referenceDate: reference).count == 7) -} - -@Test -func unifiedUsageHistoryBuildsReconcilingModelAndProjectSeries() throws { - let reference = try #require(ISO8601DateFormatter().date(from: "2026-09-08T12:00:00Z")) - let claude = "claude" - let codex = "codex" - // 2026-09-08 is a Tuesday: its Monday-start week key is 2026-09-07, and - // 2026-09-01 belongs to the week opening 2026-08-31. - let report = LocalUsageReport( - status: .ready, - stale: false, - error: nil, - provenance: LocalUsageProvenance( - engine: "ccusage", - version: "20.0.20", - generatedAt: "2026-09-08T12:00:00Z", - timezone: "UTC", - window: "all", - detectedAgents: [claude, codex], - excludedAgents: [], - codexRoots: [], - sourceFingerprint: "sha256:fixture", - pricingComplete: true, - fallbackModels: [], - unpricedModels: [] - ), - daily: [ - usagePeriod("2026-09-08", agent: claude, generated: 10, model: "sonnet"), - usagePeriod("2026-09-08", agent: codex, generated: 40, model: "gpt-5.6"), - usagePeriod("2026-09-01", agent: claude, generated: 20, model: "opus"), - ], - weekly: [ - usagePeriod("2026-08-31", agent: claude, generated: 20, model: "opus"), - usagePeriod("2026-09-07", agent: claude, generated: 50, model: "sonnet"), - ], - monthly: [], - sessions: [ - usageSession( - "s1", agent: claude, activity: "2026-09-08T10:00:00Z", - project: "/Users/x/fleet/codevetter"), - usageSession( - "s2", agent: claude, activity: "2026-09-01T10:00:00Z", - project: "/Users/x/fleet/starboard"), - usageSession("s3", agent: codex, activity: "2026-09-08T11:00:00Z"), - // In-window but its week has no ccusage period row: counted in the - // breakdown, unchartable in the stacked series. - usageSession("s4", agent: claude, activity: "2026-08-20T10:00:00Z", project: "/x/app"), - ], - totals: .zero, - devin: nil - ) - - let projection = UsageViewProjection( - report: report, - selectedAgents: [claude, codex], - window: .thirtyDays, - scale: .week, - referenceDate: reference - ) - - #expect(projection.trendPeriodKeys == ["2026-08-31", "2026-09-07"]) - - // Model dimension reconciles with each weekly row's own model breakdown - // (the daily fixtures intentionally differ from the weekly ones). - let modelsByName = Dictionary(uniqueKeysWithValues: projection.modelSeries.map { ($0.name, $0) }) - #expect(modelsByName.keys.sorted() == ["opus", "sonnet"]) - #expect(modelsByName["opus"]?.points.map(\.tokens) == [20, 0]) - #expect(modelsByName["sonnet"]?.points.map(\.tokens) == [0, 50]) - #expect(projection.modelSeries.first?.name == "sonnet") - - // Project dimension buckets sessions onto their last-activity week; - // unattributed sessions stay visible instead of disappearing. - let projectsByName = Dictionary( - uniqueKeysWithValues: projection.projectSeries.map { ($0.name, $0) }) - #expect(projectsByName["codevetter"]?.points.map(\.tokens) == [0, 10]) - #expect(projectsByName["starboard"]?.points.map(\.tokens) == [10, 0]) - #expect(projectsByName["Unattributed"]?.points.map(\.tokens) == [0, 10]) - #expect(projectsByName["codevetter"]?.detail == "/Users/x/fleet/codevetter") - #expect(projection.attributedSessionCount == 3) - #expect(projection.sessionCount == 4) - - // Breakdown rows reconcile with every in-window session, including the - // session whose missing activity makes it unchartable. - let rowsByName = Dictionary( - uniqueKeysWithValues: projection.projectBreakdown.map { ($0.name, $0) }) - #expect(rowsByName.count == 4) - #expect(rowsByName["app"]?.tokens == 10) - #expect(rowsByName["Unattributed"]?.tokens == 10) - - // Agent filtering flows through both dimensions. - let claudeOnly = UsageViewProjection( - report: report, - selectedAgents: [claude], - window: .thirtyDays, - scale: .week, - referenceDate: reference - ) - // Agent filtering removes the Codex session, so Unattributed disappears - // from the project dimension entirely. - #expect(!claudeOnly.projectBreakdown.contains { $0.name == "Unattributed" }) - #expect(claudeOnly.projectSeries.map(\.name).sorted() == ["codevetter", "starboard"]) - #expect(claudeOnly.projectBreakdown.map(\.name).sorted() == ["app", "codevetter", "starboard"]) -} - -@Test -func devinUsageProjectsTheSelectedWindowWithoutJoiningCcusageTotals() throws { - let payload = Data( - """ - { - "status":"ready", - "source":"CodeVetter SQLite", - "sessions":18, - "generated_tokens":950000, - "cache_read_tokens":310000, - "output_tokens":80000, - "cost_usd":3.84, - "models":[], - "windows":[ - {"window":"1w","since":"2026-08-26","sessions":3,"generated_tokens":120000,"cache_read_tokens":40000,"cost_usd":0.52,"models":[]}, - {"window":"30d","since":"2026-08-03","sessions":8,"generated_tokens":420000,"cache_read_tokens":140000,"cost_usd":1.71,"models":[]}, - {"window":"90d","since":"2026-06-04","sessions":14,"generated_tokens":760000,"cache_read_tokens":250000,"cost_usd":3.02,"models":[]}, - {"window":"all","since":null,"sessions":18,"generated_tokens":950000,"cache_read_tokens":310000,"cost_usd":3.84,"models":[]} - ], - "limitations":["Devin remains separate from ccusage totals."] - } - """.utf8 - ) - let summary = try JSONDecoder().decode(DevinUsageSummary.self, from: payload) - - #expect(summary.projection(for: .oneWeek).sessions == 3) - #expect(summary.projection(for: .thirtyDays).generatedTokens == 420_000) - #expect(summary.projection(for: .ninetyDays).cacheReadTokens == 250_000) - #expect(summary.projection(for: .allTime).costUSD == 3.84) -} - -@MainActor -@Test -func largeUsageReportDecodesAndRendersWithinTheNativeGate() throws { - let agentNames = ["claude", "codex", "grok"] - let sessionCount = 2_500 - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0)! - let today = calendar.startOfDay(for: Date()) - let dayFormatter = DateFormatter() - dayFormatter.calendar = calendar - dayFormatter.timeZone = calendar.timeZone - dayFormatter.dateFormat = "yyyy-MM-dd" - let monthFormatter = DateFormatter() - monthFormatter.calendar = calendar - monthFormatter.timeZone = calendar.timeZone - monthFormatter.dateFormat = "yyyy-MM" - let periods: [[String: Any]] = (0..<365).map { index in - let agents: [[String: Any]] = agentNames.enumerated().map { offset, agent in - let amount = UInt64((index + 1) * (offset + 1) * 100) - return [ - "agent": agent, - "totals": usageTotals(amount), - "models": [ - [ - "model": "\(agent)-model-\(offset)", - "totals": usageTotals(amount), - "fallback": offset == 0, - "priced": offset != 0, - ] - ], - ] - } - return [ - "period": dayFormatter.string( - from: calendar.date(byAdding: .day, value: index - 364, to: today)! - ), - "totals": usageTotals(UInt64((index + 1) * 600)), - "agents": agents, - "models": [], - ] - } - let monthlyPeriods: [[String: Any]] = (0..<12).map { index in - var period = periods[index * 30] - period["period"] = monthFormatter.string( - from: calendar.date(byAdding: .month, value: index - 11, to: today)! - ) - return period - } - var sessions = [[String: Any]]() - for index in 0.. String { - #"{"input_tokens":\#(input),"cache_creation_tokens":2,"cache_read_tokens":3,"output_tokens":4,"total_tokens":51,"cost_usd":0.30000000000000004}"# -} - -private func parentJSON(_ totals: String?) -> Data { - let field = totals.map { ",\"totals\":\($0)" } ?? "" - return Data(#"{"session_id":"s","agent":"claude","reasoning_output_tokens":0,"model":"m","fallback":false,"priced":true,"period":"2026-09-01","models":[],"agents":[],"project":"p"\#(field)}"#.utf8) -} - -private func decodedTotals(_ data: Data) throws -> [LocalUsageTotals] { - let decoder = JSONDecoder() - return [ - try decoder.decode(LocalUsageSession.self, from: data).totals, - try decoder.decode(LocalUsageModel.self, from: data).totals, - try decoder.decode(LocalUsageAgent.self, from: data).totals, - try decoder.decode(LocalUsagePeriod.self, from: data).totals, - try decoder.decode(LocalUsageProject.self, from: data).totals, - ] -} - -@Test func nestedUsageTotalsPreserveUInt64AndCostPrecision() throws { - let data = parentJSON(totalsJSON("18446744073709551615")) - for totals in try decodedTotals(data) { - #expect(totals.inputTokens == UInt64.max) - #expect(totals.cacheCreationTokens == 2) - #expect(totals.cacheReadTokens == 3) - #expect(totals.outputTokens == 4) - #expect(totals.totalTokens == 51) - #expect(totals.costUSD.bitPattern == Double(0.30000000000000004).bitPattern) - } - let session = try JSONDecoder().decode(LocalUsageSession.self, from: data) - let roundTrip = try JSONDecoder().decode(LocalUsageSession.self, from: JSONEncoder().encode(session)) - #expect(roundTrip.totals == session.totals) - #expect(roundTrip.sessionID == session.sessionID) - #expect(roundTrip.project == session.project) - #expect(roundTrip.lastActivity == nil) -} - -@Test(arguments: ["-1", "18446744073709551616", "null", "true", "\"42\"", "1.5"]) -func nestedUsageTotalsRejectInvalidTokenCounts(_ value: String) { - let data = parentJSON(totalsJSON(value)) - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageSession.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageModel.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageAgent.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsagePeriod.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageProject.self, from: data) } -} - -@Test(arguments: [nil, "null", "5", "[]", "{}"] as [String?]) -func nestedUsageTotalsRequireCompleteObject(_ totals: String?) { - let data = parentJSON(totals) - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageSession.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageModel.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageAgent.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsagePeriod.self, from: data) } - #expect(throws: DecodingError.self) { try JSONDecoder().decode(LocalUsageProject.self, from: data) } -} - -@Test func nestedUsageDecodingPreservesOptionalFields() throws { - let decoder = JSONDecoder() - let base = String(decoding: parentJSON(totalsJSON()), as: UTF8.self) - let absentProject = base.replacingOccurrences(of: ",\"project\":\"p\"", with: "") - for optionalFields in ["", ",\"project\":null,\"last_activity\":null", ",\"project\":\"p\",\"last_activity\":\"2026-09-01T00:00:00Z\""] { - let json = String(absentProject.dropLast()) + optionalFields + "}" - let session = try decoder.decode(LocalUsageSession.self, from: Data(json.utf8)) - #expect(session.project == (optionalFields.contains("\"p\"") ? "p" : nil)) - #expect(session.lastActivity == (optionalFields.contains("2026") ? "2026-09-01T00:00:00Z" : nil)) - } - for projects in ["", ",\"projects\":null", ",\"projects\":[]"] { - let json = String(base.dropLast()) + projects + "}" - let period = try decoder.decode(LocalUsagePeriod.self, from: Data(json.utf8)) - #expect(projects.contains("[]") ? period.projects?.isEmpty == true : period.projects == nil) - } -} - -private func reportJSON(sessions: String?) -> Data { - let field = sessions.map { ",\"sessions\":\($0)" } ?? "" - return Data(#"{"status":"ready","stale":false,"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-09-01T00:00:00Z","timezone":"UTC","window":"all","detected_agents":["claude"],"excluded_agents":[],"codex_roots":[],"source_fingerprint":"fixture","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"totals":\#(totalsJSON())\#(field)}"#.utf8) -} - -@Test(arguments: [nil, "null", "{}", "[null]", "[true]"] as [String?]) -func nestedUsageReportRequiresSessionObjects(_ sessions: String?) { - #expect(throws: DecodingError.self) { - try JSONDecoder().decode(LocalUsageReport.self, from: reportJSON(sessions: sessions)) - } -} - -@Test func nestedUsageReportPreservesSessionOrderAndRoundTrip() throws { - let first = String(decoding: parentJSON(totalsJSON("18446744073709551615")), as: UTF8.self) - let second = first.replacingOccurrences(of: "\"session_id\":\"s\"", with: "\"session_id\":\"second\"") - let decoder = JSONDecoder() - let report = try decoder.decode(LocalUsageReport.self, from: reportJSON(sessions: "[\(first),\(second)]")) - let restored = try decoder.decode(LocalUsageReport.self, from: JSONEncoder().encode(report)) - #expect(restored.sessions.map(\.sessionID) == ["s", "second"]) - #expect(restored.sessions.allSatisfy { $0.totals.inputTokens == UInt64.max }) - #expect(restored.totals == report.totals) - #expect(restored.error == nil) - #expect(restored.devin == nil) - #expect(try decoder.decode(LocalUsageReport.self, from: reportJSON(sessions: "[]")).sessions.isEmpty) - #expect(throws: DecodingError.self) { - try decoder.decode(LocalUsageReport.self, from: reportJSON(sessions: "[\(first),null]")) - } -} diff --git a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/UsagePresentationTests.swift b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/UsagePresentationTests.swift deleted file mode 100644 index 1f2ea47e..00000000 --- a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/UsagePresentationTests.swift +++ /dev/null @@ -1,230 +0,0 @@ -import AppKit -import Foundation -import SwiftUI -import Testing - -@testable import CodeVetterFeature - -private func usageHistoryFixture() -> LocalUsageReport { - let days = (1...28).map { day -> LocalUsagePeriod in - let claude = localUsageTotals(UInt64(day * 1200)) - let codex = localUsageTotals(UInt64(day * 2400)) - let models = [ - LocalUsageModel(model: "Claude Sonnet", totals: claude, fallback: false, priced: true), - LocalUsageModel(model: "GPT-5.6", totals: codex, fallback: false, priced: true), - ] - var row = LocalUsagePeriod( - period: String(format: "2026-09-%02d", day), totals: claude.adding(codex), - agents: [ - LocalUsageAgent(agent: "claude", totals: claude, models: [models[0]]), - LocalUsageAgent(agent: "codex", totals: codex, models: [models[1]]), - ], models: models) - row.projects = [ - LocalUsageProject(project: "/workspace/CodeVetter", agent: "claude", totals: claude) - ] - return row - } - return LocalUsageReport( - status: .ready, stale: false, error: nil, - provenance: LocalUsageProvenance( - engine: "ccusage", version: "20.0.20", - generatedAt: "2026-09-28T12:00:00Z", timezone: "UTC", window: "all", - detectedAgents: ["claude", "codex"], excludedAgents: [], codexRoots: [], - sourceFingerprint: "fixture:usage-directions", pricingComplete: true, - fallbackModels: [], unpricedModels: []), - daily: days, weekly: [], monthly: [], sessions: [], - totals: days.reduce(.zero) { $0.adding($1.totals) }, devin: nil) -} - -@Test func usageHistoryMetricsAndGroupingsReconcileOnOneBoundary() throws { - let report = usageHistoryFixture() - let date = try #require(ISO8601DateFormatter().date(from: "2026-09-15T12:00:00Z")) - for scale in UsageScale.allCases { - for grouping in UsageGrouping.allCases { - for metric in UsageMetric.allCases { - let p = UsageViewProjection( - report: report, selectedAgents: ["claude", "codex"], - window: .oneWeek, scale: scale, referenceDate: date, grouping: grouping, metric: metric) - #expect(abs(p.history.total - metric.value(p.totals)) < 0.000_001) - #expect(abs(p.history.buckets.reduce(0) { $0 + $1.total } - p.history.total) < 0.000_001) - #expect(p.activeDays == 7) - #expect(p.history.grouping == grouping) - } - } - } - let weekly = UsageViewProjection( - report: report, selectedAgents: ["claude"], window: .oneWeek, - scale: .week, referenceDate: date - ).history - #expect(weekly.buckets.map(\.period) == ["2026-09-07", "2026-09-14"]) - #expect(weekly.series.count == 1) -} - -@Test func usageProjectsKeepUnknownAndRejectExcessBreakdowns() throws { - let report = usageHistoryFixture() - let p = UsageHistoryProjection( - periods: report.daily, agents: [], scale: .week, - grouping: .project, metric: .tokens) - #expect(p.series.count == 2) - #expect(abs(p.unattributed / p.total - 2.0 / 3.0) < 0.000_001) - var invalid = report.daily[0] - invalid.projects = [LocalUsageProject(project: "bad", agent: "claude", totals: report.totals)] - let rejected = UsageHistoryProjection( - periods: [invalid], agents: ["claude"], scale: .day, - grouping: .project, metric: .tokens) - #expect(rejected.total == rejected.unattributed) - let decoded = try JSONDecoder().decode(LocalUsageReport.self, from: JSONEncoder().encode(report)) - #expect(decoded.daily[0].projects?.first?.project == "/workspace/CodeVetter") -} - -@Test func usageHistoryBoundsSeriesAndPeriodsWithoutDroppingTotals() { - let rows = (0..<365).map { index in - usagePeriod( - String(format: "%04d-01-01", 2000 + index), agent: "codex", - generated: UInt64(index + 1), model: "model-\(index % 12)") - } - let p = UsageHistoryProjection( - periods: rows, agents: [], scale: .day, grouping: .model, metric: .tokens) - #expect(p.buckets.count == 180) - #expect(p.buckets.first?.period == "Earlier") - #expect(p.visibleSeries.count == 5) - #expect(p.total == rows.reduce(0) { $0 + Double($1.totals.generatedTokens) }) - for bucket in p.buckets { - #expect( - abs(p.visibleSeries.reduce(0) { $0 + p.value(in: bucket, series: $1) } - bucket.total) - < 0.000_001) - } - #expect(p.chartValues.count == p.buckets.count) - for (index, bucket) in p.buckets.enumerated() { - #expect(p.chartValues[index].count == p.visibleSeries.count) - for (seriesIndex, series) in p.visibleSeries.enumerated() { - #expect( - abs(p.chartValues[index][seriesIndex] - p.value(in: bucket, series: series)) < 0.000_001) - } - } -} - -private func allowanceWindow(_ remaining: Double, reset: Int64?, duration: UInt64? = 10_080) - -> ProviderQuotaWindow -{ - ProviderQuotaWindow( - id: "weekly", label: "Weekly window", usedPercent: 100 - remaining, - remainingPercent: remaining, windowDurationMinutes: duration, - resetsAtUnix: reset, resetDescription: nil) -} - -@Test func usageAllowanceColorsAndPaceRemainIndependent() throws { - let date = try #require(ISO8601DateFormatter().date(from: "2026-09-15T12:00:00Z")) - let checked = "2026-09-15T12:00:00Z" - let reset = Int64(date.timeIntervalSince1970 + 604_800 * 0.10) - for (remaining, level) in [ - (0.0, UsageAllowanceState.Level.exhausted), (13, .low), (20, .low), - (20.1, .watch), (40, .watch), (40.1, .healthy), (100, .healthy), - ] { - #expect( - UsageAllowanceState( - window: allowanceWindow(remaining, reset: reset), checkedAt: checked, - available: true, now: date - ).level == level) - } - let lowButUnderPace = UsageAllowanceState( - window: allowanceWindow(13, reset: reset), checkedAt: checked, - available: true, now: date) - #expect(lowButUnderPace.level == .low) - #expect(abs((lowButUnderPace.paceDelta ?? 0) - 3) < 0.000_001) - #expect(lowButUnderPace.paceLabel == "3 pp under even-use pace") - for window in [ - allowanceWindow(13, reset: nil), allowanceWindow(13, reset: reset, duration: nil), - allowanceWindow(13, reset: Int64(date.timeIntervalSince1970 - 1)), - ] { - #expect( - UsageAllowanceState(window: window, checkedAt: checked, available: true, now: date).paceDelta - == nil) - } - #expect( - UsageAllowanceState( - window: allowanceWindow(68, reset: reset), checkedAt: checked, - available: false, now: date - ).level == .unknown) - #expect( - UsageAllowanceState( - window: allowanceWindow(68, reset: reset), checkedAt: checked, - available: true, now: date.addingTimeInterval(301) - ).level == .unknown) - #expect( - UsageAllowanceState( - window: allowanceWindow(.nan, reset: reset), checkedAt: checked, - available: true, now: date - ).level == .unknown) -} - -@MainActor @Test func usageProjectionCacheTracksGroupingAndMetric() { - let model = WorkbenchModel() - model.usageReport = usageHistoryFixture() - model.usageWindow = .allTime - let report = model.usageReport! - #expect(model.usageProjection(for: report).history.grouping == .model) - model.usageGrouping = .project - #expect(model.usageDimension == .project) - model.usageMetric = .cost - let p = model.usageProjection(for: report) - #expect(p.history.grouping == .project) - #expect(p.history.metric == .cost) - #expect(p.history.total == UsageMetric.cost.value(p.totals)) - model.usageDimension = .model - model.usageMetric = .cache - let cache = model.usageProjection(for: report) - #expect(cache.history.grouping == .model) - #expect(cache.history.metric == .cache) - #expect(cache.history.total == Double(cache.totals.cacheReadTokens)) -} - -@MainActor @Test func usageApprovedDirectionRendersOffscreen() throws { - let model = WorkbenchModel() - // Prevent view lifecycle tasks from collecting real usage during fixture renders. - model.usageLoading = true - model.providerQuotaLoading = true - model.section = .usage - model.usageReport = usageHistoryFixture() - model.usageWindow = .allTime - model.usageScale = .week - model.usageSelectedAgents = ["claude", "codex"] - let checked = ISO8601DateFormatter().string(from: Date()) - let reset = Int64(Date().timeIntervalSince1970 + 604_800 * 0.22) - let json = """ - {"schema_version":"codevetter.provider-quota/v1","generated_at":"\(checked)","limitations":[],"providers":[ - {"provider":"codex","status":"ready","source":"fixture","checked_at":"\(checked)","windows":[{"id":"weekly","label":"Weekly window","used_percent":87,"remaining_percent":13,"window_duration_minutes":10080,"resets_at_unix":\(reset)}]}, - {"provider":"claude","status":"ready","source":"fixture","checked_at":"\(checked)","windows":[{"id":"weekly","label":"Weekly window","used_percent":32,"remaining_percent":68,"window_duration_minutes":10080,"resets_at_unix":\(reset)}]}]} - """ - model.providerQuotaReceipt = try JSONDecoder().decode( - ProviderQuotaReceipt.self, from: Data(json.utf8)) - let directory = FileManager.default.temporaryDirectory.appending( - path: "codevetter-usage-review-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - for width: CGFloat in [980, 1280, 1440] { - for appearance in [NSAppearance.Name.darkAqua, .aqua] { - try captureWorkbench( - model, at: directory.appending(path: "model-\(Int(width))-\(appearance.rawValue).png"), - appearance: appearance, width: width, height: width == 980 ? 640 : 900) - } - } - model.usageGrouping = .project - model.usageMetric = .cost - try captureWorkbench( - model, at: directory.appending(path: "project-cost.png"), appearance: .darkAqua, height: 900) - let history = model.usageProjection(for: model.usageReport!).history - for width: CGFloat in [390, 768, 1440] { - let host = NSHostingView( - rootView: UsageHistoryView(history: history, pricingComplete: true) - .padding(20).frame(width: width, height: 540, alignment: .topLeading) - .background(Color.black).preferredColorScheme(.dark)) - host.appearance = NSAppearance(named: .darkAqua) - host.frame = NSRect(x: 0, y: 0, width: width, height: 540) - host.layoutSubtreeIfNeeded() - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - let data = try #require(bitmap.representation(using: .png, properties: [:])) - try data.write(to: directory.appending(path: "history-\(Int(width)).png")) - } - print("USAGE_REVIEW_ARTIFACTS \(directory.path)") -} diff --git a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/WorkbenchCoherenceTests.swift b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/WorkbenchCoherenceTests.swift index 0a36a334..d37e5fa4 100644 --- a/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/WorkbenchCoherenceTests.swift +++ b/apps/macos/CodeVetterPackage/Tests/CodeVetterFeatureTests/WorkbenchCoherenceTests.swift @@ -7,6 +7,13 @@ import Testing @MainActor @Suite struct WorkbenchCoherenceTests { + @Test func generalUsageIsNotANavigableWorkspace() { + #expect(!WorkbenchSection.navigationSections.contains(.usage)) + #expect(WorkbenchSection.navigationSections.map(\.rawValue) == [ + "Explore", "Review", "Testing", "Performance", "Runs", "Settings", + ]) + } + private func model() -> WorkbenchModel { // Never read the operator's data or invoke a real verification command. WorkbenchModel( diff --git a/apps/macos/CodeVetterUITests/CodeVetterUITests.swift b/apps/macos/CodeVetterUITests/CodeVetterUITests.swift index 2168f408..7691c78d 100644 --- a/apps/macos/CodeVetterUITests/CodeVetterUITests.swift +++ b/apps/macos/CodeVetterUITests/CodeVetterUITests.swift @@ -30,17 +30,12 @@ final class CodeVetterUITests: XCTestCase { app.launch() XCTAssertTrue(app.textFields["navigator-github-url"].waitForExistence(timeout: 5)) for destination in [ - "Usage", "Explore", "Review", "Testing", "Performance", "Runs", "Settings", + "Explore", "Review", "Testing", "Performance", "Runs", "Settings", ] { XCTAssertTrue(app.buttons[destination].exists, "Missing retained surface: \(destination)") } - app.menuBars.menuBarItems["View"].click() - XCTAssertTrue(app.menuItems["Usage"].exists) - app.typeKey(.escape, modifierFlags: []) - app.typeKey("1", modifierFlags: .command) - assertSelected(app.buttons["workbench-section-usage"]) - XCTAssertTrue(app.buttons["Usage refresh"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["Usage"].exists) app.buttons["Runs"].click() assertSelected(app.buttons["Runs"]) @@ -70,7 +65,6 @@ final class CodeVetterUITests: XCTestCase { let reopenedSearch = app.textFields["command-palette-search"] XCTAssertTrue(reopenedSearch.waitForExistence(timeout: 2)) reopenedSearch.typeKey(.downArrow, modifierFlags: []) - reopenedSearch.typeKey(.downArrow, modifierFlags: []) reopenedSearch.typeKey(.return, modifierFlags: []) XCTAssertTrue(palette.waitForNonExistence(timeout: 3)) assertSelected(app.buttons["workbench-section-review"]) @@ -135,50 +129,12 @@ final class CodeVetterUITests: XCTestCase { } @MainActor - func testUsageWorkspacePreservesLocalAndLiveProviderBoundaries() throws { - let snapshotDirectory = testingRepository.appending( - path: "usage-snapshots", directoryHint: .isDirectory) - try FileManager.default.createDirectory( - at: snapshotDirectory, - withIntermediateDirectories: true - ) - let usageJSON = - #"{"status":"ready","stale":false,"error":null,"provenance":{"engine":"ccusage","version":"20.0.20","generated_at":"2026-09-04T00:00:00Z","timezone":"UTC","window":"all","detected_agents":["claude","codex"],"excluded_agents":[],"codex_roots":[],"source_fingerprint":"sha256:ui-test","pricing_complete":true,"fallback_models":[],"unpriced_models":[]},"daily":[],"weekly":[],"monthly":[],"sessions":[],"totals":{"input_tokens":1,"cache_creation_tokens":2,"cache_read_tokens":3,"output_tokens":4,"total_tokens":10,"cost_usd":0.25}}"# - let quotaJSON = - #"{"schema_version":"codevetter.provider-quota/v1","generated_at":"2026-09-04T00:00:00Z","providers":[{"provider":"claude","status":"ready","source":"fixture","checked_at":"2026-09-04T00:00:00Z","plan":"team","windows":[{"id":"current","label":"Current window","used_percent":12,"remaining_percent":88,"window_duration_minutes":null,"resets_at_unix":null,"reset_description":"2:20am"},{"id":"weekly","label":"Weekly window","used_percent":33,"remaining_percent":67,"window_duration_minutes":null,"resets_at_unix":null,"reset_description":"Sep 6 at 5:30pm"}],"credits":null,"reset_credits":null,"message":null},{"provider":"codex","status":"ready","source":"fixture","checked_at":"2026-09-04T00:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"Weekly window","used_percent":25,"remaining_percent":75,"window_duration_minutes":10080,"resets_at_unix":null,"reset_description":null}],"credits":null,"reset_credits":null,"message":null}],"limitations":[]}"# - try usageJSON.write( - to: snapshotDirectory.appending(path: "local-usage.json"), - atomically: true, - encoding: .utf8 - ) - try quotaJSON.write( - to: snapshotDirectory.appending(path: "provider-quota.json"), - atomically: true, - encoding: .utf8 - ) + func testRetiredUsageLaunchRouteOpensExplore() throws { let app = XCUIApplication() - app.launchArguments = [ - "--ui-test-section", "Usage", - "--ui-test-usage-cache", snapshotDirectory.path, - ] + app.launchArguments = ["--ui-test-section", "Usage"] app.launch() - XCTAssertTrue(app.buttons["Usage refresh"].waitForExistence(timeout: 5)) - - let usageSection = app.buttons["workbench-section-usage"] - XCTAssertTrue(usageSection.waitForExistence(timeout: 2)) - usageSection.click() - - assertSelected(app.buttons["workbench-section-usage"]) - XCTAssertTrue(app.buttons["Usage refresh"].exists) - let claudeAllowance = app.descendants(matching: .any)["provider-allowance-claude"] - XCTAssertTrue( - claudeAllowance.waitForExistence(timeout: 5), - "Claude allowance must restore from the saved snapshot" - ) - XCTAssertTrue( - app.descendants(matching: .any)["provider-allowance-codex"].waitForExistence(timeout: 2), - "Codex allowance must restore from the same saved snapshot" - ) + assertSelected(app.buttons["Explore"]) + XCTAssertFalse(app.buttons["Usage"].exists) } @MainActor @@ -239,7 +195,7 @@ final class CodeVetterUITests: XCTestCase { XCTAssertTrue(app.buttons["Refresh general settings"].exists) XCTAssertTrue(app.staticTexts["Saved on this Mac"].exists) for section in [ - "General", "Appearance", "Integrations", "Agents", "Agent MCP", "Notifications", "Usage", + "General", "Appearance", "Integrations", "Agents", "Agent MCP", "Notifications", "History", "Rubrics", "Ops", "Memories", "About", ] { XCTAssertTrue(app.buttons[section].exists, "Missing settings section: \(section)") diff --git a/scripts/run-native-checks.mjs b/scripts/run-native-checks.mjs index b780b5ff..e7e75ccb 100644 --- a/scripts/run-native-checks.mjs +++ b/scripts/run-native-checks.mjs @@ -10,7 +10,6 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const nativePerformanceGateTests = [ 'hundredRunLedgerDecodesAndRendersWithinTheNativeGate', 'largeUnpackProjectionDecodesAndRendersWithinTheNativeGate', - 'largeUsageReportDecodesAndRendersWithinTheNativeGate', 'hundredRowPerformanceReceiptDecodesAndRendersWithinTheNativeGate', 'hundredJourneyTestingReceiptDecodesAndRendersWithinTheNativeGate', ]; diff --git a/scripts/run-native-checks.test.mjs b/scripts/run-native-checks.test.mjs index 38cc760f..94c7d4e3 100644 --- a/scripts/run-native-checks.test.mjs +++ b/scripts/run-native-checks.test.mjs @@ -45,7 +45,7 @@ test('native automation defaults to the non-activating background lane', () => { desktopIdleApproved: false, }); const commands = nativeCheckCommands(parsed); - assert.equal(commands.length, 7); + assert.equal(commands.length, 6); assert.ok(commands.every((command) => command.backgroundSafe)); assert.ok( commands.every( @@ -56,7 +56,7 @@ test('native automation defaults to the non-activating background lane', () => { packagePath: 'apps/macos/CodeVetterPackage', parallel: false, }); - const performanceCommands = commands.slice(1, 6); + const performanceCommands = commands.slice(1, 5); assert.ok( performanceCommands.every( (command) => JSON.parse(command.arguments[3]).configuration === 'Release' @@ -67,7 +67,6 @@ test('native automation defaults to the non-activating background lane', () => { [ 'hundredRunLedgerDecodesAndRendersWithinTheNativeGate', 'largeUnpackProjectionDecodesAndRendersWithinTheNativeGate', - 'largeUsageReportDecodesAndRendersWithinTheNativeGate', 'hundredRowPerformanceReceiptDecodesAndRendersWithinTheNativeGate', 'hundredJourneyTestingReceiptDecodesAndRendersWithinTheNativeGate', ] @@ -168,7 +167,7 @@ test('full qualification keeps background checks before the foreground lane', () ); assert.deepEqual( commands.map((command) => command.backgroundSafe), - [true, true, true, true, true, true, true, false] + [true, true, true, true, true, true, false] ); }); From d6a1e5902f4df67ca12c20ea3ad7ca6568cf87e3 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Fri, 25 Sep 2026 21:21:39 +0530 Subject: [PATCH 2/2] fix(native): omit retired Usage from review packet --- scripts/render-native-owner-review.mjs | 13 +++++++--- scripts/render-native-owner-review.test.mjs | 28 +++++++++++++++------ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/scripts/render-native-owner-review.mjs b/scripts/render-native-owner-review.mjs index 791831f8..7013d216 100644 --- a/scripts/render-native-owner-review.mjs +++ b/scripts/render-native-owner-review.mjs @@ -2,7 +2,7 @@ import { spawnSync, execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,7 +20,6 @@ const galleryTemplate = resolve( ); export const nativeOwnerReviewRenders = Object.freeze([ - ['CODEVETTER_USAGE_SCREENSHOT_PATH', 'usage.png'], ['CODEVETTER_UNPACK_SCREENSHOT_PATH', 'repo-unpack.png'], ['CODEVETTER_UNPACK_QUERY_DESK_SCREENSHOT_PATH', 'repository-query-desk.png'], ['CODEVETTER_UNPACK_QUERY_SCREENSHOT_PATH', 'repository-query-evidence-workbench.png'], @@ -76,12 +75,20 @@ export function buildOwnerReviewManifest(entries, renderedAt = new Date()) { }; } +export function buildOwnerReviewGallery(entries) { + const renderedPaths = new Set(entries.map((entry) => entry.path)); + return readFileSync(galleryTemplate, 'utf8').replace( + /^[ \t]*
(renderedPaths.has(path) ? card : '') + ); +} + export function finalizeOwnerReview(outputRoot = defaultOutputRoot) { const root = resolve(outputRoot); const entries = nativeOwnerReviewRenders.map(([, path]) => artifact(join(root, path), path)); const manifest = buildOwnerReviewManifest(entries); writeFileSync(join(root, 'owner-review-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - copyFileSync(galleryTemplate, join(root, 'gallery.html')); + writeFileSync(join(root, 'gallery.html'), buildOwnerReviewGallery(entries)); return manifest; } diff --git a/scripts/render-native-owner-review.test.mjs b/scripts/render-native-owner-review.test.mjs index 513da4f3..af959240 100644 --- a/scripts/render-native-owner-review.test.mjs +++ b/scripts/render-native-owner-review.test.mjs @@ -3,15 +3,16 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { + buildOwnerReviewGallery, buildOwnerReviewManifest, nativeOwnerReviewRenders, ownerReviewEnvironment, } from './render-native-owner-review.mjs'; -test('owner-review render contract contains 36 unique environment and image identities', () => { - assert.equal(nativeOwnerReviewRenders.length, 36); - assert.equal(new Set(nativeOwnerReviewRenders.map(([key]) => key)).size, 36); - assert.equal(new Set(nativeOwnerReviewRenders.map(([, path]) => path)).size, 36); +test('owner-review render contract contains 35 unique environment and image identities', () => { + assert.equal(nativeOwnerReviewRenders.length, 35); + assert.equal(new Set(nativeOwnerReviewRenders.map(([key]) => key)).size, 35); + assert.equal(new Set(nativeOwnerReviewRenders.map(([, path]) => path)).size, 35); }); test('owner-review render contract matches the checked manifest identities', () => { @@ -20,14 +21,17 @@ test('owner-review render contract matches the checked manifest identities', () ); assert.deepEqual( nativeOwnerReviewRenders.map(([, path]) => path).toSorted(), - manifest.entries.map((entry) => entry.path).toSorted() + manifest.entries + .filter((entry) => entry.path !== 'usage.png') + .map((entry) => entry.path) + .toSorted() ); }); test('owner-review environment resolves every render under the requested output root', () => { const environment = ownerReviewEnvironment('/fixture/review'); - assert.equal(Object.keys(environment).length, 36); - assert.equal(environment.CODEVETTER_USAGE_SCREENSHOT_PATH, '/fixture/review/usage.png'); + assert.equal(Object.keys(environment).length, 35); + assert.equal(environment.CODEVETTER_USAGE_SCREENSHOT_PATH, undefined); assert.equal( environment.CODEVETTER_OPS_SETTINGS_LIGHT_SCREENSHOT_PATH, '/fixture/review/settings-ops-light.png' @@ -35,10 +39,18 @@ test('owner-review environment resolves every render under the requested output }); test('owner-review manifest keeps visual acceptance pending', () => { - const entries = [{ path: 'usage.png', pixels: '2560x1600', sha256: 'a'.repeat(64) }]; + const entries = [{ path: 'repo-unpack.png', pixels: '2560x1600', sha256: 'a'.repeat(64) }]; const manifest = buildOwnerReviewManifest(entries, new Date('2026-09-02T07:00:00Z')); assert.equal(manifest.schema_version, 'codevetter.native-owner-review/v1'); assert.equal(manifest.rendered_at, '2026-09-02'); assert.equal(manifest.owner_acceptance, 'pending'); assert.deepEqual(manifest.entries, entries); }); + +test('owner-review gallery omits retired cards while retaining every current render', () => { + const entries = nativeOwnerReviewRenders.map(([, path]) => ({ path })); + const gallery = buildOwnerReviewGallery(entries); + assert.doesNotMatch(gallery, /data-render="usage\.png"/); + assert.equal([...gallery.matchAll(/data-render="/g)].length, entries.length); + assert.match(gallery, /data-render="repo-unpack\.png"/); +});