diff --git a/Sources/CodexBar/CodexSessionsWatcher.swift b/Sources/CodexBar/CodexSessionsWatcher.swift new file mode 100644 index 000000000..10fe034e4 --- /dev/null +++ b/Sources/CodexBar/CodexSessionsWatcher.swift @@ -0,0 +1,54 @@ +import Darwin +import Dispatch +import Foundation + +final class CodexSessionsWatcher { + let watchedPaths: [String] + private(set) var isWatching: Bool = false + + private let queue = DispatchQueue(label: "com.steipete.CodexBar.codexSessionsWatcher", qos: .utility) + private var descriptors: [CInt] = [] + private var sources: [DispatchSourceFileSystemObject] = [] + private let onChange: @Sendable () -> Void + + init(urls: [URL], onChange: @escaping @Sendable () -> Void) { + self.watchedPaths = urls.map(\.path).sorted() + self.onChange = onChange + self.start(urls: urls) + self.isWatching = !self.sources.isEmpty + } + + deinit { + self.stop() + } + + private func start(urls: [URL]) { + for url in urls where FileManager.default.fileExists(atPath: url.path) { + let descriptor = Darwin.open(url.path, O_EVTONLY) + guard descriptor >= 0 else { continue } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .extend, .delete, .rename, .attrib, .link, .revoke], + queue: self.queue) + + source.setEventHandler { [onChange = self.onChange] in + onChange() + } + source.setCancelHandler { + Darwin.close(descriptor) + } + source.resume() + + self.descriptors.append(descriptor) + self.sources.append(source) + } + } + + private func stop() { + let activeSources = self.sources + self.sources.removeAll() + self.descriptors.removeAll() + activeSources.forEach { $0.cancel() } + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index ae6ec617a..231859807 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -2,6 +2,14 @@ import CodexBarCore import Foundation extension SettingsStore { + static var codexSessionAnalyticsWindowPresets: [Int] { + [10, 20, 50, 100] + } + + static func normalizedCodexSessionAnalyticsWindowSize(_ value: Int) -> Int { + self.codexSessionAnalyticsWindowPresets.contains(value) ? value : 20 + } + private var codexPersistedActiveSource: CodexActiveSource { if let persistedSource = self.providerConfig(for: .codex)?.codexActiveSource { return persistedSource diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 44d83a023..bb7eb3033 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -166,6 +166,15 @@ extension SettingsStore { } } + var codexSessionAnalyticsWindowSize: Int { + get { self.defaultsState.codexSessionAnalyticsWindowSize } + set { + let normalized = Self.normalizedCodexSessionAnalyticsWindowSize(newValue) + self.defaultsState.codexSessionAnalyticsWindowSize = normalized + self.userDefaults.set(normalized, forKey: "codexSessionAnalyticsWindowSize") + } + } + var costUsageEnabled: Bool { get { self.defaultsState.costUsageEnabled } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f799822ef..d5b5b1671 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -19,6 +19,7 @@ extension SettingsStore { _ = self.historicalTrackingEnabled _ = self.showAllTokenAccountsInMenu _ = self.menuBarMetricPreferencesRaw + _ = self.codexSessionAnalyticsWindowSize _ = self.costUsageEnabled _ = self.hidePersonalInfo _ = self.randomBlinkEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 23872508c..4c74f539e 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -230,6 +230,12 @@ extension SettingsStore { forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let storedAnalyticsWindowSize = userDefaults.object(forKey: "codexSessionAnalyticsWindowSize") as? Int + let codexSessionAnalyticsWindowSize = Self.normalizedCodexSessionAnalyticsWindowSize( + storedAnalyticsWindowSize ?? 20) + if storedAnalyticsWindowSize != codexSessionAnalyticsWindowSize { + userDefaults.set(codexSessionAnalyticsWindowSize, forKey: "codexSessionAnalyticsWindowSize") + } return SettingsDefaultsState( refreshFrequency: refreshFrequency, @@ -264,7 +270,8 @@ extension SettingsStore { mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, - providerDetectionCompleted: providerDetectionCompleted) + providerDetectionCompleted: providerDetectionCompleted, + codexSessionAnalyticsWindowSize: codexSessionAnalyticsWindowSize) } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..68a8c02d4 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -34,4 +34,5 @@ struct SettingsDefaultsState { var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var codexSessionAnalyticsWindowSize: Int } diff --git a/Sources/CodexBar/StatusItemController+HostedSubviewMenus.swift b/Sources/CodexBar/StatusItemController+HostedSubviewMenus.swift new file mode 100644 index 000000000..e13537925 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+HostedSubviewMenus.swift @@ -0,0 +1,36 @@ +import AppKit + +extension StatusItemController { + func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { + let ids: Set = [ + "usageBreakdownChart", + "creditsHistoryChart", + "costHistoryChart", + "usageHistoryChart", + "sessionAnalyticsContent", + "sessionAnalyticsEmptyState", + ] + return menu.items.contains { item in + guard let id = item.representedObject as? String else { return false } + return ids.contains(id) + } + } + + func isSessionAnalyticsSubviewMenu(_ menu: NSMenu) -> Bool { + menu.items.contains { item in + guard let id = item.representedObject as? String else { return false } + return id.hasPrefix("sessionAnalytics") + } + } + + func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { + let ids: Set = [ + "usageBreakdownChart", + "creditsHistoryChart", + ] + return menu.items.contains { item in + guard let id = item.representedObject as? String else { return false } + return ids.contains(id) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7436e381f..845a8476f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -84,6 +84,13 @@ extension StatusItemController { } func menuWillOpen(_ menu: NSMenu) { + if self.isSessionAnalyticsRootMenu(menu) { + self.populateSessionAnalyticsSubmenu(menu) + self.refreshHostedSubviewHeights(in: menu) + self.openMenus[ObjectIdentifier(menu)] = menu + return + } + if self.isHostedSubviewMenu(menu) { self.refreshHostedSubviewHeights(in: menu) if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { @@ -252,7 +259,9 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + let addedUsageHistory = self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) + let addedSessionAnalytics = self.addSessionAnalyticsMenuItemIfNeeded(to: menu, provider: currentProvider) + if addedUsageHistory || addedSessionAnalytics { menu.addItem(.separator()) } } @@ -304,7 +313,9 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + let addedUsageHistory = self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) + let addedSessionAnalytics = self.addSessionAnalyticsMenuItemIfNeeded(to: menu, provider: currentProvider) + if addedUsageHistory || addedSessionAnalytics { menu.addItem(.separator()) } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) @@ -799,6 +810,12 @@ extension StatusItemController { continue } + if self.isSessionAnalyticsRootMenu(menu) { + self.populateSessionAnalyticsSubmenu(menu) + self.refreshHostedSubviewHeights(in: menu) + continue + } + if self.isHostedSubviewMenu(menu) { self.refreshHostedSubviewHeights(in: menu) continue @@ -925,6 +942,7 @@ extension StatusItemController { id: String, width: CGFloat, submenu: NSMenu? = nil, + showsSubmenuIndicator: Bool = true, submenuIndicatorAlignment: Alignment = .topTrailing, submenuIndicatorTopPadding: CGFloat = 8, onClick: (() -> Void)? = nil) -> NSMenuItem @@ -944,7 +962,7 @@ extension StatusItemController { let highlightState = MenuCardHighlightState() let wrapped = MenuCardSectionContainerView( highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, + showsSubmenuIndicator: submenu != nil && showsSubmenuIndicator, submenuIndicatorAlignment: submenuIndicatorAlignment, submenuIndicatorTopPadding: submenuIndicatorTopPadding) { @@ -1484,30 +1502,6 @@ extension StatusItemController { return submenu } - private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", - "costHistoryChart", - "usageHistoryChart", - ] - return menu.items.contains { item in - guard let id = item.representedObject as? String else { return false } - return ids.contains(id) - } - } - - private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ - "usageBreakdownChart", - "creditsHistoryChart", - ] - return menu.items.contains { item in - guard let id = item.representedObject as? String else { return false } - return ids.contains(id) - } - } - private func refreshHostedSubviewHeights(in menu: NSMenu) { let enabledProviders = self.store.enabledProvidersForDisplay() let width = self.menuCardWidth(for: enabledProviders, menu: menu) diff --git a/Sources/CodexBar/StatusItemController+SessionAnalyticsMenu.swift b/Sources/CodexBar/StatusItemController+SessionAnalyticsMenu.swift new file mode 100644 index 000000000..30350d536 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+SessionAnalyticsMenu.swift @@ -0,0 +1,879 @@ +import AppKit +import CodexBarCore +import SwiftUI + +enum SessionAnalyticsMenuIdentifiers { + static let rootMenuTitle = "sessionAnalyticsRoot" +} + +private final class SessionAnalyticsMenuHostingView: NSHostingView { + override var allowsVibrancy: Bool { + true + } +} + +private enum SessionAnalyticsSummaryFocus: String, CaseIterable { + case sessionsAnalyzed + case medianDuration + case medianToolCalls + case toolFailureRate + + var title: String { + switch self { + case .sessionsAnalyzed: "Sessions analyzed" + case .medianDuration: "Median duration" + case .medianToolCalls: "Median tool calls" + case .toolFailureRate: "Tool failure rate" + } + } + + func value(in snapshot: CodexSessionAnalyticsSnapshot) -> String { + switch self { + case .sessionsAnalyzed: + "\(snapshot.sessionsAnalyzed)" + case .medianDuration: + SessionAnalyticsFormatters.durationText(snapshot.medianSessionDurationSeconds) + case .medianToolCalls: + SessionAnalyticsFormatters.decimalText(snapshot.medianToolCallsPerSession) + case .toolFailureRate: + SessionAnalyticsFormatters.percentageText(snapshot.toolFailureRate) + } + } +} + +private struct SessionAnalyticsWindowPresetButton: View { + let value: Int + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Text("\(self.value)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(self.selected ? Color.white : Color(nsColor: .labelColor)) + .frame(minWidth: 34) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(self.selected + ? Color(red: 0 / 255, green: 122 / 255, blue: 255 / 255) + : Color(nsColor: .controlBackgroundColor))) + } + .buttonStyle(.plain) + } +} + +private struct SessionAnalyticsWindowSelectorView: View { + let selectedWindow: Int + let statusText: String + let width: CGFloat + let onSelect: (Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Window") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(nsColor: .labelColor)) + + HStack(spacing: 8) { + ForEach(SettingsStore.codexSessionAnalyticsWindowPresets, id: \.self) { value in + SessionAnalyticsWindowPresetButton( + value: value, + selected: value == self.selectedWindow) + { + self.onSelect(value) + } + } + } + + Text("Analyzing recent \(self.selectedWindow) sessions") + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + + Text(self.statusText) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(width: self.width, alignment: .leading) + } +} + +private struct SessionAnalyticsMetricRowView: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(self.title) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Spacer(minLength: 8) + Text(self.value) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } +} + +private struct SessionAnalyticsToolRowView: View { + let tool: CodexToolAggregate + let sessionsAnalyzed: Int + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.tool.name) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Spacer(minLength: 8) + Text("\(self.tool.callCount) calls") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + } + + Text(self.secondaryText) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + private var secondaryText: String { + [ + "\(self.tool.sessionCountUsingTool)/\(self.sessionsAnalyzed) sessions", + "\(SessionAnalyticsFormatters.percentageText(self.tool.callShare)) share", + ].joined(separator: " • ") + } +} + +private struct SessionAnalyticsSessionRowView: View { + let session: CodexSessionSummary + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.session.title) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Spacer(minLength: 8) + Text(self.session.startedAt.relativeDescription()) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + } + + Text(self.primaryStats) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + + Text(self.secondaryStats) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + private var primaryStats: String { + [ + SessionAnalyticsFormatters.durationText(self.session.durationSeconds), + "\(self.session.toolCallCount) calls", + "\(self.session.toolFailureCount) failures", + ].joined(separator: " • ") + } + + private var secondaryStats: String { + [ + "\(self.session.verificationAttemptCount) checks", + self.session.tokenUsage.map { + "\(SessionAnalyticsFormatters.compactTokenText($0.totalTokens)) tok" + } ?? "token n/a", + ].joined(separator: " • ") + } +} + +private struct SessionAnalyticsEmptyStateView: View { + let error: String? + let statusText: String? + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("No local Codex session data found.") + .font(.system(size: 12)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + + if let statusText, !statusText.isEmpty { + Text(statusText) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + + if let error, !error.isEmpty { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(width: self.width, alignment: .leading) + } +} + +private struct SessionAnalyticsDetailPanel: View { + let title: String + let subtitle: String? + let lines: [(String, String)] + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(self.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(nsColor: .labelColor)) + .lineLimit(3) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(2) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(self.lines.enumerated()), id: \.offset) { _, line in + HStack(alignment: .top, spacing: 12) { + Text(line.0) + .font(.system(size: 11)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(2) + + Text(line.1) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color(nsColor: .labelColor)) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(width: self.width, alignment: .leading) + } +} + +private struct SessionAnalyticsChartLegendRow: View { + let color: Color + let title: String + let value: String + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(self.color) + .frame(width: 8, height: 8) + + Text(self.title) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + + Spacer(minLength: 8) + + Text(self.value) + .font(.system(size: 10)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + } + } +} + +private struct SessionAnalyticsToolShareSlice: Identifiable { + let id: String + let title: String + let value: Int + let color: Color +} + +private struct SessionAnalyticsDonutSlice: Shape { + let startAngle: Angle + let endAngle: Angle + let innerRadiusFraction: CGFloat + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let outerRadius = min(rect.width, rect.height) / 2 + let innerRadius = outerRadius * self.innerRadiusFraction + let adjustedStart = self.startAngle - .degrees(90) + let adjustedEnd = self.endAngle - .degrees(90) + + var path = Path() + path.addArc( + center: center, + radius: outerRadius, + startAngle: adjustedStart, + endAngle: adjustedEnd, + clockwise: false) + path.addArc( + center: center, + radius: innerRadius, + startAngle: adjustedEnd, + endAngle: adjustedStart, + clockwise: true) + path.closeSubpath() + return path + } +} + +private struct SessionAnalyticsToolShareChartView: View { + let snapshot: CodexSessionAnalyticsSnapshot + let width: CGFloat + + private let palette: [Color] = [ + Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255), + Color(red: 88 / 255, green: 204 / 255, blue: 167 / 255), + Color(red: 255 / 255, green: 170 / 255, blue: 60 / 255), + Color(red: 242 / 255, green: 99 / 255, blue: 126 / 255), + Color(red: 145 / 255, green: 118 / 255, blue: 255 / 255), + Color(nsColor: .tertiaryLabelColor), + ] + + var body: some View { + let slices = self.chartSlices + let totalCalls = max(self.snapshot.summaryDiagnostics.totalCalls, slices.reduce(0) { $0 + $1.value }) + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 14) { + ZStack { + ForEach(Array(slices.enumerated()), id: \.element.id) { index, slice in + let startAngle = self.startAngle(for: index, slices: slices) + let endAngle = self.endAngle(for: index, slices: slices) + let donutSlice = SessionAnalyticsDonutSlice( + startAngle: startAngle, + endAngle: endAngle, + innerRadiusFraction: 0.58) + donutSlice.fill(slice.color) + } + + VStack(spacing: 2) { + Text(SessionAnalyticsFormatters.compactTokenText(totalCalls)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(nsColor: .labelColor)) + Text("tool calls") + .font(.system(size: 10)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + } + .frame(width: 92, height: 92) + + VStack(alignment: .leading, spacing: 6) { + ForEach(slices.prefix(5)) { slice in + SessionAnalyticsChartLegendRow( + color: slice.color, + title: slice.title, + value: self.legendValue(for: slice, totalCalls: totalCalls)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 14) + .padding(.bottom, 6) + .frame(width: self.width, alignment: .leading) + } + + private var chartSlices: [SessionAnalyticsToolShareSlice] { + let topTools = Array(self.snapshot.topTools.prefix(5)) + let totalCalls = max( + self.snapshot.summaryDiagnostics.totalCalls, + topTools.reduce(0) { $0 + $1.callCount }) + let baseSlices = topTools.enumerated().map { index, tool in + SessionAnalyticsToolShareSlice( + id: tool.name, + title: tool.name, + value: tool.callCount, + color: self.palette[index % self.palette.count]) + } + + let otherCalls = max(0, totalCalls - baseSlices.reduce(0) { $0 + $1.value }) + if otherCalls > 0 { + return baseSlices + [ + SessionAnalyticsToolShareSlice( + id: "other", + title: "Other", + value: otherCalls, + color: self.palette.last ?? Color(nsColor: .tertiaryLabelColor)), + ] + } + return baseSlices + } + + private func startAngle( + for index: Int, + slices: [SessionAnalyticsToolShareSlice]) -> Angle + { + let total = max(1, slices.reduce(0) { $0 + $1.value }) + let preceding = slices.prefix(index).reduce(0) { $0 + $1.value } + return .degrees(Double(preceding) / Double(total) * 360) + } + + private func endAngle( + for index: Int, + slices: [SessionAnalyticsToolShareSlice]) -> Angle + { + let total = max(1, slices.reduce(0) { $0 + $1.value }) + let current = slices.prefix(index + 1).reduce(0) { $0 + $1.value } + return .degrees(Double(current) / Double(total) * 360) + } + + private func legendValue(for slice: SessionAnalyticsToolShareSlice, totalCalls: Int) -> String { + let share = totalCalls > 0 ? Double(slice.value) / Double(totalCalls) : 0 + return "\(SessionAnalyticsFormatters.percentageText(share)) • \(slice.value)" + } +} + +private enum SessionAnalyticsFormatters { + private static let daySeconds = 24 * 60 * 60 + private static let hourSeconds = 60 * 60 + private static let millionThreshold = 1e6 + private static let thousandThreshold = 1e3 + + static let fullNumberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + return formatter + }() + + static let absoluteDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + static func durationText(_ duration: TimeInterval) -> String { + let rounded = Int(duration.rounded()) + if rounded >= Self.daySeconds { + return "\(rounded / Self.daySeconds)d \(rounded % Self.daySeconds / Self.hourSeconds)h" + } + if rounded >= Self.hourSeconds { + return "\(rounded / Self.hourSeconds)h \(rounded % Self.hourSeconds / 60)m" + } + if rounded >= 60 { + return "\(rounded / 60)m \(rounded % 60)s" + } + return "\(rounded)s" + } + + static func decimalText(_ value: Double) -> String { + if value.rounded() == value { + return "\(Int(value))" + } + return String(format: "%.1f", value) + } + + static func percentageText(_ value: Double) -> String { + String(format: "%.0f%%", value * 100) + } + + static func compactTokenText(_ value: Int) -> String { + let absolute = Double(abs(value)) + let sign = value < 0 ? "-" : "" + + if absolute >= Self.millionThreshold { + return sign + String(format: "%.1fM", absolute / Self.millionThreshold) + } + if absolute >= Self.thousandThreshold { + return sign + String(format: "%.0fK", absolute / Self.thousandThreshold) + } + return sign + "\(value)" + } + + static func fullTokenText(_ value: Int) -> String { + self.fullNumberFormatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + static func absoluteTimestampText(_ date: Date) -> String { + self.absoluteDateFormatter.string(from: date) + } + + static func shortSessionIdentifier(_ id: String) -> String { + if id.count <= 12 { + return id + } + return String(id.suffix(12)) + } + + static func topToolsText(for session: CodexSessionSummary) -> String { + let tools = session.toolCountsByName + .sorted { + if $0.value != $1.value { + return $0.value > $1.value + } + return $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending + } + .prefix(3) + + guard !tools.isEmpty else { return "No tool calls" } + return tools.map { "\($0.key) (\($0.value))" }.joined(separator: ", ") + } +} + +extension StatusItemController { + func isSessionAnalyticsRootMenu(_ menu: NSMenu) -> Bool { + menu.title == SessionAnalyticsMenuIdentifiers.rootMenuTitle + } + + @discardableResult + func addSessionAnalyticsMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .codex else { return false } + let submenu = self.makeSessionAnalyticsSubmenu() + let width: CGFloat = 310 + let item = self.makeMenuCardItem( + HStack(spacing: 0) { + Text("Session Analytics") + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 8) + }, + id: "sessionAnalyticsSubmenu", + width: width, + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + menu.addItem(item) + return true + } + + private func makeSessionAnalyticsSubmenu() -> NSMenu { + let submenu = NSMenu() + submenu.autoenablesItems = false + submenu.delegate = self + submenu.title = SessionAnalyticsMenuIdentifiers.rootMenuTitle + return submenu + } + + func populateSessionAnalyticsSubmenu(_ submenu: NSMenu) { + self.store.requestCodexSessionAnalyticsRefreshIfStale(reason: "session analytics submenu") + submenu.removeAllItems() + + let snapshot = self.store.codexSessionAnalyticsSnapshot() + let error = self.store.lastCodexSessionAnalyticsError() + let statusText = self.store.codexSessionAnalyticsStatusText() + + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem() + item.isEnabled = false + item.representedObject = snapshot == nil ? "sessionAnalyticsEmptyState" : "sessionAnalyticsContent" + submenu.addItem(item) + return + } + + let width: CGFloat = 310 + + guard let snapshot else { + submenu.addItem(self.makeSessionAnalyticsHostedItem( + SessionAnalyticsEmptyStateView(error: error, statusText: statusText, width: width), + id: "sessionAnalyticsEmptyState", + width: width)) + return + } + + submenu.addItem(self.makeSessionAnalyticsHostedItem( + SessionAnalyticsWindowSelectorView( + selectedWindow: self.settings.codexSessionAnalyticsWindowSize, + statusText: statusText, + width: width) + { [weak submenu] value in + guard value != self.settings.codexSessionAnalyticsWindowSize else { return } + self.settings.codexSessionAnalyticsWindowSize = value + self.store.refreshCodexSessionAnalyticsIfNeeded() + DispatchQueue.main.async { + guard let submenu else { return } + self.populateSessionAnalyticsSubmenu(submenu) + } + }, + id: "sessionAnalyticsContent", + width: width)) + + submenu.addItem(.separator()) + submenu.addItem(self.makeSessionAnalyticsSectionHeaderItem("Summary")) + for focus in SessionAnalyticsSummaryFocus.allCases { + submenu.addItem(self.makeSessionAnalyticsSummaryItem( + focus: focus, + snapshot: snapshot, + width: width)) + } + + submenu.addItem(.separator()) + submenu.addItem(self.makeSessionAnalyticsSectionHeaderItem("Top Tools")) + if snapshot.topTools.isEmpty { + submenu.addItem(self.makeSessionAnalyticsNoteItem("No tool calls found.")) + } else { + submenu.addItem(self.makeSessionAnalyticsHostedItem( + SessionAnalyticsToolShareChartView(snapshot: snapshot, width: width), + id: "sessionAnalyticsToolChart", + width: width)) + for (index, tool) in snapshot.topTools.enumerated() { + submenu.addItem(self.makeSessionAnalyticsToolItem( + tool: tool, + snapshot: snapshot, + width: width, + index: index)) + } + } + + submenu.addItem(.separator()) + submenu.addItem(self.makeSessionAnalyticsSectionHeaderItem("Recent Sessions")) + if snapshot.recentSessions.isEmpty { + submenu.addItem(self.makeSessionAnalyticsNoteItem("No recent sessions found.")) + } else { + for session in snapshot.recentSessions { + submenu.addItem(self.makeSessionAnalyticsSessionItem(session: session, width: width)) + } + } + } + + private func makeSessionAnalyticsSummaryItem( + focus: SessionAnalyticsSummaryFocus, + snapshot: CodexSessionAnalyticsSnapshot, + width: CGFloat) -> NSMenuItem + { + self.makeMenuCardItem( + SessionAnalyticsMetricRowView( + title: focus.title, + value: focus.value(in: snapshot)), + id: "sessionAnalyticsSummary.\(focus.rawValue)", + width: width, + submenu: self.makeSessionAnalyticsSummaryDetailSubmenu(focus: focus, snapshot: snapshot), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + } + + private func makeSessionAnalyticsToolItem( + tool: CodexToolAggregate, + snapshot: CodexSessionAnalyticsSnapshot, + width: CGFloat, + index: Int) -> NSMenuItem + { + self.makeMenuCardItem( + SessionAnalyticsToolRowView( + tool: tool, + sessionsAnalyzed: snapshot.sessionsAnalyzed), + id: "sessionAnalyticsTool.\(index)", + width: width, + submenu: self.makeSessionAnalyticsToolDetailSubmenu(tool: tool, snapshot: snapshot), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + } + + private func makeSessionAnalyticsSessionItem( + session: CodexSessionSummary, + width: CGFloat) -> NSMenuItem + { + self.makeMenuCardItem( + SessionAnalyticsSessionRowView(session: session), + id: "sessionAnalyticsSession.\(session.id)", + width: width, + submenu: self.makeSessionAnalyticsSessionDetailSubmenu(session: session), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + } + + private func makeSessionAnalyticsSummaryDetailSubmenu( + focus: SessionAnalyticsSummaryFocus, + snapshot: CodexSessionAnalyticsSnapshot) -> NSMenu + { + let diagnostics = snapshot.summaryDiagnostics + let durationSpread = [ + SessionAnalyticsFormatters.durationText(diagnostics.durationP25Seconds), + SessionAnalyticsFormatters.durationText(diagnostics.durationP50Seconds), + SessionAnalyticsFormatters.durationText(diagnostics.durationP75Seconds), + ].joined(separator: " / ") + let toolCallSpread = [ + SessionAnalyticsFormatters.decimalText(diagnostics.avgToolCalls), + SessionAnalyticsFormatters.decimalText(snapshot.medianToolCallsPerSession), + SessionAnalyticsFormatters.decimalText(diagnostics.toolCallsP75), + ].joined(separator: " / ") + let topFailingTool = diagnostics.topFailingToolName.map { + "\($0) • \(diagnostics.topFailingToolFailures)" + } ?? "No failures" + + let lines: [(String, String)] = switch focus { + case .sessionsAnalyzed: + [ + ("Window span", SessionAnalyticsFormatters.durationText(diagnostics.windowSpanSeconds)), + ("Token coverage", "\(diagnostics.sessionsWithTokens)/\(snapshot.sessionsAnalyzed) sessions"), + ("Failure coverage", "\(diagnostics.sessionsWithFailures)/\(snapshot.sessionsAnalyzed) sessions"), + ("Check coverage", "\(diagnostics.sessionsWithChecks)/\(snapshot.sessionsAnalyzed) sessions"), + ] + case .medianDuration: + [ + ("p25 / p50 / p75", durationSpread), + ("Longest session", SessionAnalyticsFormatters.durationText(diagnostics.longestSessionDurationSeconds)), + ("Top 3 share", SessionAnalyticsFormatters.percentageText(diagnostics.top3DurationShare)), + ] + case .medianToolCalls: + [ + ("avg / median / p75", toolCallSpread), + (">50 calls", "\(diagnostics.sessionsOver50Calls) sessions"), + (">100 calls", "\(diagnostics.sessionsOver100Calls) sessions"), + ("Peak session calls", "\(diagnostics.maxToolCallsInSingleSession)"), + ] + case .toolFailureRate: + [ + ("Failed / total calls", "\(diagnostics.failedCalls) / \(diagnostics.totalCalls)"), + ("Failed sessions", "\(diagnostics.sessionsWithFailures)/\(snapshot.sessionsAnalyzed) sessions"), + ("Top failing tool", topFailingTool), + ] + } + + return self.makeSessionAnalyticsDetailSubmenu( + title: focus.title, + subtitle: "Window diagnostics", + lines: lines, + id: "sessionAnalyticsContent") + } + + private func makeSessionAnalyticsToolDetailSubmenu( + tool: CodexToolAggregate, + snapshot: CodexSessionAnalyticsSnapshot) -> NSMenu + { + let maxCallsText = tool.maxCallsSessionTitle.map { + "\(tool.maxCallsInSingleSession) • \($0)" + } ?? "\(tool.maxCallsInSingleSession)" + let failureText = + "\(tool.failureCount) • \(SessionAnalyticsFormatters.percentageText(tool.failureRate)) rate" + + return self.makeSessionAnalyticsDetailSubmenu( + title: tool.name, + subtitle: "Recent window behavior", + lines: [ + ("Used in", "\(tool.sessionCountUsingTool)/\(snapshot.sessionsAnalyzed) sessions"), + ("Share of calls", SessionAnalyticsFormatters.percentageText(tool.callShare)), + ("Avg active-session calls", SessionAnalyticsFormatters.decimalText(tool.averageCallsPerActiveSession)), + ("Max in one session", maxCallsText), + ("Failures", failureText), + ("Long-running calls", "\(tool.longRunningCount)"), + ], + id: "sessionAnalyticsContent") + } + + private func makeSessionAnalyticsSessionDetailSubmenu(session: CodexSessionSummary) -> NSMenu { + let statsText = [ + SessionAnalyticsFormatters.durationText(session.durationSeconds), + "\(session.toolCallCount) calls", + "\(session.toolFailureCount) failures", + "\(session.longRunningCallCount) long-running", + "\(session.verificationAttemptCount) checks", + ].joined(separator: " • ") + let tokenLines: [(String, String)] + if let tokenUsage = session.tokenUsage { + let inputCached = + "\(SessionAnalyticsFormatters.fullTokenText(tokenUsage.inputTokens)) / " + + "\(SessionAnalyticsFormatters.fullTokenText(tokenUsage.cachedInputTokens))" + let outputReasoning = + "\(SessionAnalyticsFormatters.fullTokenText(tokenUsage.outputTokens)) / " + + "\(SessionAnalyticsFormatters.fullTokenText(tokenUsage.reasoningOutputTokens))" + tokenLines = [ + ("Total tokens", "\(SessionAnalyticsFormatters.fullTokenText(tokenUsage.totalTokens)) tok"), + ("Input / Cached", inputCached), + ("Output / Reasoning", outputReasoning), + ] + } else { + tokenLines = [("Total tokens", "No token data")] + } + + return self.makeSessionAnalyticsDetailSubmenu( + title: session.title, + subtitle: SessionAnalyticsFormatters.absoluteTimestampText(session.startedAt), + lines: [ + ("Session ID", SessionAnalyticsFormatters.shortSessionIdentifier(session.id)), + ("Stats", statsText), + ] + tokenLines + [ + ("Top tools", SessionAnalyticsFormatters.topToolsText(for: session)), + ], + id: "sessionAnalyticsContent") + } + + private func makeSessionAnalyticsDetailSubmenu( + title: String, + subtitle: String?, + lines: [(String, String)], + id: String) -> NSMenu + { + let submenu = NSMenu() + submenu.autoenablesItems = false + submenu.delegate = self + + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem() + item.isEnabled = false + item.representedObject = id + submenu.addItem(item) + return submenu + } + + let width: CGFloat = 310 + submenu.addItem(self.makeSessionAnalyticsHostedItem( + SessionAnalyticsDetailPanel( + title: title, + subtitle: subtitle, + lines: lines, + width: width), + id: id, + width: width)) + return submenu + } + + private func makeSessionAnalyticsHostedItem( + _ view: some View, + id: String, + width: CGFloat) -> NSMenuItem + { + let hosting = SessionAnalyticsMenuHostingView(rootView: view) + let controller = NSHostingController(rootView: view) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let item = NSMenuItem() + item.view = hosting + item.isEnabled = false + item.representedObject = id + return item + } + + private func makeSessionAnalyticsSectionHeaderItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } + + private func makeSessionAnalyticsNoteItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } +} diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 26330607f..a03b1090f 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -227,6 +227,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard let self else { return } self.observeStoreChanges() self.invalidateMenus() + if self.openMenus.values + .contains(where: { self.isSessionAnalyticsRootMenu($0) || self.isSessionAnalyticsSubviewMenu($0) + }) + { + self.refreshOpenMenusIfNeeded() + } self.updateIcons() self.updateBlinkingState() } diff --git a/Sources/CodexBar/UsageStore+CodexSessionAnalytics.swift b/Sources/CodexBar/UsageStore+CodexSessionAnalytics.swift new file mode 100644 index 000000000..6c14b7610 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexSessionAnalytics.swift @@ -0,0 +1,313 @@ +import AppKit +import CodexBarCore +import Foundation + +extension UsageStore { + func codexSessionAnalyticsSnapshot() -> CodexSessionAnalyticsSnapshot? { + self.codexSessionAnalytics + } + + func lastCodexSessionAnalyticsError() -> String? { + self.codexSessionAnalyticsError + } + + func codexSessionAnalyticsStatusText() -> String { + if self.codexSessionAnalyticsIsRefreshing { + return self.codexSessionAnalytics == nil ? "Building local analytics…" : "Refreshing…" + } + + if let generatedAt = self.codexSessionAnalytics?.generatedAt ?? self + .codexSessionAnalyticsLastSuccessfulRefreshAt + { + return "Updated \(generatedAt.relativeDescription())" + } + + return "No cached analytics yet" + } + + func bootstrapCodexSessionAnalyticsCache() { + guard let index = self.codexSessionAnalyticsIndexer.loadPersistedIndex() else { + self.codexSessionAnalyticsIndex = CodexSessionAnalyticsIndex(dirty: true) + self.codexSessionAnalyticsDirty = true + self.codexSessionAnalytics = nil + return + } + + self.applyCodexSessionAnalyticsIndex(index, now: index.lastSuccessfulRefreshAt ?? .now) + self.codexSessionAnalyticsError = nil + } + + func requestCodexSessionAnalyticsRefreshIfStale(reason: String) { + self.codexSessionAnalyticsLastInteractionAt = Date() + self.applyCodexSessionAnalyticsSnapshot( + windowSize: self.settings.codexSessionAnalyticsWindowSize, + preserveExisting: false) + self.startCodexSessionAnalyticsWatcherIfNeeded() + + guard self.shouldRefreshCodexSessionAnalyticsOnInteraction() else { return } + self.scheduleCodexSessionAnalyticsRefresh(reason: reason) + } + + func refreshCodexSessionAnalyticsIfNeeded(force: Bool = false) { + self.codexSessionAnalyticsLastInteractionAt = Date() + self.applyCodexSessionAnalyticsSnapshot(windowSize: self.settings.codexSessionAnalyticsWindowSize) + self.startCodexSessionAnalyticsWatcherIfNeeded() + + if force { + self.scheduleCodexSessionAnalyticsRefresh(reason: "forced refresh", force: true) + return + } + + guard self.shouldRefreshCodexSessionAnalyticsOnInteraction() else { return } + self.scheduleCodexSessionAnalyticsRefresh(reason: "manual refresh") + } + + func updateCodexSessionAnalyticsBackgroundWork() { + guard self.startupBehavior.automaticallyStartsBackgroundWork, self.isEnabled(.codex) else { + self.codexSessionAnalyticsWarmupTask?.cancel() + self.codexSessionAnalyticsWarmupTask = nil + self.codexSessionAnalyticsWatcher = nil + return + } + + self.startCodexSessionAnalyticsWatcherIfNeeded() + + guard self.codexSessionAnalyticsRefreshTask == nil else { return } + guard self.codexSessionAnalyticsIndex == nil || + self.codexSessionAnalyticsDirty || + self.isCodexSessionAnalyticsValidationDue() + else { return } + + self.scheduleCodexSessionAnalyticsRefresh( + reason: "startup warmup", + delay: self.codexSessionAnalyticsStartupWarmupDelay) + } + + func handleCodexSessionAnalyticsWatcherEvent() { + self.codexSessionAnalyticsDirty = true + + guard self.startupBehavior.automaticallyStartsBackgroundWork, self.isEnabled(.codex) else { return } + guard NSApp?.isActive == true else { return } + + self.scheduleCodexSessionAnalyticsRefresh( + reason: "filesystem change", + delay: self.codexSessionAnalyticsRefreshDebounce) + } + + private func shouldRefreshCodexSessionAnalyticsOnInteraction() -> Bool { + if self.codexSessionAnalyticsRefreshTask != nil { + return false + } + + if self.codexSessionAnalyticsIndex == nil || self.codexSessionAnalyticsDirty { + return true + } + + return self.isCodexSessionAnalyticsValidationDue() + } + + private func isCodexSessionAnalyticsValidationDue(now: Date = .now) -> Bool { + guard let lastSuccessfulRefreshAt = self.codexSessionAnalyticsLastSuccessfulRefreshAt ?? + self.codexSessionAnalyticsIndex?.lastSuccessfulRefreshAt + else { + return true + } + + return now.timeIntervalSince(lastSuccessfulRefreshAt) > self.codexSessionAnalyticsValidationInterval + } + + private func startCodexSessionAnalyticsWatcherIfNeeded() { + guard self.startupBehavior.automaticallyStartsBackgroundWork, self.isEnabled(.codex) else { return } + + let roots = self.codexSessionAnalyticsIndexer.watchRoots() + let watchedPaths = roots.map(\.path).sorted() + + if self.codexSessionAnalyticsWatcher?.watchedPaths == watchedPaths { + return + } + + let watcher = CodexSessionsWatcher(urls: roots) { [weak self] in + Task { @MainActor [weak self] in + self?.handleCodexSessionAnalyticsWatcherEvent() + } + } + + self.codexSessionAnalyticsWatcher = watcher.isWatching ? watcher : nil + } + + private func scheduleCodexSessionAnalyticsRefresh( + reason _: String, + delay: Duration? = nil, + force: Bool = false) + { + if force { + self.codexSessionAnalyticsWarmupTask?.cancel() + self.codexSessionAnalyticsWarmupTask = nil + self.codexSessionAnalyticsRefreshTask?.cancel() + self.codexSessionAnalyticsRefreshTask = nil + self.codexSessionAnalyticsRefreshToken = nil + } else { + if self.codexSessionAnalyticsRefreshTask != nil { + return + } + if delay != nil, self.codexSessionAnalyticsWarmupTask != nil { + return + } + } + + guard delay != nil || self.codexSessionAnalyticsRefreshTask == nil else { return } + + if let delay { + self.codexSessionAnalyticsWarmupTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + self.codexSessionAnalyticsWarmupTask = nil + self.startCodexSessionAnalyticsRefreshTask() + } + return + } + + self.startCodexSessionAnalyticsRefreshTask() + } + + private func startCodexSessionAnalyticsRefreshTask() { + guard self.codexSessionAnalyticsRefreshTask == nil else { return } + + self.codexSessionAnalyticsWarmupTask?.cancel() + self.codexSessionAnalyticsWarmupTask = nil + self.codexSessionAnalyticsIsRefreshing = true + + let refreshToken = UUID() + self.codexSessionAnalyticsRefreshToken = refreshToken + + let existingIndex = self.codexSessionAnalyticsIndex + let indexer = self.codexSessionAnalyticsIndexer + let windowSizes = Self.codexSessionAnalyticsWindowSizes(current: self.settings.codexSessionAnalyticsWindowSize) + let backgroundTask = Task.detached(priority: .utility) { + let refreshStartedAt = Date() + + do { + let index = try indexer.refreshIndex(existing: existingIndex, now: refreshStartedAt) + let snapshots = CodexSessionAnalyticsSnapshotBuilder.buildSnapshots( + from: index, + windowSizes: windowSizes, + now: refreshStartedAt) + return CodexSessionAnalyticsRefreshResult( + index: index, + snapshots: snapshots, + error: nil, + refreshAt: refreshStartedAt) + } catch { + return CodexSessionAnalyticsRefreshResult( + index: nil, + snapshots: nil, + error: error.localizedDescription, + refreshAt: refreshStartedAt) + } + } + + self.codexSessionAnalyticsRefreshTask = Task { @MainActor [weak self] in + let result = await withTaskCancellationHandler( + operation: { + await backgroundTask.value + }, + onCancel: { + backgroundTask.cancel() + }) + + guard let self else { return } + guard !Task.isCancelled else { return } + self.finishCodexSessionAnalyticsRefresh( + token: refreshToken, + index: result.index, + snapshots: result.snapshots, + error: result.error, + refreshAt: result.refreshAt) + } + } + + private func finishCodexSessionAnalyticsRefresh( + token: UUID, + index: CodexSessionAnalyticsIndex?, + snapshots: [Int: CodexSessionAnalyticsSnapshot]?, + error: String?, + refreshAt: Date) + { + guard self.codexSessionAnalyticsRefreshToken == token else { return } + + self.codexSessionAnalyticsRefreshTask = nil + self.codexSessionAnalyticsRefreshToken = nil + self.codexSessionAnalyticsIsRefreshing = false + + if let index, let snapshots { + self.codexSessionAnalyticsIndex = index + self.codexSessionAnalyticsDirty = false + self.codexSessionAnalyticsLastSuccessfulRefreshAt = index.lastSuccessfulRefreshAt ?? refreshAt + self.codexSessionAnalyticsCacheByWindow = snapshots + self.lastCodexSessionAnalyticsRefreshAt = self.codexSessionAnalyticsLastSuccessfulRefreshAt + self.lastCodexSessionAnalyticsRefreshAtByWindow = Dictionary( + uniqueKeysWithValues: snapshots.keys.map { + ($0, self.codexSessionAnalyticsLastSuccessfulRefreshAt ?? refreshAt) + }) + self.codexSessionAnalyticsError = nil + self.codexSessionAnalyticsErrorCacheByWindow.removeAll() + self.applyCodexSessionAnalyticsSnapshot( + windowSize: self.settings.codexSessionAnalyticsWindowSize, + preserveExisting: false) + return + } + + self.codexSessionAnalyticsDirty = true + self.codexSessionAnalyticsError = error + if let error { + for windowSize in Self + .codexSessionAnalyticsWindowSizes(current: self.settings.codexSessionAnalyticsWindowSize) + { + self.codexSessionAnalyticsErrorCacheByWindow[windowSize] = error + } + } + self.applyCodexSessionAnalyticsSnapshot(windowSize: self.settings.codexSessionAnalyticsWindowSize) + } + + private func applyCodexSessionAnalyticsIndex(_ index: CodexSessionAnalyticsIndex, now: Date) { + self.codexSessionAnalyticsIndex = index + self.codexSessionAnalyticsDirty = index.dirty + self.codexSessionAnalyticsLastSuccessfulRefreshAt = index.lastSuccessfulRefreshAt ?? now + self.codexSessionAnalyticsCacheByWindow = CodexSessionAnalyticsSnapshotBuilder.buildSnapshots( + from: index, + windowSizes: Self.codexSessionAnalyticsWindowSizes(current: self.settings.codexSessionAnalyticsWindowSize), + now: now) + self.lastCodexSessionAnalyticsRefreshAt = self.codexSessionAnalyticsLastSuccessfulRefreshAt + self.lastCodexSessionAnalyticsRefreshAtByWindow = Dictionary( + uniqueKeysWithValues: self.codexSessionAnalyticsCacheByWindow.keys.map { + ($0, self.codexSessionAnalyticsLastSuccessfulRefreshAt ?? now) + }) + self.applyCodexSessionAnalyticsSnapshot(windowSize: self.settings.codexSessionAnalyticsWindowSize) + } + + private func applyCodexSessionAnalyticsSnapshot(windowSize: Int, preserveExisting: Bool = true) { + let existingSnapshot = self.codexSessionAnalytics + self.codexSessionAnalytics = self.codexSessionAnalyticsCacheByWindow[windowSize] + if self.codexSessionAnalytics == nil { + self.codexSessionAnalytics = self.codexSessionAnalyticsCacheByWindow.values + .max { lhs, rhs in + lhs.sessionsAnalyzed < rhs.sessionsAnalyzed + } + } + if preserveExisting, self.codexSessionAnalytics == nil { + self.codexSessionAnalytics = existingSnapshot + } + } + + private static func codexSessionAnalyticsWindowSizes(current: Int) -> [Int] { + Array(Set(SettingsStore.codexSessionAnalyticsWindowPresets + [current])).sorted() + } +} + +private struct CodexSessionAnalyticsRefreshResult: Sendable { + let index: CodexSessionAnalyticsIndex? + let snapshots: [Int: CodexSessionAnalyticsSnapshot]? + let error: String? + let refreshAt: Date +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 18225a92f..23378080b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -17,6 +17,8 @@ extension UsageStore { _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight + _ = self.codexSessionAnalytics + _ = self.codexSessionAnalyticsError _ = self.credits _ = self.lastCreditsError _ = self.openAIDashboard @@ -58,6 +60,7 @@ extension UsageStore { guard let self else { return } self.observeSettingsChanges() self.probeLogs = [:] + self.updateCodexSessionAnalyticsBackgroundWork() guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() self.updateProviderRuntimes() @@ -103,6 +106,9 @@ final class UsageStore { var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] + var codexSessionAnalytics: CodexSessionAnalyticsSnapshot? + var codexSessionAnalyticsError: String? + var codexSessionAnalyticsIsRefreshing = false var credits: CreditsSnapshot? var lastCreditsError: String? var openAIDashboard: OpenAIDashboardSnapshot? @@ -175,11 +181,28 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored var lastCodexSessionAnalyticsRefreshAt: Date? + @ObservationIgnored var lastCodexSessionAnalyticsRefreshAtByWindow: [Int: Date] = [:] + @ObservationIgnored var codexSessionAnalyticsCacheByWindow: [Int: CodexSessionAnalyticsSnapshot] = [:] + @ObservationIgnored var codexSessionAnalyticsErrorCacheByWindow: [Int: String] = [:] + @ObservationIgnored var codexSessionAnalyticsIndex: CodexSessionAnalyticsIndex? + @ObservationIgnored var codexSessionAnalyticsDirty = false + @ObservationIgnored var codexSessionAnalyticsLastInteractionAt: Date? + @ObservationIgnored var codexSessionAnalyticsLastSuccessfulRefreshAt: Date? + @ObservationIgnored var codexSessionAnalyticsRefreshTask: Task? + @ObservationIgnored var codexSessionAnalyticsWarmupTask: Task? + @ObservationIgnored var codexSessionAnalyticsRefreshToken: UUID? + @ObservationIgnored var codexSessionAnalyticsWatcher: CodexSessionsWatcher? @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 - @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored var codexSessionAnalyticsLoader = CodexSessionAnalyticsLoader() + @ObservationIgnored var codexSessionAnalyticsIndexer = CodexSessionAnalyticsIndexer() + @ObservationIgnored let codexSessionAnalyticsRefreshDebounce: Duration = .seconds(1.5) + @ObservationIgnored let codexSessionAnalyticsStartupWarmupDelay: Duration = .seconds(3) + @ObservationIgnored let codexSessionAnalyticsValidationInterval: TimeInterval = 5 * 60 + @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( @@ -224,6 +247,7 @@ final class UsageStore { implementation.makeRuntime().map { (implementation.id, $0) } }) self.planUtilizationHistory = planUtilizationHistoryStore.load() + self.bootstrapCodexSessionAnalyticsCache() self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( @@ -247,6 +271,7 @@ final class UsageStore { await self?.refreshHistoricalDatasetIfNeeded() } Task { await self.refresh() } + self.updateCodexSessionAnalyticsBackgroundWork() self.startTimer() self.startTokenTimer() } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexSessionAnalyticsIndex.swift b/Sources/CodexBarCore/Providers/Codex/CodexSessionAnalyticsIndex.swift new file mode 100644 index 000000000..80138a5de --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexSessionAnalyticsIndex.swift @@ -0,0 +1,788 @@ +import Foundation + +public enum CodexSessionAnalyticsIndexIO { + private static let artifactVersion = 1 + + public static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("session-analytics", isDirectory: true) + .appendingPathComponent("codex-v\(self.artifactVersion).json", isDirectory: false) + } + + public static func load(cacheRoot: URL? = nil) -> CodexSessionAnalyticsIndex? { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + guard let data = try? Data(contentsOf: url) else { return nil } + guard let index = try? JSONDecoder().decode(CodexSessionAnalyticsIndex.self, from: data) else { return nil } + guard index.version == self.artifactVersion else { return nil } + return index + } + + public static func save(index: CodexSessionAnalyticsIndex, cacheRoot: URL? = nil) { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let tmpURL = directory.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(index)) ?? Data() + + do { + try data.write(to: tmpURL, options: [.atomic]) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmpURL) + } else { + try FileManager.default.moveItem(at: tmpURL, to: url) + } + } catch { + try? FileManager.default.removeItem(at: tmpURL) + } + } +} + +public enum CodexSessionAnalyticsRootKind: String, Codable, Sendable { + case active + case archived +} + +public struct CodexSessionAnalyticsIndexedFile: Sendable, Equatable, Codable { + public let path: String + public let sizeBytes: Int64 + public let mtimeUnixMs: Int64 + public let rootKind: CodexSessionAnalyticsRootKind + public let session: CodexSessionSummary? + + public init( + path: String, + sizeBytes: Int64, + mtimeUnixMs: Int64, + rootKind: CodexSessionAnalyticsRootKind, + session: CodexSessionSummary?) + { + self.path = path + self.sizeBytes = sizeBytes + self.mtimeUnixMs = mtimeUnixMs + self.rootKind = rootKind + self.session = session + } +} + +public struct CodexSessionAnalyticsIndex: Sendable, Equatable, Codable { + public var version: Int + public var lastSuccessfulRefreshAt: Date? + public var lastDiscoveryAt: Date? + public var dirty: Bool + public var files: [String: CodexSessionAnalyticsIndexedFile] + public var parseErrorsByPath: [String: String] + + public init( + version: Int = 1, + lastSuccessfulRefreshAt: Date? = nil, + lastDiscoveryAt: Date? = nil, + dirty: Bool = false, + files: [String: CodexSessionAnalyticsIndexedFile] = [:], + parseErrorsByPath: [String: String] = [:]) + { + self.version = version + self.lastSuccessfulRefreshAt = lastSuccessfulRefreshAt + self.lastDiscoveryAt = lastDiscoveryAt + self.dirty = dirty + self.files = files + self.parseErrorsByPath = parseErrorsByPath + } +} + +public struct CodexSessionAnalyticsIndexer: @unchecked Sendable { + private let env: [String: String] + private let fileManager: FileManager + private let homeDirectoryURL: URL? + private let cacheRoot: URL? + private let parser: CodexSessionAnalyticsParser + + public init( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + homeDirectoryURL: URL? = nil, + cacheRoot: URL? = nil) + { + self.env = env + self.fileManager = fileManager + self.homeDirectoryURL = homeDirectoryURL + self.cacheRoot = cacheRoot + self.parser = CodexSessionAnalyticsParser() + } + + public func loadPersistedIndex() -> CodexSessionAnalyticsIndex? { + CodexSessionAnalyticsIndexIO.load(cacheRoot: self.cacheRoot) + } + + public func persist(index: CodexSessionAnalyticsIndex) { + CodexSessionAnalyticsIndexIO.save(index: index, cacheRoot: self.cacheRoot) + } + + public func watchRoots() -> [URL] { + [self.sessionsRoot(), self.archivedSessionsRoot()] + } + + public func refreshIndex( + existing: CodexSessionAnalyticsIndex?, + now: Date = .now, + persist: Bool = true) throws -> CodexSessionAnalyticsIndex + { + let priorIndex = existing ?? CodexSessionAnalyticsIndex() + let candidates = self.rolloutCandidates() + var files: [String: CodexSessionAnalyticsIndexedFile] = [:] + files.reserveCapacity(candidates.count) + + var parseErrors: [String: String] = [:] + parseErrors.reserveCapacity(priorIndex.parseErrorsByPath.count) + + for candidate in candidates { + let cached = priorIndex.files[candidate.path] + let shouldReparse = + cached == nil || + cached?.sizeBytes != candidate.sizeBytes || + cached?.mtimeUnixMs != candidate.mtimeUnixMs || + priorIndex.parseErrorsByPath[candidate.path] != nil + + if !shouldReparse, let cached { + files[candidate.path] = cached + continue + } + + do { + let parsed = try self.parser.parseSessionFile(candidate.url) + files[candidate.path] = candidate.indexedFile(session: parsed ?? cached?.session) + if parsed == nil { + parseErrors[candidate.path] = "No valid session summary found." + } + } catch { + files[candidate.path] = candidate.indexedFile(session: cached?.session) + parseErrors[candidate.path] = error.localizedDescription + } + } + + let index = CodexSessionAnalyticsIndex( + version: 1, + lastSuccessfulRefreshAt: now, + lastDiscoveryAt: now, + dirty: false, + files: files, + parseErrorsByPath: parseErrors) + if persist { + self.persist(index: index) + } + return index + } + + private func codexHomeRoot() -> URL { + if let raw = self.env["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + { + return URL(fileURLWithPath: raw, isDirectory: true) + } + + return (self.homeDirectoryURL ?? self.fileManager.homeDirectoryForCurrentUser) + .appendingPathComponent(".codex", isDirectory: true) + } + + private func sessionsRoot() -> URL { + self.codexHomeRoot().appendingPathComponent("sessions", isDirectory: true) + } + + private func archivedSessionsRoot() -> URL { + self.codexHomeRoot().appendingPathComponent("archived_sessions", isDirectory: true) + } + + private func rolloutCandidates() -> [CodexSessionAnalyticsFileCandidate] { + let keys: Set = [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey] + let options: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles, .skipsPackageDescendants] + let roots: [(URL, CodexSessionAnalyticsRootKind)] = [ + (self.sessionsRoot(), .active), + (self.archivedSessionsRoot(), .archived), + ] + + var candidates: [CodexSessionAnalyticsFileCandidate] = [] + candidates.reserveCapacity(64) + + for (root, rootKind) in roots { + guard self.fileManager.fileExists(atPath: root.path), + let enumerator = self.fileManager.enumerator( + at: root, + includingPropertiesForKeys: Array(keys), + options: options) + else { continue } + + for case let fileURL as URL in enumerator { + guard fileURL.lastPathComponent.hasPrefix("rollout-"), + fileURL.pathExtension.lowercased() == "jsonl" + else { continue } + + let resourceValues = try? fileURL.resourceValues(forKeys: keys) + guard resourceValues?.isRegularFile != false else { continue } + + let mtimeUnixMs = Int64((resourceValues?.contentModificationDate ?? .distantPast) + .timeIntervalSince1970 * 1000) + let sizeBytes = Int64(resourceValues?.fileSize ?? 0) + candidates.append(CodexSessionAnalyticsFileCandidate( + url: fileURL, + path: fileURL.path, + sizeBytes: sizeBytes, + mtimeUnixMs: mtimeUnixMs, + rootKind: rootKind)) + } + } + + return candidates.sorted { lhs, rhs in + if lhs.mtimeUnixMs != rhs.mtimeUnixMs { + return lhs.mtimeUnixMs > rhs.mtimeUnixMs + } + if lhs.rootKind != rhs.rootKind { + return lhs.rootKind == .active + } + return lhs.path < rhs.path + } + } +} + +public enum CodexSessionAnalyticsSnapshotBuilder { + public static func buildSnapshots( + from index: CodexSessionAnalyticsIndex, + windowSizes: [Int], + now: Date = .now) -> [Int: CodexSessionAnalyticsSnapshot] + { + let uniqueWindowSizes = Array(Set(windowSizes.filter { $0 > 0 })).sorted() + guard !uniqueWindowSizes.isEmpty else { return [:] } + + return Dictionary(uniqueKeysWithValues: uniqueWindowSizes.compactMap { windowSize in + self.buildSnapshot(from: index, maxSessions: windowSize, now: now).map { (windowSize, $0) } + }) + } + + public static func buildSnapshot( + from index: CodexSessionAnalyticsIndex, + maxSessions: Int, + now: Date = .now) -> CodexSessionAnalyticsSnapshot? + { + guard maxSessions > 0 else { return nil } + + let recentSessions = Array(self.resolvedSessions(from: index).prefix(maxSessions)) + guard !recentSessions.isEmpty else { return nil } + + let generatedAt = index.lastSuccessfulRefreshAt ?? now + let totalToolCalls = recentSessions.reduce(0) { $0 + $1.toolCallCount } + let totalFailures = recentSessions.reduce(0) { $0 + $1.toolFailureCount } + let medianDuration = self.percentile(recentSessions.map(\.durationSeconds), percentile: 0.5) + let medianToolCalls = self.percentile( + recentSessions.map { Double($0.toolCallCount) }, + percentile: 0.5) + let toolFailureRate = totalToolCalls > 0 ? Double(totalFailures) / Double(totalToolCalls) : 0 + + let allToolAggregates = self.makeToolAggregates(from: recentSessions, totalToolCalls: totalToolCalls) + let summaryDiagnostics = self.makeSummaryDiagnostics( + from: recentSessions, + totalToolCalls: totalToolCalls, + totalFailures: totalFailures, + allToolAggregates: allToolAggregates) + + return CodexSessionAnalyticsSnapshot( + generatedAt: generatedAt, + sessions: recentSessions, + medianSessionDurationSeconds: medianDuration, + medianToolCallsPerSession: medianToolCalls, + toolFailureRate: toolFailureRate, + topTools: Array(allToolAggregates.prefix(5)), + summaryDiagnostics: summaryDiagnostics) + } + + private static func resolvedSessions(from index: CodexSessionAnalyticsIndex) -> [CodexSessionSummary] { + var chosenBySessionID: [String: CodexSessionAnalyticsIndexedFile] = [:] + + for file in index.files.values { + guard let session = file.session else { continue } + if let existing = chosenBySessionID[session.id] { + if self.isPreferredIndexedFile(file, over: existing) { + chosenBySessionID[session.id] = file + } + } else { + chosenBySessionID[session.id] = file + } + } + + return chosenBySessionID.values + .compactMap(\.session) + .sorted(by: self.isPreferredSession(_:_:)) + } + + private static func isPreferredIndexedFile( + _ lhs: CodexSessionAnalyticsIndexedFile, + over rhs: CodexSessionAnalyticsIndexedFile) -> Bool + { + if lhs.mtimeUnixMs != rhs.mtimeUnixMs { + return lhs.mtimeUnixMs > rhs.mtimeUnixMs + } + if lhs.rootKind != rhs.rootKind { + return lhs.rootKind == .active + } + return lhs.path < rhs.path + } + + private static func makeToolAggregates( + from sessions: [CodexSessionSummary], + totalToolCalls: Int) -> [CodexToolAggregate] + { + var callTotals: [String: Int] = [:] + var sessionTotals: [String: Int] = [:] + var maxCalls: [String: Int] = [:] + var maxCallsSessionTitle: [String: String] = [:] + var failureTotals: [String: Int] = [:] + var failedSessionTotals: [String: Int] = [:] + var longRunningTotals: [String: Int] = [:] + + for session in sessions { + for (name, count) in session.toolCountsByName { + callTotals[name, default: 0] += count + sessionTotals[name, default: 0] += 1 + if count > maxCalls[name, default: 0] { + maxCalls[name] = count + maxCallsSessionTitle[name] = session.title + } + } + + for (name, failureCount) in session.toolFailureCountsByName { + failureTotals[name, default: 0] += failureCount + if failureCount > 0 { + failedSessionTotals[name, default: 0] += 1 + } + } + + for (name, longRunningCount) in session.toolLongRunningCountsByName { + longRunningTotals[name, default: 0] += longRunningCount + } + } + + return callTotals.map { name, callCount in + let activeSessionCount = sessionTotals[name, default: 0] + let failureCount = failureTotals[name, default: 0] + return CodexToolAggregate( + name: name, + callCount: callCount, + sessionCountUsingTool: activeSessionCount, + callShare: totalToolCalls > 0 ? Double(callCount) / Double(totalToolCalls) : 0, + averageCallsPerActiveSession: activeSessionCount > 0 ? + Double(callCount) / Double(activeSessionCount) : 0, + maxCallsInSingleSession: maxCalls[name, default: 0], + maxCallsSessionTitle: maxCallsSessionTitle[name], + failureCount: failureCount, + failureRate: callCount > 0 ? Double(failureCount) / Double(callCount) : 0, + sessionsWithToolFailure: failedSessionTotals[name, default: 0], + longRunningCount: longRunningTotals[name, default: 0]) + } + .sorted { + if $0.callCount != $1.callCount { + return $0.callCount > $1.callCount + } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + private static func makeSummaryDiagnostics( + from sessions: [CodexSessionSummary], + totalToolCalls: Int, + totalFailures: Int, + allToolAggregates: [CodexToolAggregate]) -> CodexSessionAnalyticsSummaryDiagnostics + { + guard !sessions.isEmpty else { return .empty } + + let sortedStartedAt = sessions.map(\.startedAt).sorted() + let sortedDurations = sessions.map(\.durationSeconds) + let sortedToolCalls = sessions.map { Double($0.toolCallCount) } + let durationSum = sessions.reduce(0.0) { $0 + $1.durationSeconds } + let top3DurationSum = sessions + .map(\.durationSeconds) + .sorted(by: >) + .prefix(3) + .reduce(0, +) + let topFailingTool = allToolAggregates + .filter { $0.failureCount > 0 } + .max(by: self.isLowerPriorityTool(_:_:)) + + return CodexSessionAnalyticsSummaryDiagnostics( + windowSpanSeconds: max( + 0, + sortedStartedAt.last?.timeIntervalSince(sortedStartedAt.first ?? .distantPast) ?? 0), + sessionsWithTokens: sessions.count(where: { $0.tokenUsage != nil }), + sessionsWithFailures: sessions.count(where: { $0.toolFailureCount > 0 }), + sessionsWithChecks: sessions.count(where: { $0.verificationAttemptCount > 0 }), + durationP25Seconds: self.percentile(sortedDurations, percentile: 0.25), + durationP50Seconds: self.percentile(sortedDurations, percentile: 0.5), + durationP75Seconds: self.percentile(sortedDurations, percentile: 0.75), + longestSessionDurationSeconds: sortedDurations.max() ?? 0, + top3DurationShare: durationSum > 0 ? top3DurationSum / durationSum : 0, + avgToolCalls: sessions.isEmpty ? 0 : Double(totalToolCalls) / Double(sessions.count), + toolCallsP75: self.percentile(sortedToolCalls, percentile: 0.75), + sessionsOver50Calls: sessions.count(where: { $0.toolCallCount > 50 }), + sessionsOver100Calls: sessions.count(where: { $0.toolCallCount > 100 }), + maxToolCallsInSingleSession: sessions.map(\.toolCallCount).max() ?? 0, + failedCalls: totalFailures, + totalCalls: totalToolCalls, + topFailingToolName: topFailingTool?.name, + topFailingToolFailures: topFailingTool?.failureCount ?? 0) + } + + private static func percentile(_ values: [Double], percentile: Double) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + if sorted.count == 1 { return sorted[0] } + + let clamped = min(max(percentile, 0), 1) + let position = Double(sorted.count - 1) * clamped + let lowerIndex = Int(position.rounded(.down)) + let upperIndex = Int(position.rounded(.up)) + if lowerIndex == upperIndex { + return sorted[lowerIndex] + } + + let fraction = position - Double(lowerIndex) + return sorted[lowerIndex] + (sorted[upperIndex] - sorted[lowerIndex]) * fraction + } + + private static func isPreferredSession(_ lhs: CodexSessionSummary, _ rhs: CodexSessionSummary) -> Bool { + if lhs.startedAt != rhs.startedAt { + return lhs.startedAt > rhs.startedAt + } + return lhs.id > rhs.id + } + + private static func isLowerPriorityTool(_ lhs: CodexToolAggregate, _ rhs: CodexToolAggregate) -> Bool { + if lhs.failureCount != rhs.failureCount { + return lhs.failureCount < rhs.failureCount + } + if lhs.callCount != rhs.callCount { + return lhs.callCount < rhs.callCount + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedDescending + } +} + +public struct CodexSessionAnalyticsParser: @unchecked Sendable { + private let iso8601Formatter: ISO8601DateFormatter + + private static let verificationPattern = try? NSRegularExpression( + pattern: verificationPatternString) + private static let nonZeroExitPattern = try? NSRegularExpression( + pattern: #"Process exited with code\s+(-?\d+)"#) + private static let wallTimePattern = try? NSRegularExpression( + pattern: #"Wall time:\s*([0-9]*\.?[0-9]+)\s*(ms|milliseconds?|s|sec|secs|second|seconds)"#, + options: [.caseInsensitive]) + private static let verificationPatternString = + #"(?i)(?:\bxcodebuild\s+test\b|\bswift\s+test\b|\bcargo\s+test\b|\bnpm\s+test\b|"# + + #"\bpnpm\s+test\b|\bbun\s+test\b|\bpytest\b|\bvitest\b|\bplaywright\b|"# + + #"\blint\b|\bbuild\b|\btest\b)"# + + public init() { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.iso8601Formatter = formatter + } + + public func parseSessionFile(_ fileURL: URL) throws -> CodexSessionSummary? { + var state = SessionAccumulator() + + try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 512 * 1024, + prefixBytes: 512 * 1024) + { line in + guard !line.wasTruncated, + let object = try? JSONSerialization.jsonObject(with: line.bytes) as? [String: Any], + let type = object["type"] as? String + else { return } + + if let eventAt = self.parseDate(object["timestamp"] as? String) { + state.recordEvent(at: eventAt) + } + + self.apply(object: object, type: type, to: &state) + } + + return state.summary(fileURL: fileURL) + } + + private func apply(object: [String: Any], type: String, to state: inout SessionAccumulator) { + switch type { + case "session_meta": + guard let payload = object["payload"] as? [String: Any] else { return } + if state.id == nil { + state.id = payload["id"] as? String + } + if state.startedAt == nil { + state.startedAt = self.parseDate(payload["timestamp"] as? String) + } + + case "event_msg": + guard let payload = object["payload"] as? [String: Any], + let payloadType = payload["type"] as? String + else { return } + Self.applyEventMessage(payload, type: payloadType, to: &state) + + case "response_item": + guard let payload = object["payload"] as? [String: Any], + let payloadType = payload["type"] as? String + else { return } + Self.applyResponseItem(payload, type: payloadType, to: &state) + + default: + return + } + } + + private func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + if let parsed = self.iso8601Formatter.date(from: value) { + return parsed + } + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: value) + } + + private static func applyEventMessage( + _ payload: [String: Any], + type: String, + to state: inout SessionAccumulator) + { + switch type { + case "user_message": + guard state.title == nil, + let message = payload["message"] as? String + else { return } + state.title = self.makeTitle(from: message) + + case "token_count": + if let usage = self.parseTokenUsage(from: payload) { + state.tokenUsage = usage + } + + default: + return + } + } + + private static func applyResponseItem( + _ payload: [String: Any], + type: String, + to state: inout SessionAccumulator) + { + switch type { + case "function_call": + state.toolCallCount += 1 + if let name = payload["name"] as? String, !name.isEmpty { + state.toolCountsByName[name, default: 0] += 1 + if let callID = payload["call_id"] as? String, !callID.isEmpty { + state.toolNamesByCallID[callID] = name + } + } + if let commandText = self.commandText(from: payload), + self.isVerificationAttempt(commandText) + { + state.verificationAttemptCount += 1 + } + + case "function_call_output": + guard let output = payload["output"] as? String else { return } + let failure = self.isFailureOutput(output) + let longRunning = self.wallTimeSeconds(in: output) > 5 + + if failure { + state.toolFailureCount += 1 + } + if longRunning { + state.longRunningCallCount += 1 + } + + guard let callID = payload["call_id"] as? String, + let toolName = state.toolNamesByCallID[callID] + else { return } + + if failure { + state.toolFailureCountsByName[toolName, default: 0] += 1 + } + if longRunning { + state.toolLongRunningCountsByName[toolName, default: 0] += 1 + } + + default: + return + } + } + + static func fallbackTitle(for fileURL: URL) -> String { + let base = fileURL.deletingPathExtension().lastPathComponent + if base.hasPrefix("rollout-") { + return String(base.dropFirst("rollout-".count)) + } + return base + } + + private static func makeTitle(from message: String) -> String { + let firstLine = message + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + let title = firstLine ?? "Untitled session" + if title.count <= 88 { + return title + } + let end = title.index(title.startIndex, offsetBy: 85) + return String(title[.. CodexSessionTokenUsage? { + guard let info = payload["info"] as? [String: Any], + let totals = info["total_token_usage"] as? [String: Any] + else { return nil } + + let totalTokens = self.toInt(totals["total_tokens"]) + guard totalTokens > 0 else { return nil } + + return CodexSessionTokenUsage( + totalTokens: totalTokens, + inputTokens: self.toInt(totals["input_tokens"]), + cachedInputTokens: self.toInt(totals["cached_input_tokens"] ?? totals["cache_read_input_tokens"]), + outputTokens: self.toInt(totals["output_tokens"]), + reasoningOutputTokens: self.toInt(totals["reasoning_output_tokens"])) + } + + private static func commandText(from payload: [String: Any]) -> String? { + guard let arguments = payload["arguments"] as? String, !arguments.isEmpty else { return nil } + guard let data = arguments.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return arguments + } + + for key in ["cmd", "chars", "text", "value"] { + if let value = object[key] as? String, !value.isEmpty { + return value + } + } + return arguments + } + + private static func isVerificationAttempt(_ commandText: String) -> Bool { + guard let regex = self.verificationPattern else { return false } + let range = NSRange(commandText.startIndex.. Bool { + if output.localizedCaseInsensitiveContains("tool call error:") { + return true + } + + guard let regex = self.nonZeroExitPattern else { return false } + let range = NSRange(output.startIndex.. TimeInterval { + guard let regex = self.wallTimePattern else { return 0 } + let range = NSRange(output.startIndex.. Int { + if let number = value as? NSNumber { + return number.intValue + } + return 0 + } +} + +private struct CodexSessionAnalyticsFileCandidate { + let url: URL + let path: String + let sizeBytes: Int64 + let mtimeUnixMs: Int64 + let rootKind: CodexSessionAnalyticsRootKind + + func indexedFile(session: CodexSessionSummary?) -> CodexSessionAnalyticsIndexedFile { + CodexSessionAnalyticsIndexedFile( + path: self.path, + sizeBytes: self.sizeBytes, + mtimeUnixMs: self.mtimeUnixMs, + rootKind: self.rootKind, + session: session) + } +} + +private struct SessionAccumulator { + var id: String? + var title: String? + var startedAt: Date? + var firstEventAt: Date? + var lastEventAt: Date? + var toolCallCount = 0 + var toolFailureCount = 0 + var longRunningCallCount = 0 + var verificationAttemptCount = 0 + var toolCountsByName: [String: Int] = [:] + var toolFailureCountsByName: [String: Int] = [:] + var toolLongRunningCountsByName: [String: Int] = [:] + var toolNamesByCallID: [String: String] = [:] + var tokenUsage: CodexSessionTokenUsage? + + mutating func recordEvent(at eventAt: Date) { + if self.firstEventAt == nil || eventAt < self.firstEventAt! { + self.firstEventAt = eventAt + } + if self.lastEventAt == nil || eventAt > self.lastEventAt! { + self.lastEventAt = eventAt + } + } + + func summary(fileURL: URL) -> CodexSessionSummary? { + let resolvedID = self.id ?? fileURL.deletingPathExtension().lastPathComponent + let resolvedStartedAt = self.startedAt ?? self.firstEventAt + guard let resolvedStartedAt else { return nil } + + let start = self.firstEventAt ?? resolvedStartedAt + let end = self.lastEventAt ?? resolvedStartedAt + let durationSeconds = max(0, end.timeIntervalSince(start)) + + return CodexSessionSummary( + id: resolvedID, + title: self.title ?? CodexSessionAnalyticsParser.fallbackTitle(for: fileURL), + startedAt: resolvedStartedAt, + durationSeconds: durationSeconds, + toolCallCount: self.toolCallCount, + toolFailureCount: self.toolFailureCount, + longRunningCallCount: self.longRunningCallCount, + verificationAttemptCount: self.verificationAttemptCount, + toolCountsByName: self.toolCountsByName, + toolFailureCountsByName: self.toolFailureCountsByName, + toolLongRunningCountsByName: self.toolLongRunningCountsByName, + tokenUsage: self.tokenUsage) + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexSessionAnalyticsLoader.swift b/Sources/CodexBarCore/Providers/Codex/CodexSessionAnalyticsLoader.swift new file mode 100644 index 000000000..61b3fc04e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexSessionAnalyticsLoader.swift @@ -0,0 +1,246 @@ +import Foundation + +public struct CodexSessionTokenUsage: Sendable, Equatable, Codable { + public let totalTokens: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let reasoningOutputTokens: Int + + public init( + totalTokens: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + reasoningOutputTokens: Int) + { + self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.reasoningOutputTokens = reasoningOutputTokens + } +} + +public struct CodexSessionAnalyticsSummaryDiagnostics: Sendable, Equatable { + public let windowSpanSeconds: TimeInterval + public let sessionsWithTokens: Int + public let sessionsWithFailures: Int + public let sessionsWithChecks: Int + public let durationP25Seconds: TimeInterval + public let durationP50Seconds: TimeInterval + public let durationP75Seconds: TimeInterval + public let longestSessionDurationSeconds: TimeInterval + public let top3DurationShare: Double + public let avgToolCalls: Double + public let toolCallsP75: Double + public let sessionsOver50Calls: Int + public let sessionsOver100Calls: Int + public let maxToolCallsInSingleSession: Int + public let failedCalls: Int + public let totalCalls: Int + public let topFailingToolName: String? + public let topFailingToolFailures: Int + + public static let empty = CodexSessionAnalyticsSummaryDiagnostics( + windowSpanSeconds: 0, + sessionsWithTokens: 0, + sessionsWithFailures: 0, + sessionsWithChecks: 0, + durationP25Seconds: 0, + durationP50Seconds: 0, + durationP75Seconds: 0, + longestSessionDurationSeconds: 0, + top3DurationShare: 0, + avgToolCalls: 0, + toolCallsP75: 0, + sessionsOver50Calls: 0, + sessionsOver100Calls: 0, + maxToolCallsInSingleSession: 0, + failedCalls: 0, + totalCalls: 0, + topFailingToolName: nil, + topFailingToolFailures: 0) + + public init( + windowSpanSeconds: TimeInterval, + sessionsWithTokens: Int, + sessionsWithFailures: Int, + sessionsWithChecks: Int, + durationP25Seconds: TimeInterval, + durationP50Seconds: TimeInterval, + durationP75Seconds: TimeInterval, + longestSessionDurationSeconds: TimeInterval, + top3DurationShare: Double, + avgToolCalls: Double, + toolCallsP75: Double, + sessionsOver50Calls: Int, + sessionsOver100Calls: Int, + maxToolCallsInSingleSession: Int, + failedCalls: Int, + totalCalls: Int, + topFailingToolName: String?, + topFailingToolFailures: Int) + { + self.windowSpanSeconds = windowSpanSeconds + self.sessionsWithTokens = sessionsWithTokens + self.sessionsWithFailures = sessionsWithFailures + self.sessionsWithChecks = sessionsWithChecks + self.durationP25Seconds = durationP25Seconds + self.durationP50Seconds = durationP50Seconds + self.durationP75Seconds = durationP75Seconds + self.longestSessionDurationSeconds = longestSessionDurationSeconds + self.top3DurationShare = top3DurationShare + self.avgToolCalls = avgToolCalls + self.toolCallsP75 = toolCallsP75 + self.sessionsOver50Calls = sessionsOver50Calls + self.sessionsOver100Calls = sessionsOver100Calls + self.maxToolCallsInSingleSession = maxToolCallsInSingleSession + self.failedCalls = failedCalls + self.totalCalls = totalCalls + self.topFailingToolName = topFailingToolName + self.topFailingToolFailures = topFailingToolFailures + } +} + +public struct CodexSessionSummary: Sendable, Equatable, Codable { + public let id: String + public let title: String + public let startedAt: Date + public let durationSeconds: TimeInterval + public let toolCallCount: Int + public let toolFailureCount: Int + public let longRunningCallCount: Int + public let verificationAttemptCount: Int + public let toolCountsByName: [String: Int] + public let toolFailureCountsByName: [String: Int] + public let toolLongRunningCountsByName: [String: Int] + public let tokenUsage: CodexSessionTokenUsage? + + public init( + id: String, + title: String, + startedAt: Date, + durationSeconds: TimeInterval, + toolCallCount: Int, + toolFailureCount: Int, + longRunningCallCount: Int, + verificationAttemptCount: Int, + toolCountsByName: [String: Int], + toolFailureCountsByName: [String: Int] = [:], + toolLongRunningCountsByName: [String: Int] = [:], + tokenUsage: CodexSessionTokenUsage? = nil) + { + self.id = id + self.title = title + self.startedAt = startedAt + self.durationSeconds = durationSeconds + self.toolCallCount = toolCallCount + self.toolFailureCount = toolFailureCount + self.longRunningCallCount = longRunningCallCount + self.verificationAttemptCount = verificationAttemptCount + self.toolCountsByName = toolCountsByName + self.toolFailureCountsByName = toolFailureCountsByName + self.toolLongRunningCountsByName = toolLongRunningCountsByName + self.tokenUsage = tokenUsage + } +} + +public struct CodexToolAggregate: Sendable, Equatable { + public let name: String + public let callCount: Int + public let sessionCountUsingTool: Int + public let callShare: Double + public let averageCallsPerActiveSession: Double + public let maxCallsInSingleSession: Int + public let maxCallsSessionTitle: String? + public let failureCount: Int + public let failureRate: Double + public let sessionsWithToolFailure: Int + public let longRunningCount: Int + + public init( + name: String, + callCount: Int, + sessionCountUsingTool: Int = 0, + callShare: Double = 0, + averageCallsPerActiveSession: Double = 0, + maxCallsInSingleSession: Int = 0, + maxCallsSessionTitle: String? = nil, + failureCount: Int = 0, + failureRate: Double = 0, + sessionsWithToolFailure: Int = 0, + longRunningCount: Int = 0) + { + self.name = name + self.callCount = callCount + self.sessionCountUsingTool = sessionCountUsingTool + self.callShare = callShare + self.averageCallsPerActiveSession = averageCallsPerActiveSession + self.maxCallsInSingleSession = maxCallsInSingleSession + self.maxCallsSessionTitle = maxCallsSessionTitle + self.failureCount = failureCount + self.failureRate = failureRate + self.sessionsWithToolFailure = sessionsWithToolFailure + self.longRunningCount = longRunningCount + } +} + +public struct CodexSessionAnalyticsSnapshot: Sendable, Equatable { + public let generatedAt: Date + public let sessions: [CodexSessionSummary] + public let medianSessionDurationSeconds: TimeInterval + public let medianToolCallsPerSession: Double + public let toolFailureRate: Double + public let topTools: [CodexToolAggregate] + public let summaryDiagnostics: CodexSessionAnalyticsSummaryDiagnostics + + public init( + generatedAt: Date, + sessions: [CodexSessionSummary], + medianSessionDurationSeconds: TimeInterval, + medianToolCallsPerSession: Double, + toolFailureRate: Double, + topTools: [CodexToolAggregate], + summaryDiagnostics: CodexSessionAnalyticsSummaryDiagnostics = .empty) + { + self.generatedAt = generatedAt + self.sessions = sessions + self.medianSessionDurationSeconds = medianSessionDurationSeconds + self.medianToolCallsPerSession = medianToolCallsPerSession + self.toolFailureRate = toolFailureRate + self.topTools = topTools + self.summaryDiagnostics = summaryDiagnostics + } + + public var sessionsAnalyzed: Int { + self.sessions.count + } + + public var recentSessions: [CodexSessionSummary] { + Array(self.sessions.prefix(8)) + } +} + +public struct CodexSessionAnalyticsLoader { + private let indexer: CodexSessionAnalyticsIndexer + + public init( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + homeDirectoryURL: URL? = nil) + { + self.indexer = CodexSessionAnalyticsIndexer( + env: env, + fileManager: fileManager, + homeDirectoryURL: homeDirectoryURL) + } + + public func loadSnapshot(maxSessions: Int = 20, now: Date = .now) throws -> CodexSessionAnalyticsSnapshot? { + let index = try self.indexer.refreshIndex(existing: nil, now: now, persist: false) + return CodexSessionAnalyticsSnapshotBuilder.buildSnapshot( + from: index, + maxSessions: maxSessions, + now: now) + } +} diff --git a/Tests/CodexBarTests/CodexSessionAnalyticsIndexerTests.swift b/Tests/CodexBarTests/CodexSessionAnalyticsIndexerTests.swift new file mode 100644 index 000000000..d50c5feb2 --- /dev/null +++ b/Tests/CodexBarTests/CodexSessionAnalyticsIndexerTests.swift @@ -0,0 +1,297 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexSessionAnalyticsIndexerTests { + @Test + func `indexer loads persisted analytics without rollout files`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 28) + let contents = try self.sessionJSONL( + id: "persisted-session", + startedAt: startedAt, + userMessage: "Persisted session", + items: [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "persisted-exec", + arguments: ["cmd": "swift test"]), + .functionCallOutput( + offset: 2, + callID: "persisted-exec", + output: "Command: test\nWall time: 1 second\nProcess exited with code 0"), + ]) + let fileURL = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-persisted-session.jsonl", + contents: contents) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + _ = try indexer.refreshIndex(existing: nil, now: startedAt) + try FileManager.default.removeItem(at: fileURL) + + let persisted = try #require(indexer.loadPersistedIndex()) + let snapshot = try #require( + CodexSessionAnalyticsSnapshotBuilder.buildSnapshot( + from: persisted, + maxSessions: 20, + now: startedAt)) + + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions.first?.id == "persisted-session") + } + + @Test + func `indexer skips reparse when fingerprint is unchanged`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 29) + let contents = try self.sessionJSONL( + id: "fingerprint-stable", + startedAt: startedAt, + userMessage: "Stable fingerprint", + items: [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "stable-exec", + arguments: ["cmd": "swift build"]), + .functionCallOutput( + offset: 2, + callID: "stable-exec", + output: "Command: build\nWall time: 1 second\nProcess exited with code 0"), + ]) + let fileURL = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-fingerprint-stable.jsonl", + contents: contents) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + let firstIndex = try indexer.refreshIndex(existing: nil, now: startedAt) + let resourceValues = try fileURL.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) + let originalMtime = try #require(resourceValues.contentModificationDate) + let originalSize = try #require(resourceValues.fileSize) + + let invalidData = Data(repeating: 0x78, count: originalSize) + try invalidData.write(to: fileURL, options: [.atomic]) + try FileManager.default.setAttributes([.modificationDate: originalMtime], ofItemAtPath: fileURL.path) + + let secondIndex = try indexer.refreshIndex(existing: firstIndex, now: startedAt.addingTimeInterval(60)) + let snapshot = try #require( + CodexSessionAnalyticsSnapshotBuilder.buildSnapshot( + from: secondIndex, + maxSessions: 20, + now: startedAt)) + + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions.first?.title == "Stable fingerprint") + #expect(secondIndex.parseErrorsByPath.isEmpty) + } + + @Test + func `indexer keeps prior summary when changed rollout becomes malformed`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 30) + let contents = try self.sessionJSONL( + id: "malformed-retry", + startedAt: startedAt, + userMessage: "Retry malformed", + items: [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "retry-exec", + arguments: ["cmd": "swift test"]), + .functionCallOutput( + offset: 2, + callID: "retry-exec", + output: "Command: test\nWall time: 1 second\nProcess exited with code 0"), + ]) + let fileURL = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-malformed-retry.jsonl", + contents: contents) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + let firstIndex = try indexer.refreshIndex(existing: nil, now: startedAt) + + try "{not-json".write(to: fileURL, atomically: true, encoding: .utf8) + + let secondIndex = try indexer.refreshIndex(existing: firstIndex, now: startedAt.addingTimeInterval(60)) + let snapshot = try #require( + CodexSessionAnalyticsSnapshotBuilder.buildSnapshot( + from: secondIndex, + maxSessions: 20, + now: startedAt)) + + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions.first?.id == "malformed-retry") + #expect(!secondIndex.parseErrorsByPath.isEmpty) + } + + @Test + func `snapshot builder prefers active file when duplicate session mtimes tie`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 31) + let activeURL = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-duplicate-active.jsonl", + contents: self.sessionJSONL( + id: "duplicate-session", + startedAt: startedAt, + userMessage: "Active wins", + items: [])) + let archivedURL = try env.writeCodexArchivedSessionFile( + filename: "rollout-duplicate-archived.jsonl", + contents: self.sessionJSONL( + id: "duplicate-session", + startedAt: startedAt, + userMessage: "Archived loses", + items: [])) + + let sharedMtime = startedAt.addingTimeInterval(120) + try FileManager.default.setAttributes([.modificationDate: sharedMtime], ofItemAtPath: activeURL.path) + try FileManager.default.setAttributes([.modificationDate: sharedMtime], ofItemAtPath: archivedURL.path) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + let index = try indexer.refreshIndex(existing: nil, now: startedAt.addingTimeInterval(180)) + let snapshot = try #require( + CodexSessionAnalyticsSnapshotBuilder.buildSnapshot( + from: index, + maxSessions: 20, + now: startedAt)) + + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions.first?.title == "Active wins") + } + + @Test + func `refresh removes deleted rollout files from the index`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 4, day: 1) + _ = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-kept.jsonl", + contents: self.sessionJSONL( + id: "kept-session", + startedAt: startedAt, + userMessage: "Kept session", + items: [])) + let deletedURL = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-deleted.jsonl", + contents: self.sessionJSONL( + id: "deleted-session", + startedAt: startedAt.addingTimeInterval(60), + userMessage: "Deleted session", + items: [])) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + let firstIndex = try indexer.refreshIndex(existing: nil, now: startedAt) + #expect(firstIndex.files.count == 2) + + try FileManager.default.removeItem(at: deletedURL) + let secondIndex = try indexer.refreshIndex(existing: firstIndex, now: startedAt.addingTimeInterval(120)) + let snapshot = try #require( + CodexSessionAnalyticsSnapshotBuilder.buildSnapshot( + from: secondIndex, + maxSessions: 20, + now: startedAt)) + + #expect(secondIndex.files.count == 1) + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions.first?.id == "kept-session") + } +} + +extension CodexSessionAnalyticsIndexerTests { + fileprivate enum SessionItem { + case functionCall(offset: TimeInterval, name: String, callID: String, arguments: [String: String]) + case functionCallOutput(offset: TimeInterval, callID: String, output: String) + } + + private func sessionJSONL( + id: String, + startedAt: Date, + userMessage: String, + items: [SessionItem]) throws -> String + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var lines: [String] = [] + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt), + "type": "session_meta", + "payload": [ + "id": id, + "timestamp": formatter.string(from: startedAt), + ], + ])) + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(0.1)), + "type": "event_msg", + "payload": [ + "type": "user_message", + "message": userMessage, + ], + ])) + + for item in items { + switch item { + case let .functionCall(offset, name, callID, arguments): + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(offset)), + "type": "response_item", + "payload": [ + "type": "function_call", + "name": name, + "arguments": self.jsonString(arguments), + "call_id": callID, + ], + ])) + case let .functionCallOutput(offset, callID, output): + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(offset)), + "type": "response_item", + "payload": [ + "type": "function_call_output", + "call_id": callID, + "output": output, + ], + ])) + } + } + + return lines.joined(separator: "\n") + "\n" + } + + private func jsonLine(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object) + return try #require(String(bytes: data, encoding: .utf8)) + } + + private func jsonString(_ object: [String: String]) throws -> String { + try self.jsonLine(object) + } +} diff --git a/Tests/CodexBarTests/CodexSessionAnalyticsLoaderTests.swift b/Tests/CodexBarTests/CodexSessionAnalyticsLoaderTests.swift new file mode 100644 index 000000000..4fc5430d9 --- /dev/null +++ b/Tests/CodexBarTests/CodexSessionAnalyticsLoaderTests.swift @@ -0,0 +1,538 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexSessionAnalyticsLoaderTests { + @Test + func `loader aggregates recent codex rollout analytics`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let baseDay = try env.makeLocalNoon(year: 2026, month: 3, day: 1) + + for index in 0..<21 { + let start = baseDay.addingTimeInterval(TimeInterval(index * 3600)) + let contents = try self.sessionJSONL( + id: "session-\(index)", + startedAt: start, + userMessage: "Session \(index)\nDo work", + items: [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "call-\(index)-exec", + arguments: ["cmd": index.isMultiple(of: 2) ? "swift test" : "rg Session"]), + .functionCallOutput( + offset: 2, + callID: "call-\(index)-exec", + output: "Command: exec\nWall time: 6.2 seconds\nProcess exited with code 0"), + .functionCall( + offset: 3, + name: index.isMultiple(of: 3) ? "write_stdin" : "mcp__vercel__get_project", + callID: "call-\(index)-followup", + arguments: ["chars": "npm test\n"]), + .functionCallOutput( + offset: 4, + callID: "call-\(index)-followup", + output: index.isMultiple(of: 4) ? "tool call error: handshake failed" : + "Command: second\nWall time: 120ms\nProcess exited with code 0"), + .tokenCount( + offset: 5, + totalTokens: 500 + index, + inputTokens: 250 + index, + cachedInputTokens: 50, + outputTokens: 200, + reasoningOutputTokens: 20), + ], + includeMalformedLine: index == 5) + _ = try env.writeCodexSessionFile( + day: start, + filename: "rollout-2026-03-\(String(format: "%02d", index + 1))-session-\(index).jsonl", + contents: contents) + } + + let archivedStart = baseDay.addingTimeInterval(TimeInterval(22 * 3600)) + let archivedContents = try self.sessionJSONL( + id: "archived-newest", + startedAt: archivedStart, + userMessage: "Archived newest", + items: [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "archived-exec", + arguments: ["cmd": "pnpm lint"]), + .functionCallOutput( + offset: 7, + callID: "archived-exec", + output: "Command: lint\nWall time: 7.0 seconds\nProcess exited with code 1"), + .tokenCount( + offset: 8, + totalTokens: 999, + inputTokens: 444, + cachedInputTokens: 55, + outputTokens: 500, + reasoningOutputTokens: 99), + ]) + _ = try env.writeCodexArchivedSessionFile( + filename: "rollout-2026-03-31-archived-newest.jsonl", + contents: archivedContents) + + let loader = CodexSessionAnalyticsLoader(env: ["CODEX_HOME": env.codexHomeRoot.path]) + let snapshot = try #require(try loader.loadSnapshot()) + + #expect(snapshot.sessions.count == 20) + #expect(snapshot.sessions.first?.id == "archived-newest") + #expect(snapshot.sessions.last?.id == "session-2") + #expect(snapshot.sessionsAnalyzed == 20) + #expect(snapshot.recentSessions.count == 8) + #expect(snapshot.topTools.first?.name == "exec_command") + #expect(snapshot.topTools.first?.callCount == 20) + #expect(snapshot.topTools.first?.sessionCountUsingTool == 20) + #expect(snapshot.sessions.contains(where: { $0.id == "session-5" && $0.toolFailureCount == 0 })) + #expect(snapshot.sessions.contains(where: { $0.id == "archived-newest" && $0.longRunningCallCount == 1 })) + #expect(snapshot.sessions.contains(where: { $0.id == "session-2" && $0.verificationAttemptCount == 2 })) + #expect(snapshot.sessions.first?.tokenUsage?.totalTokens == 999) + #expect(snapshot.summaryDiagnostics.sessionsWithTokens == 20) + #expect(snapshot.summaryDiagnostics.sessionsWithFailures > 0) + #expect(snapshot.toolFailureRate > 0) + #expect(snapshot.medianSessionDurationSeconds > 0) + #expect(snapshot.medianToolCallsPerSession == 2) + } + + @Test + func `loader honors maxSessions window and latest total token usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let baseDay = try env.makeLocalNoon(year: 2026, month: 3, day: 10) + + for index in 0..<12 { + let start = baseDay.addingTimeInterval(TimeInterval(index * 3600)) + let contents = try self.sessionJSONL( + id: "window-\(index)", + startedAt: start, + userMessage: "Window \(index)", + items: [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "window-\(index)-exec", + arguments: ["cmd": "swift build"]), + .functionCallOutput( + offset: 2, + callID: "window-\(index)-exec", + output: "Command: build\nWall time: 2 seconds\nProcess exited with code 0"), + .tokenCount( + offset: 3, + totalTokens: 100 + index, + inputTokens: 50 + index, + cachedInputTokens: 10, + outputTokens: 40, + reasoningOutputTokens: 5), + .tokenCount( + offset: 4, + totalTokens: 200 + index, + inputTokens: 100 + index, + cachedInputTokens: 20, + outputTokens: 80, + reasoningOutputTokens: 10), + ]) + _ = try env.writeCodexSessionFile( + day: start, + filename: "rollout-window-\(index).jsonl", + contents: contents) + } + + let loader = CodexSessionAnalyticsLoader(env: ["CODEX_HOME": env.codexHomeRoot.path]) + + let snapshot10 = try #require(try loader.loadSnapshot(maxSessions: 10)) + #expect(snapshot10.sessions.count == 10) + #expect(snapshot10.sessions.first?.id == "window-11") + #expect(snapshot10.sessions.last?.id == "window-2") + #expect(snapshot10.sessions.first?.tokenUsage?.totalTokens == 211) + + let snapshot20 = try #require(try loader.loadSnapshot(maxSessions: 20)) + #expect(snapshot20.sessions.count == 12) + + let snapshot50 = try #require(try loader.loadSnapshot(maxSessions: 50)) + #expect(snapshot50.sessions.count == 12) + + let snapshot100 = try #require(try loader.loadSnapshot(maxSessions: 100)) + #expect(snapshot100.sessions.count == 12) + } + + @Test + func `loader computes summary and per-tool diagnostics from call ids`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let baseDay = try env.makeLocalNoon(year: 2026, month: 3, day: 20) + + for (id, startOffset, title, items) in self.diagnosticSessions() { + let startedAt = baseDay.addingTimeInterval(startOffset) + let contents = try self.sessionJSONL( + id: id, + startedAt: startedAt, + userMessage: title, + items: items) + _ = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-\(id).jsonl", + contents: contents) + } + + let loader = CodexSessionAnalyticsLoader(env: ["CODEX_HOME": env.codexHomeRoot.path]) + let snapshot = try #require(try loader.loadSnapshot(maxSessions: 100)) + let diagnostics = snapshot.summaryDiagnostics + + #expect(snapshot.sessions.count == 4) + #expect(diagnostics.windowSpanSeconds == 10800) + #expect(diagnostics.sessionsWithTokens == 3) + #expect(diagnostics.sessionsWithFailures == 2) + #expect(diagnostics.sessionsWithChecks == 2) + #expect(diagnostics.durationP25Seconds == 17.5) + #expect(diagnostics.durationP50Seconds == 30) + #expect(diagnostics.durationP75Seconds == 50) + #expect(diagnostics.longestSessionDurationSeconds == 80) + #expect(abs(diagnostics.top3DurationShare - (14.0 / 15.0)) < 0.0001) + #expect(diagnostics.avgToolCalls == 2.5) + #expect(diagnostics.toolCallsP75 == 3.25) + #expect(diagnostics.sessionsOver50Calls == 0) + #expect(diagnostics.sessionsOver100Calls == 0) + #expect(diagnostics.maxToolCallsInSingleSession == 4) + #expect(diagnostics.failedCalls == 2) + #expect(diagnostics.totalCalls == 10) + #expect(diagnostics.topFailingToolName == "exec_command") + #expect(diagnostics.topFailingToolFailures == 1) + + let execTool = try #require(snapshot.topTools.first(where: { $0.name == "exec_command" })) + #expect(execTool.callCount == 6) + #expect(execTool.sessionCountUsingTool == 3) + #expect(abs(execTool.callShare - 0.6) < 0.0001) + #expect(execTool.averageCallsPerActiveSession == 2.0) + #expect(execTool.maxCallsInSingleSession == 3) + #expect(execTool.maxCallsSessionTitle == "Session four") + #expect(execTool.failureCount == 1) + #expect(abs(execTool.failureRate - (1.0 / 6.0)) < 0.0001) + #expect(execTool.sessionsWithToolFailure == 1) + #expect(execTool.longRunningCount == 1) + + let writeTool = try #require(snapshot.topTools.first(where: { $0.name == "write_stdin" })) + #expect(writeTool.callCount == 3) + #expect(writeTool.sessionCountUsingTool == 2) + #expect(abs(writeTool.callShare - 0.3) < 0.0001) + #expect(writeTool.averageCallsPerActiveSession == 1.5) + #expect(writeTool.maxCallsInSingleSession == 2) + #expect(writeTool.maxCallsSessionTitle == "Session two") + #expect(writeTool.failureCount == 1) + #expect(abs(writeTool.failureRate - (1.0 / 3.0)) < 0.0001) + #expect(writeTool.sessionsWithToolFailure == 1) + #expect(writeTool.longRunningCount == 1) + } + + @Test + func `loader ignores token events without total token usage and falls back to home dot codex`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-analytics-home-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let dotCodex = root.appendingPathComponent(".codex", isDirectory: true) + let sessions = dotCodex.appendingPathComponent("sessions/2026/03/29", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + + let startedAt = Date(timeIntervalSince1970: 1_743_206_400) + let fileURL = sessions.appendingPathComponent("rollout-2026-03-29-home.jsonl") + try self.sessionJSONL( + id: "home-session", + startedAt: startedAt, + userMessage: "Home fallback", + items: [ + .functionCall(offset: 1, name: "exec_command", callID: "home-exec", arguments: ["cmd": "swift build"]), + .functionCallOutput( + offset: 5.2, + callID: "home-exec", + output: "Command: build\nWall time: 5.2 seconds\nProcess exited with code 0"), + .lastOnlyTokenCount(offset: 6, totalTokens: 999), + ]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let loader = CodexSessionAnalyticsLoader(env: [:], homeDirectoryURL: root) + let snapshot = try #require(try loader.loadSnapshot()) + + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions.first?.id == "home-session") + #expect(snapshot.sessions.first?.verificationAttemptCount == 1) + #expect(snapshot.sessions.first?.tokenUsage == nil) + } +} + +extension CodexSessionAnalyticsLoaderTests { + fileprivate typealias DiagnosticSession = (String, TimeInterval, String, [SessionItem]) + + fileprivate enum SessionItem { + case functionCall(offset: TimeInterval, name: String, callID: String, arguments: [String: String]) + case functionCallOutput(offset: TimeInterval, callID: String, output: String) + case tokenCount( + offset: TimeInterval, + totalTokens: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + reasoningOutputTokens: Int) + case lastOnlyTokenCount(offset: TimeInterval, totalTokens: Int) + } + + private func diagnosticSessions() -> [DiagnosticSession] { + [ + ( + "diagnostic-1", + 0, + "Session one", + [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "s1-exec-a", + arguments: ["cmd": "swift test"]), + .functionCallOutput( + offset: 5, + callID: "s1-exec-a", + output: "Command: test\nWall time: 6 seconds\nProcess exited with code 0"), + .functionCall( + offset: 6, + name: "exec_command", + callID: "s1-exec-b", + arguments: ["cmd": "rg foo"]), + .functionCallOutput( + offset: 10, + callID: "s1-exec-b", + output: "Command: rg\nWall time: 1 second\nProcess exited with code 2"), + .tokenCount( + offset: 10, + totalTokens: 1000, + inputTokens: 600, + cachedInputTokens: 100, + outputTokens: 300, + reasoningOutputTokens: 40), + ]), + ( + "diagnostic-2", + 3600, + "Session two", + [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "s2-exec", + arguments: ["cmd": "rg bar"]), + .functionCallOutput( + offset: 6, + callID: "s2-exec", + output: "Command: rg\nWall time: 1 second\nProcess exited with code 0"), + .functionCall( + offset: 8, + name: "write_stdin", + callID: "s2-write-a", + arguments: ["chars": "npm test\n"]), + .functionCallOutput( + offset: 15, + callID: "s2-write-a", + output: "tool call error: handshake failed"), + .functionCall( + offset: 16, + name: "write_stdin", + callID: "s2-write-b", + arguments: ["chars": "pnpm lint\n"]), + .functionCallOutput( + offset: 20, + callID: "s2-write-b", + output: "Command: lint\nWall time: 6 seconds\nProcess exited with code 0"), + .tokenCount( + offset: 20, + totalTokens: 2000, + inputTokens: 1100, + cachedInputTokens: 200, + outputTokens: 700, + reasoningOutputTokens: 90), + ]), + ( + "diagnostic-3", + 7200, + "Session three", + [ + .functionCall( + offset: 1, + name: "write_stdin", + callID: "s3-write", + arguments: ["chars": "hello"]), + .functionCallOutput( + offset: 40, + callID: "s3-write", + output: "Command: echo\nWall time: 1 second\nProcess exited with code 0"), + ]), + ( + "diagnostic-4", + 10800, + "Session four", + [ + .functionCall( + offset: 1, + name: "exec_command", + callID: "s4-exec-a", + arguments: ["cmd": "rg one"]), + .functionCallOutput( + offset: 20, + callID: "s4-exec-a", + output: "Command: rg\nWall time: 1 second\nProcess exited with code 0"), + .functionCall( + offset: 30, + name: "exec_command", + callID: "s4-exec-b", + arguments: ["cmd": "rg two"]), + .functionCallOutput( + offset: 50, + callID: "s4-exec-b", + output: "Command: rg\nWall time: 1 second\nProcess exited with code 0"), + .functionCall( + offset: 60, + name: "exec_command", + callID: "s4-exec-c", + arguments: ["cmd": "rg three"]), + .functionCallOutput( + offset: 70, + callID: "s4-exec-c", + output: "Command: rg\nWall time: 1 second\nProcess exited with code 0"), + .functionCall( + offset: 75, + name: "request_user_input", + callID: "s4-request", + arguments: ["question": "continue?"]), + .functionCallOutput( + offset: 80, + callID: "s4-request", + output: "accepted"), + .tokenCount( + offset: 80, + totalTokens: 3000, + inputTokens: 1700, + cachedInputTokens: 300, + outputTokens: 1000, + reasoningOutputTokens: 120), + ]), + ] + } + + private func sessionJSONL( + id: String, + startedAt: Date, + userMessage: String, + items: [SessionItem], + includeMalformedLine: Bool = false) throws -> String + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var lines: [String] = [] + let sessionMeta: [String: Any] = [ + "timestamp": formatter.string(from: startedAt), + "type": "session_meta", + "payload": [ + "id": id, + "timestamp": formatter.string(from: startedAt), + ], + ] + let userMessageEvent: [String: Any] = [ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(0.1)), + "type": "event_msg", + "payload": [ + "type": "user_message", + "message": userMessage, + ], + ] + + try lines.append(self.jsonLine(sessionMeta)) + try lines.append(self.jsonLine(userMessageEvent)) + if includeMalformedLine { + lines.append("{not-json") + } + + for item in items { + switch item { + case let .functionCall(offset, name, callID, arguments): + let payload: [String: Any] = try [ + "type": "function_call", + "name": name, + "arguments": self.jsonString(arguments), + "call_id": callID, + ] + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(offset)), + "type": "response_item", + "payload": payload, + ])) + + case let .functionCallOutput(offset, callID, output): + let payload: [String: Any] = [ + "type": "function_call_output", + "call_id": callID, + "output": output, + ] + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(offset)), + "type": "response_item", + "payload": payload, + ])) + + case let .tokenCount( + offset, + totalTokens, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens): + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(offset)), + "type": "event_msg", + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "total_tokens": totalTokens, + "input_tokens": inputTokens, + "cached_input_tokens": cachedInputTokens, + "output_tokens": outputTokens, + "reasoning_output_tokens": reasoningOutputTokens, + ], + ], + ], + ])) + + case let .lastOnlyTokenCount(offset, totalTokens): + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(offset)), + "type": "event_msg", + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "total_tokens": totalTokens, + ], + ], + ], + ])) + } + } + + return lines.joined(separator: "\n") + "\n" + } + + private func jsonLine(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object) + return try #require(String(bytes: data, encoding: .utf8)) + } + + private func jsonString(_ object: [String: String]) throws -> String { + try self.jsonLine(object) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 7fedb5e7d..992da22a9 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -159,6 +159,28 @@ struct SettingsStoreCoverageTests { fileManager: fileManager)) } + @Test + func `codex session analytics window size persists and normalizes`() throws { + let suite = "SettingsStoreCoverageTests-codex-analytics-window" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(first.codexSessionAnalyticsWindowSize == 20) + + first.codexSessionAnalyticsWindowSize = 50 + #expect(defaults.integer(forKey: "codexSessionAnalyticsWindowSize") == 50) + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.codexSessionAnalyticsWindowSize == 50) + + defaults.set(13, forKey: "codexSessionAnalyticsWindowSize") + let third = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(third.codexSessionAnalyticsWindowSize == 20) + #expect(defaults.integer(forKey: "codexSessionAnalyticsWindowSize") == 20) + } + @Test func `ensure token loaders execute`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/StatusMenuSessionAnalyticsTests.swift b/Tests/CodexBarTests/StatusMenuSessionAnalyticsTests.swift new file mode 100644 index 000000000..d3260b39b --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSessionAnalyticsTests.swift @@ -0,0 +1,194 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct StatusMenuSessionAnalyticsTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.menuRefreshEnabled = false + } + + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSessionAnalyticsTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `codex menu includes session analytics submenu when snapshot is present`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let snapshot = CodexSessionAnalyticsSnapshot( + generatedAt: Date(), + sessions: [ + CodexSessionSummary( + id: "session-1", + title: "Test session", + startedAt: Date(), + durationSeconds: 45, + toolCallCount: 3, + toolFailureCount: 1, + longRunningCallCount: 1, + verificationAttemptCount: 1, + toolCountsByName: ["exec_command": 2, "write_stdin": 1]), + ], + medianSessionDurationSeconds: 45, + medianToolCallsPerSession: 3, + toolFailureRate: 1.0 / 3.0, + topTools: [ + CodexToolAggregate(name: "exec_command", callCount: 2), + CodexToolAggregate(name: "write_stdin", callCount: 1), + ]) + store.codexSessionAnalytics = snapshot + store.codexSessionAnalyticsCacheByWindow[settings.codexSessionAnalyticsWindowSize] = snapshot + store.lastCodexSessionAnalyticsRefreshAt = Date() + store.codexSessionAnalyticsLastSuccessfulRefreshAt = Date() + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let analyticsItem = menu.items.first { ($0.representedObject as? String) == "sessionAnalyticsSubmenu" } + #expect(analyticsItem != nil) + #expect(analyticsItem?.submenu?.items.isEmpty == true) + if let submenu = analyticsItem?.submenu { + controller.menuWillOpen(submenu) + } + #expect(analyticsItem?.submenu?.items.contains { + ($0.representedObject as? String) == "sessionAnalyticsContent" + } == true) + } + + @Test + func `codex session analytics submenu exposes empty state without local data`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.codexSessionAnalytics = nil + store.codexSessionAnalyticsError = nil + store.lastCodexSessionAnalyticsRefreshAt = Date() + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let analyticsItem = menu.items.first { ($0.representedObject as? String) == "sessionAnalyticsSubmenu" } + #expect(analyticsItem != nil) + #expect(analyticsItem?.submenu?.items.isEmpty == true) + if let submenu = analyticsItem?.submenu { + controller.menuWillOpen(submenu) + } + #expect(analyticsItem?.submenu?.items.contains { + ($0.representedObject as? String) == "sessionAnalyticsEmptyState" + } == true) + } + + @Test + func `codex session analytics submenu repopulates when reopened`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.codexSessionAnalytics = nil + store.codexSessionAnalyticsError = nil + store.lastCodexSessionAnalyticsRefreshAt = Date() + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let analyticsItem = try #require(menu.items.first { + ($0.representedObject as? String) == "sessionAnalyticsSubmenu" + }) + let submenu = try #require(analyticsItem.submenu) + + controller.menuWillOpen(submenu) + #expect(submenu.items.contains { + ($0.representedObject as? String) == "sessionAnalyticsEmptyState" + }) + + let snapshot = CodexSessionAnalyticsSnapshot( + generatedAt: Date(), + sessions: [ + CodexSessionSummary( + id: "session-2", + title: "Updated session", + startedAt: Date(), + durationSeconds: 30, + toolCallCount: 2, + toolFailureCount: 0, + longRunningCallCount: 0, + verificationAttemptCount: 1, + toolCountsByName: ["exec_command": 2]), + ], + medianSessionDurationSeconds: 30, + medianToolCallsPerSession: 2, + toolFailureRate: 0, + topTools: [ + CodexToolAggregate(name: "exec_command", callCount: 2), + ]) + store.codexSessionAnalytics = snapshot + store.codexSessionAnalyticsCacheByWindow[settings.codexSessionAnalyticsWindowSize] = snapshot + store.lastCodexSessionAnalyticsRefreshAtByWindow.removeAll() + store.codexSessionAnalyticsErrorCacheByWindow.removeAll() + store.lastCodexSessionAnalyticsRefreshAt = Date() + store.codexSessionAnalyticsLastSuccessfulRefreshAt = Date() + + controller.menuWillOpen(submenu) + #expect(submenu.items.contains { + ($0.representedObject as? String) == "sessionAnalyticsContent" + }) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCodexSessionAnalyticsTests.swift b/Tests/CodexBarTests/UsageStoreCodexSessionAnalyticsTests.swift new file mode 100644 index 000000000..746f99db2 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreCodexSessionAnalyticsTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct UsageStoreCodexSessionAnalyticsTests { + @Test + func `store bootstraps analytics snapshot from persisted index`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 26) + _ = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-bootstrap.jsonl", + contents: self.sessionJSONL( + id: "bootstrap-session", + startedAt: startedAt, + userMessage: "Bootstrap session", + items: [])) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + _ = try indexer.refreshIndex(existing: nil, now: startedAt) + + let store = self.makeStore() + store.codexSessionAnalyticsIndexer = indexer + store.bootstrapCodexSessionAnalyticsCache() + + #expect(store.codexSessionAnalytics?.sessions.first?.id == "bootstrap-session") + #expect(store.codexSessionAnalyticsStatusText().contains("Updated")) + } + + @Test + func `request refresh keeps cached snapshot while background refresh updates it`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let oldStartedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 27) + _ = try env.writeCodexSessionFile( + day: oldStartedAt, + filename: "rollout-old.jsonl", + contents: self.sessionJSONL( + id: "old-session", + startedAt: oldStartedAt, + userMessage: "Old session", + items: [])) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + _ = try indexer.refreshIndex(existing: nil, now: oldStartedAt) + + let store = self.makeStore() + store.codexSessionAnalyticsIndexer = indexer + store.bootstrapCodexSessionAnalyticsCache() + #expect(store.codexSessionAnalytics?.sessions.first?.id == "old-session") + + let newStartedAt = oldStartedAt.addingTimeInterval(3600) + _ = try env.writeCodexSessionFile( + day: newStartedAt, + filename: "rollout-new.jsonl", + contents: self.sessionJSONL( + id: "new-session", + startedAt: newStartedAt, + userMessage: "New session", + items: [])) + + store.requestCodexSessionAnalyticsRefreshIfStale(reason: "test interaction") + #expect(store.codexSessionAnalytics?.sessions.first?.id == "old-session") + + await store.codexSessionAnalyticsRefreshTask?.value + + #expect(store.codexSessionAnalytics?.sessions.first?.id == "new-session") + #expect(store.codexSessionAnalyticsIsRefreshing == false) + #expect(store.codexSessionAnalyticsDirty == false) + } + + @Test + func `watcher event marks analytics dirty without starting refresh while app is inactive`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let startedAt = try env.makeLocalNoon(year: 2026, month: 3, day: 28) + _ = try env.writeCodexSessionFile( + day: startedAt, + filename: "rollout-dirty.jsonl", + contents: self.sessionJSONL( + id: "dirty-session", + startedAt: startedAt, + userMessage: "Dirty session", + items: [])) + + let indexer = CodexSessionAnalyticsIndexer( + env: ["CODEX_HOME": env.codexHomeRoot.path], + cacheRoot: env.cacheRoot) + _ = try indexer.refreshIndex(existing: nil, now: startedAt) + + let store = self.makeStore() + store.codexSessionAnalyticsIndexer = indexer + store.bootstrapCodexSessionAnalyticsCache() + + store.codexSessionAnalyticsDirty = false + store.handleCodexSessionAnalyticsWatcherEvent() + + #expect(store.codexSessionAnalyticsDirty == true) + #expect(store.codexSessionAnalyticsRefreshTask == nil) + } +} + +extension UsageStoreCodexSessionAnalyticsTests { + private func makeStore() -> UsageStore { + let suite = "UsageStoreCodexSessionAnalyticsTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + } + + private func sessionJSONL( + id: String, + startedAt: Date, + userMessage: String, + items: [[String: Any]]) throws -> String + { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var lines: [String] = [] + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt), + "type": "session_meta", + "payload": [ + "id": id, + "timestamp": formatter.string(from: startedAt), + ], + ])) + try lines.append(self.jsonLine([ + "timestamp": formatter.string(from: startedAt.addingTimeInterval(0.1)), + "type": "event_msg", + "payload": [ + "type": "user_message", + "message": userMessage, + ], + ])) + try lines.append(contentsOf: items.map(self.jsonLine(_:))) + return lines.joined(separator: "\n") + "\n" + } + + private func jsonLine(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object) + return try #require(String(bytes: data, encoding: .utf8)) + } +}