From 950431df2d3a0825ed5d64a9df9b351d933f60a1 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:46:34 +0200 Subject: [PATCH 1/7] fix: make Codex-assisted insights actionable --- .../CodexLimits/CodexAssistedInsights.swift | 411 ++++++++++++------ Sources/CodexLimits/CodexSourceContent.swift | 127 ++++-- Sources/CodexLimits/MenuContentView.swift | 33 +- .../CodexAssistedInsightTests.swift | 403 +++++++++++++++-- .../CodexSourceAnalysisTests.swift | 104 ++++- 5 files changed, 869 insertions(+), 209 deletions(-) diff --git a/Sources/CodexLimits/CodexAssistedInsights.swift b/Sources/CodexLimits/CodexAssistedInsights.swift index 0b7c4a6..c54357d 100644 --- a/Sources/CodexLimits/CodexAssistedInsights.swift +++ b/Sources/CodexLimits/CodexAssistedInsights.swift @@ -61,6 +61,7 @@ enum CodexAssistedModelCatalog { enum CodexMetadataEvidenceField: String, Codable, CaseIterable, Sendable { case usageRemaining = "usage_remaining" + case paceGuidance = "pace_guidance" case accountTokenActivity = "account_token_activity" case localTokenActivity = "local_token_activity" case activity @@ -68,23 +69,60 @@ enum CodexMetadataEvidenceField: String, Codable, CaseIterable, Sendable { case activeTimeAvailable = "active_time_available" } -enum CodexAssistedInsightKind: String, Codable, CaseIterable, Sendable { - case usageRemaining = "usage_remaining_status" - case accountTokenActivity = "account_token_activity" - case localTokenActivity = "local_token_activity" - case activity = "activity_summary" - case usagePerToken = "usage_per_token_change" - case activeTimeAvailable = "active_time_available" +enum CodexAssistedResponseStatus: String, Codable, Sendable { + case insight + case insufficientEvidence = "insufficient_evidence" +} - var evidenceField: CodexMetadataEvidenceField { - switch self { - case .usageRemaining: .usageRemaining - case .accountTokenActivity: .accountTokenActivity - case .localTokenActivity: .localTokenActivity - case .activity: .activity - case .usagePerToken: .usagePerToken - case .activeTimeAvailable: .activeTimeAvailable - } +struct CodexAssistedNarrative: Codable, Equatable, Sendable { + let status: CodexAssistedResponseStatus + let title: String + let finding: String + let whyItMatters: String + let recommendation: String + + var isValid: Bool { + switch status { + case .insight: + return Self.isText(title, maximumLength: 80) + && Self.isText(finding, maximumLength: 360) + && Self.isText(whyItMatters, maximumLength: 360) + && Self.isText(recommendation, maximumLength: 360) + && !containsUnsupportedClaim + case .insufficientEvidence: + return title == "Not enough evidence" + && Self.isText(finding, maximumLength: 360) + && whyItMatters.isEmpty + && recommendation.isEmpty + } + } + + private var containsUnsupportedClaim: Bool { + let words = Set( + [title, finding, whyItMatters, recommendation] + .joined(separator: " ") + .lowercased() + .split { !$0.isLetter } + .map(String.init) + ) + return !words.isDisjoint(with: [ + "because", "billing", "caused", "causes", "cost", + "efficiency", "efficient", "price", "pricing", "proves", + "quality", "waste", "will" + ]) + } + + private static func isText( + _ text: String, + maximumLength: Int + ) -> Bool { + !text.isEmpty + && text.count <= maximumLength + && text == text.trimmingCharacters(in: .whitespacesAndNewlines) + && text.unicodeScalars.allSatisfy { + !CharacterSet.controlCharacters.contains($0) + && !CharacterSet.decimalDigits.contains($0) + } } } @@ -140,6 +178,18 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { let observedInterval: EpochRange? } + struct PaceGuidance: Codable, Equatable, Sendable { + let status: String + let expectedRemainingAtReset: Double + let safetyRemainingAtReset: Double + let recommendedPercentPerDay: Double + let currentPercentPerDay: Double + let historicalPercentPerDay: Double? + let historicalReferenceSource: String? + let coverage: String + let confidence: String + } + struct AppliedFilters: Codable, Equatable, Sendable { let project: Bool let taskTree: Bool @@ -159,6 +209,7 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { let usageRemaining: UsageRemaining let weeklyResetAt: Int64? let evidence: Evidence + let paceGuidance: PaceGuidance? let accountTokenActivity: AccountTokens let localTokenActivity: LocalTokens let activity: Activity @@ -240,6 +291,33 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { coverage: reader.evidence.coverage.rawValue, confidence: reader.evidence.confidence.rawValue ), + paceGuidance: reader.guidance.flatMap { guidance in + let forecast = guidance.forecast + let values = [ + forecast.expectedRemainingAtReset, + forecast.safetyRemainingAtReset, + forecast.recommendedPercentPerDay, + forecast.currentPercentPerDay + ] + guard values.allSatisfy(\.isFinite), + forecast.historicalReference?.percentPerDay.isFinite + ?? true else { + return nil + } + return PaceGuidance( + status: forecast.status.rawValue, + expectedRemainingAtReset: forecast.expectedRemainingAtReset, + safetyRemainingAtReset: forecast.safetyRemainingAtReset, + recommendedPercentPerDay: forecast.recommendedPercentPerDay, + currentPercentPerDay: forecast.currentPercentPerDay, + historicalPercentPerDay: forecast + .historicalReference?.percentPerDay, + historicalReferenceSource: forecast + .historicalReferenceSource?.rawValue, + coverage: reader.evidence.coverage.rawValue, + confidence: reader.evidence.confidence.rawValue + ) + }, accountTokenActivity: AccountTokens( tokens: reader.accountTokenActivity.tokens, state: reader.accountTokenActivity.state.rawValue, @@ -330,57 +408,6 @@ enum CodexAssistedRequestError: Error { enum CodexAssistedRequestFactory { static let maximumMetadataBytes = 8_192 static let maximumSourceBytes = 65_536 - static let toolProvidingFeatures: Set = [ - "apps", - "artifact", - "auth_elicitation", - "browser_use", - "browser_use_external", - "browser_use_full_cdp_access", - "code_mode", - "code_mode_buffered_exec", - "code_mode_host", - "code_mode_only", - "computer_use", - "default_mode_request_user_input", - "deferred_executor", - "enable_mcp_apps", - "executor_capability_discovery", - "goals", - "guardian_approval", - "hooks", - "image_generation", - "in_app_browser", - "multi_agent", - "multi_agent_v2", - "plugins", - "plugin_sharing", - "remote_plugin", - "request_permissions_tool", - "shell_snapshot", - "shell_tool", - "skill_mcp_dependency_install", - "skill_search", - "standalone_web_search", - "tool_call_mcp_elicitation", - "tool_suggest", - "unified_exec", - "workspace_dependencies" - ] - static let benignEnabledFeatures: Set = [ - "collaboration_modes", - "enable_request_compression", - "fast_mode", - "mentions_v2", - "personality", - "remote_compaction_v2", - "resize_all_images", - "sqlite", - "steer", - "terminal_resize_reflow", - "tool_search_always_defer_mcp_tools", - "tui_app_server" - ] static func modelList(id: Int, cursor: String? = nil) -> [String: Any] { return [ @@ -586,8 +613,9 @@ enum CodexAssistedRequestFactory { private static let baseInstructions = "Analyze only the JSON supplied in the user message. Do not use tools, files, commands, network access, other tasks, or outside knowledge." - private static let developerInstructions = - "Return only the current output schema. For metadata, choose the single most useful supported insight kind. For Source Content, choose only a High-confidence pattern supported by at least two exact category and one-based item references. Never quote or reproduce Source Content. If the evidence does not meet that bar, return an error." + private static let developerInstructions = """ + Return only the current output schema. For Metadata analysis, the app preflights metadata for usefulness. When pace_guidance and usage_remaining are available, return insight with exactly those two evidence fields; explain what the current pace implies, why that matters before reset, and one concrete conditional adjustment, calibrated to their confidence even when coverage is Low or Partial. Otherwise, produce a Metadata insight only when at least two available High- or Complete-coverage evidence fields support a useful relationship. Keep title, finding, whyItMatters, and recommendation free of digits and the words because, billing, caused, causes, cost, efficiency, efficient, price, pricing, proves, quality, waste, will. Do not merely restate visible values or dates in prose, invent facts, claim causality, judge task quality or efficiency, or make billing, pricing, cost, or exact per-task allowance claims. If Metadata evidence cannot support a useful conclusion, return insufficient_evidence with title \"Not enough evidence\", explain what evidence is missing in finding, and leave the other prose and references empty. For Source Content analysis, require a High-confidence pattern supported by at least two exact category and one-based item references. Never quote or reproduce Source Content. + """ private static func requestText(metadata: String) -> String { """ @@ -600,11 +628,30 @@ enum CodexAssistedRequestFactory { private static let metadataOutputSchema: [String: Any] = [ "type": "object", "additionalProperties": false, - "required": ["insightKind"], + "required": [ + "status", "title", "finding", "whyItMatters", + "recommendation", "evidenceFields" + ], "properties": [ - "insightKind": [ + "status": [ "type": "string", - "enum": CodexAssistedInsightKind.allCases.map(\.rawValue) + "enum": [ + CodexAssistedResponseStatus.insight.rawValue, + CodexAssistedResponseStatus.insufficientEvidence.rawValue + ] + ], + "title": ["type": "string", "maxLength": 80], + "finding": ["type": "string", "maxLength": 360], + "whyItMatters": ["type": "string", "maxLength": 360], + "recommendation": ["type": "string", "maxLength": 360], + "evidenceFields": [ + "type": "array", + "maxItems": 4, + "items": [ + "type": "string", + "enum": CodexMetadataEvidenceField.allCases + .map(\.rawValue) + ] ] ] ] @@ -612,15 +659,24 @@ enum CodexAssistedRequestFactory { private static let sourceOutputSchema: [String: Any] = [ "type": "object", "additionalProperties": false, - "required": ["sourceInsightKind", "evidence"], + "required": [ + "status", "title", "finding", "whyItMatters", + "recommendation", "evidence" + ], "properties": [ - "sourceInsightKind": [ + "status": [ "type": "string", - "enum": CodexSourceInsightKind.allCases.map(\.rawValue) + "enum": [ + CodexAssistedResponseStatus.insight.rawValue, + CodexAssistedResponseStatus.insufficientEvidence.rawValue + ] ], + "title": ["type": "string", "maxLength": 80], + "finding": ["type": "string", "maxLength": 360], + "whyItMatters": ["type": "string", "maxLength": 360], + "recommendation": ["type": "string", "maxLength": 360], "evidence": [ "type": "array", - "minItems": 2, "maxItems": 6, "items": [ "type": "object", @@ -636,7 +692,6 @@ enum CodexAssistedRequestFactory { "type": "array", "minItems": 1, "maxItems": 4, - "uniqueItems": true, "items": [ "type": "integer", "minimum": 1 @@ -717,8 +772,11 @@ struct CodexAssistedAnalysisScope: Equatable, Sendable { var fingerprint: String { let identity = Identity( + analysisContractVersion: 2, accountPartitionID: accountPartitionID, - payloadFingerprint: payload.fingerprint, + currentWindowResetAt: timeRange == .currentWindow + ? payload.weeklyResetAt + : nil, sourceSelectionFingerprint: sourceSelectionFingerprint, sourceCategories: sourceCategories, timeRange: timeRange, @@ -780,8 +838,9 @@ struct CodexAssistedAnalysisScope: Equatable, Sendable { } private struct Identity: Codable { + let analysisContractVersion: Int let accountPartitionID: String? - let payloadFingerprint: String + let currentWindowResetAt: Int64? let sourceSelectionFingerprint: String? let sourceCategories: [String]? let timeRange: AnalyticsTimeRange @@ -792,26 +851,52 @@ struct CodexAssistedAnalysisScope: Equatable, Sendable { } } -private struct DecodedCodexAssistedResult: Decodable { - let insightKind: CodexAssistedInsightKind +struct CodexMetadataInsightSelection: Decodable, Equatable, Sendable { + let status: CodexAssistedResponseStatus + let title: String + let finding: String + let whyItMatters: String + let recommendation: String + let evidenceFields: [CodexMetadataEvidenceField] + + var narrative: CodexAssistedNarrative { + CodexAssistedNarrative( + status: status, + title: title, + finding: finding, + whyItMatters: whyItMatters, + recommendation: recommendation + ) + } } enum CodexAssistedResultDecoder { static func decode( _ text: String - ) throws -> CodexAssistedInsightKind { + ) throws -> CodexMetadataInsightSelection { let data = Data(text.utf8) guard let object = try JSONSerialization.jsonObject( with: data ) as? [String: Any], - Set(object.keys) == ["insightKind"] else { + Set(object.keys) == [ + "status", "title", "finding", "whyItMatters", + "recommendation", "evidenceFields" + ] else { throw CodexAssistedRequestError.invalidResult } let decoded = try JSONDecoder().decode( - DecodedCodexAssistedResult.self, + CodexMetadataInsightSelection.self, from: data ) - return decoded.insightKind + guard decoded.narrative.isValid, + decoded.status == .insufficientEvidence + ? decoded.evidenceFields.isEmpty + : (2 ... 4).contains(decoded.evidenceFields.count), + Set(decoded.evidenceFields).count + == decoded.evidenceFields.count else { + throw CodexAssistedRequestError.invalidResult + } + return decoded } } @@ -827,33 +912,89 @@ struct CodexAssistedEvidenceEnvelope: Equatable, Sendable { enum CodexAssistedEvidenceResolver { private struct Item { - let title: String - let summary: String let evidence: String let intervals: [DateInterval] let coverage: CoverageLevel } + static func canAnalyze( + payload: CodexMetadataAnalysisPayload + ) -> Bool { + guard UsageFreshness(rawValue: payload.evidence.freshness) == .fresh else { + return false + } + let available = Dictionary( + uniqueKeysWithValues: CodexMetadataEvidenceField.allCases.compactMap { + field in item(for: field, payload: payload).map { (field, $0) } + } + ) + if available[.paceGuidance] != nil, + available[.usageRemaining] != nil { + return true + } + return available.values.filter { + $0.coverage == .complete || $0.coverage == .high + }.count >= 2 + } + static func resolve( - kind: CodexAssistedInsightKind, + selection: CodexMetadataInsightSelection, payload: CodexMetadataAnalysisPayload ) -> CodexAssistedEvidenceEnvelope? { + if selection.status == .insufficientEvidence { + return CodexAssistedEvidenceEnvelope( + title: selection.title, + summary: selection.finding, + evidence: [], + intervals: [], + freshness: UsageFreshness( + rawValue: payload.evidence.freshness + ) ?? .unavailable, + coverage: .unavailable, + confidence: .unavailable + ) + } guard UsageFreshness(rawValue: payload.evidence.freshness) == .fresh, - let item = item( - for: kind.evidenceField, - payload: payload - ), - item.coverage == .complete || item.coverage == .high else { + selection.narrative.isValid else { return nil } + let items = selection.evidenceFields.compactMap { + item(for: $0, payload: payload) + } + let usesPaceGuidance = selection.evidenceFields.contains(.paceGuidance) + && selection.evidenceFields.contains(.usageRemaining) + guard items.count == selection.evidenceFields.count, + usesPaceGuidance || items.allSatisfy({ + $0.coverage == .complete || $0.coverage == .high + }) else { + return nil + } + let intervals = items.flatMap(\.intervals).reduce( + into: [DateInterval]() + ) { + if !$0.contains($1) { $0.append($1) } + } return CodexAssistedEvidenceEnvelope( - title: item.title, - summary: item.summary, - evidence: [item.evidence], - intervals: item.intervals, + title: selection.title, + summary: selection.finding, + evidence: [ + "Why it matters: \(selection.whyItMatters)", + "Try next: \(selection.recommendation)" + ] + items.map(\.evidence), + intervals: intervals, freshness: .fresh, - coverage: item.coverage, - confidence: .high + coverage: usesPaceGuidance + ? CoverageLevel( + rawValue: payload.paceGuidance?.coverage ?? "" + ) ?? .unavailable + : items.allSatisfy({ $0.coverage == .complete }) + ? .complete + : .high, + confidence: usesPaceGuidance + ? ConfidenceLevel( + rawValue: payload.paceGuidance?.confidence ?? "" + ) ?? .unavailable + : .high ) } @@ -870,12 +1011,39 @@ enum CodexAssistedEvidenceResolver { return nil } return Item( - title: "Usage remaining", - summary: "You have \(number(percent))% usage remaining in this weekly window.", evidence: "Usage remaining: \(number(percent))%.", intervals: [interval], coverage: .complete ) + case .paceGuidance: + guard let guidance = payload.paceGuidance, + let status = PaceStatus(rawValue: guidance.status), + let coverage = CoverageLevel(rawValue: guidance.coverage), + let confidence = ConfidenceLevel( + rawValue: guidance.confidence + ), + coverage != .unavailable, + coverage != .notApplicable, + confidence != .unavailable, + let interval = dateInterval(payload.usageRemaining.interval), + [ + guidance.expectedRemainingAtReset, + guidance.safetyRemainingAtReset, + guidance.recommendedPercentPerDay, + guidance.currentPercentPerDay + ].allSatisfy({ $0.isFinite && $0 >= 0 }) else { + return nil + } + guard guidance.historicalPercentPerDay.map({ + $0.isFinite && $0 >= 0 + }) ?? true else { + return nil + } + return Item( + evidence: "Pace guidance: \(paceTitle(status)); current \(number(guidance.currentPercentPerDay))% per day, recommended up to \(number(guidance.recommendedPercentPerDay))% per day, expected \(number(guidance.expectedRemainingAtReset))% left at reset.", + intervals: [interval], + coverage: coverage + ) case .accountTokenActivity: guard payload.accountTokenActivity.state == "exact", let tokens = payload.accountTokenActivity.tokens, @@ -885,8 +1053,6 @@ enum CodexAssistedEvidenceResolver { return nil } return Item( - title: "Account Token Activity", - summary: "Account Token Activity is \(tokens.formatted()) tokens for this period.", evidence: "Account Token Activity: \(tokens.formatted()) tokens.", intervals: [interval], coverage: .complete @@ -902,8 +1068,6 @@ enum CodexAssistedEvidenceResolver { return nil } return Item( - title: "Local Token Activity", - summary: "Local Token Activity is \(tokens.formatted()) tokens for this period.", evidence: "Local Token Activity: \(tokens.formatted()) tokens.", intervals: [interval], coverage: coverage @@ -919,12 +1083,7 @@ enum CodexAssistedEvidenceResolver { let peak = payload.activity.peakConcurrentTasks.map { " Peak concurrent Tasks: \($0)." } ?? "" - let peakSummary = payload.activity.peakConcurrentTasks.map { - " Peak concurrent Tasks reached \($0)." - } ?? "" return Item( - title: "Activity", - summary: "Active time is \(Int(activeSeconds.rounded()).formatted()) seconds for this period.\(peakSummary)", evidence: "Active time: \(Int(activeSeconds.rounded()).formatted()) seconds.\(peak)", intervals: [interval], coverage: coverage @@ -945,8 +1104,6 @@ enum CodexAssistedEvidenceResolver { return nil } return Item( - title: "Usage per token", - summary: "Usage per token is \(number(multiplier))× the reference for this period.", evidence: "Usage per token: \(number(multiplier))× the reference.", intervals: [current, reference], coverage: coverage @@ -965,8 +1122,6 @@ enum CodexAssistedEvidenceResolver { return nil } return Item( - title: "Estimated active time available", - summary: "Estimated active time available is \(Int(lower.rounded()).formatted())–\(Int(upper.rounded()).formatted()) seconds.", evidence: "Estimated active time available: \(Int(lower.rounded()).formatted())–\(Int(upper.rounded()).formatted()) seconds.", intervals: [interval], coverage: coverage @@ -974,6 +1129,14 @@ enum CodexAssistedEvidenceResolver { } } + private static func paceTitle(_ status: PaceStatus) -> String { + switch status { + case .slowDown: "above the sustainable pace" + case .onTrack: "on track" + case .roomToUseMore: "below the available pace" + } + } + private static func dateInterval( _ range: CodexMetadataAnalysisPayload.EpochRange? ) -> DateInterval? { @@ -1185,7 +1348,7 @@ final class CodexAssistedInsightStore: ObservableObject { func result( for scope: CodexAssistedAnalysisScope ) -> CodexAssistedAnalysisResult? { - if resultScope == scope, let result { + if resultScope?.fingerprint == scope.fingerprint, let result { return result } return persistedResults.last { @@ -1200,7 +1363,7 @@ final class CodexAssistedInsightStore: ObservableObject { let selectionFingerprint = sourceSelection?.fingerprint var candidates: [CodexAssistedAnalysisResult] = [] if let result, - resultScope == scope + resultScope?.fingerprint == scope.fingerprint || ( selectionFingerprint != nil && resultScope?.sourceSelectionFingerprint @@ -1277,7 +1440,8 @@ final class CodexAssistedInsightStore: ObservableObject { ) { guard analysisTask == nil, let profile, - showsAnalyzeAction else { + showsAnalyzeAction, + CodexAssistedEvidenceResolver.canAnalyze(payload: payload) else { return } lastSourceRequest = nil @@ -1832,7 +1996,7 @@ actor CodexAssistedClient: CodexAssistedInsightServicing { responseText ) guard let resolved = CodexAssistedEvidenceResolver.resolve( - kind: decoded, + selection: decoded, payload: evidencePayload ) else { throw CodexAssistedRequestError.invalidResult @@ -1985,17 +2149,10 @@ actor CodexAssistedClient: CodexAssistedInsightServicing { } for item in page { guard let name = item["name"] as? String, - let enabled = item["enabled"] as? Bool else { + item["enabled"] is Bool else { throw CodexAssistedClientError.invalidResponse } - if CodexAssistedRequestFactory.toolProvidingFeatures - .contains(name) { - featureNames.append(name) - } else if enabled, - !CodexAssistedRequestFactory - .benignEnabledFeatures.contains(name) { - throw CodexAssistedClientError.toolUseBlocked - } + featureNames.append(name) } cursor = result["nextCursor"] as? String if cursor == nil { break } diff --git a/Sources/CodexLimits/CodexSourceContent.swift b/Sources/CodexLimits/CodexSourceContent.swift index b6fe10d..9244df2 100644 --- a/Sources/CodexLimits/CodexSourceContent.swift +++ b/Sources/CodexLimits/CodexSourceContent.swift @@ -194,47 +194,28 @@ struct CodexSourceAnalysisPayload: Codable, Equatable, Sendable { } } -enum CodexSourceInsightKind: String, Codable, CaseIterable, Sendable { - case repeatedWork = "repeated_work" - case verificationGap = "verification_gap" - case repeatedToolSteps = "repeated_tool_steps" - case longExchanges = "long_exchanges" - case checksAfterChanges = "checks_after_changes" - - var title: String { - switch self { - case .repeatedWork: "Repeated work" - case .verificationGap: "Verification gap" - case .repeatedToolSteps: "Repeated tool steps" - case .longExchanges: "Long exchanges" - case .checksAfterChanges: "Checks followed changes" - } - } - - var summary: String { - switch self { - case .repeatedWork: - "Codex found the same work in more than one selected item." - case .verificationGap: - "Codex found changes or completion claims without matching checks in the selected items." - case .repeatedToolSteps: - "Codex found tool or command steps that needed more than one attempt." - case .longExchanges: - "Codex found long or repeated exchanges that may work better as a smaller task." - case .checksAfterChanges: - "Codex found checks that followed changes in the selected items." - } - } -} - struct CodexSourceEvidenceReference: Codable, Equatable, Sendable { let category: CodexSourceContentCategory let itemNumbers: [Int] } struct CodexSourceInsightSelection: Codable, Equatable, Sendable { - let sourceInsightKind: CodexSourceInsightKind + let status: CodexAssistedResponseStatus + let title: String + let finding: String + let whyItMatters: String + let recommendation: String let evidence: [CodexSourceEvidenceReference] + + var narrative: CodexAssistedNarrative { + CodexAssistedNarrative( + status: status, + title: title, + finding: finding, + whyItMatters: whyItMatters, + recommendation: recommendation + ) + } } enum CodexSourceResultDecoder { @@ -247,13 +228,39 @@ enum CodexSourceResultDecoder { guard let object = try JSONSerialization.jsonObject( with: data ) as? [String: Any], - Set(object.keys) == ["sourceInsightKind", "evidence"] else { + Set(object.keys) == [ + "status", "title", "finding", "whyItMatters", + "recommendation", "evidence" + ] else { throw CodexAssistedRequestError.invalidResult } let decoded = try JSONDecoder().decode( CodexSourceInsightSelection.self, from: data ) + guard decoded.narrative.isValid, + !exposesSourceContent( + decoded.narrative, + payload: payload + ) else { + throw CodexAssistedRequestError.invalidResult + } + if decoded.status == .insufficientEvidence { + guard decoded.evidence.isEmpty else { + throw CodexAssistedRequestError.invalidResult + } + return CodexAssistedEvidenceEnvelope( + title: decoded.title, + summary: decoded.finding, + evidence: [], + intervals: [], + freshness: UsageFreshness( + rawValue: metadata.evidence.freshness + ) ?? .unavailable, + coverage: .unavailable, + confidence: .unavailable + ) + } guard decoded.evidence.count >= 2, decoded.evidence.count <= 6 else { throw CodexAssistedRequestError.invalidResult @@ -301,9 +308,12 @@ enum CodexSourceResultDecoder { throw CodexAssistedRequestError.invalidResult } return CodexAssistedEvidenceEnvelope( - title: decoded.sourceInsightKind.title, - summary: decoded.sourceInsightKind.summary, - evidence: labels, + title: decoded.title, + summary: decoded.finding, + evidence: [ + "Why it matters: \(decoded.whyItMatters)", + "Try next: \(decoded.recommendation)" + ] + labels, intervals: [interval], freshness: UsageFreshness( rawValue: metadata.evidence.freshness @@ -312,6 +322,47 @@ enum CodexSourceResultDecoder { confidence: .high ) } + + private static func exposesSourceContent( + _ narrative: CodexAssistedNarrative, + payload: CodexSourceAnalysisPayload + ) -> Bool { + let outputWords = words( + [ + narrative.title, narrative.finding, + narrative.whyItMatters, narrative.recommendation + ].joined(separator: " ") + ) + let outputPhrases = Set(phrases(outputWords)) + for value in payload.sourceContent.values.flatMap({ $0 }) { + let sourceWords = words(value) + if sourceWords.count >= 4, + !outputPhrases.isDisjoint(with: phrases(sourceWords)) { + return true + } + let source = sourceWords.joined(separator: " ") + if !source.isEmpty, + source.count >= 8, + sourceWords.count < 4, + outputWords.joined(separator: " ").contains(source) { + return true + } + } + return false + } + + private static func words(_ text: String) -> [String] { + text.lowercased() + .split { !$0.isLetter && !$0.isNumber } + .map(String.init) + } + + private static func phrases(_ words: [String]) -> Set { + guard words.count >= 4 else { return [] } + return Set((0 ... words.count - 4).map { + words[$0 ..< $0 + 4].joined(separator: " ") + }) + } } protocol CodexSourceContentReading: Sendable { diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 3448c13..db635be 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -3999,6 +3999,13 @@ struct InsightsWorkspace: View { } else if assistedInsights.wasCancelled { Text("Analysis stopped.") .foregroundStyle(.secondary) + } else if !CodexAssistedEvidenceResolver.canAnalyze( + payload: assistedPayload + ) { + Text( + "More usage history is needed before metadata analysis can add useful guidance." + ) + .foregroundStyle(.secondary) } else { Text("Ask Codex to analyze the metadata shown here.") .foregroundStyle(.secondary) @@ -4012,19 +4019,25 @@ struct InsightsWorkspace: View { if assistedInsights.showsAnalyzeAction, !assistedInsights.isRunning { HStack(spacing: 10) { - Button( - assistedInsights.result(for: assistedScope) == nil - ? "Analyze metadata" - : "Analyze metadata again" + if CodexAssistedEvidenceResolver.canAnalyze( + payload: assistedPayload ) { - assistedInsights.startAnalysis( - payload: assistedPayload, - scope: assistedScope + Button( + assistedInsights.result( + for: assistedScope + ) == nil + ? "Analyze metadata" + : "Analyze metadata again" + ) { + assistedInsights.startAnalysis( + payload: assistedPayload, + scope: assistedScope + ) + } + .accessibilityHint( + "Sends bounded metadata to Codex and uses your allowance" ) } - .accessibilityHint( - "Sends bounded metadata to Codex and uses your allowance" - ) if let sourceSelection { Button("Analyze Source Content") { diff --git a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift index 7bdb04c..d978441 100644 --- a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift +++ b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift @@ -155,6 +155,17 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertEqual(sandbox["type"] as? String, "readOnly") XCTAssertEqual(sandbox["networkAccess"] as? Bool, false) XCTAssertNil(turnParams["multiAgentMode"]) + let schema = try XCTUnwrap( + turnParams["outputSchema"] as? [String: Any] + ) + let properties = try XCTUnwrap( + schema["properties"] as? [String: Any] + ) + XCTAssertNotNil(properties["finding"]) + XCTAssertNotNil(properties["whyItMatters"]) + XCTAssertNotNil(properties["recommendation"]) + XCTAssertNotNil(properties["evidenceFields"]) + XCTAssertNil(properties["insightKind"]) } func testMetadataPayloadIsBoundedAndContainsNoSourceContent() throws { @@ -184,6 +195,7 @@ final class CodexAssistedInsightTests: XCTestCase { "usageRemaining", "weeklyResetAt", "evidence", + "paceGuidance", "accountTokenActivity", "localTokenActivity", "activity", @@ -230,6 +242,61 @@ final class CodexAssistedInsightTests: XCTestCase { } } + func testMetadataPayloadReusesLowCoveragePaceGuidance() throws { + let now = Date(timeIntervalSince1970: 2_000_000) + let reset = now.addingTimeInterval(5 * 86_400) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: UsageSnapshot( + mainLimit: LimitReading( + limitId: "weekly", + name: "Weekly", + window: UsageWindow( + remainingPercent: 37, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: now + ), + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-86_400), + remainingPercent: 39, + resetsAt: reset + ), + UsageSample( + observedAt: now.addingTimeInterval(-43_200), + remainingPercent: 38, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ) + + let payload = CodexMetadataAnalysisPayload.make( + reader: reader, + exploration: .initial, + now: now + ) + let guidance = try XCTUnwrap(payload.paceGuidance) + + XCTAssertEqual(reader.evidence.coverage, .low) + XCTAssertEqual(guidance.status, "roomToUseMore") + XCTAssertEqual(guidance.coverage, "low") + XCTAssertEqual(guidance.confidence, "low") + XCTAssertTrue( + CodexAssistedEvidenceResolver.canAnalyze(payload: payload) + ) + } + func testInformationTipNamesCodexAndAllowanceUse() { XCTAssertEqual( CodexAssistedCopy.informationTip, @@ -252,6 +319,45 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertEqual(calls.analysisCalls, 0) } + func testLocalUsefulnessGateAvoidsAnAllowanceRequest() async { + let service = AssistedServiceFixture( + catalogResult: .success(eligibleProfile()), + analysisResult: .succeeded(analysisResult()) + ) + let store = CodexAssistedInsightStore(service: service) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: nil, + samples: [], + safetyBuffer: 3, + sourceState: .available, + now: Date(timeIntervalSince1970: 2_000), + previousStatus: nil + ) + ) + let payload = CodexMetadataAnalysisPayload.make( + reader: reader, + exploration: .initial, + now: Date(timeIntervalSince1970: 2_000) + ) + await store.checkAvailability() + + XCTAssertFalse( + CodexAssistedEvidenceResolver.canAnalyze(payload: payload) + ) + store.startAnalysis( + payload: payload, + scope: CodexAssistedAnalysisScope( + exploration: .initial, + payload: payload + ) + ) + + let calls = await service.snapshot() + XCTAssertEqual(calls.analysisCalls, 0) + XCTAssertFalse(store.isRunning) + } + func testMissingProfileAndCatalogFailureHideTheAction() async { let missing = AssistedServiceFixture( catalogResult: .success(nil), @@ -337,7 +443,17 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertNil( store.result( for: analysisScope( - payload: metadataPayload(generatedAt: 2_001) + payload: metadataPayload(weeklyResetAt: 4_000) + ) + ) + ) + XCTAssertNotNil( + store.result( + for: analysisScope( + payload: metadataPayload( + generatedAt: 2_001, + remainingPercent: 36 + ) ) ) ) @@ -495,6 +611,14 @@ final class CodexAssistedInsightTests: XCTestCase { accountPartitionID: "account-a" ) XCTAssertNotNil(restoredStore.result(for: scope)) + XCTAssertNotNil( + restoredStore.result( + for: analysisScope( + accountPartitionID: "account-a", + payload: metadataPayload(remainingPercent: 36) + ) + ) + ) var filteredExploration = AnalyticsExplorationState.initial filteredExploration.filters.projectID = "another-project" let filteredScope = CodexAssistedAnalysisScope( @@ -842,26 +966,70 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertFalse(store.wasCancelled) } - func testResultDecoderRejectsWeakOrUnboundedOutput() throws { + func testResultDecoderAcceptsAnalysisAndRejectsWeakOrUnsafeOutput() throws { + let decoded = try CodexAssistedResultDecoder.decode( + """ + { + "status":"insight", + "title":"Allowance intensity increased", + "finding":"Usage per token is higher while local activity remains well covered.", + "whyItMatters":"The selected work may reach the reset with less useful capacity than the reference period.", + "recommendation":"Try a smaller task scope and compare the next bounded period with the same reference.", + "evidenceFields":["usage_per_token","local_token_activity"] + } + """ + ) + XCTAssertEqual(decoded.status, .insight) + XCTAssertEqual( + decoded.evidenceFields, + [.usagePerToken, .localTokenActivity] + ) XCTAssertEqual( try CodexAssistedResultDecoder.decode( - #"{"insightKind":"usage_per_token_change"}"# - ), - .usagePerToken + """ + { + "status":"insufficient_evidence", + "title":"Not enough evidence", + "finding":"A comparable reference period is missing.", + "whyItMatters":"", + "recommendation":"", + "evidenceFields":[] + } + """ + ).status, + .insufficientEvidence ) XCTAssertThrowsError( try CodexAssistedResultDecoder.decode( - #"{"insightKind":"unknown"}"# + #"{"insightKind":"usage_per_token_change"}"# ) ) XCTAssertThrowsError( try CodexAssistedResultDecoder.decode( - #"{"insightKind":"activity_summary","summary":"Unsupported free text"}"# + """ + { + "status":"insight", + "title":"Unsupported cause", + "finding":"Usage changed because the tasks were inefficient.", + "whyItMatters":"Useful capacity may be lower.", + "recommendation":"Try a smaller scope.", + "evidenceFields":["usage_per_token","local_token_activity"] + } + """ ) ) XCTAssertThrowsError( try CodexAssistedResultDecoder.decode( - #"{"title":"Pattern","summary":"A weak guess","evidenceFields":["usage_per_token"]}"# + """ + { + "status":"insight", + "title":"One visible fact", + "finding":"Usage remaining is available.", + "whyItMatters":"The weekly window is active.", + "recommendation":"Try checking it later.", + "evidenceFields":["usage_remaining"] + } + """ ) ) } @@ -875,6 +1043,7 @@ final class CodexAssistedInsightTests: XCTestCase { usageRemaining: weak.usageRemaining, weeklyResetAt: weak.weeklyResetAt, evidence: weak.evidence, + paceGuidance: weak.paceGuidance, accountTokenActivity: weak.accountTokenActivity, localTokenActivity: .init( tokens: weak.localTokenActivity.tokens, @@ -888,19 +1057,64 @@ final class CodexAssistedInsightTests: XCTestCase { ) XCTAssertNil( CodexAssistedEvidenceResolver.resolve( - kind: .localTokenActivity, + selection: metadataSelection( + evidenceFields: [ + .localTokenActivity, + .usageRemaining + ] + ), payload: weak ) ) let resolved = CodexAssistedEvidenceResolver.resolve( - kind: .usagePerToken, + selection: metadataSelection( + evidenceFields: [.usagePerToken, .usageRemaining] + ), payload: metadataPayload() ) - XCTAssertEqual(resolved?.title, "Usage per token") + XCTAssertEqual(resolved?.title, "Allowance intensity increased") XCTAssertEqual( resolved?.summary, - "Usage per token is 1.25× the reference for this period." + "Usage per token is higher than the reference while usage remains available." + ) + XCTAssertEqual(resolved?.evidence.count, 4) + XCTAssertEqual(resolved?.confidence, .high) + let withheld = CodexAssistedEvidenceResolver.resolve( + selection: CodexMetadataInsightSelection( + status: .insufficientEvidence, + title: "Not enough evidence", + finding: "A comparable reference period is missing.", + whyItMatters: "", + recommendation: "", + evidenceFields: [] + ), + payload: metadataPayload() ) + XCTAssertEqual(withheld?.title, "Not enough evidence") + XCTAssertEqual(withheld?.confidence, .unavailable) + } + + func testLowCoveragePaceGuidanceStillProducesCautiousAction() { + let payload = lowCoveragePacePayload() + let resolved = CodexAssistedEvidenceResolver.resolve( + selection: CodexMetadataInsightSelection( + status: .insight, + title: "Use available headroom", + finding: "The observed pace is below the sustainable pace for this window.", + whyItMatters: "Unused headroom may remain when the allowance resets.", + recommendation: "Try moving closer to the recommended pace, then compare the refreshed forecast.", + evidenceFields: [.paceGuidance, .usageRemaining] + ), + payload: payload + ) + + XCTAssertTrue( + CodexAssistedEvidenceResolver.canAnalyze(payload: payload) + ) + XCTAssertEqual(resolved?.title, "Use available headroom") + XCTAssertEqual(resolved?.coverage, .low) + XCTAssertEqual(resolved?.confidence, .low) + XCTAssertEqual(resolved?.evidence.count, 4) } func testLiveClientReadsCatalogAndRunsOneEphemeralAnalysis() async throws { @@ -922,7 +1136,7 @@ final class CodexAssistedInsightTests: XCTestCase { let requests = fixture.snapshot() XCTAssertEqual(result.source, "Codex-assisted") - XCTAssertEqual(result.title, "Usage per token") + XCTAssertEqual(result.title, "Allowance intensity increased") XCTAssertEqual(result.overhead.durationSeconds, 12) XCTAssertEqual( result.overhead.accountMovement, @@ -971,7 +1185,7 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertEqual(requests.rateLimitReads, 2) } - func testLiveClientRejectsUnknownEnabledFeatureBeforeThreadStart() async { + func testLiveClientDisablesUnknownEnabledFeature() async { let fixture = CodexAssistedProtocolFixture( addsUnknownEnabledFeature: true ) @@ -986,12 +1200,40 @@ final class CodexAssistedInsightTests: XCTestCase { profile: eligibleProfile() ) - guard case .failed = outcome else { - return XCTFail("Expected an unknown feature to fail closed") + guard case .succeeded = outcome else { + return XCTFail("Expected the advertised feature to be disabled") } let requests = fixture.snapshot() - XCTAssertEqual(requests.threadStartCount, 0) - XCTAssertEqual(requests.turnStartCount, 0) + XCTAssertEqual(requests.threadStartCount, 1) + XCTAssertEqual(requests.turnStartCount, 1) + XCTAssertTrue(requests.isolationVerified) + } + + func testLiveClientRejectsUntrustedFeatureLists() async { + for featureList in [ + FeatureListFixture.malformed, + .failed + ] { + let fixture = CodexAssistedProtocolFixture( + featureList: featureList + ) + let client = CodexAssistedClient( + makeConnection: { try fixture.makeConnection() }, + timeout: 1, + now: { fixture.now() } + ) + + let outcome = await client.analyze( + payload: metadataPayload(), + profile: eligibleProfile() + ) + + guard case .failed = outcome else { + XCTFail("Expected the untrusted feature list to fail closed") + continue + } + XCTAssertEqual(fixture.snapshot().threadStartCount, 0) + } } func testLiveClientRequiresAnExplicitEmptyInstructionSourceList() async throws { @@ -1250,22 +1492,35 @@ final class CodexAssistedInsightTests: XCTestCase { } private func metadataPayload( - generatedAt: Int64 = 2_000 + generatedAt: Int64 = 2_000, + remainingPercent: Double = 37, + weeklyResetAt: Int64 = 3_000 ) -> CodexMetadataAnalysisPayload { CodexMetadataAnalysisPayload( schemaVersion: 1, generatedAt: generatedAt, range: .init(start: 1_000, end: 2_000), usageRemaining: .init( - percent: 37, - interval: .init(start: 1_000, end: 3_000) + percent: remainingPercent, + interval: .init(start: 1_000, end: weeklyResetAt) ), - weeklyResetAt: 3_000, + weeklyResetAt: weeklyResetAt, evidence: .init( freshness: "fresh", coverage: "high", confidence: "high" ), + paceGuidance: .init( + status: "roomToUseMore", + expectedRemainingAtReset: 14, + safetyRemainingAtReset: 10, + recommendedPercentPerDay: 8, + currentPercentPerDay: 5, + historicalPercentPerDay: 6, + historicalReferenceSource: "Account history", + coverage: "high", + confidence: "high" + ), accountTokenActivity: .init( tokens: 1_200_000, state: "exact", @@ -1309,6 +1564,77 @@ final class CodexAssistedInsightTests: XCTestCase { ) } + private func metadataSelection( + evidenceFields: [CodexMetadataEvidenceField] + ) -> CodexMetadataInsightSelection { + CodexMetadataInsightSelection( + status: .insight, + title: "Allowance intensity increased", + finding: "Usage per token is higher than the reference while usage remains available.", + whyItMatters: "The selected work may reach the reset with less useful capacity than the reference period.", + recommendation: "Try a smaller task scope and compare the next bounded period with the same reference.", + evidenceFields: evidenceFields + ) + } + + private func lowCoveragePacePayload() -> CodexMetadataAnalysisPayload { + let base = metadataPayload() + return CodexMetadataAnalysisPayload( + schemaVersion: base.schemaVersion, + generatedAt: base.generatedAt, + range: base.range, + usageRemaining: base.usageRemaining, + weeklyResetAt: base.weeklyResetAt, + evidence: .init( + freshness: "fresh", + coverage: "low", + confidence: "low" + ), + paceGuidance: .init( + status: "roomToUseMore", + expectedRemainingAtReset: 14, + safetyRemainingAtReset: 10, + recommendedPercentPerDay: 8, + currentPercentPerDay: 5, + historicalPercentPerDay: nil, + historicalReferenceSource: nil, + coverage: "low", + confidence: "low" + ), + accountTokenActivity: .init( + tokens: nil, + state: "unavailable", + interval: nil + ), + localTokenActivity: .init( + tokens: nil, + coverage: "unavailable", + interval: nil + ), + activity: .init( + activeSeconds: nil, + peakConcurrentTasks: nil, + coverage: "unavailable", + interval: nil + ), + usagePerToken: .init( + multiplier: nil, + coverage: "unavailable", + confidence: "unavailable", + currentInterval: nil, + referenceInterval: nil + ), + activeTimeAvailable: .init( + lowerSeconds: nil, + upperSeconds: nil, + coverage: "unavailable", + confidence: "unavailable", + observedInterval: nil + ), + scope: base.scope + ) + } + private func analysisScope( accountPartitionID: String? = nil, payload: CodexMetadataAnalysisPayload? = nil @@ -1524,12 +1850,19 @@ private enum InstructionSourcesFixture { case malformed } +private enum FeatureListFixture { + case valid + case malformed + case failed +} + private final class CodexAssistedProtocolFixture: @unchecked Sendable { private let lock = NSLock() private let sendsToolCall: Bool private let sendsUnknownItem: Bool private let dropsCatalogConnection: Bool private let addsUnknownEnabledFeature: Bool + private let featureList: FeatureListFixture private let delaysThreadResponse: Bool private let accountIsMissing: Bool private let instructionSources: InstructionSourcesFixture @@ -1553,6 +1886,7 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable { sendsUnknownItem: Bool = false, dropsCatalogConnection: Bool = false, addsUnknownEnabledFeature: Bool = false, + featureList: FeatureListFixture = .valid, delaysThreadResponse: Bool = false, accountIsMissing: Bool = false, instructionSources: InstructionSourcesFixture = .empty, @@ -1563,6 +1897,7 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable { self.sendsUnknownItem = sendsUnknownItem self.dropsCatalogConnection = dropsCatalogConnection self.addsUnknownEnabledFeature = addsUnknownEnabledFeature + self.featureList = featureList self.delaysThreadResponse = delaysThreadResponse self.accountIsMissing = accountIsMissing self.instructionSources = instructionSources @@ -1649,7 +1984,14 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable { let unknown = addsUnknownEnabledFeature ? #",{"name":"future_tool","stage":"stable","enabled":true,"defaultEnabled":true}"# : "" - response = #"{"id":\#(id),"result":{"data":[{"name":"apps","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"multi_agent","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"fast_mode","stage":"stable","enabled":true,"defaultEnabled":true}\#(unknown)],"nextCursor":null}}"# + response = switch featureList { + case .valid: + #"{"id":\#(id),"result":{"data":[{"name":"apps","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"multi_agent","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"fast_mode","stage":"stable","enabled":true,"defaultEnabled":true}\#(unknown)],"nextCursor":null}}"# + case .malformed: + #"{"id":\#(id),"result":{"data":[{"name":"apps"}],"nextCursor":null}}"# + case .failed: + #"{"id":\#(id),"error":{"code":-32603,"message":"feature list unavailable"}}"# + } case "config/read": lock.withLock { configReads += 1 } response = #"{"id":\#(id),"result":{"config":{"mcp_servers":{"local-server":{"enabled":true}}},"origins":{}}}"# @@ -1661,11 +2003,16 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable { as? [String: Bool] let servers = config?["mcp_servers"] as? [String: [String: Bool]] + let expectedFeatures = Dictionary( + uniqueKeysWithValues: ( + ["apps", "multi_agent", "fast_mode"] + + (addsUnknownEnabledFeature + ? ["future_tool"] + : []) + ).map { ($0, false) } + ) isolationVerified = - features == [ - "apps": false, - "multi_agent": false - ] + features == expectedFeatures && servers == [ "local-server": ["enabled": false] ] @@ -1707,7 +2054,7 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable { "item": [ "id": "message-1", "type": "agentMessage", - "text": #"{"insightKind":"usage_per_token_change"}"# + "text": #"{"status":"insight","title":"Allowance intensity increased","finding":"Usage per token is higher than the reference while local activity remains well covered.","whyItMatters":"The selected work may reach the reset with less useful capacity than the reference period.","recommendation":"Try a smaller task scope and compare the next bounded period with the same reference.","evidenceFields":["usage_per_token","local_token_activity"]}"# ] ] ], diff --git a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift index cecb9c9..54844e9 100644 --- a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift +++ b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift @@ -142,7 +142,7 @@ final class CodexSourceAnalysisTests: XCTestCase { ) } - func testSourceResultUsesOnlyValidatedReferencesAndDerivedCopy() throws { + func testSourceResultUsesValidatedReferencesWithoutExposingContent() throws { let draft = CodexSourceContentDraft( selection: sourceSelection(), values: [ @@ -158,7 +158,11 @@ final class CodexSourceAnalysisTests: XCTestCase { let result = try CodexSourceResultDecoder.decode( """ { - "sourceInsightKind":"repeated_work", + "status":"insight", + "title":"Repeated effort", + "finding":"Several selected exchanges revisit the same requested outcome.", + "whyItMatters":"Repeated exchanges can make the task harder to verify.", + "recommendation":"Try stating the acceptance check before the next similar task.", "evidence":[ {"category":"prompts","itemNumbers":[1,2]}, {"category":"commands","itemNumbers":[1]} @@ -169,14 +173,39 @@ final class CodexSourceAnalysisTests: XCTestCase { metadata: metadataPayload() ) - XCTAssertEqual(result.title, "Repeated work") + XCTAssertEqual(result.title, "Repeated effort") XCTAssertEqual( result.evidence, - ["Prompts · items 1, 2", "Commands · item 1"] + [ + "Why it matters: Repeated exchanges can make the task harder to verify.", + "Try next: Try stating the acceptance check before the next similar task.", + "Prompts · items 1, 2", + "Commands · item 1" + ] ) XCTAssertEqual(result.confidence, .high) XCTAssertFalse(result.summary.contains("Private prompt")) XCTAssertFalse(result.evidence.joined().contains("swift test")) + + XCTAssertThrowsError( + try CodexSourceResultDecoder.decode( + """ + { + "status":"insight", + "title":"Repeated effort", + "finding":"Private prompt Follow-up appears again.", + "whyItMatters":"Repeated exchanges can hide the acceptance check.", + "recommendation":"Try stating the check before the next task.", + "evidence":[ + {"category":"prompts","itemNumbers":[1,2]}, + {"category":"commands","itemNumbers":[1]} + ] + } + """, + payload: payload, + metadata: metadataPayload() + ) + ) } func testSourceResultRejectsMissingOrOutOfRangeEvidence() throws { @@ -192,7 +221,11 @@ final class CodexSourceAnalysisTests: XCTestCase { try CodexSourceResultDecoder.decode( """ { - "sourceInsightKind":"repeated_work", + "status":"insight", + "title":"Repeated effort", + "finding":"Several selected exchanges revisit the same requested outcome.", + "whyItMatters":"Repeated exchanges can make the task harder to verify.", + "recommendation":"Try stating the acceptance check before the next similar task.", "evidence":[ {"category":"prompts","itemNumbers":[1]}, {"category":"prompts","itemNumbers":[2]} @@ -205,6 +238,51 @@ final class CodexSourceAnalysisTests: XCTestCase { ) } + func testSourceResultWithholdsWhenEvidenceIsInsufficient() throws { + let payload = try CodexSourceAnalysisPayload( + draft: CodexSourceContentDraft( + selection: sourceSelection(), + values: [.prompts: ["Only item"]] + ), + categories: [.prompts] + ) + + let result = try CodexSourceResultDecoder.decode( + """ + { + "status":"insufficient_evidence", + "title":"Not enough evidence", + "finding":"The selected content does not contain a repeated supported pattern.", + "whyItMatters":"", + "recommendation":"", + "evidence":[] + } + """, + payload: payload, + metadata: metadataPayload() + ) + + XCTAssertEqual(result.title, "Not enough evidence") + XCTAssertEqual(result.confidence, .unavailable) + XCTAssertTrue(result.evidence.isEmpty) + XCTAssertThrowsError( + try CodexSourceResultDecoder.decode( + """ + { + "status":"insufficient_evidence", + "title":"Not enough evidence", + "finding":"Only item does not show a repeated supported pattern.", + "whyItMatters":"", + "recommendation":"", + "evidence":[] + } + """, + payload: payload, + metadata: metadataPayload() + ) + ) + } + func testEmptyScopeReadsNothing() async { let recorder = SourceRequestRecorder() let reader = CodexSourceContentReader { request in @@ -797,7 +875,10 @@ final class CodexSourceAnalysisTests: XCTestCase { XCTAssertFalse(text.contains("\"responses\"")) XCTAssertFalse(text.contains("\"paths\"")) XCTAssertFalse(text.contains("\"metadata\"")) - XCTAssertNotNil(properties["sourceInsightKind"]) + XCTAssertNotNil(properties["status"]) + XCTAssertNotNil(properties["finding"]) + XCTAssertNotNil(properties["whyItMatters"]) + XCTAssertNotNil(properties["recommendation"]) XCTAssertNotNil(properties["evidence"]) XCTAssertNil(properties["insightKind"]) } @@ -971,6 +1052,17 @@ final class CodexSourceAnalysisTests: XCTestCase { coverage: "high", confidence: "high" ), + paceGuidance: .init( + status: "roomToUseMore", + expectedRemainingAtReset: 14, + safetyRemainingAtReset: 10, + recommendedPercentPerDay: 8, + currentPercentPerDay: 5, + historicalPercentPerDay: 6, + historicalReferenceSource: "Account history", + coverage: "high", + confidence: "high" + ), accountTokenActivity: .init( tokens: 1_000, state: "exact", From a208d5e4e959bcb533a1507108b1ea31b7e523b1 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:26:24 +0200 Subject: [PATCH 2/7] feat: add secure in-app updates --- .github/workflows/release.yml | 116 ++++++++++++++++++++++ Package.resolved | 15 +++ Package.swift | 11 +- README.md | 6 +- Resources/Info.plist | 16 ++- Scripts/build-app.sh | 64 +++++++++++- Scripts/qa-app.sh | 58 ++++++++++- Scripts/validate-release.sh | 64 ++++++++++++ Sources/CodexLimits/AppUpdater.swift | 57 +++++++++++ Sources/CodexLimits/MenuContentView.swift | 20 ++++ docs/releasing.md | 25 +++++ 11 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 Package.resolved create mode 100755 Scripts/validate-release.sh create mode 100644 Sources/CodexLimits/AppUpdater.swift create mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f0820c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Stable version without the v prefix, for example 0.2.7 + required: true + type: string + dry_run: + description: Build and validate without creating a GitHub Release + required: true + default: true + type: boolean + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Build signed update + runs-on: macos-15 + timeout-minutes: 20 + environment: release + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate version + run: Scripts/validate-release.sh '${{ inputs.version }}' + + - name: Build universal app + env: + CODEX_LIMITS_UNIVERSAL: 1 + run: Scripts/build-app.sh + + - name: Prepare release notes and archive + env: + GH_TOKEN: '${{ github.token }}' + VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + artifacts="$RUNNER_TEMP/release-artifacts" + archive="Codex-Limits-$VERSION.zip" + mkdir -p "$artifacts" + previous_tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1) + gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="v$VERSION" \ + -f target_commitish="$GITHUB_SHA" \ + -f previous_tag_name="$previous_tag" \ + --jq .body > "$artifacts/Codex-Limits-$VERSION.md" + ditto -c -k --sequesterRsrc --keepParent \ + '.build/release/Codex Limits.app' \ + "$artifacts/$archive" + echo "ARTIFACTS=$artifacts" >> "$GITHUB_ENV" + echo "ARCHIVE=$archive" >> "$GITHUB_ENV" + + - name: Sign archive and feed + env: + SPARKLE_PRIVATE_KEY: '${{ secrets.SPARKLE_PRIVATE_KEY }}' + VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + test -n "$SPARKLE_PRIVATE_KEY" + printf '%s' "$SPARKLE_PRIVATE_KEY" | \ + .build/universal-arm64/artifacts/sparkle/Sparkle/bin/generate_appcast \ + --ed-key-file - \ + --download-url-prefix \ + "https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/" \ + --embed-release-notes \ + --maximum-deltas 0 \ + --critical-update-version '' \ + "$ARTIFACTS" + + - name: Validate update artifacts + run: | + set -euo pipefail + app='.build/release/Codex Limits.app' + codesign --verify --deep --strict "$app" + archs=$(lipo -archs "$app/Contents/MacOS/CodexLimits") + test "$archs" = 'x86_64 arm64' -o "$archs" = 'arm64 x86_64' + xmllint --noout "$ARTIFACTS/appcast.xml" + grep -q 'sparkle:edSignature' "$ARTIFACTS/appcast.xml" + grep -q 'sparkle:criticalUpdate' "$ARTIFACTS/appcast.xml" + + - name: Upload dry-run artifacts + if: inputs.dry_run + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: Codex-Limits-${{ inputs.version }} + path: | + ${{ env.ARTIFACTS }}/${{ env.ARCHIVE }} + ${{ env.ARTIFACTS }}/appcast.xml + ${{ env.ARTIFACTS }}/Codex-Limits-${{ inputs.version }}.md + if-no-files-found: error + + - name: Create draft release + if: inputs.dry_run == false + env: + GH_TOKEN: '${{ github.token }}' + VERSION: '${{ inputs.version }}' + run: | + gh release create "v$VERSION" \ + --draft \ + --target "$GITHUB_SHA" \ + --title "Codex Limits $VERSION" \ + --notes-file "$ARTIFACTS/Codex-Limits-$VERSION.md" \ + "$ARTIFACTS/$ARCHIVE" \ + "$ARTIFACTS/appcast.xml" diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..ee38a95 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "67c9b3e2c57199671372138fd7fafb42a4af4ccf81e4f0197819ae0b3070893a", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "79bc9e872948e47877e76f194cb0c8e0412b0b90", + "version" : "2.9.5" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 4ff29cf..da46f5a 100644 --- a/Package.swift +++ b/Package.swift @@ -8,8 +8,17 @@ let package = Package( products: [ .executable(name: "CodexLimits", targets: ["CodexLimits"]) ], + dependencies: [ + .package( + url: "https://github.com/sparkle-project/Sparkle", + exact: "2.9.5" + ) + ], targets: [ - .executableTarget(name: "CodexLimits"), + .executableTarget( + name: "CodexLimits", + dependencies: ["Sparkle"] + ), .testTarget( name: "CodexLimitsTests", dependencies: ["CodexLimits"], diff --git a/README.md b/README.md index d5f7613..1c46c8b 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The app keeps weak estimates out of guidance and Insights. The Usage remaining c - Copies account usage samples to a private folder that you choose. - Deletes all Codex Limits analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`. - Refreshes on launch, after wake, when you open the menu, every ten minutes, or on request. -- Runs as a native SwiftUI menu-bar app with no third-party runtime dependencies. +- Runs as a native SwiftUI menu-bar app and uses Sparkle to verify and install signed updates. - Does not redeem resets, change Codex settings, or control Tasks. ## How it works @@ -131,7 +131,7 @@ The script creates an ad-hoc signed app at `.build/release/Codex Limits.app`. La open ".build/release/Codex Limits.app" ``` -This project offers no prebuilt or notarized app. Open `Package.swift` in Xcode to work on the source. +Stable releases include a universal app for Apple Silicon and Intel. The app is not Developer ID signed or notarized, so the first manual installation remains subject to macOS Gatekeeper. After that, the app can detect and install EdDSA-signed stable updates. Open `Package.swift` in Xcode to work on the source. ## Test @@ -143,7 +143,7 @@ The tests use made-up usage data. Do not commit exported account data or local a ## Current limitations -- You must build the app from source. +- Existing 0.2.6 and older installations require one final manual update to a version that includes the in-app updater. - Account and local values can differ because this Mac may not observe every Codex Task. - Estimates need account readings near both ends of a time range and enough similar local work. - `Analyze with Codex` appears only when Codex offers GPT-5.6 Luna with Medium reasoning. diff --git a/Resources/Info.plist b/Resources/Info.plist index 4adf947..2d1f101 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.6 + 0.2.7 CFBundleVersion - 7 + 8 LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion @@ -28,5 +28,17 @@ NSPrincipalClass NSApplication + SUAllowsAutomaticUpdates + + SUEnableAutomaticChecks + + SUFeedURL + https://github.com/thrr87/codex-limits/releases/latest/download/appcast.xml + SUPublicEDKey + 3HnMDZs+eAgmWfY3G8N0OgGKaYX1O+opzEYIaiYBR58= + SURequireSignedFeed + + SUVerifyUpdateBeforeExtraction + diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index a089aec..c9e2a33 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -14,11 +14,60 @@ if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then build_args+=(-Xswiftc -DCODEX_LIMITS_QA) fi -xcrun swift build "${build_args[@]}" +if [[ ${CODEX_LIMITS_UNIVERSAL:-0} == 1 ]]; then + for architecture in arm64 x86_64; do + scratch="$project_dir/.build/universal-$architecture" + xcrun swift build "${build_args[@]}" \ + --triple "$architecture-apple-macosx14.0" \ + --scratch-path "$scratch" \ + --cache-path "$project_dir/.build/package-cache" + done + arm_release="$project_dir/.build/universal-arm64/arm64-apple-macosx/release" + intel_release="$project_dir/.build/universal-x86_64/x86_64-apple-macosx/release" + executable="$project_dir/.build/release/CodexLimits" + mkdir -p "${executable:h}" + lipo -create \ + "$arm_release/CodexLimits" \ + "$intel_release/CodexLimits" \ + -output "$executable" + framework="$arm_release/Sparkle.framework" +else + xcrun swift build "${build_args[@]}" + executable="$project_dir/.build/release/CodexLimits" + framework="$project_dir/.build/release/Sparkle.framework" +fi + rm -rf "$app_dir" -mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" -cp .build/release/CodexLimits "$app_dir/Contents/MacOS/CodexLimits" +mkdir -p \ + "$app_dir/Contents/MacOS" \ + "$app_dir/Contents/Resources" \ + "$app_dir/Contents/Frameworks" +cp "$executable" "$app_dir/Contents/MacOS/CodexLimits" +install_name_tool -add_rpath \ + @loader_path/../Frameworks \ + "$app_dir/Contents/MacOS/CodexLimits" +ditto "$framework" "$app_dir/Contents/Frameworks/Sparkle.framework" cp Resources/Info.plist "$app_dir/Contents/Info.plist" +if [[ -n ${CODEX_LIMITS_VERSION:-} ]]; then + /usr/libexec/PlistBuddy -c \ + "Set :CFBundleShortVersionString $CODEX_LIMITS_VERSION" \ + "$app_dir/Contents/Info.plist" +fi +if [[ -n ${CODEX_LIMITS_BUILD:-} ]]; then + /usr/libexec/PlistBuddy -c \ + "Set :CFBundleVersion $CODEX_LIMITS_BUILD" \ + "$app_dir/Contents/Info.plist" +fi +if [[ -n ${CODEX_LIMITS_FEED_URL:-} ]]; then + /usr/libexec/PlistBuddy -c \ + "Set :SUFeedURL $CODEX_LIMITS_FEED_URL" \ + "$app_dir/Contents/Info.plist" +fi +if [[ -n ${CODEX_LIMITS_PUBLIC_ED_KEY:-} ]]; then + /usr/libexec/PlistBuddy -c \ + "Set :SUPublicEDKey $CODEX_LIMITS_PUBLIC_ED_KEY" \ + "$app_dir/Contents/Info.plist" +fi if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then /usr/libexec/PlistBuddy -c \ "Set :CFBundleIdentifier com.github.thrr87.CodexLimits.QA" \ @@ -26,7 +75,14 @@ if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then /usr/libexec/PlistBuddy -c \ "Set :CFBundleDisplayName Codex Limits QA" \ "$app_dir/Contents/Info.plist" + /usr/libexec/PlistBuddy -c \ + "Add :NSAppTransportSecurity dict" \ + "$app_dir/Contents/Info.plist" + /usr/libexec/PlistBuddy -c \ + "Add :NSAppTransportSecurity:NSAllowsLocalNetworking bool true" \ + "$app_dir/Contents/Info.plist" fi -codesign --force --sign - "$app_dir" +codesign --force --deep --sign - "$app_dir" +codesign --verify --deep --strict "$app_dir" print -r -- "$app_dir" diff --git a/Scripts/qa-app.sh b/Scripts/qa-app.sh index 8b36ea0..a2ff540 100755 --- a/Scripts/qa-app.sh +++ b/Scripts/qa-app.sh @@ -3,13 +3,19 @@ set -euo pipefail project_dir=${0:A:h:h} app_dir="$project_dir/.build/release/Codex Limits.app" +update_dir="$project_dir/.build/qa-update" executable_pattern="$project_dir/.build/.*/Codex Limits.app/Contents/MacOS/CodexLimits" relative_executable_pattern="\\.build/.*/Codex Limits\\.app/Contents/MacOS/CodexLimits" +update_executable_pattern="$update_dir/Codex Limits QA.app/Contents/MacOS/CodexLimits" action=${1:-launch} cleanup() { pkill -f "$executable_pattern" 2>/dev/null || true pkill -f "$relative_executable_pattern" 2>/dev/null || true + pkill -f "$update_executable_pattern" 2>/dev/null || true + if [[ -f "$update_dir/server.pid" ]]; then + kill "$(<"$update_dir/server.pid")" 2>/dev/null || true + fi } case "$action" in @@ -21,8 +27,58 @@ case "$action" in CODEX_LIMITS_QA=1 "$project_dir/Scripts/build-app.sh" open "$app_dir" ;; + update) + cleanup + rm -rf "$update_dir" + mkdir -p "$update_dir/feed" + + key_material=$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + xcrun swift -e 'import CryptoKit; import Foundation; let key = Curve25519.Signing.PrivateKey(); print(key.rawRepresentation.base64EncodedString(), key.publicKey.rawRepresentation.base64EncodedString())') + private_key=${key_material%% *} + public_key=${key_material#* } + + feed_url="http://127.0.0.1:8765/appcast.xml" + CODEX_LIMITS_QA=1 \ + CODEX_LIMITS_VERSION=0.2.6 \ + CODEX_LIMITS_BUILD=7 \ + CODEX_LIMITS_FEED_URL="$feed_url" \ + CODEX_LIMITS_PUBLIC_ED_KEY="$public_key" \ + "$project_dir/Scripts/build-app.sh" + ditto "$app_dir" "$update_dir/Codex Limits QA.app" + + CODEX_LIMITS_QA=1 \ + CODEX_LIMITS_VERSION=0.2.7 \ + CODEX_LIMITS_BUILD=8 \ + CODEX_LIMITS_FEED_URL="$feed_url" \ + CODEX_LIMITS_PUBLIC_ED_KEY="$public_key" \ + "$project_dir/Scripts/build-app.sh" + archive="$update_dir/feed/Codex-Limits-QA-0.2.7.zip" + ditto -c -k --sequesterRsrc --keepParent "$app_dir" "$archive" + print -r -- 'Secure in-app updates are ready for local QA.' \ + > "$update_dir/feed/Codex-Limits-QA-0.2.7.md" + print -rn -- "$private_key" | \ + "$project_dir/.build/artifacts/sparkle/Sparkle/bin/generate_appcast" \ + --ed-key-file - \ + --download-url-prefix "http://127.0.0.1:8765/" \ + --embed-release-notes \ + --maximum-deltas 0 \ + --critical-update-version '' \ + "$update_dir/feed" + + nohup python3 -m http.server 8765 --bind 127.0.0.1 \ + --directory "$update_dir/feed" \ + "$update_dir/server.log" 2>&1 & + print -r -- $! > "$update_dir/server.pid" + for _ in {1..20}; do + curl --silent --fail "$feed_url" >/dev/null && break + sleep 0.1 + done + curl --silent --fail "$feed_url" >/dev/null + open "$update_dir/Codex Limits QA.app" + wait "$(<"$update_dir/server.pid")" + ;; *) - print -u2 "Usage: $0 [launch|cleanup]" + print -u2 "Usage: $0 [launch|update|cleanup]" exit 64 ;; esac diff --git a/Scripts/validate-release.sh b/Scripts/validate-release.sh new file mode 100755 index 0000000..2111c9b --- /dev/null +++ b/Scripts/validate-release.sh @@ -0,0 +1,64 @@ +#!/bin/zsh +set -euo pipefail + +autoload -Uz is-at-least + +is_newer_than() { + local candidate=$1 + local previous=$2 + ! is-at-least "$candidate" "$previous" +} + +if [[ ${1:-} == --self-test ]]; then + is_newer_than 0.2.7 0.2.6 + ! is_newer_than 0.2.7 0.2.7 + ! is_newer_than 0.2.7 0.2.8 + print "Release version checks passed" + exit +fi + +version=${1:?"Usage: $0 VERSION"} +[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { + print -u2 "Release version must be stable semantic versioning, for example 0.2.7" + exit 64 +} + +project_dir=${0:A:h:h} +plist="$project_dir/Resources/Info.plist" +plist_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist") +build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist") +[[ $plist_version == $version ]] || { + print -u2 "Info.plist version is $plist_version, expected $version" + exit 65 +} +[[ $build == <-> ]] || { + print -u2 "CFBundleVersion must be an integer" + exit 65 +} + +cd "$project_dir" +tag="v$version" +if git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then + print -u2 "$tag already exists" + exit 65 +fi + +latest_tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1) +if [[ -n $latest_tag ]]; then + latest_version=${latest_tag#v} + is_newer_than "$version" "$latest_version" || { + print -u2 "$version must be newer than $latest_version" + exit 65 + } + + previous_plist=$(mktemp) + trap 'rm -f "$previous_plist"' EXIT + git show "$latest_tag:Resources/Info.plist" > "$previous_plist" + previous_build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$previous_plist") + (( build > previous_build )) || { + print -u2 "Build $build must be greater than $previous_build" + exit 65 + } +fi + +print "Validated $tag (build $build)" diff --git a/Sources/CodexLimits/AppUpdater.swift b/Sources/CodexLimits/AppUpdater.swift new file mode 100644 index 0000000..e0dcc65 --- /dev/null +++ b/Sources/CodexLimits/AppUpdater.swift @@ -0,0 +1,57 @@ +import Sparkle + +@MainActor +final class AppUpdater: NSObject, ObservableObject { + @Published private(set) var availableVersion: String? + + private var started = false + private lazy var controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: self + ) + + func start() { + guard !started else { return } + started = true + + controller.startUpdater() + controller.updater.updateCheckInterval = 6 * 60 * 60 + controller.updater.checkForUpdatesInBackground() + } + + func showAvailableUpdate() { + controller.checkForUpdates(nil) + } +} + +@MainActor +extension AppUpdater: SPUUpdaterDelegate { + func updater( + _ updater: SPUUpdater, + didFindValidUpdate item: SUAppcastItem + ) { + availableVersion = item.displayVersionString + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + availableVersion = nil + } +} + +extension AppUpdater: SPUStandardUserDriverDelegate { + nonisolated var supportsGentleScheduledUpdateReminders: Bool { true } + + nonisolated func standardUserDriverShouldHandleShowingScheduledUpdate( + _ update: SUAppcastItem, + andInImmediateFocus immediateFocus: Bool + ) -> Bool { + false + } + + nonisolated func standardUserDriverWillHandleShowingUpdate( + _ handleShowingUpdate: Bool, + forUpdate update: SUAppcastItem, + state: SPUUserUpdateState + ) {} +} diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index db635be..f9dbdd3 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -6,6 +6,7 @@ struct MenuContentView: View { @ObservedObject var monitor: UsageMonitor @StateObject private var workspace: AnalyticsWorkspaceStore @StateObject private var assistedInsights: CodexAssistedInsightStore + @StateObject private var updater = AppUpdater() @Environment(\.openSettings) private var openSettings init( @@ -38,6 +39,8 @@ struct MenuContentView: View { await monitor.setResetReminderEnabled(isEnabled) } }, + availableUpdateVersion: updater.availableVersion, + showAvailableUpdate: updater.showAvailableUpdate, settings: showSettings ) .padding(.horizontal, 20) @@ -75,6 +78,9 @@ struct MenuContentView: View { .padding(.vertical, 12) } .frame(width: layout.width, height: layout.height) + .task { + updater.start() + } .task(id: workspace.state) { let state = workspace.state await monitor.setLocalAnalyticsVisible( @@ -297,6 +303,8 @@ private struct WorkspaceHeader: View { let resetReminderState: ResetReminderState let refresh: () -> Void let setResetReminderEnabled: (Bool) -> Void + let availableUpdateVersion: String? + let showAvailableUpdate: () -> Void let settings: () -> Void var body: some View { @@ -330,6 +338,18 @@ private struct WorkspaceHeader: View { .help("Refresh") .accessibilityLabel("Refresh usage") + if let availableUpdateVersion { + Button(action: showAvailableUpdate) { + Image(systemName: "arrow.down.circle") + } + .buttonStyle(.borderless) + .help("Upgrade to \(availableUpdateVersion)") + .accessibilityLabel("Upgrade Codex Limits") + .accessibilityValue( + "Version \(availableUpdateVersion) is available" + ) + } + Button(action: settings) { Image(systemName: "gearshape") } diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..4017d1f --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,25 @@ +# Releasing Codex Limits + +Releases are prepared by the manual `Release` GitHub Actions workflow. It builds one universal macOS app, signs the update archive and feed with Sparkle EdDSA, and stops at a Draft GitHub Release. + +## One-time setup + +1. Create a protected GitHub environment named `release` and restrict it to the `main` branch. +2. Store the exported Sparkle private key as the environment secret `SPARKLE_PRIVATE_KEY`. +3. Store a second copy of the private key in Bitwarden. Never commit or paste it into an issue, pull request, workflow input, or chat. +4. Keep the public key in `Resources/Info.plist`. + +Losing the EdDSA private key prevents ad-hoc-signed installations from trusting future updates. Keep both protected copies. + +## Release flow + +1. Ask Codex to prepare a release and provide the stable version number. +2. Codex runs tests and QA, then updates `CFBundleShortVersionString` and increments `CFBundleVersion`. +3. Run `Scripts/validate-release.sh VERSION` and the `Release` workflow with `dry_run` enabled. +4. Inspect the universal app archive, signed `appcast.xml`, generated notes, and workflow result. +5. Run the workflow with `dry_run` disabled. It creates a Draft Release only. +6. Inspect the draft and explicitly tell Codex to publish it. + +Publishing a stable GitHub Release makes its `appcast.xml` available through the repository's `releases/latest/download` URL. Drafts and prereleases are not returned by that URL. + +The app and update archive are ad-hoc signed because this project has no Apple Developer ID certificate. Sparkle still verifies the EdDSA-signed feed and archive. A user's first manual installation remains subject to macOS Gatekeeper; in-app updates do not remove that first-install limitation. From fe307292b69c1bd8f042c764c9e64fb9860b29cc Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:28:17 +0200 Subject: [PATCH 3/7] feat: add Claude and Grok usage history with unified overview --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 5 + CONTEXT.md | 309 ++++ Package.swift | 15 +- README.md | 48 +- Scripts/build-app.sh | 8 + Scripts/check-grok-exit.py | 90 ++ Scripts/measure-app-idle.sh | 46 + Scripts/validate-release.sh | 46 +- .../AllowanceHistory.swift | 261 +++ .../ClaudeIntegrationCore/ClaudeRelay.swift | 322 ++++ Sources/CodexLimits/AnalyticsWorkspace.swift | 153 +- .../CodexLimits/ClaudeCodeIntegration.swift | 999 ++++++++++++ .../CodexLimits/CodexAssistedInsights.swift | 49 +- Sources/CodexLimits/CodexClient.swift | 97 +- Sources/CodexLimits/CodexLimitsApp.swift | 163 +- Sources/CodexLimits/GrokBillingClient.swift | 427 +++++ Sources/CodexLimits/GrokIntegration.swift | 505 ++++++ .../IntegrationAllowanceChart.swift | 134 ++ .../CodexLimits/IntegrationPreferences.swift | 168 ++ .../CodexLimits/LocalCoverageEvaluator.swift | 17 - Sources/CodexLimits/LocalTokenActivity.swift | 13 +- Sources/CodexLimits/MenuContentView.swift | 1422 ++++++++++++----- Sources/CodexLimits/ResetReminder.swift | 25 +- Sources/CodexLimits/SettingsView.swift | 391 ++++- Sources/CodexLimits/UsageHistory.swift | 1008 +++++++++++- .../CodexLimits/UsageIntelligenceEngine.swift | 198 +-- Sources/CodexLimits/UsageMonitor.swift | 529 +++++- .../CodexLimits/UsageOverviewSnapshot.swift | 90 ++ Sources/CodexLimits/UsageReceipts.swift | 91 -- Sources/CodexLimitsClaudeRelay/main.swift | 40 + .../AllowanceHistoryTests.swift | 310 ++++ .../AnalyticsWorkspaceTests.swift | 311 +--- .../ClaudeCodeSetupServiceTests.swift | 522 ++++++ Tests/CodexLimitsTests/ClaudeRelayTests.swift | 185 +++ .../CodexAssistedInsightTests.swift | 119 -- Tests/CodexLimitsTests/CodexClientTests.swift | 40 + .../CodexSourceAnalysisTests.swift | 28 - .../GrokBillingClientTests.swift | 226 +++ .../GrokIntegrationTests.swift | 324 ++++ .../IntegrationAllowanceChartTests.swift | 81 + .../IntegrationPreferencesTests.swift | 157 ++ .../LocalActivityPerformanceTests.swift | 110 -- .../LocalTokenActivityTests.swift | 6 - .../CodexLimitsTests/ResetReminderTests.swift | 23 +- .../CodexLimitsTests/UsageHistoryTests.swift | 397 ++++- .../UsageIntelligenceEngineTests.swift | 2 + .../UsageMonitorHistoryTests.swift | 304 +++- .../UsageOverviewSnapshotTests.swift | 114 ++ docs/MEASUREMENT-CONTRACT.md | 343 ++++ docs/PRODUCT-LANGUAGE.md | 125 ++ ...elected-folder-for-shared-usage-history.md | 6 +- docs/adr/0007-local-only-analytics.md | 3 + ...-user-initiated-codex-assisted-insights.md | 7 + .../0009-keep-account-control-read-only.md | 3 + ...retain-analytics-history-until-deletion.md | 9 + docs/adr/0011-weekly-allowance-is-primary.md | 5 + ...d-driven-bounded-integration-collection.md | 17 + ...-capability-driven-integration-surfaces.md | 9 + ...nge-and-eventual-history-reconciliation.md | 17 + docs/images/codex-limits-dashboard.png | Bin 59846 -> 0 bytes docs/prd/local-codex-analytics-workspace.md | 6 +- docs/prd/multi-integration-workspace.md | 373 +++++ docs/releasing.md | 4 +- ...code-integration-feasibility-2026-08-18.md | 354 ++++ .../codex-limits-user-research-2026-07-27.md | 421 +++++ .../codex-limits-validation-2026-07-27.md | 667 ++++++++ .../codexbar-method-comparison-2026-08-21.md | 222 +++ ...rprise-analytics-feasibility-2026-08-06.md | 203 +++ ...k-build-acp-runtime-contract-2026-08-22.md | 78 + .../grok-build-validation-2026-09-10.md | 74 + ...gration-v1-validation-spikes-2026-08-22.md | 200 +++ ...ndor-integration-feasibility-2026-08-18.md | 387 +++++ .../opencode-integration-2026-08-18.md | 291 ++++ .../reset-graph-root-cause-2026-08-06.md | 153 ++ ...ai-grok-provider-feasibility-2026-08-18.md | 392 +++++ docs/usage-history-sync.md | 45 - 77 files changed, 13693 insertions(+), 1651 deletions(-) create mode 100644 CONTEXT.md create mode 100644 Scripts/check-grok-exit.py create mode 100755 Scripts/measure-app-idle.sh create mode 100644 Sources/ClaudeIntegrationCore/AllowanceHistory.swift create mode 100644 Sources/ClaudeIntegrationCore/ClaudeRelay.swift create mode 100644 Sources/CodexLimits/ClaudeCodeIntegration.swift create mode 100644 Sources/CodexLimits/GrokBillingClient.swift create mode 100644 Sources/CodexLimits/GrokIntegration.swift create mode 100644 Sources/CodexLimits/IntegrationAllowanceChart.swift create mode 100644 Sources/CodexLimits/IntegrationPreferences.swift delete mode 100644 Sources/CodexLimits/LocalCoverageEvaluator.swift create mode 100644 Sources/CodexLimits/UsageOverviewSnapshot.swift create mode 100644 Sources/CodexLimitsClaudeRelay/main.swift create mode 100644 Tests/CodexLimitsTests/AllowanceHistoryTests.swift create mode 100644 Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift create mode 100644 Tests/CodexLimitsTests/ClaudeRelayTests.swift create mode 100644 Tests/CodexLimitsTests/GrokBillingClientTests.swift create mode 100644 Tests/CodexLimitsTests/GrokIntegrationTests.swift create mode 100644 Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift create mode 100644 Tests/CodexLimitsTests/IntegrationPreferencesTests.swift create mode 100644 Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift create mode 100644 docs/MEASUREMENT-CONTRACT.md create mode 100644 docs/PRODUCT-LANGUAGE.md create mode 100644 docs/adr/0007-local-only-analytics.md create mode 100644 docs/adr/0008-user-initiated-codex-assisted-insights.md create mode 100644 docs/adr/0009-keep-account-control-read-only.md create mode 100644 docs/adr/0010-retain-analytics-history-until-deletion.md create mode 100644 docs/adr/0011-weekly-allowance-is-primary.md create mode 100644 docs/adr/0012-demand-driven-bounded-integration-collection.md create mode 100644 docs/adr/0013-capability-driven-integration-surfaces.md create mode 100644 docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md delete mode 100644 docs/images/codex-limits-dashboard.png create mode 100644 docs/prd/multi-integration-workspace.md create mode 100644 docs/research/claude-code-integration-feasibility-2026-08-18.md create mode 100644 docs/research/codex-limits-user-research-2026-07-27.md create mode 100644 docs/research/codex-limits-validation-2026-07-27.md create mode 100644 docs/research/codexbar-method-comparison-2026-08-21.md create mode 100644 docs/research/enterprise-analytics-feasibility-2026-08-06.md create mode 100644 docs/research/grok-build-acp-runtime-contract-2026-08-22.md create mode 100644 docs/research/grok-build-validation-2026-09-10.md create mode 100644 docs/research/multi-integration-v1-validation-spikes-2026-08-22.md create mode 100644 docs/research/multi-vendor-integration-feasibility-2026-08-18.md create mode 100644 docs/research/opencode-integration-2026-08-18.md create mode 100644 docs/research/reset-graph-root-cause-2026-08-06.md create mode 100644 docs/research/xai-grok-provider-feasibility-2026-08-18.md delete mode 100644 docs/usage-history-sync.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bf51e5..0646646 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,4 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run tests - run: swift test + run: swift test -c release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f0820c..5240447 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,9 +83,14 @@ jobs: run: | set -euo pipefail app='.build/release/Codex Limits.app' + helper="$app/Contents/Helpers/CodexLimitsClaudeRelay" codesign --verify --deep --strict "$app" + test -x "$helper" + codesign --verify --strict "$helper" archs=$(lipo -archs "$app/Contents/MacOS/CodexLimits") test "$archs" = 'x86_64 arm64' -o "$archs" = 'arm64 x86_64' + helper_archs=$(lipo -archs "$helper") + test "$helper_archs" = 'x86_64 arm64' -o "$helper_archs" = 'arm64 x86_64' xmllint --noout "$ARTIFACTS/appcast.xml" grep -q 'sparkle:edSignature' "$ARTIFACTS/appcast.xml" grep -q 'sparkle:criticalUpdate' "$ARTIFACTS/appcast.xml" diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..5556863 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,309 @@ +# Codex Usage Analytics + +This context describes how Codex Limits represents account allowance and local activity from supported coding-agent integrations, while preserving the deeper Codex guidance and analytics that require Codex-specific evidence. + +## Language + +**Integration**: +A user-managed coding-agent product that Codex Limits observes through an official local interface. An Integration may expose account allowance, local activity, or both. +_Avoid_: Vendor, provider when referring to the whole integrated product + +**Enabled Integration**: +An Integration the user has chosen to include in Codex Limits. Only Enabled Integrations may perform source work or appear in the Integration Overview; enabling one does not imply that every Integration Capability is supported or currently available. +_Avoid_: Installed integration, detected integration, active provider + +**Integration Snapshot**: +The latest normalized observation for one Integration and capability, carrying its source, source version, observed time, and Freshness. A snapshot never combines unlike facts from multiple Integrations. +_Avoid_: Combined provider state, live data when only last observed + +**Integration Capability**: +A supported class of information exposed by an Integration: Account Allowance, Account Facts, Local Activity, Guidance, or Analysis. Capabilities are independent; supporting one never implies support for the others. +_Avoid_: Provider parity, reduced Codex feature set + +**Unsupported Capability**: +An Integration Capability that the Integration does not expose under the current product contract. It is omitted from the reader experience rather than displayed as zero or temporarily unavailable. +_Avoid_: Missing data, zero usage, unavailable source + +**Integration Overview**: +The at-a-glance surface that presents the strongest supported facts for every enabled Integration without combining unlike allowances or forcing identical cards. +_Avoid_: Combined allowance, provider leaderboard + +**Menu Bar Metric**: +The single fixed, named metric selected from an Enabled Integration for display beside the menu bar icon, or None. The selection names both the Integration and quantity; Codex Limits never combines metrics from multiple Integrations. +_Avoid_: Primary provider, combined menu metric, most constrained integration + +**Integration Readiness**: +The device-local ability of an Enabled Integration to produce its supported facts. Readiness describes setup and source compatibility independently from whether the Integration is enabled. +_Avoid_: Enabled when meaning ready, installed when meaning detected + +**Freshness**: +The age and current availability of an Integration Snapshot relative to its source-specific policy. A failed refresh preserves the last valid snapshot and marks it stale; Freshness does not express accuracy. +_Avoid_: Confidence, live when the source is event-driven + +**Expired Allowance Snapshot**: +An otherwise valid Account Allowance observation whose known reset boundary has passed without a post-reset observation. Its former percentage is not displayed as the current allowance. +_Avoid_: Stale percentage after reset, zero remaining + +**Bounded Working Set**: +The fixed upper bound on history, source payload, decoded facts, and reader state kept in resident memory for the current operation or visible range. On-disk retention may grow without making the working set grow proportionally. +_Avoid_: Loading all retained history, retention limit when meaning memory limit + +**Local Installation Partition**: +An on-device history boundary used when an Integration does not expose a stable supported account identity. It is never synchronized or joined with another Mac in v1. +_Avoid_: Anonymous account, shared unknown account + +**Analytics Workspace**: +The unified, screen-aware product surface containing current guidance and switchable Graphs, Facts, and Insights views. +_Avoid_: Status widget, separate analytics app + +**Local-only**: +A product boundary in which Codex-derived data, analysis, and derived history remain on the user’s device and are not transmitted as product telemetry. +_Avoid_: Private, anonymous cloud analytics + +**Source Content**: +Prompts, responses, code, paths, commands, and tool output contained in existing Codex records. +_Avoid_: Metadata, usage data + +**Derived Record**: +A compact fact, classification, aggregate, or fingerprint produced from Source Content without duplicating that content. +_Avoid_: Raw log copy, transcript + +**Analytics History**: +Derived Records kept without a time limit until the user deletes them. `Delete analytics history` removes the entire store owned by Codex Limits, including account usage samples in the selected sync folder. Rebuild requires a separate user action and can restore only facts whose Codex sources remain available. +_Avoid_: Cloud history, raw archive + +**Machine-local Time**: +Reader-facing dates and times shown using the Mac's current calendar and time-zone settings, including daylight-saving changes. +_Avoid_: Fixed CET, reader-facing UTC + +**Rolling 24-hour Interval**: +The exact 86,400 seconds ending at the current instant on the Mac, shown in Machine-local Time. A daylight-saving transition may make its displayed clock times differ by one hour without changing its duration; stale observations do not move the interval into the past. +_Avoid_: Today, yesterday, calendar day + +**Rolling Preset Range**: +A preset duration such as 3 days, 4 weeks, or 12 weeks ending at the current instant on the Mac. Available observations fill the range without moving its end to the latest observation. +_Avoid_: Preset ending at data freshness + +**Selected-range Token Activity**: +Token Activity from complete Token Activity Intervals contained inside the selected interval. It may cover only part of the selected interval and never implies that missing or boundary-crossing time contained zero activity. +_Avoid_: Full-range total when observations cover only part of the range + +**Observed Interval**: +The actual period between the readings that support a displayed fact. When it is shorter than the selected range, the product shows its start and end instead of replacing the fact with a coverage grade. +_Avoid_: Coverage label as a substitute for the actual period + +**Token Activity Interval**: +The Token Activity measured between two account readings. A zero-token interval is an observation, while missing or future time remains empty; the product does not invent when non-zero activity occurred inside an interval. +_Avoid_: Hourly activity inferred by evenly spreading an interval total + +**Allowance**: +The remaining account capacity reported for a Codex rate-limit window. +_Avoid_: Tokens, credits, balance + +**Usage Remaining**: +The reader-facing percentage of Allowance that remains in a window. Codex Limits uses this orientation for every primary usage display. +_Avoid_: Usage left, allowance remaining, usage used + +**Allowance Window**: +A bounded period with a reported allowance and a scheduled reset time. +_Avoid_: Subscription limit, weekly entitlement + +**Current-window Range**: +The full current Allowance Window from its start through its scheduled reset. Observed series leave future time empty; the range does not stop at the latest observation. +_Avoid_: Current window to date, observed range + +**Weekly Allowance Window**: +The Codex Allowance Window whose reported duration is 10,080 minutes. It is the primary window for the menu bar, current guidance, Runway, Suggested Pace, and default Usage remaining chart. +_Avoid_: Most depleted window, combined limit + +**Account Movement**: +An observed change in allowance between two compatible account readings. A long interval limits knowledge of when movement happened inside it, but does not erase the known total movement. +_Avoid_: Charge, billed usage + +**Allowance Break**: +A boundary created by a reset, correction, account change, or incompatible increase in Usage Remaining. No Runway pace calculation crosses it; observation restarts on its latest side. +_Avoid_: Negative allowance use, pace calculated through a reset + +**Token Activity**: +The number of tokens reported for observed Codex activity, either by the account summary or by local task records. It is observed consumption, not a token entitlement, and is never projected into the future. +_Avoid_: Token allowance, token limit + +**Account Token Activity**: +Token Activity reported by the account summary using one strongest available method. Lifetime-token intervals are primary; UTC Account Daily Token Buckets are a fallback and are never mixed with them in one selected-range total. +_Avoid_: Local tokens, weekly token allowance + +**Account Reading Timeline**: +One chronological series of compatible readings for the same account and Allowance Window, merged from every synced installation. Installations provide additional observations of one account counter; their values are never added together. +_Avoid_: Per-Mac account totals, sum of installation counters + +**Account Counter Break**: +A boundary where the lifetime-token counter decreases or readings otherwise become incompatible. No Token Activity Interval crosses the boundary; valid intervals on either side remain factual. +_Avoid_: Negative Token Activity, discarding every valid interval in the selected range + +**Account Daily Token Bucket**: +Token Activity reported by the account API for a UTC calendar-day interval. It contributes to a selected-range total only when its whole interval is inside that range; changing its displayed timezone never turns it into a Machine-local calendar-day total. +_Avoid_: Local daily total, clipped daily bucket + +**Current-window Token Activity**: +Selected-range Token Activity from the start of the current Allowance Window through the latest observation. While the window remains open, it is shown as activity "so far," never as a complete-window total. +_Avoid_: Complete weekly token total before reset + +**Account Facts**: +Values returned directly by the account API, including lifetime tokens, peak daily tokens, longest running turn, streaks, credits, and spend-control state when available. +_Avoid_: Estimates, local diagnostics + +**Local Token Activity**: +Token Activity observed in local task records and attributable to a task, agent, or model. +_Avoid_: Account total, billed tokens + +**Local Coverage**: +An assessment of how much account-visible activity can be represented by local task records for the same period. It is numeric only when the source definitions and time boundaries align. +_Avoid_: Match rate, missing billing + +**Allowance Intensity**: +The observed Account Movement associated with a unit of Token Activity under a particular workload mix. +_Avoid_: Token price, billing rate + +**Equivalent Capacity**: +An extrapolation of how much Token Activity would correspond to a full allowance at the observed workload mix and Allowance Intensity. +_Avoid_: Weekly token allowance, guaranteed capacity + +**Comparable Workload Cost**: +The Allowance Intensity of a workload cohort compared with its Reference Baseline. +_Avoid_: Limit reduction, price increase + +**Comparable Work**: +Two bounded weekly intervals from the same account whose observed model, reasoning, cache, and Local Coverage mix passes the current measurement contract. +_Avoid_: Similar-looking task, same prompt + +**Reference Baseline**: +The median of the four previous complete High-comparability weekly windows unless the user pins another qualifying historical period. +_Avoid_: Average week, official baseline + +**Banked Reset**: +An available account reset that can restore eligible allowance windows when redeemed. +_Avoid_: Emergency reset, bonus credit + +**Read-only Analytics**: +The part of Codex Limits that reads data, calculates metrics, shows guidance, sends reminders, and runs user-requested analysis without changing Codex state. +_Avoid_: Read-only product + +**Reset Reminder**: +A local notification scheduled before the Next Known Expiry. It prompts the user to check the reset and never redeems it. +_Avoid_: Reset Automation, auto-use + +**Reminder Lead Time**: +The interval between a Reset Reminder and the Next Known Expiry. The default is 24 hours. +_Avoid_: Trigger time, auto-use window + +**Next Known Expiry**: +The earliest expiry among the banked-reset details currently available to the product. It is not necessarily the earliest expiry across all banked resets when detail coverage is incomplete. +_Avoid_: Oldest reset expiry + +**Reset Detail Coverage**: +The number of banked-reset expiry details available compared with the authoritative reset count. The product shows incomplete coverage next to the reset count. +_Avoid_: Hidden details, reset confidence + +**Local Activity**: +Codex work observed on the user’s machine, including task, agent, model, token, context, and tool activity when available. +_Avoid_: Billed usage, account charge + +**Active Turn**: +A Codex turn between its observed start and completion. Waiting and polling remain part of the turn but are classified separately from execution. +_Avoid_: Open thread, foreground window + +**Active Task Tree**: +A Task Tree in which at least one observable turn is active. +_Avoid_: Open project, open conversation + +**Active Time**: +Observed elapsed time in the current Allowance Window during which at least one Active Task Tree is active. Overlapping activity counts once. Execution and waiting are separated when the source allows it. +_Avoid_: Compute time, billed time + +**Estimated Active Time Available**: +A confidence-bounded range for additional Active Time before the current allowance is exhausted, based on recent comparable work. It appears only when Local Coverage is high enough. +_Avoid_: Runtime per week, guaranteed hours + +**Concurrency**: +The number of Active Task Trees at a point in time. +_Avoid_: Open threads, running agents + +**Project**: +The folder or project group presented by Codex, shown with the same short name. The product reuses this hierarchy and does not infer, rename, or replace it. +_Avoid_: Custom workspace, project alias + +**Task**: +A root Codex task as presented by Codex. It is the stable root of a usage receipt. +_Avoid_: Project, inferred topic + +**Task Tree**: +A Task together with the descendant agent tasks whose relationships are observable. +_Avoid_: Session when referring to the full hierarchy + +**Usage Receipt**: +A factual summary of Local Activity for a Task Tree, with drill-down to agents and turns and kept distinct from Account Movement. +_Avoid_: Cost receipt, billing receipt + +**Runway**: +The estimated time until the current allowance is exhausted at the Account Movement pace observed over the latest Rolling 24-hour Interval, or its shorter available portion. +_Avoid_: Runtime entitlement, hours per week + +**Current Estimate**: +The future Usage Remaining projection from the latest account reading using the same recent pace as Runway. It is a derived estimate, never an observed account value. +_Avoid_: Actual usage, projection using a different pace from Runway + +**Suggested Pace**: +The non-negative maximum future rate of allowance consumption, expressed in percentage points per day and derived from current Usage Remaining, time to reset, and the chosen safety buffer. It does not require historical pace observations; zero means the buffer has been reached. +_Avoid_: Official quota, guaranteed pace + +**Insight**: +A structured observation or recommendation supported by named evidence, freshness, coverage, and confidence. +_Avoid_: Tip, verdict + +**Usage Deviation**: +An observed usage rate outside the user’s personal reference range under comparable conditions. It appears as a passive Insight with the measured change, comparison period, Coverage, and Confidence, without claiming a cause. +_Avoid_: Anomaly, billing error, limit reduction + +**Deterministic Insight**: +An Insight produced from local facts and explicit rules without invoking a model. +_Avoid_: AI insight, generated insight + +**Codex-assisted Insight**: +A user-requested interpretation generated by Codex from bounded evidence. It sends a request to Codex and consumes the user’s allowance. +_Avoid_: Local insight, automatic insight + +**Insight Execution Profile**: +The exact model and reasoning level used for a Codex-assisted Insight. The required initial profile is GPT-5.6 Luna Medium; when the current model catalog does not advertise that exact profile, the action is not shown and no fallback runs. +_Avoid_: Task model, inherited model + +**Metadata-only Analysis**: +A Codex-assisted analysis whose evidence excludes Source Content. The user’s explicit analyze action is sufficient authorization to start it. +_Avoid_: Local analysis + +**Source-backed Analysis**: +A Codex-assisted analysis whose evidence includes Source Content. It requires a preflight that identifies the content categories that will be sent. +_Avoid_: Full-context analysis + +**Analytics Overhead**: +Local Activity from a Codex-assisted request and the bounded Account Movement observed during it. The product does not claim that concurrent account movement was caused by the analysis. +_Avoid_: Free analysis, background usage + +**Coverage**: +The degree to which the expected source data for a metric or insight was available. +_Avoid_: Accuracy + +**Confidence**: +The product’s assessment of how strongly the available evidence supports a derived metric or insight. +_Avoid_: Certainty + +**Unavailable Reason**: +The shortest specific explanation of why a value cannot be calculated, such as a missing boundary reading, reset or correction, or account change. +_Avoid_: Not enough data without a reason, coverage jargon in the primary message + +**Evidence Details**: +An optional drill-down containing a value's source, Observed Interval, Coverage, Confidence, and caveats. These details remain available without cluttering the primary result. +_Avoid_: Evidence metadata as the primary message + +**Unattributed Movement**: +Account Movement that cannot be associated with observed Local Activity. +_Avoid_: Hidden charge, unexplained billing diff --git a/Package.swift b/Package.swift index da46f5a..8fd9642 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,11 @@ let package = Package( name: "CodexLimits", platforms: [.macOS(.v14)], products: [ - .executable(name: "CodexLimits", targets: ["CodexLimits"]) + .executable(name: "CodexLimits", targets: ["CodexLimits"]), + .executable( + name: "CodexLimitsClaudeRelay", + targets: ["CodexLimitsClaudeRelay"] + ) ], dependencies: [ .package( @@ -17,11 +21,16 @@ let package = Package( targets: [ .executableTarget( name: "CodexLimits", - dependencies: ["Sparkle"] + dependencies: ["ClaudeIntegrationCore", "Sparkle"] + ), + .target(name: "ClaudeIntegrationCore"), + .executableTarget( + name: "CodexLimitsClaudeRelay", + dependencies: ["ClaudeIntegrationCore"] ), .testTarget( name: "CodexLimitsTests", - dependencies: ["CodexLimits"], + dependencies: ["ClaudeIntegrationCore", "CodexLimits"], resources: [.copy("Fixtures")] ) ] diff --git a/README.md b/README.md index 1c46c8b..f720a3b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## What it shows -Codex shows Usage remaining. Codex Limits shows when it resets, how it changed, and which local Tasks this Mac observed. +Codex shows Usage remaining. Codex Limits shows when it resets, how it changed, and which local Tasks this Mac observed. The current development build also includes opt-in Claude Code and Grok Beta Integrations with Usage remaining history. Claude records its seven-day and five-hour allowances during normal activity; Grok records its returned weekly or monthly usage pool. OpenCode remains deferred. Open the menu to see: @@ -33,12 +33,12 @@ Open the menu to see: - Account and local token activity. - Active time, concurrency, and Usage Receipts for the Task Trees this Mac can read. - Checks that run on this Mac and an optional `Analyze with Codex` action. +- Claude Code's last observed seven-day and five-hour Usage remaining, with recorded history when that Beta Integration is enabled and an eligible Pro or Max account supplies the data. +- Grok Usage remaining, recorded history, its reset, and available plan, prepaid, and pay-as-you-go facts when Grok Beta is enabled. -Switch among three views: +Choose `All` for compact current-window charts beside each Integration’s remaining allowance and reset. Blue shows recorded usage remaining; the green dashed line shows the target. Select a row to open its detail. -- **Graphs** — Usage remaining, Token activity, Usage per token, and Concurrency. -- **Facts** — account facts, banked resets, Other limits, Active Time, and Usage Receipts. -- **Insights** — local checks, saved observations, and analysis you ask Codex to run. +Codex, Claude Code, and Grok share the same detail layout: Integration name, remaining allowance, reset, and chart. Codex keeps pace and runway under `Usage details`. Its `More` menu opens Token activity, Facts and reset reminders, or Insights. @@ -61,6 +61,8 @@ Codex Limits keeps three kinds of values separate: The app keeps weak estimates out of guidance and Insights. The Usage remaining chart may still show a Current or Past estimate when it has enough fresh points to show a useful direction. The chart names its source, Coverage, and Confidence. +Claude and Grok charts show actual observations recorded on this Mac. They start with available data; an older latest-only cache contributes one point. Their current estimates need at least two compatible observations separated by a minute, a fresh latest reading, and no gap over thirty minutes, reset, or correction. Token counts never stand in for an allowance reading. + ## Features - Uses the weekly Codex limit as the main Usage remaining value. @@ -74,18 +76,20 @@ The app keeps weak estimates out of guidance and Insights. The Usage remaining c - Asks Codex to analyze selected data only after you click an analysis button. - Lists the selected Source Content types—prompts, responses, code, paths, commands, and tool output—before you send them to Codex. - Copies account usage samples to a private folder that you choose. -- Deletes all Codex Limits analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`. -- Refreshes on launch, after wake, when you open the menu, every ten minutes, or on request. +- Deletes Codex analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`. +- Refreshes Codex at launch, after wake, and every ten minutes only when its weekly metric is selected; visible or explicit reads remain bounded. Grok uses a ten-minute cadence only while selected for the menu bar, backs off after failures, and performs due reads when visible. Claude Code is event-driven and adds no polling timer. - Runs as a native SwiftUI menu-bar app and uses Sparkle to verify and install signed updates. - Does not redeem resets, change Codex settings, or control Tasks. ## How it works -1. Codex Limits starts your installed Codex CLI and reads account data through its local app server. +1. Codex Limits starts your installed Codex CLI and reads account data through its local app server when Codex has demand. 2. It reads local Codex records without taking control of a Task. -3. It stores small history files on your Mac and keeps each account separate. -4. It uses those sources to make charts, facts, and Insights. -5. It sends a request to Codex only when you choose an `Analyze with Codex` action. +3. If you explicitly set up Claude Code Beta, Claude Code sends bounded allowance fields to a short-lived local helper during normal Claude activity; Codex Limits does not prompt Claude or poll it. +4. If you enable Grok Beta, the app reads billing through your official Grok Build CLI. The CLI manages its own login and service connection; no prompt or coding session is created. +5. It stores compact history files on your Mac and keeps each Codex account separate. Claude and Grok each retain their own local observation history until you delete it; their active charts read a bounded view of the latest 84 days. +6. It uses those sources to make provider-specific cards without combining their allowances. +7. It sends an analysis request to Codex only when you choose an `Analyze with Codex` action. Coverage says how much needed data the app saw. Confidence says how well that data supports an estimate. Low-confidence chart lines do not change guidance or Insights. @@ -94,6 +98,8 @@ Coverage says how much needed data the app saw. Confidence says how well that da Codex Limits keeps analytics local by default: - It does not copy or store your Codex credentials. +- It does not read or store Claude credentials, prompts, responses, session identifiers, model names, transcripts, or project paths. Claude setup changes only the user status line after confirmation and never overwrites an existing status line. +- Grok reads use the official CLI’s supported ACP extension. Codex Limits does not read Grok credentials or cookies, call its private billing backend directly, or store raw CLI output. - It sends no usage data to this project or its author. - It stores account readings and local summaries in the app's Application Support directory. - It does not copy prompts, responses, code, paths, commands, or tool output into Analytics History. @@ -102,10 +108,11 @@ Codex Limits keeps analytics local by default: - `Analyze Source Content` shows each content type before you send it. - Each request to Codex uses your Codex allowance. The buttons appear only when Codex offers the required model and reasoning level. - Reset reminders use local macOS notifications. The app asks for permission when you first enable the reminder. -- If you enable history sync, it copies only usage samples to the selected folder. Preferences, credentials, and raw Codex responses stay on your Mac. +- If you enable history sync, it copies only Codex usage samples to the selected folder. Claude and Grok history, preferences, credentials, and raw Codex responses stay on your Mac. - Synced JSON files contain observation times, remaining percentages, and reset times. Choose a folder that you do not share with other people. -- `Delete analytics history` removes Codex Limits history on this Mac and in the selected sync folder. It keeps your preferences and source Codex records. -- The Codex CLI contacts the Codex service during normal account reads and user-requested Codex analysis. +- `Delete analytics history` removes Codex analytics history on this Mac and in the selected sync folder. It keeps your preferences and source Codex records. +- The Codex CLI contacts the Codex service during normal account reads and user-requested Codex analysis. Grok Build contacts its service during enabled usage reads. +- `Delete Claude Code data…` and `Delete Grok data…` disable that Integration and remove its app-owned history, snapshot, and setup or executable preference. The integrated product's own records and login remain intact. Do not attach raw CLI output or screenshots containing account usage to public issues. @@ -113,9 +120,11 @@ Do not attach raw CLI output or screenshots containing account usage to public i - macOS 14 or later - Xcode 16.4 or later -- A signed-in, Homebrew-managed Codex CLI at `/opt/homebrew/bin/codex` or `/usr/local/bin/codex` +- A signed-in standalone Codex CLI to use the Codex Integration. Known Homebrew and native installer locations are detected, and Settings offers `Locate…` for another executable path. +- Claude Code is optional. Its Beta allowance card requires explicit setup and an eligible Pro or Max account; Free can run Claude Code but does not provide the required allowance fields. +- Grok Build is optional. Its Beta allowance card requires a compatible official CLI and a Grok login with available allowance data. Version 1.0.25 passed a real billing read on 2026-09-10. -Codex Limits does not use a Codex binary bundled with another app. Install and update the standalone CLI yourself. +Codex Limits does not use a Codex binary bundled with another app. Install and update each standalone CLI yourself. OpenCode is not included in v1. ## Build from source @@ -136,13 +145,18 @@ Stable releases include a universal app for Apple Silicon and Intel. The app is ## Test ```sh -swift test +swift test -c release ``` The tests use made-up usage data. Do not commit exported account data or local app state as test data. +For a local Grok check, build and open the app, enable `Grok` in Settings, and select `Grok — Current-period usage remaining`. Check the Grok detail and `All` views, wait at least 30 seconds before an explicit refresh, then disable Grok and confirm its menu value disappears. See the [Grok validation note](docs/research/grok-build-validation-2026-09-10.md) for expected behavior and remaining release checks. + ## Current limitations +- Multi-integration release acceptance remains pending: the all-enabled idle comparison, eligible Claude observation, and eight-hour lifecycle soak are incomplete. Local development testing can proceed. +- Claude and Grok sources do not provide a stable account identity. Their histories describe this local installation, do not sync, and cannot reconstruct usage from before observations were recorded. + - Existing 0.2.6 and older installations require one final manual update to a version that includes the in-app updater. - Account and local values can differ because this Mac may not observe every Codex Task. - Estimates need account readings near both ends of a time range and enough similar local work. diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index c9e2a33..0c27404 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -25,24 +25,32 @@ if [[ ${CODEX_LIMITS_UNIVERSAL:-0} == 1 ]]; then arm_release="$project_dir/.build/universal-arm64/arm64-apple-macosx/release" intel_release="$project_dir/.build/universal-x86_64/x86_64-apple-macosx/release" executable="$project_dir/.build/release/CodexLimits" + claude_relay="$project_dir/.build/release/CodexLimitsClaudeRelay" mkdir -p "${executable:h}" lipo -create \ "$arm_release/CodexLimits" \ "$intel_release/CodexLimits" \ -output "$executable" + lipo -create \ + "$arm_release/CodexLimitsClaudeRelay" \ + "$intel_release/CodexLimitsClaudeRelay" \ + -output "$claude_relay" framework="$arm_release/Sparkle.framework" else xcrun swift build "${build_args[@]}" executable="$project_dir/.build/release/CodexLimits" + claude_relay="$project_dir/.build/release/CodexLimitsClaudeRelay" framework="$project_dir/.build/release/Sparkle.framework" fi rm -rf "$app_dir" mkdir -p \ "$app_dir/Contents/MacOS" \ + "$app_dir/Contents/Helpers" \ "$app_dir/Contents/Resources" \ "$app_dir/Contents/Frameworks" cp "$executable" "$app_dir/Contents/MacOS/CodexLimits" +cp "$claude_relay" "$app_dir/Contents/Helpers/CodexLimitsClaudeRelay" install_name_tool -add_rpath \ @loader_path/../Frameworks \ "$app_dir/Contents/MacOS/CodexLimits" diff --git a/Scripts/check-grok-exit.py b/Scripts/check-grok-exit.py new file mode 100644 index 0000000..4e46781 --- /dev/null +++ b/Scripts/check-grok-exit.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Check normal app-exit cleanup with fake Grok processes; run with python3.""" + +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time + + +def alive(pid): + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + +root = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory(prefix="codex-limits-grok-exit-") as temporary: + work = Path(temporary) + main = work / "main.swift" + main.write_text("""import Darwin +import Foundation +@main struct ExitProbe { + static func main() async { + let executable = URL(fileURLWithPath: CommandLine.arguments[1]) + let record = CommandLine.arguments[2] + Task { _ = try? await GrokBillingClient().fetch(executableURL: executable) } + let deadline = ProcessInfo.processInfo.systemUptime + 3 + while !FileManager.default.fileExists(atPath: record) + && ProcessInfo.processInfo.systemUptime < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + guard FileManager.default.fileExists(atPath: record) else { exit(2) } + exit(0) + } +} +""") + parent = work / "parent" + subprocess.run([ + "/usr/bin/xcrun", "swiftc", "-parse-as-library", + "-module-cache-path", str(work / "modules"), + str(root / "Sources/CodexLimits/GrokBillingClient.swift"), + str(main), "-o", str(parent), + ], check=True, timeout=120) + + for mode in ("graceful", "forced"): + record = work / f"{mode}.pids" + fake = work / f"fake-{mode}" + setup = "trap '' TERM\n" if mode == "forced" else "" + cleanup = "" if mode == "forced" else ( + "trap 'kill \"$child\" 2>/dev/null; wait \"$child\" 2>/dev/null; exit 0' TERM\n" + ) + fake.write_text( + "#!/bin/sh\n" + setup + "/bin/sleep 60 &\nchild=$!\n" + cleanup + + 'printf \'%s %s\\n\' "$$" "$child" > "$GROK_EXIT_CHECK_RECORD"\n' + + 'wait "$child"\n' + ) + fake.chmod(0o700) + environment = {**os.environ, "GROK_EXIT_CHECK_RECORD": str(record)} + process = subprocess.Popen( + [str(parent), str(fake), str(record)], env=environment, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + started = time.monotonic() + try: + assert process.wait(timeout=5) == 0, f"{mode}: parent failed" + elapsed = time.monotonic() - started + assert elapsed < 3, f"{mode}: exit took {elapsed:.3f}s" + pids = [int(value) for value in record.read_text().split()] + assert len(pids) == 2, f"{mode}: missing fake process IDs" + deadline = time.monotonic() + 1 + while any(alive(pid) for pid in pids) and time.monotonic() < deadline: + time.sleep(0.01) + assert not any(alive(pid) for pid in pids), f"{mode}: orphaned fake process" + print(f"{mode}: normal exit in {elapsed:.3f}s; CLI and child absent") + finally: + if process.poll() is None: + process.kill() + process.wait() + if record.exists(): + recorded = [int(value) for value in record.read_text().split()] + if any(alive(pid) for pid in recorded): + try: + os.killpg(recorded[0], signal.SIGKILL) + except ProcessLookupError: + pass diff --git a/Scripts/measure-app-idle.sh b/Scripts/measure-app-idle.sh new file mode 100755 index 0000000..2fd12a5 --- /dev/null +++ b/Scripts/measure-app-idle.sh @@ -0,0 +1,46 @@ +#!/bin/zsh +set -euo pipefail + +if [[ ${1:-} == --self-test ]]; then + output=$(mktemp /private/tmp/codex-limits-idle-self-test.XXXXXX.csv) + trap 'rm -f "$output"' EXIT + "$0" $$ 2 1 "$output" >/dev/null + [[ $(wc -l < "$output") -eq 3 ]] + print "Idle sampler checks passed" + exit +fi + +pid=${1:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} +duration=${2:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} +interval=${3:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} +output=${4:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} + +[[ $pid == <-> && $pid -gt 0 ]] +[[ $duration == <-> && $duration -gt 0 ]] +[[ $interval == <-> && $interval -gt 0 && $interval -le 60 ]] +kill -0 "$pid" + +samples=$(( (duration + interval - 1) / interval )) +print 'timestamp,parent_rss_kib,parent_cpu_percent,child_count,child_rss_kib,child_cpu_percent' > "$output" + +for ((sample = 1; sample <= samples; sample++)); do + parent=$(/bin/ps -o rss=,%cpu= -p "$pid" | awk '{$1=$1; print}') + [[ -n $parent ]] || { + print -u2 "Process $pid ended after $((sample - 1)) samples" + exit 66 + } + read -r parent_rss parent_cpu <<< "$parent" + children=$(/bin/ps -axo ppid=,rss=,%cpu= | awk -v parent="$pid" ' + $1 == parent { count += 1; rss += $2; cpu += $3 } + END { printf "%d %d %.3f", count, rss, cpu } + ') + read -r child_count child_rss child_cpu <<< "$children" + print "$(date +%s),$parent_rss,$parent_cpu,$child_count,$child_rss,$child_cpu" >> "$output" + + if (( sample % 6 == 0 || sample == samples )); then + print -u2 "sample $sample/$samples rss=${parent_rss}KiB cpu=${parent_cpu}% children=$child_count" + fi + if (( sample < samples )); then + sleep "$interval" + fi +done diff --git a/Scripts/validate-release.sh b/Scripts/validate-release.sh index 2111c9b..9324b7a 100755 --- a/Scripts/validate-release.sh +++ b/Scripts/validate-release.sh @@ -9,11 +9,40 @@ is_newer_than() { ! is-at-least "$candidate" "$previous" } +is_accepted_multi_integration_status() { + [[ $1 == 'Status: Accepted for v1 implementation' ]] +} + +release_gate_passed() { + local gate=$1 + awk -F '|' -v gate="$gate" ' + function trim(value) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + return value + } + trim($2) == gate { + found = 1 + passed = trim($4) == "Passed" + } + END { exit !(found && passed) } + ' +} + if [[ ${1:-} == --self-test ]]; then is_newer_than 0.2.7 0.2.6 ! is_newer_than 0.2.7 0.2.7 ! is_newer_than 0.2.7 0.2.8 - print "Release version checks passed" + is_accepted_multi_integration_status \ + 'Status: Accepted for v1 implementation' + ! is_accepted_multi_integration_status \ + 'Status: Needs revision — release gates remain' + release_gate_passed 'Eligible Claude account observation' <<< \ + '| Eligible Claude account observation | Recorded evidence | Passed |' + ! release_gate_passed 'Eligible Claude account observation' <<< \ + '| Eligible Claude account observation | Not run | Pending |' + ! release_gate_passed 'Eight-hour mixed lifecycle soak' <<< \ + '| Another gate | Recorded evidence | Passed |' + print "Release validator checks passed" exit fi @@ -24,6 +53,21 @@ version=${1:?"Usage: $0 VERSION"} } project_dir=${0:A:h:h} +multi_integration_prd="$project_dir/docs/prd/multi-integration-workspace.md" +multi_integration_status=$(sed -n '/^Status: /{p;q;}' "$multi_integration_prd") +is_accepted_multi_integration_status "$multi_integration_status" || { + print -u2 "Multi-integration v1 release gates are not accepted" + exit 65 +} +for gate in \ + 'All-enabled idle comparison' \ + 'Eligible Claude account observation' \ + 'Eight-hour mixed lifecycle soak'; do + release_gate_passed "$gate" < "$multi_integration_prd" || { + print -u2 "Multi-integration release gate is not passed: $gate" + exit 65 + } +done plist="$project_dir/Resources/Info.plist" plist_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist") build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist") diff --git a/Sources/ClaudeIntegrationCore/AllowanceHistory.swift b/Sources/ClaudeIntegrationCore/AllowanceHistory.swift new file mode 100644 index 0000000..5f52c02 --- /dev/null +++ b/Sources/ClaudeIntegrationCore/AllowanceHistory.swift @@ -0,0 +1,261 @@ +import Darwin +import Foundation + +public struct AllowanceObservation: Codable, Equatable, Hashable, Sendable { + public let metric: String + public let observedAt: Date + public let remainingPercent: Double + public let resetsAt: Date + public let startsAt: Date? + public let source: String? + + public init( + metric: String, + observedAt: Date, + remainingPercent: Double, + resetsAt: Date, + startsAt: Date? = nil, + source: String? = nil + ) { + self.metric = metric + self.observedAt = observedAt + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + self.startsAt = startsAt + self.source = source + } + + public var isValid: Bool { + hasValidFields && observedAt < resetsAt + } + + fileprivate var hasValidFields: Bool { + !metric.isEmpty && metric.utf8.count <= 64 + && metric.utf8.allSatisfy { + (48 ... 57).contains($0) || (65 ... 90).contains($0) + || (97 ... 122).contains($0) || $0 == 45 || $0 == 95 + } + && Self.validDate(observedAt) && Self.validDate(resetsAt) + && remainingPercent.isFinite && (0 ... 100).contains(remainingPercent) + && (startsAt.map { Self.validDate($0) && $0 <= observedAt } ?? true) + && (source.map { + !$0.isEmpty && $0.utf8.count <= 64 && $0.utf8.allSatisfy { + (48 ... 57).contains($0) || (65 ... 90).contains($0) + || (97 ... 122).contains($0) || [45, 46, 95].contains($0) + } + } ?? true) + } + + static func validDate(_ date: Date) -> Bool { + let seconds = date.timeIntervalSince1970 + return seconds.isFinite && (0 ..< 253_402_300_800).contains(seconds) + } +} + +public enum AllowanceHistoryError: Error, Equatable { + case invalidRecord + case readLimitExceeded + case writeFailed +} + +public enum AllowanceHistory { + public static let retainedViewDays = 84 + public static let maximumFileBytes = 4 * 1_024 * 1_024 + public static let maximumReadBytes = 32 * 1_024 * 1_024 + public static let maximumRecordBytes = 512 + public static let maximumReadRecords = 200_000 + private static let maximumAppendRecords = 64 + + public static func append( + _ observations: [AllowanceObservation], + in directory: URL + ) throws { + guard observations.count <= maximumAppendRecords else { + throw AllowanceHistoryError.invalidRecord + } + guard observations.allSatisfy(\.hasValidFields) else { + throw AllowanceHistoryError.invalidRecord + } + let observations = observations.filter { $0.observedAt < $0.resetsAt } + guard !observations.isEmpty else { return } + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: directory.path + ) + let days = Dictionary(grouping: observations, by: { dayName($0.observedAt) }) + for day in days.keys.sorted() { + try appendDay(days[day]!.sorted(by: ordered), to: directory.appendingPathComponent(day)) + } + } + + public static func read(in directory: URL, now: Date, since: Date? = nil) throws -> [AllowanceObservation] { + guard AllowanceObservation.validDate(now), + since.map(AllowanceObservation.validDate) ?? true else { + throw AllowanceHistoryError.invalidRecord + } + let cutoff = max(since ?? Date(timeIntervalSince1970: 0), + now.addingTimeInterval(-Double(retainedViewDays) * 86_400)) + guard cutoff <= now else { return [] } + var day = calendar.startOfDay(for: cutoff) + var totalBytes = 0 + var recordCount = 0 + var observations: Set = [] + while day <= now { + try Task.checkCancellation() + let name = dayName(day) + let file = directory.appendingPathComponent(name) + if FileManager.default.fileExists(atPath: file.path) { + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + var data = Data() + while let chunk = try handle.read(upToCount: min(64 * 1_024, maximumFileBytes + 1 - data.count)), + !chunk.isEmpty { + data.append(chunk) + guard data.count <= maximumFileBytes, + totalBytes + data.count <= maximumReadBytes else { + throw AllowanceHistoryError.readLimitExceeded + } + try Task.checkCancellation() + } + totalBytes += data.count + var lineStart = data.startIndex + for newline in data.indices where data[newline] == 10 { + let line = data[lineStart ..< newline] + lineStart = data.index(after: newline) + recordCount += 1 + guard recordCount <= maximumReadRecords else { + throw AllowanceHistoryError.readLimitExceeded + } + let observation = try decode(line) + guard dayName(observation.observedAt) == name else { + throw AllowanceHistoryError.invalidRecord + } + if observation.observedAt >= cutoff, observation.observedAt <= now { + observations.insert(observation) + } + } + if let end = data.lastIndex(of: 10) { + guard data.distance(from: data.index(after: end), to: data.endIndex) <= maximumRecordBytes else { + throw AllowanceHistoryError.invalidRecord + } + } else if data.count > maximumRecordBytes { + throw AllowanceHistoryError.invalidRecord + } + } + day = day.addingTimeInterval(86_400) + } + return observations.sorted(by: ordered) + } + + public static func delete(in directory: URL) throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + private static func appendDay(_ observations: [AllowanceObservation], to url: URL) throws { + let descriptor = open(url.path, O_CREAT | O_RDWR | O_APPEND, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(descriptor) } + let lockDeadline = ProcessInfo.processInfo.systemUptime + 0.25 + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + try Task.checkCancellation() + guard errno == EWOULDBLOCK || errno == EINTR, + ProcessInfo.processInfo.systemUptime < lockDeadline else { + throw AllowanceHistoryError.writeFailed + } + usleep(10_000) + } + defer { flock(descriptor, LOCK_UN) } + guard fchmod(descriptor, S_IRUSR | S_IWUSR) == 0 else { throw AllowanceHistoryError.writeFailed } + let size = lseek(descriptor, 0, SEEK_END) + guard size >= 0 else { throw AllowanceHistoryError.writeFailed } + let tailCount = min(Int(size), (maximumAppendRecords + 2) * maximumRecordBytes) + var bytes = [UInt8](repeating: 0, count: tailCount) + let count = pread(descriptor, &bytes, tailCount, size - off_t(tailCount)) + guard count == tailCount else { throw AllowanceHistoryError.writeFailed } + var tail = Data(bytes) + if !tail.isEmpty, tail.last != 10 { + let partialStart = tail.lastIndex(of: 10).map { tail.index(after: $0) } ?? tail.startIndex + let partial = tail[partialStart...] + guard partial.count <= maximumRecordBytes else { throw AllowanceHistoryError.invalidRecord } + if (try? decode(partial)) != nil { + try write(Data([10]), to: descriptor) + tail.append(10) + } else { + guard ftruncate(descriptor, size - off_t(partial.count)) == 0 else { + throw AllowanceHistoryError.writeFailed + } + tail.removeSubrange(partialStart...) + } + } + if size > tailCount, let newline = tail.firstIndex(of: 10) { + tail.removeSubrange(...newline) + } + let recent = try completeLines(tail).map(decode) + guard recent.allSatisfy({ dayName($0.observedAt) == url.lastPathComponent }) else { + throw AllowanceHistoryError.invalidRecord + } + var seen = Set(recent) + var latest = Dictionary(grouping: recent, by: \.metric).mapValues { + $0.map(\.observedAt).max()! + } + // ponytail: ordered sources need only bounded-tail retry deduplication; + // arbitrary historical imports would need an indexed merge path. + let encoder = JSONEncoder() + for observation in observations { + guard latest[observation.metric].map({ observation.observedAt >= $0 }) ?? true, + seen.insert(observation).inserted else { continue } + var data = try encoder.encode(observation) + guard data.count <= maximumRecordBytes else { throw AllowanceHistoryError.invalidRecord } + data.append(10) + try write(data, to: descriptor) + latest[observation.metric] = observation.observedAt + } + } + + private static func write(_ data: Data, to descriptor: Int32) throws { + try data.withUnsafeBytes { bytes in + var written = 0 + while written < bytes.count { + let count = Darwin.write(descriptor, bytes.baseAddress!.advanced(by: written), bytes.count - written) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw AllowanceHistoryError.writeFailed } + written += count + } + } + } + + private static func completeLines(_ data: Data) -> [Data.SubSequence] { + guard let last = data.lastIndex(of: 10) else { return [] } + return data[.. AllowanceObservation { + guard !line.isEmpty, line.count <= maximumRecordBytes, + let value = try? JSONDecoder().decode(AllowanceObservation.self, from: line), + value.isValid else { throw AllowanceHistoryError.invalidRecord } + return value + } + + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func dayName(_ date: Date) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d.jsonl", parts.year!, parts.month!, parts.day!) + } + + private static func ordered(_ lhs: AllowanceObservation, _ rhs: AllowanceObservation) -> Bool { + if lhs.observedAt != rhs.observedAt { return lhs.observedAt < rhs.observedAt } + if lhs.metric != rhs.metric { return lhs.metric < rhs.metric } + if lhs.resetsAt != rhs.resetsAt { return lhs.resetsAt < rhs.resetsAt } + return lhs.remainingPercent < rhs.remainingPercent + } +} diff --git a/Sources/ClaudeIntegrationCore/ClaudeRelay.swift b/Sources/ClaudeIntegrationCore/ClaudeRelay.swift new file mode 100644 index 0000000..c146e55 --- /dev/null +++ b/Sources/ClaudeIntegrationCore/ClaudeRelay.swift @@ -0,0 +1,322 @@ +import Darwin +import Foundation + +public struct ClaudeAllowanceWindowSnapshot: Codable, Equatable, Sendable { + public let remainingPercent: Double + public let resetsAt: Date + + public init(remainingPercent: Double, resetsAt: Date) { + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + } +} + +public struct ClaudeAllowanceSnapshot: Codable, Equatable, Sendable { + public let version: Int + public let observedAt: Date + public let cliVersion: String? + public let fiveHour: ClaudeAllowanceWindowSnapshot? + public let sevenDay: ClaudeAllowanceWindowSnapshot? + public let historyWriteFailed: Bool? + + public init( + version: Int = 1, + observedAt: Date, + cliVersion: String?, + fiveHour: ClaudeAllowanceWindowSnapshot?, + sevenDay: ClaudeAllowanceWindowSnapshot?, + historyWriteFailed: Bool? = nil + ) { + self.version = version + self.observedAt = observedAt + self.cliVersion = cliVersion + self.fiveHour = fiveHour + self.sevenDay = sevenDay + self.historyWriteFailed = historyWriteFailed + } + + public var historyObservations: [AllowanceObservation] { + [ + ("claude-seven-day", sevenDay, 7 * 86_400.0), + ("claude-five-hour", fiveHour, 5 * 3_600.0) + ].compactMap { metric, window, duration in + guard let window else { return nil } + let observation = AllowanceObservation( + metric: metric, + observedAt: observedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt, + startsAt: window.resetsAt.addingTimeInterval(-duration), + source: "statusLine.rate_limits" + ) + return observation.isValid ? observation : nil + } + } +} + +public enum ClaudeRelayError: Error, Equatable { + case inputTooLarge + case invalidInput + case noAllowance + case disabled + case lockUnavailable +} + +public enum ClaudeRelay { + public static let snapshotChangedNotificationName = + "com.github.thrr87.CodexLimits.ClaudeSnapshotChanged" + public static let maximumInputBytes = 256 * 1_024 + public static let maximumCacheBytes = 64 * 1_024 + public static let equivalentWriteInterval: TimeInterval = 30 + public static let unavailableStatusLine = "Usage unavailable" + + public static func readBoundedInput( + from handle: FileHandle + ) throws -> Data { + var input = Data() + while input.count < maximumInputBytes + 1 { + let remaining = maximumInputBytes + 1 - input.count + guard let chunk = try handle.read( + upToCount: min(64 * 1_024, remaining) + ), !chunk.isEmpty else { + break + } + input.append(chunk) + } + return input + } + + public static func decode( + _ data: Data, + observedAt: Date + ) throws -> ClaudeAllowanceSnapshot { + guard data.count <= maximumInputBytes else { + throw ClaudeRelayError.inputTooLarge + } + let input: Input + do { + input = try JSONDecoder().decode(Input.self, from: data) + } catch { + throw ClaudeRelayError.invalidInput + } + let fiveHour = try window(input.rateLimits?.fiveHour) + let sevenDay = try window(input.rateLimits?.sevenDay) + guard fiveHour != nil || sevenDay != nil else { + throw ClaudeRelayError.noAllowance + } + return ClaudeAllowanceSnapshot( + observedAt: observedAt, + cliVersion: acceptedVersion(input.version), + fiveHour: fiveHour, + sevenDay: sevenDay + ) + } + + public static func statusLine( + for snapshot: ClaudeAllowanceSnapshot + ) -> String { + var values: [String] = [] + if let sevenDay = snapshot.sevenDay { + values.append( + "7d \(Int(sevenDay.remainingPercent.rounded()))% remaining" + ) + } + if let fiveHour = snapshot.fiveHour { + values.append( + "5h \(Int(fiveHour.remainingPercent.rounded()))% remaining" + ) + } + return values.joined(separator: " · ") + } + + public static func storeIfNewer( + _ snapshot: ClaudeAllowanceSnapshot, + at cacheURL: URL, + enabledMarkerURL: URL + ) throws -> Bool { + guard let markerIdentity = enabledMarkerIdentity(at: enabledMarkerURL) else { + throw ClaudeRelayError.disabled + } + return try withCacheLock(at: cacheURL, waits: true) { + guard enabledMarkerIdentity(at: enabledMarkerURL) == markerIdentity else { + throw ClaudeRelayError.disabled + } + let existing = try? readSnapshot(at: cacheURL) + if let existing { + if existing.observedAt >= snapshot.observedAt { + return false + } + if existing.cliVersion == snapshot.cliVersion, + existing.fiveHour == snapshot.fiveHour, + existing.sevenDay == snapshot.sevenDay, + snapshot.observedAt.timeIntervalSince(existing.observedAt) + < equivalentWriteInterval { + return false + } + } + var historyFailed = false + do { + try AllowanceHistory.append( + (existing?.historyObservations ?? []) + snapshot.historyObservations, + in: historyDirectory(for: cacheURL) + ) + } catch { + historyFailed = true + } + let stored = ClaudeAllowanceSnapshot( + version: snapshot.version, + observedAt: snapshot.observedAt, + cliVersion: snapshot.cliVersion, + fiveHour: snapshot.fiveHour, + sevenDay: snapshot.sevenDay, + historyWriteFailed: historyFailed ? true : nil + ) + let data = try JSONEncoder().encode(stored) + guard data.count <= maximumCacheBytes else { + throw ClaudeRelayError.invalidInput + } + try data.write(to: cacheURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: cacheURL.path + ) + return true + } + } + + public static func deleteSnapshotIfIdle(at cacheURL: URL) throws { + try withCacheLock(at: cacheURL, waits: false) { + try AllowanceHistory.delete(in: historyDirectory(for: cacheURL)) + if FileManager.default.fileExists(atPath: cacheURL.path) { + try FileManager.default.removeItem(at: cacheURL) + } + } + } + + public static func historyDirectory(for cacheURL: URL) -> URL { + cacheURL.deletingLastPathComponent().appendingPathComponent("History", isDirectory: true) + } + + private static func withCacheLock( + at cacheURL: URL, + waits: Bool, + operation: () throws -> T + ) throws -> T { + // Keep this empty lock file: waiting relays may hold its inode after deletion. + let lockURL = cacheURL.appendingPathExtension("lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw ClaudeRelayError.lockUnavailable } + defer { + flock(descriptor, LOCK_UN) + close(descriptor) + } + guard flock(descriptor, LOCK_EX | (waits ? 0 : LOCK_NB)) == 0 else { + throw ClaudeRelayError.lockUnavailable + } + return try operation() + } + + private static func enabledMarkerIdentity(at url: URL) -> NSObject? { + var uncachedURL = url + uncachedURL.removeAllCachedResourceValues() + let values = try? uncachedURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) + return values?.fileResourceIdentifier as? NSObject + } + + public static func readSnapshot(at cacheURL: URL) throws + -> ClaudeAllowanceSnapshot { + let value = try JSONDecoder().decode( + ClaudeAllowanceSnapshot.self, + from: checkedCacheData(at: cacheURL) + ) + guard value.version == 1, + value.observedAt.timeIntervalSinceReferenceDate.isFinite, + value.cliVersion == acceptedVersion(value.cliVersion), + value.fiveHour != nil || value.sevenDay != nil, + value.fiveHour.map(isValid) ?? true, + value.sevenDay.map(isValid) ?? true else { + throw ClaudeRelayError.invalidInput + } + return value + } + + private static func window(_ input: Input.Window?) throws + -> ClaudeAllowanceWindowSnapshot? { + guard let input else { return nil } + guard input.usedPercentage.isFinite, + (0 ... 100).contains(input.usedPercentage), + input.resetsAt.isFinite else { + throw ClaudeRelayError.invalidInput + } + let resetsAt = Date(timeIntervalSince1970: input.resetsAt) + guard resetsAt.timeIntervalSinceReferenceDate.isFinite else { + throw ClaudeRelayError.invalidInput + } + return ClaudeAllowanceWindowSnapshot( + remainingPercent: 100 - input.usedPercentage, + resetsAt: resetsAt + ) + } + + private static func acceptedVersion(_ version: String?) -> String? { + guard let version, + !version.isEmpty, + version.utf8.count <= 64, + version.unicodeScalars.allSatisfy({ + $0.value >= 0x20 && $0.value <= 0x7e + }) else { + return nil + } + return version + } + + private static func isValid( + _ window: ClaudeAllowanceWindowSnapshot + ) -> Bool { + window.remainingPercent.isFinite + && (0 ... 100).contains(window.remainingPercent) + && window.resetsAt.timeIntervalSinceReferenceDate.isFinite + } + + private static func checkedCacheData(at url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= maximumCacheBytes else { + throw ClaudeRelayError.invalidInput + } + let data = try Data(contentsOf: url) + guard data.count <= maximumCacheBytes else { + throw ClaudeRelayError.invalidInput + } + return data + } + + private struct Input: Decodable { + let version: String? + let rateLimits: RateLimits? + + enum CodingKeys: String, CodingKey { + case version + case rateLimits = "rate_limits" + } + + struct RateLimits: Decodable { + let fiveHour: Window? + let sevenDay: Window? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + } + } + + struct Window: Decodable { + let usedPercentage: Double + let resetsAt: Double + + enum CodingKeys: String, CodingKey { + case usedPercentage = "used_percentage" + case resetsAt = "resets_at" + } + } + } +} diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index db4302e..101438b 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -16,13 +16,13 @@ enum AnalyticsGraph: String, CaseIterable, Codable, Identifiable, Sendable { case usagePerToken = "Usage per token" case concurrency = "Concurrency" - var id: String { rawValue } - - static let coreCases: [AnalyticsGraph] = [ + static let coreCases: [Self] = [ .usageRemaining, - .tokenActivity + .tokenActivity, ] + var id: String { rawValue } + var usesAccountScope: Bool { self == .usageRemaining || self == .tokenActivity @@ -73,46 +73,6 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { } } -func accountTokenInterval( - at date: Date, - in intervals: [AccountTokenActivityInterval], - within range: DateInterval -) -> AccountTokenActivityInterval? { - intervals.first { - $0.start >= range.start - && $0.end <= range.end - && $0.start <= date - && date <= $0.end - } -} - -func steppedAccountTokenInterval( - in intervals: [AccountTokenActivityInterval], - from selected: AccountTokenActivityInterval?, - by offset: Int -) -> AccountTokenActivityInterval? { - let ordered = intervals.sorted { - $0.start == $1.start ? $0.end < $1.end : $0.start < $1.start - } - guard !ordered.isEmpty else { return nil } - guard let selected, - let index = ordered.firstIndex(of: selected) else { - return offset < 0 ? ordered.last : ordered.first - } - return ordered[min(max(index + offset, 0), ordered.count - 1)] -} - -func retainedAccountTokenInterval( - _ selected: AccountTokenActivityInterval?, - in intervals: [AccountTokenActivityInterval], - range: DateInterval -) -> AccountTokenActivityInterval? { - guard let selected, - selected.start >= range.start, - selected.end <= range.end else { return nil } - return intervals.first { $0 == selected } -} - struct AccountTokenActivityDisplayInterval: Equatable, Hashable, Identifiable, Sendable { let sourceIntervals: [AccountTokenActivityInterval] @@ -240,7 +200,7 @@ struct AnalyticsExplorationState: Codable, Equatable, Sendable { ) var usesLocalAnalytics: Bool { - section == .graphs && graph.usesLocalAnalytics + section != .graphs || graph.usesLocalAnalytics } } @@ -255,39 +215,39 @@ final class AnalyticsWorkspaceStore: ObservableObject { [String: InsightDisposition] private let defaults: UserDefaults + private let keyPrefix: String - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard, keyPrefix: String = "") { self.defaults = defaults - state = Self.restoredState(from: defaults) + self.keyPrefix = keyPrefix + state = Self.restoredState(from: defaults, keyPrefix: keyPrefix) insightDispositions = Self.restoredInsightDispositions( - from: defaults + from: defaults, keyPrefix: keyPrefix ) } static func restoredState( - from defaults: UserDefaults + from defaults: UserDefaults, keyPrefix: String = "" ) -> AnalyticsExplorationState { - guard let data = defaults.data(forKey: Self.persistenceKey), + guard let data = defaults.data(forKey: keyPrefix + Self.persistenceKey), let restored = try? JSONDecoder().decode( AnalyticsExplorationState.self, from: data ) else { return .initial } - guard AnalyticsGraph.coreCases.contains(restored.graph) else { - var core = restored - core.graph = .usageRemaining - core.filters = .all - return core + var state = restored + if !AnalyticsGraph.coreCases.contains(state.graph) { + state.graph = .usageRemaining } - return restored + return state } static func restoredInsightDispositions( - from defaults: UserDefaults + from defaults: UserDefaults, keyPrefix: String = "" ) -> [String: InsightDisposition] { guard let data = defaults.data( - forKey: Self.insightDispositionsPersistenceKey + forKey: keyPrefix + Self.insightDispositionsPersistenceKey ), let restored = try? JSONDecoder().decode( [String: InsightDisposition].self, from: data @@ -344,7 +304,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { if let data = try? JSONEncoder().encode(next) { defaults.set( data, - forKey: Self.insightDispositionsPersistenceKey + forKey: keyPrefix + Self.insightDispositionsPersistenceKey ) } } @@ -408,7 +368,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { guard next != state else { return } state = next if let data = try? JSONEncoder().encode(next) { - defaults.set(data, forKey: Self.persistenceKey) + defaults.set(data, forKey: keyPrefix + Self.persistenceKey) } } } @@ -480,7 +440,7 @@ enum UsageChartPointSource: Equatable, Sendable { case .derivedEstimate: UsageValueSource.derivedEstimate.rawValue case .accountHistory: UsageForecastReferenceSource.accountHistory.rawValue case .tokenEstimate: UsageForecastReferenceSource.tokenEstimate.rawValue - case .weeklyTarget: "Weekly target" + case .weeklyTarget: "Target" } } } @@ -500,48 +460,11 @@ struct UsageChartSelection: Equatable, Sendable { in chart: UsageChartSnapshot, within visibleRange: DateInterval? = nil ) -> UsageChartSelection? { - [ - nearestCandidate( - in: chart.allObserved, - series: .observed, - priority: 0, - source: .account, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.currentProjection, - series: .currentEstimate, - priority: 1, - source: .derivedEstimate, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.historicalProjection, - series: .pastEstimate, - priority: 2, - source: .accountHistory, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.estimatedBackfill, - series: .estimatedBackfill, - priority: 3, - source: .tokenEstimate, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.target, - series: .target, - priority: 4, - source: .weeklyTarget, - to: date, - within: visibleRange - ) - ].compactMap { $0 }.min { + candidates(in: chart) + .filter { + visibleRange?.contains($0.point.date) ?? true + } + .min { let leftDistance = abs($0.point.date.timeIntervalSince(date)) let rightDistance = abs($1.point.date.timeIntervalSince(date)) if leftDistance == rightDistance { @@ -558,30 +481,6 @@ struct UsageChartSelection: Equatable, Sendable { } } - private static func nearestCandidate( - in points: [UsageChartPoint], - series: UsageChartSeries, - priority: Int, - source: UsageChartPointSource, - to date: Date, - within visibleRange: DateInterval? - ) -> Candidate? { - guard let point = nearestPoint( - in: points, - to: date, - date: \.date, - within: visibleRange - ) else { - return nil - } - return Candidate( - series: series, - point: point, - priority: priority, - source: source - ) - } - private static func candidates( in chart: UsageChartSnapshot ) -> [Candidate] { diff --git a/Sources/CodexLimits/ClaudeCodeIntegration.swift b/Sources/CodexLimits/ClaudeCodeIntegration.swift new file mode 100644 index 0000000..eddae26 --- /dev/null +++ b/Sources/CodexLimits/ClaudeCodeIntegration.swift @@ -0,0 +1,999 @@ +import ClaudeIntegrationCore +import Foundation + +enum ClaudeCodeReadiness: Equatable, Sendable { + case disabled + case checking + case notFound + case setUp + case waitingForData + case ready + case conflict + case updateRequired + case manualCleanupRequired + case failed +} + +struct ClaudeCodeInspection: Equatable, Sendable { + let readiness: ClaudeCodeReadiness + let snapshot: ClaudeAllowanceSnapshot? +} + +struct ClaudeCodeIntegrationPaths: Sendable { + let executableCandidates: [URL] + let settingsURL: URL + let dataDirectory: URL + let helperURL: URL + + var cacheURL: URL { dataDirectory.appendingPathComponent("snapshot.json") } + var enabledMarkerURL: URL { dataDirectory.appendingPathComponent("enabled") } + var installRecordURL: URL { dataDirectory.appendingPathComponent("install.json") } + var historyDirectory: URL { ClaudeRelay.historyDirectory(for: cacheURL) } + + static func live() -> Self { + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + let applicationSupport = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? fileManager.temporaryDirectory + let bundleID = Bundle.main.bundleIdentifier + ?? "com.github.thrr87.CodexLimits" + return Self( + executableCandidates: [ + URL(fileURLWithPath: "/opt/homebrew/bin/claude"), + URL(fileURLWithPath: "/usr/local/bin/claude"), + home.appendingPathComponent(".local/bin/claude") + ], + settingsURL: home.appendingPathComponent(".claude/settings.json"), + dataDirectory: applicationSupport + .appendingPathComponent(bundleID, isDirectory: true) + .appendingPathComponent("Integrations/ClaudeCode", isDirectory: true), + helperURL: Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers", isDirectory: true) + .appendingPathComponent("CodexLimitsClaudeRelay") + ) + } + + static func isolatedQA(base: URL, bundleURL: URL) -> Self { + let helperURL = bundleURL + .appendingPathComponent("Contents/Helpers", isDirectory: true) + .appendingPathComponent("CodexLimitsClaudeRelay") + return Self( + executableCandidates: [helperURL], + settingsURL: base + .appendingPathComponent("Fixtures/ClaudeCode", isDirectory: true) + .appendingPathComponent("settings.json"), + dataDirectory: base + .appendingPathComponent("Integrations/ClaudeCode", isDirectory: true), + helperURL: helperURL + ) + } +} + +actor ClaudeCodeSetupService { + enum SetupError: Error { + case notFound + case conflict + case updateRequired + case invalidSettings + } + + private struct StatusLineConfiguration: Codable, Equatable { + let type: String + let command: String + let padding: Int + } + + private struct InstallRecord: Codable { + let version: Int + let settingsPath: String + let configuration: StatusLineConfiguration + let createdSettingsFile: Bool? + } + + private enum StoredStatusLine { + case absent + case value(Data) + } + + private static let maximumSettingsBytes = 1_000_000 + private let paths: ClaudeCodeIntegrationPaths + private var selectedExecutableURL: URL? + private var configurationGeneration: UInt64 = 0 + + init( + paths: ClaudeCodeIntegrationPaths = .live(), + selectedExecutableURL: URL? = nil + ) { + self.paths = paths + self.selectedExecutableURL = selectedExecutableURL + } + + func inspect() -> ClaudeCodeInspection { + let snapshot = try? ClaudeRelay.readSnapshot(at: paths.cacheURL) + guard executableURL() != nil else { + return ClaudeCodeInspection(readiness: .notFound, snapshot: snapshot) + } + guard isExecutable(paths.helperURL) else { + return ClaudeCodeInspection( + readiness: .updateRequired, + snapshot: snapshot + ) + } + do { + let current = try storedStatusLine() + let owned = try ownedConfigurationData() + switch current { + case .absent: + return ClaudeCodeInspection( + readiness: .setUp, + snapshot: snapshot + ) + case let .value(data) where owned.contains(data): + let enabled = FileManager.default.fileExists( + atPath: paths.enabledMarkerURL.path + ) + return ClaudeCodeInspection( + readiness: enabled + ? (snapshot == nil ? .waitingForData : .ready) + : .setUp, + snapshot: snapshot + ) + case .value: + return ClaudeCodeInspection( + readiness: .conflict, + snapshot: snapshot + ) + } + } catch { + return ClaudeCodeInspection(readiness: .failed, snapshot: snapshot) + } + } + + func setUp() throws -> ClaudeCodeInspection { + guard executableURL() != nil else { throw SetupError.notFound } + guard isExecutable(paths.helperURL) else { + throw SetupError.updateRequired + } + let configuration = expectedConfiguration() + let expectedData = try canonicalData(configuration) + let settingsFileExisted = FileManager.default.fileExists( + atPath: paths.settingsURL.path + ) + let previousRecord = installRecord() + switch try storedStatusLine() { + case .absent: + break + case let .value(data): + guard try ownedConfigurationData().contains(data) + || data == expectedData else { + throw SetupError.conflict + } + } + + try FileManager.default.createDirectory( + at: paths.dataDirectory, + withIntermediateDirectories: true + ) + let record = InstallRecord( + version: 1, + settingsPath: paths.settingsURL.standardizedFileURL.path, + configuration: configuration, + createdSettingsFile: previousRecord?.configuration == configuration + && previousRecord?.createdSettingsFile == true + ? true + : !settingsFileExisted + ) + try writePrivate( + try JSONEncoder().encode(record), + to: paths.installRecordURL + ) + try writeStatusLine(configuration) + configurationGeneration &+= 1 + try writePrivate(Data(), to: paths.enabledMarkerURL) + return inspect() + } + + func selectExecutable(_ url: URL) -> ClaudeCodeInspection? { + guard isExecutable(url) else { return nil } + selectedExecutableURL = url.standardizedFileURL + return inspect() + } + + func deactivate() -> Bool { + guard stopWrites() else { return false } + do { + switch try storedStatusLine() { + case .absent: + try removeEmptyCreatedSettingsFile() + try? FileManager.default.removeItem(at: paths.installRecordURL) + return true + case let .value(data): + guard try ownedConfigurationData().contains(data) else { + return false + } + try removeStatusLine() + try? FileManager.default.removeItem(at: paths.installRecordURL) + return true + } + } catch { + return false + } + } + + func readSnapshot() -> ClaudeAllowanceSnapshot? { + try? ClaudeRelay.readSnapshot(at: paths.cacheURL) + } + + func readHistory() throws -> [AllowanceObservation] { + if let snapshot = readSnapshot() { + try AllowanceHistory.append(snapshot.historyObservations, in: paths.historyDirectory) + } + return try AllowanceHistory.read(in: paths.historyDirectory, now: Date()) + } + + func readOverview( + current: AllowanceObservation?, now: Date, safetyBuffer: Double + ) -> (snapshot: UsageOverviewSnapshot?, historyReadFailed: Bool) { + guard let since = UsageOverviewSnapshot.historyReadStart(current: current, now: now) else { + return (nil, false) + } + do { + try AllowanceHistory.append([current].compactMap { $0 }, in: paths.historyDirectory) + let observations = try AllowanceHistory.read(in: paths.historyDirectory, now: now, since: since) + return (UsageOverviewSnapshot( + observations: observations, current: current, now: now, safetyBuffer: safetyBuffer + ), false) + } catch { + return (UsageOverviewSnapshot( + observations: [], current: current, now: now, safetyBuffer: safetyBuffer + ), true) + } + } + + @discardableResult + func stopWrites() -> Bool { + configurationGeneration &+= 1 + do { + if FileManager.default.fileExists(atPath: paths.enabledMarkerURL.path) { + try FileManager.default.removeItem(at: paths.enabledMarkerURL) + } + return true + } catch { + return false + } + } + + func hasStoredData() -> Bool { + [ + paths.cacheURL, + paths.historyDirectory, + paths.enabledMarkerURL, + paths.installRecordURL + ].contains { + FileManager.default.fileExists(atPath: $0.path) + } + } + + func deleteData() async -> Bool { + let deactivated = deactivate() + let generation = configurationGeneration + guard !FileManager.default.fileExists(atPath: paths.enabledMarkerURL.path) else { + return false + } + if FileManager.default.fileExists(atPath: paths.dataDirectory.path) { + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while true { + guard generation == configurationGeneration, + !Task.isCancelled else { return false } + do { + try ClaudeRelay.deleteSnapshotIfIdle(at: paths.cacheURL) + break + } catch ClaudeRelayError.lockUnavailable { + guard ContinuousClock.now < deadline else { return false } + do { + try await Task.sleep(for: .milliseconds(20)) + } catch { + return false + } + } catch { + return false + } + } + } + var removed = true + for url in [ + paths.enabledMarkerURL, + paths.installRecordURL + ] where FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + removed = false + } + } + selectedExecutableURL = nil + return deactivated && removed + } + + private func executableURL() -> URL? { + ([selectedExecutableURL].compactMap { $0 } + + paths.executableCandidates).first(where: isExecutable) + } + + private func isExecutable(_ url: URL) -> Bool { + let resolved = url.resolvingSymlinksInPath() + let values = try? resolved.resourceValues( + forKeys: [.isRegularFileKey] + ) + return values?.isRegularFile == true + && FileManager.default.isExecutableFile(atPath: resolved.path) + } + + private func expectedConfiguration() -> StatusLineConfiguration { + StatusLineConfiguration( + type: "command", + command: [ + shellQuoted(paths.helperURL.path), + "--cache", + shellQuoted(paths.cacheURL.path), + "--enabled-marker", + shellQuoted(paths.enabledMarkerURL.path) + ].joined(separator: " "), + padding: 0 + ) + } + + private func ownedConfigurationData() throws -> Set { + var result: Set = [try canonicalData(expectedConfiguration())] + if let record = installRecord() { + result.insert(try canonicalData(record.configuration)) + } + return result + } + + private func installRecord() -> InstallRecord? { + guard let data = try? checkedData(at: paths.installRecordURL), + let record = try? JSONDecoder().decode( + InstallRecord.self, + from: data + ), record.version == 1, + record.settingsPath + == paths.settingsURL.standardizedFileURL.path else { + return nil + } + return record + } + + private func storedStatusLine() throws -> StoredStatusLine { + guard FileManager.default.fileExists(atPath: paths.settingsURL.path) else { + return .absent + } + let object = try settingsObject() + guard let value = object["statusLine"] else { return .absent } + guard JSONSerialization.isValidJSONObject(value) else { + throw SetupError.invalidSettings + } + return .value(try JSONSerialization.data( + withJSONObject: value, + options: [.sortedKeys] + )) + } + + private func writeStatusLine( + _ configuration: StatusLineConfiguration + ) throws { + var object = try settingsObject(ifMissing: [:]) + object["statusLine"] = try JSONSerialization.jsonObject( + with: canonicalData(configuration) + ) + try writeSettings(object) + } + + private func removeStatusLine() throws { + var object = try settingsObject() + object.removeValue(forKey: "statusLine") + if object.isEmpty, installRecord()?.createdSettingsFile == true { + try FileManager.default.removeItem(at: paths.settingsURL) + } else { + try writeSettings(object) + } + } + + private func removeEmptyCreatedSettingsFile() throws { + guard installRecord()?.createdSettingsFile == true, + FileManager.default.fileExists(atPath: paths.settingsURL.path), + try settingsObject().isEmpty else { + return + } + try FileManager.default.removeItem(at: paths.settingsURL) + } + + private func settingsObject( + ifMissing fallback: [String: Any]? = nil + ) throws -> [String: Any] { + guard FileManager.default.fileExists(atPath: paths.settingsURL.path) else { + if let fallback { return fallback } + throw SetupError.invalidSettings + } + let object = try JSONSerialization.jsonObject( + with: checkedData(at: paths.settingsURL) + ) + guard let dictionary = object as? [String: Any] else { + throw SetupError.invalidSettings + } + return dictionary + } + + private func writeSettings(_ object: [String: Any]) throws { + guard JSONSerialization.isValidJSONObject(object) else { + throw SetupError.invalidSettings + } + try FileManager.default.createDirectory( + at: paths.settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) + guard data.count <= Self.maximumSettingsBytes else { + throw SetupError.invalidSettings + } + try writePrivate(data, to: paths.settingsURL) + } + + private func canonicalData( + _ configuration: StatusLineConfiguration + ) throws -> Data { + let encoded = try JSONEncoder().encode(configuration) + let object = try JSONSerialization.jsonObject(with: encoded) + return try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) + } + + private func checkedData(at url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= Self.maximumSettingsBytes else { + throw SetupError.invalidSettings + } + let data = try Data(contentsOf: url) + guard data.count <= Self.maximumSettingsBytes else { + throw SetupError.invalidSettings + } + return data + } + + private func writePrivate(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } + + private func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +@MainActor +final class ClaudeCodeIntegrationStore: ObservableObject { + private struct DemandRequest: Equatable { + let lifecycle: UInt64 + let demand: UInt64 + } + + @Published private(set) var readiness: ClaudeCodeReadiness + @Published private(set) var snapshot: ClaudeAllowanceSnapshot? + @Published private(set) var history: [AllowanceObservation] = [] + @Published private(set) var overview: UsageOverviewSnapshot? + @Published private(set) var historyIssue: String? + @Published private(set) var hasStoredData = false + @Published private(set) var displayNow = Date() + + var displayFreshness: ClaudeAllowanceSnapshot.DisplayFreshness? { + guard let snapshot else { return nil } + let freshness = snapshot.displayFreshness(now: displayNow) + guard freshness == .fresh else { return freshness } + switch readiness { + case .notFound, .setUp, .conflict, .updateRequired, + .manualCleanupRequired, .failed: + return .stale + default: + return freshness + } + } + + private let service: ClaudeCodeSetupService + private let integrationWorkCoordinator: IntegrationWorkCoordinator + private var enabled: Bool + private var menuBarSourceActive: Bool + private var visible = false + private var historyVisible = false + private var overviewSafetyBuffer: Double = 3 + private var lifecycleGeneration: UInt64 = 0 + private var demandGeneration: UInt64 = 0 + private var notificationObserver: NSObjectProtocol? + private var boundaryTask: Task? + private var readinessRefreshRequest: DemandRequest? + private var snapshotReadRequest: DemandRequest? + private var snapshotReadPending = false + + init( + isEnabled: Bool, + menuBarSourceActive: Bool = true, + service: ClaudeCodeSetupService = ClaudeCodeSetupService(), + integrationWorkCoordinator: IntegrationWorkCoordinator = + IntegrationWorkCoordinator() + ) { + enabled = isEnabled + self.menuBarSourceActive = menuBarSourceActive + self.service = service + self.integrationWorkCoordinator = integrationWorkCoordinator + readiness = isEnabled ? .checking : .disabled + if isEnabled { + observeSnapshots() + if menuBarSourceActive { + Task { [weak self] in + await self?.refreshReadiness(priority: .automatic) + } + } + } + } + + func settingsPresented() async { + let generation = lifecycleGeneration + let expectedEnabled = enabled + if expectedEnabled { + await refreshReadiness(priority: .settings) + return + } + await integrationWorkCoordinator.run(priority: .settings) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: expectedEnabled) else { + return + } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: false) else { + return + } + self.hasStoredData = hasStoredData + if hasStoredData { + let snapshot = await self.service.readSnapshot() + guard self.isCurrent(generation, enabled: false) else { + return + } + self.setSnapshot(snapshot) + } + } + } + + func setEnabled(_ enabled: Bool) async { + guard enabled != self.enabled else { return } + self.enabled = enabled + lifecycleGeneration &+= 1 + let generation = lifecycleGeneration + if enabled { + readiness = .checking + observeSnapshots() + await refreshReadiness(priority: .explicit) + } else { + history = [] + overview = nil + historyVisible = false + snapshotReadPending = false + stopObservingSnapshots() + boundaryTask?.cancel() + boundaryTask = nil + await service.stopWrites() + guard isCurrent(generation, enabled: false) else { return } + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: false) else { + return + } + let removed = await self.service.deactivate() + guard self.isCurrent(generation, enabled: false) else { return } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: false) else { return } + self.hasStoredData = hasStoredData + self.readiness = removed + ? .disabled + : .manualCleanupRequired + } + } + } + + func setMenuBarSourceActive(_ active: Bool) async { + guard active != menuBarSourceActive else { return } + menuBarSourceActive = active + demandGeneration &+= 1 + scheduleNextBoundary() + if active { + await refreshReadiness(priority: .automatic) + } + } + + func setVisible(_ visible: Bool, includeHistory: Bool = true, safetyBuffer: Double = 3) async { + guard !Task.isCancelled else { return } + let historyVisible = visible && includeHistory + let buffer = SafetyBufferPolicy.normalized(safetyBuffer) + guard visible != self.visible || historyVisible != self.historyVisible + || buffer != overviewSafetyBuffer else { return } + self.visible = visible + self.historyVisible = historyVisible + overviewSafetyBuffer = buffer + overview = nil + if !historyVisible { history = [] } + demandGeneration &+= 1 + scheduleNextBoundary() + if visible { + await refreshReadiness(priority: .visible) + } + } + + func setUp() async { + guard enabled else { return } + let generation = lifecycleGeneration + readiness = .checking + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true) else { + return + } + do { + let inspection = try await self.service.setUp() + guard self.isCurrent(generation, enabled: true) else { return } + self.apply(inspection) + self.hasStoredData = true + await self.refreshHistory(for: DemandRequest( + lifecycle: generation, demand: self.demandGeneration + )) + } catch ClaudeCodeSetupService.SetupError.notFound { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .notFound + } catch ClaudeCodeSetupService.SetupError.conflict { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .conflict + } catch ClaudeCodeSetupService.SetupError.updateRequired { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .updateRequired + } catch { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .failed + } + } + } + + func selectExecutable(_ url: URL) async -> Bool { + guard enabled else { return false } + let generation = lifecycleGeneration + readiness = .checking + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true) else { + return + } + guard let inspection = await self.service.selectExecutable(url) else { + self.readiness = .notFound + return + } + guard self.isCurrent(generation, enabled: true) else { return } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: true) else { return } + self.apply(inspection) + self.hasStoredData = hasStoredData + await self.refreshHistory(for: DemandRequest( + lifecycle: generation, demand: self.demandGeneration + )) + } + return isCurrent(generation, enabled: true) + && readiness != .checking + && readiness != .notFound + } + + func checkForNewObservation( + priority: IntegrationWorkPriority = .explicit + ) async { + guard enabled else { return } + let generation = lifecycleGeneration + let request = DemandRequest( + lifecycle: generation, + demand: demandGeneration + ) + guard demandIsCurrent(request, priority: priority) else { return } + if snapshotReadRequest == request { + if priority == .automatic { snapshotReadPending = true } + return + } + snapshotReadRequest = request + defer { + if snapshotReadRequest == request { + snapshotReadRequest = nil + if snapshotReadPending { + snapshotReadPending = false + Task { @MainActor [weak self] in + await self?.readAutomaticSnapshotIfDemanded() + } + } + } + } + await integrationWorkCoordinator.run(priority: priority) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + let snapshot = await self.service.readSnapshot() + guard self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + self.setSnapshot(snapshot) + if snapshot != nil { + switch self.readiness { + case .ready, .waitingForData, .failed, .checking: + self.readiness = .ready + default: + break + } + } else if self.snapshot != nil { + self.readiness = .failed + } else if self.readiness == .ready { + self.readiness = .waitingForData + } + await self.refreshHistory(for: request) + } + } + + func deleteData() async { + enabled = false + menuBarSourceActive = false + visible = false + historyVisible = false + overview = nil + snapshotReadPending = false + lifecycleGeneration &+= 1 + let generation = lifecycleGeneration + stopObservingSnapshots() + boundaryTask?.cancel() + boundaryTask = nil + await service.stopWrites() + guard isCurrent(generation, enabled: false) else { return } + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: false) else { + return + } + let removed = await self.service.deleteData() + guard self.isCurrent(generation, enabled: false) else { return } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: false) else { return } + self.snapshot = nil + self.history = [] + self.overview = nil + self.historyIssue = nil + self.hasStoredData = hasStoredData + self.displayNow = Date() + self.readiness = removed + ? .disabled + : .manualCleanupRequired + } + } + + private func refreshReadiness( + priority: IntegrationWorkPriority + ) async { + guard enabled else { return } + let generation = lifecycleGeneration + let request = DemandRequest( + lifecycle: generation, + demand: demandGeneration + ) + guard readinessRefreshRequest != request, + demandIsCurrent(request, priority: priority) else { return } + readinessRefreshRequest = request + defer { + if readinessRefreshRequest == request { + readinessRefreshRequest = nil + } + } + readiness = .checking + await integrationWorkCoordinator.run(priority: priority) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + let inspection = await self.service.inspect() + guard self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: true) else { return } + self.apply(inspection) + self.hasStoredData = hasStoredData + await self.refreshHistory(for: request) + } + } + + private func refreshHistory(for request: DemandRequest) async { + guard !Task.isCancelled, visible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + if !historyVisible { + let result = await service.readOverview( + current: snapshot?.historyObservations.first { $0.metric == "claude-seven-day" }, + now: displayNow, safetyBuffer: overviewSafetyBuffer + ) + guard !Task.isCancelled, visible, !historyVisible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + overview = result.snapshot + historyIssue = result.historyReadFailed ? "Claude Code usage history couldn’t be read." + : snapshot?.historyWriteFailed == true ? "Some Claude Code usage history couldn’t be saved." : nil + return + } + do { + let history = try await service.readHistory() + guard historyVisible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + self.history = history + historyIssue = snapshot?.historyWriteFailed == true + ? "Some Claude Code usage history couldn’t be saved." + : nil + } catch { + guard historyVisible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + historyIssue = "Claude Code usage history couldn’t be read." + } + } + + private func isCurrent( + _ generation: UInt64, + enabled: Bool + ) -> Bool { + lifecycleGeneration == generation && self.enabled == enabled + } + + private func demandIsCurrent( + _ request: DemandRequest, + priority: IntegrationWorkPriority + ) -> Bool { + guard request.lifecycle == lifecycleGeneration else { return false } + switch priority { + case .visible: + return request.demand == demandGeneration && visible + case .automatic: + return request.demand == demandGeneration + && (menuBarSourceActive || visible) + case .explicit, .settings: + return true + } + } + + private func apply(_ inspection: ClaudeCodeInspection) { + readiness = inspection.snapshot == nil && snapshot != nil + && inspection.readiness == .waitingForData + ? .failed + : inspection.readiness + setSnapshot(inspection.snapshot) + if inspection.snapshot != nil { + hasStoredData = true + } + } + + private func observeSnapshots() { + guard notificationObserver == nil else { return } + notificationObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name( + ClaudeRelay.snapshotChangedNotificationName + ), + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.readAutomaticSnapshotIfDemanded() + } + } + } + + private func readAutomaticSnapshotIfDemanded() async { + guard menuBarSourceActive || visible else { return } + await checkForNewObservation(priority: .automatic) + } + + private func stopObservingSnapshots() { + guard let notificationObserver else { return } + DistributedNotificationCenter.default().removeObserver( + notificationObserver + ) + self.notificationObserver = nil + } + + private func setSnapshot(_ snapshot: ClaudeAllowanceSnapshot?) { + if let snapshot { + self.snapshot = snapshot + hasStoredData = true + if snapshot.historyWriteFailed == true { + historyIssue = "Some Claude Code usage history couldn’t be saved." + } + } + displayNow = Date() + if let overview, overview.range.end <= displayNow { self.overview = nil } + scheduleNextBoundary() + } + + private func scheduleNextBoundary() { + boundaryTask?.cancel() + guard enabled, menuBarSourceActive || visible, let snapshot else { + boundaryTask = nil + return + } + let now = Date() + let candidates = [ + snapshot.observedAt.addingTimeInterval(30 * 60), + snapshot.fiveHour?.resetsAt, + snapshot.sevenDay?.resetsAt + ].compactMap { $0 }.filter { $0 > now }.sorted() + guard let boundary = candidates.first else { + boundaryTask = nil + return + } + boundaryTask = Task { [weak self] in + do { + try await Task.sleep( + for: .seconds(boundary.timeIntervalSinceNow) + ) + } catch { + return + } + guard let self else { return } + displayNow = Date() + if let overview, overview.range.end <= displayNow { self.overview = nil } + scheduleNextBoundary() + } + } + + deinit { + boundaryTask?.cancel() + if let notificationObserver { + DistributedNotificationCenter.default().removeObserver( + notificationObserver + ) + } + } +} + +extension ClaudeAllowanceSnapshot { + enum DisplayFreshness: Equatable { + case fresh + case stale + case expired + } + + func displayFreshness(now: Date) -> DisplayFreshness { + if let primaryReset = sevenDay?.resetsAt ?? fiveHour?.resetsAt, + primaryReset <= now { + return .expired + } + return now.timeIntervalSince(observedAt) <= 30 * 60 + ? .fresh + : .stale + } + + func sevenDayMenuBarText(now: Date) -> String { + guard let sevenDay, sevenDay.resetsAt > now else { return "—" } + return "\(Int(sevenDay.remainingPercent.rounded()))%" + } +} diff --git a/Sources/CodexLimits/CodexAssistedInsights.swift b/Sources/CodexLimits/CodexAssistedInsights.swift index c54357d..c56a214 100644 --- a/Sources/CodexLimits/CodexAssistedInsights.swift +++ b/Sources/CodexLimits/CodexAssistedInsights.swift @@ -217,26 +217,6 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { let activeTimeAvailable: ActiveTimeAvailable let scope: Scope - var fingerprint: String { - let encoder = JSONEncoder() - guard let data = try? encoder.encode(self), - var object = try? JSONSerialization.jsonObject( - with: data - ) as? [String: Any] else { - return "" - } - object.removeValue(forKey: "generatedAt") - guard let stableData = try? JSONSerialization.data( - withJSONObject: object, - options: [.sortedKeys] - ) else { - return "" - } - return SHA256.hash(data: stableData) - .map { String(format: "%02x", $0) } - .joined() - } - static func make( reader: UsageReaderSnapshot, exploration: AnalyticsExplorationState, @@ -1318,23 +1298,9 @@ final class CodexAssistedInsightStore: ObservableObject { } } - var showsCard: Bool { - showsAnalyzeAction - || isRunning - || overhead != nil - || !persistedResults.isEmpty - } - - func showsCard(for scope: CodexAssistedAnalysisScope) -> Bool { - showsAnalyzeAction - || isRunning - || overhead != nil - || result(for: scope) != nil - } - func showsCard( for scope: CodexAssistedAnalysisScope, - sourceSelection: CodexSourceSelection? + sourceSelection: CodexSourceSelection? = nil ) -> Bool { showsAnalyzeAction || isRunning @@ -1345,20 +1311,9 @@ final class CodexAssistedInsightStore: ObservableObject { ) != nil } - func result( - for scope: CodexAssistedAnalysisScope - ) -> CodexAssistedAnalysisResult? { - if resultScope?.fingerprint == scope.fingerprint, let result { - return result - } - return persistedResults.last { - $0.scopeFingerprint == scope.fingerprint - }?.result - } - func result( for scope: CodexAssistedAnalysisScope, - sourceSelection: CodexSourceSelection? + sourceSelection: CodexSourceSelection? = nil ) -> CodexAssistedAnalysisResult? { let selectionFingerprint = sourceSelection?.fingerprint var candidates: [CodexAssistedAnalysisResult] = [] diff --git a/Sources/CodexLimits/CodexClient.swift b/Sources/CodexLimits/CodexClient.swift index aaad5d6..7429c93 100644 --- a/Sources/CodexLimits/CodexClient.swift +++ b/Sources/CodexLimits/CodexClient.swift @@ -69,7 +69,7 @@ enum CodexClientError: LocalizedError { } final class CodexAppServerConnection: @unchecked Sendable { - private static let defaultMaximumLineBytes = 16 * 1_024 * 1_024 + private static let defaultMaximumLineBytes = 8 * 1_024 * 1_024 let input: FileHandle let isRunning: () -> Bool @@ -264,6 +264,19 @@ private final class CodexAppServerProcessOwner: @unchecked Sendable { } } +private final class CodexExecutableSelection: @unchecked Sendable { + private let lock = NSLock() + private var url: URL? + + func get() -> URL? { + lock.withLock { url } + } + + func set(_ url: URL?) { + lock.withLock { self.url = url } + } +} + actor CodexClient { static let shared = CodexClient( makeConnection: CodexClient.liveConnection, @@ -272,14 +285,20 @@ actor CodexClient { private static let weeklyWindowDurationMinutes = 10_080 private static let executablePaths = [ - "/opt/homebrew/bin/codex", - "/usr/local/bin/codex" + URL(fileURLWithPath: "/opt/homebrew/bin/codex"), + URL(fileURLWithPath: "/usr/local/bin/codex"), + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/bin/codex") ] + private static let executableSelection = CodexExecutableSelection() private let makeConnection: () throws -> CodexAppServerConnection private let executableIdentity: () -> String? private let protocolGate = CodexProtocolGate() private let timeoutNanoseconds: UInt64 + private let connectionIdleNanoseconds: UInt64 private var connection: CodexAppServerConnection? + private var connectionIdleTask: Task? + private var connectionIdleGeneration: UInt64 = 0 private var initialized = false private var serverCLIVersion: String? private var connectionExecutableIdentity: String? @@ -308,17 +327,35 @@ actor CodexClient { init( makeConnection: @escaping () throws -> CodexAppServerConnection = CodexClient.liveConnection, executableIdentity: @escaping () -> String? = { nil }, - timeout: TimeInterval = 15 + timeout: TimeInterval = 10, + connectionIdleTimeout: TimeInterval = 5 ) { self.makeConnection = makeConnection self.executableIdentity = executableIdentity timeoutNanoseconds = UInt64(max(timeout, 0.001) * 1_000_000_000) + connectionIdleNanoseconds = UInt64( + max(connectionIdleTimeout, 0.001) * 1_000_000_000 + ) } static func fetch() async throws -> CodexFetchResult { try await shared.fetch(fetchedAt: Date()) } + static func cancelFetch() async { + await shared.cancelInFlightFetch() + } + + static func selectExecutable(_ url: URL?) -> Bool { + guard let url else { + executableSelection.set(nil) + return true + } + guard isExecutable(url) else { return false } + executableSelection.set(url.standardizedFileURL) + return true + } + func fetch(fetchedAt: Date) async throws -> CodexFetchResult { if let inFlightFetch { return try await inFlightFetch.value @@ -333,6 +370,11 @@ actor CodexClient { return try await task.value } + private func cancelInFlightFetch() { + inFlightFetch?.cancel() + invalidateConnection() + } + func threadProjectionResponse( for request: ThreadProjectionReadRequest ) async throws -> Data { @@ -353,17 +395,45 @@ actor CodexClient { _ operation: () async throws -> T ) async throws -> T { await protocolGate.enter() + cancelScheduledConnectionRelease() do { try Task.checkCancellation() let result = try await operation() + scheduleConnectionRelease() await protocolGate.leave() return result } catch { + scheduleConnectionRelease() await protocolGate.leave() throw error } } + private func cancelScheduledConnectionRelease() { + connectionIdleGeneration &+= 1 + connectionIdleTask?.cancel() + connectionIdleTask = nil + } + + private func scheduleConnectionRelease() { + cancelScheduledConnectionRelease() + let generation = connectionIdleGeneration + connectionIdleTask = Task { [weak self, connectionIdleNanoseconds] in + do { + try await Task.sleep(nanoseconds: connectionIdleNanoseconds) + } catch { + return + } + await self?.releaseConnectionIfIdle(generation: generation) + } + } + + private func releaseConnectionIfIdle(generation: UInt64) { + guard generation == connectionIdleGeneration else { return } + connectionIdleTask = nil + invalidateConnection() + } + private func fetchWithReconnect( fetchedAt: Date ) async throws -> CodexFetchResult { @@ -628,12 +698,18 @@ actor CodexClient { } private static func liveExecutableURL() -> URL? { - executablePaths.lazy.compactMap { path -> URL? in - guard FileManager.default.isExecutableFile(atPath: path) else { - return nil - } - return URL(fileURLWithPath: path).resolvingSymlinksInPath() - }.first + let selected = executableSelection.get().map { [$0] } ?? [] + return (selected + executablePaths).first(where: isExecutable)? + .resolvingSymlinksInPath() + } + + private static func isExecutable(_ url: URL) -> Bool { + let resolved = url.resolvingSymlinksInPath() + let values = try? resolved.resourceValues( + forKeys: [.isRegularFileKey] + ) + return values?.isRegularFile == true + && FileManager.default.isExecutableFile(atPath: resolved.path) } private static func liveExecutableIdentity() -> String? { @@ -731,6 +807,7 @@ actor CodexClient { } deinit { + connectionIdleTask?.cancel() connection?.stop() } diff --git a/Sources/CodexLimits/CodexLimitsApp.swift b/Sources/CodexLimits/CodexLimitsApp.swift index 33e5f60..3f1ae40 100644 --- a/Sources/CodexLimits/CodexLimitsApp.swift +++ b/Sources/CodexLimits/CodexLimitsApp.swift @@ -3,6 +3,9 @@ import SwiftUI @main struct CodexLimitsApp: App { @StateObject private var monitor: UsageMonitor + @StateObject private var integrations: IntegrationPreferences + @StateObject private var claudeCode: ClaudeCodeIntegrationStore + @StateObject private var grok: GrokIntegrationStore #if CODEX_LIMITS_QA @StateObject private var assistedInsights: CodexAssistedInsightStore #endif @@ -10,20 +13,48 @@ struct CodexLimitsApp: App { init() { #if CODEX_LIMITS_QA + let bundleID = Bundle.main.bundleIdentifier ?? "com.github.thrr87.CodexLimits.QA" let base = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first? .appendingPathComponent( - "com.github.thrr87.CodexLimits.QA", + bundleID, isDirectory: true ) ?? FileManager.default.temporaryDirectory.appendingPathComponent( - "com.github.thrr87.CodexLimits.QA", + bundleID, isDirectory: true ) let defaults = UserDefaults( - suiteName: "com.github.thrr87.CodexLimits.QA.defaults" + suiteName: bundleID + ".defaults" ) ?? .standard + let integrations = IntegrationPreferences(defaults: defaults) + _ = CodexClient.selectExecutable(integrations.codexExecutableURL) + let integrationWorkCoordinator = IntegrationWorkCoordinator() + let claudePaths = ClaudeCodeIntegrationPaths.isolatedQA( + base: base, + bundleURL: Bundle.main.bundleURL + ) + _integrations = StateObject(wrappedValue: integrations) + _grok = StateObject(wrappedValue: GrokIntegrationStore( + isEnabled: integrations.isEnabled(.grok), + menuBarSourceActive: integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining, + selectedExecutableURL: integrations.grokExecutableURL, + cacheURL: base.appendingPathComponent("Integrations/Grok/snapshot.json"), + integrationWorkCoordinator: integrationWorkCoordinator + )) + _claudeCode = StateObject( + wrappedValue: ClaudeCodeIntegrationStore( + isEnabled: integrations.isEnabled(.claudeCode), + menuBarSourceActive: integrations.menuBarMetric + == .claudeSevenDayUsageRemaining, + service: ClaudeCodeSetupService( + paths: claudePaths, + selectedExecutableURL: integrations.claudeExecutableURL + ), + integrationWorkCoordinator: integrationWorkCoordinator + ) + ) analyticsDefaults = defaults let collector = LocalActivityCollector( stateDirectory: base.appendingPathComponent( @@ -46,7 +77,11 @@ struct CodexLimitsApp: App { "History", isDirectory: true ), - localActivityCollector: collector + isEnabled: integrations.isEnabled(.codex), + menuBarSourceActive: integrations.menuBarMetric + == .codexWeeklyUsageRemaining, + localActivityCollector: collector, + integrationWorkCoordinator: integrationWorkCoordinator ) ) _assistedInsights = StateObject( @@ -58,7 +93,35 @@ struct CodexLimitsApp: App { #else LoginItem.enableByDefault() analyticsDefaults = .standard - _monitor = StateObject(wrappedValue: UsageMonitor()) + let integrations = IntegrationPreferences() + _ = CodexClient.selectExecutable(integrations.codexExecutableURL) + let integrationWorkCoordinator = IntegrationWorkCoordinator() + _integrations = StateObject(wrappedValue: integrations) + _grok = StateObject(wrappedValue: GrokIntegrationStore( + isEnabled: integrations.isEnabled(.grok), + menuBarSourceActive: integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining, + selectedExecutableURL: integrations.grokExecutableURL, + integrationWorkCoordinator: integrationWorkCoordinator + )) + _claudeCode = StateObject( + wrappedValue: ClaudeCodeIntegrationStore( + isEnabled: integrations.isEnabled(.claudeCode), + menuBarSourceActive: integrations.menuBarMetric + == .claudeSevenDayUsageRemaining, + service: ClaudeCodeSetupService( + selectedExecutableURL: integrations.claudeExecutableURL + ), + integrationWorkCoordinator: integrationWorkCoordinator + ) + ) + _monitor = StateObject( + wrappedValue: UsageMonitor( + isEnabled: integrations.isEnabled(.codex), + menuBarSourceActive: integrations.menuBarMetric + == .codexWeeklyUsageRemaining, + integrationWorkCoordinator: integrationWorkCoordinator + ) + ) #endif } @@ -68,6 +131,9 @@ struct CodexLimitsApp: App { Window("Codex Limits QA", id: "qa-window") { MenuContentView( monitor: monitor, + integrations: integrations, + claudeCode: claudeCode, + grok: grok, defaults: analyticsDefaults, assistedInsights: assistedInsights ) @@ -77,20 +143,101 @@ struct CodexLimitsApp: App { MenuBarExtra { MenuContentView( monitor: monitor, + integrations: integrations, + claudeCode: claudeCode, + grok: grok, defaults: analyticsDefaults ) } label: { HStack(spacing: 4) { Image(systemName: "gauge.with.dots.needle.50percent") - Text(monitor.readerSnapshot.menuBarText) - .monospacedDigit() + if integrations.menuBarMetric != .none { + Text(menuBarText) + .monospacedDigit() + if selectedMenuMetricIsStale { + Image(systemName: "clock.badge.exclamationmark") + .accessibilityHidden(true) + } + } } + .accessibilityLabel(menuBarAccessibilityLabel) } .menuBarExtraStyle(.window) #endif Settings { - SettingsView(monitor: monitor) + SettingsView( + monitor: monitor, + integrations: integrations, + claudeCode: claudeCode, + grok: grok + ) + .defaultAppStorage(analyticsDefaults) + } + } + + private var menuBarText: String { + switch integrations.menuBarMetric { + case .none: + "" + case .codexWeeklyUsageRemaining: + monitor.readerSnapshot.menuBarText + case .claudeSevenDayUsageRemaining: + claudeCode.snapshot?.sevenDayMenuBarText( + now: claudeCode.displayNow + ) ?? "—" + case .grokCurrentPeriodUsageRemaining: + grok.menuBarText + } + } + + private var menuBarAccessibilityLabel: String { + switch integrations.menuBarMetric { + case .none: + return "Codex Limits" + case .codexWeeklyUsageRemaining: + let value = monitor.readerSnapshot.weeklyUsageRemaining.map { + $0.window.remainingPercent.formatted( + .number.precision(.fractionLength(0 ... 2)) + ) + " percent remaining" + } ?? "unavailable" + return "\(integrations.menuBarMetric.displayName), \(value), \(monitor.readerSnapshot.freshness.rawValue)" + case .claudeSevenDayUsageRemaining: + let freshness = claudeCode.displayFreshness.map { + switch $0 { + case .fresh: "fresh" + case .stale: "stale" + case .expired: "new usage observation needed" + } + } ?? "unavailable" + let value = claudeCode.snapshot?.sevenDay.flatMap { + $0.resetsAt > claudeCode.displayNow ? $0 : nil + }.map { + $0.remainingPercent.formatted( + .number.precision(.fractionLength(0 ... 2)) + ) + " percent remaining" + } ?? "unavailable" + return "\(integrations.menuBarMetric.displayName), \(value), \(freshness)" + case .grokCurrentPeriodUsageRemaining: + let value = grok.currentSnapshot.map { + $0.remainingPercent.formatted(.number.precision(.fractionLength(0 ... 2))) + + " percent remaining, \($0.period.rawValue)" + } ?? "unavailable" + let freshness = grok.currentSnapshot == nil ? "unavailable" : (grok.isStale ? "stale" : "fresh") + return "\(integrations.menuBarMetric.displayName), \(value), \(freshness)" + } + } + + private var selectedMenuMetricIsStale: Bool { + switch integrations.menuBarMetric { + case .codexWeeklyUsageRemaining: + return monitor.readerSnapshot.freshness == .stale + case .claudeSevenDayUsageRemaining: + return claudeCode.displayFreshness == .stale + case .grokCurrentPeriodUsageRemaining: + return grok.isStale + case .none: + return false } } } diff --git a/Sources/CodexLimits/GrokBillingClient.swift b/Sources/CodexLimits/GrokBillingClient.swift new file mode 100644 index 0000000..2888c0d --- /dev/null +++ b/Sources/CodexLimits/GrokBillingClient.swift @@ -0,0 +1,427 @@ +import Darwin +import Foundation + +enum GrokBillingError: Error, LocalizedError, Equatable { + case notFound, authenticationRequired, unsupported, missingAllowance + case unknownPeriod, invalidReset, invalidResponse, timedOut, connectionLost, failed + + var errorDescription: String? { + switch self { + case .notFound: "Grok Build could not be found." + case .authenticationRequired: "Sign in with grok login to read your usage." + case .unsupported: "This version of Grok Build does not support usage reads." + case .missingAllowance: "Grok has not provided an allowance for this account." + case .unknownPeriod: "Grok returned an unsupported usage period." + case .invalidReset: "Grok did not provide a valid reset date." + case .invalidResponse: "Grok returned usage data that could not be read." + case .timedOut: "Grok took too long to respond." + case .connectionLost: "The connection to Grok ended before usage was received." + case .failed: "Grok usage could not be read. Try again later." + } + } +} + +struct GrokAllowanceSnapshot: Codable, Equatable, Sendable { + enum Period: String, Codable, Sendable { case weekly, monthly } + + let reportedUsedPercent: Double + let period: Period + let resetsAt: Date + let observedAt: Date + let sourceVersion: String? + let subscriptionTier: String? + let prepaidBalanceUSD: Double? + let onDemandUsedUSD: Double? + let onDemandCapUSD: Double? + let isUnifiedBilling: Bool? + let measurementSource: String + let startsAt: Date? + + init( + reportedUsedPercent: Double, + period: Period, + resetsAt: Date, + observedAt: Date, + sourceVersion: String?, + subscriptionTier: String?, + prepaidBalanceUSD: Double?, + onDemandUsedUSD: Double?, + onDemandCapUSD: Double?, + isUnifiedBilling: Bool?, + measurementSource: String, + startsAt: Date? = nil + ) { + self.reportedUsedPercent = reportedUsedPercent + self.period = period + self.resetsAt = resetsAt + self.observedAt = observedAt + self.sourceVersion = sourceVersion + self.subscriptionTier = subscriptionTier + self.prepaidBalanceUSD = prepaidBalanceUSD + self.onDemandUsedUSD = onDemandUsedUSD + self.onDemandCapUSD = onDemandCapUSD + self.isUnifiedBilling = isUnifiedBilling + self.measurementSource = measurementSource + self.startsAt = startsAt + } + + var remainingPercent: Double { + 100 - min(100, max(0, reportedUsedPercent)) + } + + var isValid: Bool { + reportedUsedPercent.isFinite + && Self.isSupportedDate(observedAt) + && Self.isSupportedDate(resetsAt) + && resetsAt.timeIntervalSince1970 > 0 + && (startsAt.map { Self.isSupportedDate($0) && $0 < resetsAt } ?? true) + && ["creditUsagePercent", "legacyCredits"].contains(measurementSource) + && [prepaidBalanceUSD, onDemandUsedUSD, onDemandCapUSD] + .allSatisfy { $0.map { $0.isFinite && $0 >= 0 } ?? true } + && sourceVersion == Self.safeText(sourceVersion, limit: 64) + && subscriptionTier == Self.safeText(subscriptionTier, limit: 80) + } + + static func decode( + _ data: Data, + observedAt: Date, + sourceVersion: String? + ) throws -> Self { + guard data.count <= 1_048_576, + isSupportedDate(observedAt), + let result = try? JSONSerialization.jsonObject(with: data) + as? [String: Any] else { + throw GrokBillingError.invalidResponse + } + guard let config = result["config"] as? [String: Any] else { + throw GrokBillingError.missingAllowance + } + let used: Double + let period: Period + let reset: Date + let start: Date? + let source: String + if config.keys.contains("creditUsagePercent") + || config.keys.contains("currentPeriod") { + guard let value = number(config["creditUsagePercent"]) else { + throw GrokBillingError.missingAllowance + } + used = value + guard let current = config["currentPeriod"] as? [String: Any] else { + throw GrokBillingError.unknownPeriod + } + switch current["type"] as? String { + case "USAGE_PERIOD_TYPE_WEEKLY": period = .weekly + case "USAGE_PERIOD_TYPE_MONTHLY": period = .monthly + default: throw GrokBillingError.unknownPeriod + } + (start, reset) = try periodDates(end: current["end"], start: current["start"]) + source = "creditUsagePercent" + } else { + guard let limit = cents(config["monthlyLimit"]), limit > 0, + let value = cents(config["used"]) else { + throw GrokBillingError.missingAllowance + } + used = value / limit * 100 + guard used.isFinite else { throw GrokBillingError.invalidResponse } + period = .monthly + (start, reset) = try periodDates( + end: config["billingPeriodEnd"], start: config["billingPeriodStart"] + ) + source = "legacyCredits" + } + let unified = config["isUnifiedBillingUser"] as? NSNumber + return Self( + reportedUsedPercent: used, + period: period, + resetsAt: reset, + observedAt: observedAt, + sourceVersion: safeText(sourceVersion, limit: 64), + subscriptionTier: safeText(result["subscription_tier"] as? String, limit: 80), + prepaidBalanceUSD: cents(config["prepaidBalance"]).map { $0 / 100 }, + onDemandUsedUSD: cents(config["onDemandUsed"]).map { $0 / 100 }, + onDemandCapUSD: cents(config["onDemandCap"]).map { $0 / 100 }, + isUnifiedBilling: unified.flatMap { + CFGetTypeID($0) == CFBooleanGetTypeID() ? $0.boolValue : nil + }, + measurementSource: source, + startsAt: start + ) + } + + private static func number(_ value: Any?) -> Double? { + guard let value = value as? NSNumber, + CFGetTypeID(value) != CFBooleanGetTypeID(), + value.doubleValue.isFinite else { return nil } + return value.doubleValue + } + + private static func cents(_ value: Any?) -> Double? { + guard let object = value as? [String: Any] else { return nil } + // The provider's protobuf JSON omits val for a present zero-valued Cent. + guard !object.isEmpty else { return 0 } + guard let value = number(object["val"]), value >= 0 else { return nil } + return value + } + + private static func periodDates(end: Any?, start: Any?) throws -> (Date?, Date) { + let parser = ISO8601DateFormatter() + func date(_ value: Any?) -> Date? { + guard let value = value as? String, value.utf8.count <= 64 else { return nil } + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = parser.date(from: value) { return date } + parser.formatOptions = [.withInternetDateTime] + return parser.date(from: value) + } + guard let end = date(end), isSupportedDate(end), end.timeIntervalSince1970 > 0 else { + throw GrokBillingError.invalidReset + } + if let start, !(start is NSNull) { + guard let start = date(start), isSupportedDate(start), start < end else { + throw GrokBillingError.invalidReset + } + return (start, end) + } + return (nil, end) + } + + private static func isSupportedDate(_ date: Date) -> Bool { + date.timeIntervalSinceReferenceDate.isFinite + && date >= .distantPast && date <= .distantFuture + } + + private static func safeText(_ value: String?, limit: Int) -> String? { + guard let value else { return nil } + let clean = String(value.unicodeScalars.filter { + !CharacterSet.controlCharacters.contains($0) + && $0.properties.generalCategory != .format + }.prefix(limit)).trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? nil : clean + } +} + +actor GrokBillingClient { + private let timeout: TimeInterval + + init(timeout: TimeInterval = 10) { + self.timeout = timeout.isFinite ? min(10, max(0.01, timeout)) : 10 + } + + func fetch(executableURL: URL) async throws -> GrokAllowanceSnapshot { + try Task.checkCancellation() + let worker = Task.detached(priority: .utility) { [timeout] in + try GrokBillingProcess.fetch(executableURL: executableURL, timeout: timeout) + } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + + static func executableURL(selected: URL?) -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + return ([selected].compactMap { $0 } + [ + home.appendingPathComponent(".grok/bin/grok"), + URL(fileURLWithPath: "/opt/homebrew/bin/grok"), + URL(fileURLWithPath: "/usr/local/bin/grok") + ]).first(where: isExecutable)?.resolvingSymlinksInPath() + } + + static func isExecutable(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + let resolved = url.resolvingSymlinksInPath() + return (try? resolved.resourceValues(forKeys: [.isRegularFileKey]))? + .isRegularFile == true + && FileManager.default.isExecutableFile(atPath: resolved.path) + } +} + +private struct GrokBillingProcess { + private static let maximumOutputBytes = 1_048_576 + + static func fetch(executableURL: URL, timeout: TimeInterval) throws -> GrokAllowanceSnapshot { + try Task.checkCancellation() + guard GrokBillingClient.isExecutable(executableURL) else { + throw GrokBillingError.notFound + } + let deadline = ProcessInfo.processInfo.systemUptime + timeout + let cwd = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexLimits-Grok-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: cwd, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + defer { try? FileManager.default.removeItem(at: cwd) } + var input: [Int32] = [-1, -1] + var output: [Int32] = [-1, -1] + guard pipe(&input) == 0 else { throw GrokBillingError.failed } + defer { for fd in input where fd >= 0 { close(fd) } } + guard pipe(&output) == 0 else { throw GrokBillingError.failed } + defer { for fd in output where fd >= 0 { close(fd) } } + for fd in input + output { + guard fcntl(fd, F_SETFD, FD_CLOEXEC) != -1 else { throw GrokBillingError.failed } + } + // A closed child stdin must report EPIPE instead of terminating the app. + guard fcntl(input[1], F_SETNOSIGPIPE, 1) != -1 else { throw GrokBillingError.failed } + var actions: posix_spawn_file_actions_t? + var attributes: posix_spawnattr_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { throw GrokBillingError.failed } + defer { posix_spawn_file_actions_destroy(&actions) } + guard posix_spawnattr_init(&attributes) == 0 else { throw GrokBillingError.failed } + defer { posix_spawnattr_destroy(&attributes) } + for status in [ + posix_spawn_file_actions_adddup2(&actions, input[0], STDIN_FILENO), + posix_spawn_file_actions_adddup2(&actions, output[1], STDOUT_FILENO), + posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0), + posix_spawn_file_actions_addchdir_np(&actions, cwd.path), + posix_spawnattr_setpgroup(&attributes, 0), + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) + ] where status != 0 { throw GrokBillingError.failed } + let argv = [executableURL.path, "agent", "--no-leader", "stdio"].map { + $0.withCString { strdup($0) } + } + [nil] + let env = ProcessInfo.processInfo.environment.map { + "\($0.key)=\($0.value)".withCString { strdup($0) } + } + [nil] + defer { for string in argv + env { free(string) } } + let pid = try GrokProcessRegistry.shared.start { pid in + argv.withUnsafeBufferPointer { args in + env.withUnsafeBufferPointer { environment in + posix_spawn(&pid, executableURL.path, &actions, &attributes, args.baseAddress!, environment.baseAddress!) + } + } + } + close(input[0]); input[0] = -1 + close(output[1]); output[1] = -1 + defer { + close(input[1]); input[1] = -1 + GrokProcessRegistry.shared.stop(pid) + } + var buffer = Data() + var totalBytes = 0 + func checkDeadline() throws { + try Task.checkCancellation() + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GrokBillingError.timedOut } + } + func request(_ method: String, id: Int, params: [String: Any]) throws { + try checkDeadline() + var message = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, "method": method, "params": params + ], options: [.withoutEscapingSlashes]) + message.append(10) + let written = message.withUnsafeBytes { bytes in + Darwin.write(input[1], bytes.baseAddress, bytes.count) + } + guard written == message.count else { throw GrokBillingError.connectionLost } + } + func response(id: Int) throws -> [String: Any] { + while true { + try checkDeadline() + if let newline = buffer.firstIndex(of: 10) { + let line = buffer.prefix(upTo: newline) + defer { buffer.removeSubrange(...newline) } + guard let object = try? JSONSerialization.jsonObject(with: line) + as? [String: Any], object["jsonrpc"] as? String == "2.0" else { + throw GrokBillingError.invalidResponse + } + guard object["id"] as? Int == id else { continue } + if let error = object["error"] as? [String: Any] { + switch error["code"] as? Int { + case -32601: throw GrokBillingError.unsupported + case -32000: throw GrokBillingError.authenticationRequired + default: throw GrokBillingError.failed + } + } + guard let result = object["result"] as? [String: Any] else { + throw GrokBillingError.invalidResponse + } + return result + } + var descriptor = pollfd(fd: output[0], events: Int16(POLLIN), revents: 0) + let ready = poll(&descriptor, 1, 25) + if ready < 0, errno == EINTR { continue } + guard ready >= 0 else { throw GrokBillingError.connectionLost } + guard ready > 0 else { continue } + var bytes = [UInt8](repeating: 0, count: 8_192) + let count = Darwin.read(output[0], &bytes, bytes.count) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw GrokBillingError.connectionLost } + totalBytes += count + guard totalBytes <= maximumOutputBytes else { throw GrokBillingError.invalidResponse } + buffer.append(contentsOf: bytes.prefix(count)) + } + } + try request("initialize", id: 1, params: [ + "protocolVersion": 1, + "clientCapabilities": ["fs": ["readTextFile": false, "writeTextFile": false], "terminal": false], + "clientInfo": ["name": "codex-limits", "version": "1"] + ]) + let initialized = try response(id: 1) + guard initialized["protocolVersion"] as? Int == 1 else { throw GrokBillingError.unsupported } + let version = (initialized["_meta"] as? [String: Any])?["agentVersion"] as? String + ?? (initialized["agentInfo"] as? [String: Any])?["version"] as? String + // ACP SDKs add this underscore automatically; raw JSON-RPC clients must include it. + try request("_x.ai/billing", id: 2, params: [:]) + let result = try response(id: 2) + return try GrokAllowanceSnapshot.decode( + JSONSerialization.data(withJSONObject: result), observedAt: Date(), sourceVersion: version + ) + } + +} + +private final class GrokProcessRegistry: @unchecked Sendable { + static let shared = GrokProcessRegistry() + + private let lock = NSLock() + private var processes: Set = [] + private var exiting = false + private let registered: Bool + + private init() { + registered = atexit { GrokProcessRegistry.shared.stopAll() } == 0 + } + + func start(_ spawn: (inout pid_t) -> Int32) throws -> pid_t { + try lock.withLock { + guard registered, !exiting else { throw GrokBillingError.connectionLost } + var pid: pid_t = 0 + guard spawn(&pid) == 0 else { throw GrokBillingError.failed } + processes.insert(pid) + return pid + } + } + + func stop(_ pid: pid_t) { + lock.withLock { + guard processes.remove(pid) != nil else { return } + Self.stopGroup(pid) + } + } + + private func stopAll() { + lock.withLock { + exiting = true + for pid in processes { Self.stopGroup(pid) } + processes.removeAll() + } + } + + private static func stopGroup(_ pid: pid_t) { + kill(-pid, SIGTERM) + let deadline = ProcessInfo.processInfo.systemUptime + 1 + repeat { + var info = siginfo_t() + let result = waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) + if result == 0, info.si_pid == pid { break } + if result == -1, errno == ECHILD { return } + usleep(10_000) + } while ProcessInfo.processInfo.systemUptime < deadline + // WNOWAIT reserves the leader's PID until the final group signal; + // another process cannot reuse it between observation and cleanup. + kill(-pid, SIGKILL) + var status: Int32 = 0 + while waitpid(pid, &status, 0) == -1 && errno == EINTR {} + } +} diff --git a/Sources/CodexLimits/GrokIntegration.swift b/Sources/CodexLimits/GrokIntegration.swift new file mode 100644 index 0000000..fc8916c --- /dev/null +++ b/Sources/CodexLimits/GrokIntegration.swift @@ -0,0 +1,505 @@ +import AppKit +import ClaudeIntegrationCore +import Combine +import Foundation + +private actor GrokSnapshotCache { + let url: URL + private var historyURL: URL { url.deletingLastPathComponent().appendingPathComponent("History") } + + init(url: URL) { self.url = url } + + func read(now: Date) throws -> GrokAllowanceSnapshot? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: 64 * 1_024 + 1) ?? Data() + guard data.count <= 64 * 1_024 else { + throw GrokBillingError.invalidResponse + } + let snapshot = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: data) + guard snapshot.isValid, snapshot.observedAt <= now.addingTimeInterval(60) else { + throw GrokBillingError.invalidResponse + } + return snapshot + } + + func write(_ snapshot: GrokAllowanceSnapshot) throws { + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + // The private directory also protects the atomic replacement before chmod. + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + try JSONEncoder().encode(snapshot).write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + func readHistory(now: Date, seed: GrokAllowanceSnapshot?) throws -> [AllowanceObservation] { + if let seed { try appendHistory(seed) } + return try AllowanceHistory.read(in: historyURL, now: now) + } + + func readOverview( + snapshot: GrokAllowanceSnapshot?, now: Date, safetyBuffer: Double + ) -> (snapshot: UsageOverviewSnapshot?, historyReadFailed: Bool) { + let current = snapshot?.historyObservation + guard let since = UsageOverviewSnapshot.historyReadStart(current: current, now: now) else { + return (nil, false) + } + do { + if let snapshot { try appendHistory(snapshot) } + let observations = try AllowanceHistory.read(in: historyURL, now: now, since: since) + return (UsageOverviewSnapshot( + observations: observations, current: current, now: now, safetyBuffer: safetyBuffer + ), false) + } catch { + return (UsageOverviewSnapshot( + observations: [], current: current, now: now, safetyBuffer: safetyBuffer + ), true) + } + } + + func appendHistory(_ snapshot: GrokAllowanceSnapshot, previous: GrokAllowanceSnapshot? = nil) throws { + try AllowanceHistory.append([previous, snapshot].compactMap { $0?.historyObservation }, in: historyURL) + } + + func exists() -> Bool { + FileManager.default.fileExists(atPath: url.path) + || FileManager.default.fileExists(atPath: historyURL.path) + } + + func delete() throws { + try AllowanceHistory.delete(in: historyURL) + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } +} + +@MainActor +final class GrokIntegrationStore: ObservableObject { + @Published private(set) var snapshot: GrokAllowanceSnapshot? + @Published private(set) var history: [AllowanceObservation] = [] + @Published private(set) var overview: UsageOverviewSnapshot? + @Published private(set) var historyIssue: String? + @Published private(set) var error: GrokBillingError? + @Published private(set) var storageIssue: String? + @Published private(set) var isRefreshing = false + @Published private(set) var hasStoredData = false + @Published private(set) var displayNow: Date + + private var enabled: Bool + private var menuBarSourceActive: Bool + private var visible = false + private var historyVisible = false + private var overviewSafetyBuffer: Double = 3 + private var overviewDemand: UInt64 = 0 + private var settingsVisible = false + private var selectedExecutableURL: URL? + private let cache: GrokSnapshotCache + private let coordinator: IntegrationWorkCoordinator + private let fetchUsage: @Sendable (URL) async throws -> GrokAllowanceSnapshot + private let now: @Sendable () -> Date + private let uptime: @Sendable () -> TimeInterval + private var cacheLoaded = false + private var historyLoaded = false + private var generation: UInt64 = 0 + private var request: Task? + private var requestPriority: IntegrationWorkPriority? + private var boundaryTask: Task? + private var observers: Set = [] + private var lastLaunchUptime: TimeInterval? + private var nextRefreshAt = Date.distantPast + private var failures = 0 + + init( + isEnabled: Bool, + menuBarSourceActive: Bool = false, + selectedExecutableURL: URL? = nil, + cacheURL: URL? = nil, + integrationWorkCoordinator: IntegrationWorkCoordinator = IntegrationWorkCoordinator(), + fetchUsage: @escaping @Sendable (URL) async throws -> GrokAllowanceSnapshot = { + try await GrokBillingClient().fetch(executableURL: $0) + }, + now: @escaping @Sendable () -> Date = { Date() }, + uptime: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + ) { + enabled = isEnabled + self.menuBarSourceActive = menuBarSourceActive + self.selectedExecutableURL = selectedExecutableURL + self.coordinator = integrationWorkCoordinator + self.fetchUsage = fetchUsage + self.now = now + self.uptime = uptime + displayNow = now() + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + self.cache = GrokSnapshotCache(url: cacheURL ?? support + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "com.github.thrr87.CodexLimits") + .appendingPathComponent("Integrations/Grok/snapshot.json")) + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didWakeNotification) + .merge(with: NotificationCenter.default.publisher(for: .NSSystemClockDidChange)) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.updateDisplayTime() + if self.enabled, self.menuBarSourceActive { + await self.refresh(force: false, priority: .automatic) + } + } + } + .store(in: &observers) + if isEnabled, menuBarSourceActive { + Task { [weak self] in await self?.refresh(force: false, priority: .automatic) } + } + } + + var currentSnapshot: GrokAllowanceSnapshot? { + snapshot.flatMap { $0.resetsAt > displayNow ? $0 : nil } + } + + var isStale: Bool { + guard let snapshot = currentSnapshot else { return false } + return error != nil || displayNow.timeIntervalSince(snapshot.observedAt) >= 30 * 60 + || snapshot.observedAt > displayNow.addingTimeInterval(60) + } + + var menuBarText: String { + currentSnapshot.map { "\(Int($0.remainingPercent.rounded()))%" } ?? "—" + } + + var statusText: String { + if isRefreshing { return "Checking" } + if let error { return error.localizedDescription } + if snapshot != nil, currentSnapshot == nil { return "New usage observation needed" } + return snapshot == nil ? "Ready to check" : (isStale ? "Stale" : "Ready") + } + + var canRefresh: Bool { + enabled && !isRefreshing && (lastLaunchUptime.map { uptime() - $0 >= 30 } ?? true) + } + + func settingsPresented() async { + guard !Task.isCancelled else { return } + settingsVisible = true + updateDisplayTime() + let expected = generation + let selected = selectedExecutableURL + await coordinator.run(priority: .settings) { @MainActor [weak self] in + guard let self, self.generation == expected, self.settingsVisible else { return } + let exists = await self.cache.exists() + guard self.generation == expected, self.settingsVisible else { return } + self.hasStoredData = exists + guard self.enabled else { return } + let executable = await Task.detached { GrokBillingClient.executableURL(selected: selected) }.value + guard self.generation == expected, self.settingsVisible else { return } + if executable == nil { self.error = .notFound } + else if self.error == .notFound { self.error = nil } + } + } + + func settingsDismissed() { + settingsVisible = false + updateDisplayTime() + } + + func setEnabled(_ enabled: Bool) async { + guard self.enabled != enabled else { return } + self.enabled = enabled + if !enabled { + history = [] + overview = nil + historyLoaded = false + } + let expected = generation &+ 1 + await cancelRequest() + guard generation == expected else { return } + if enabled { await refresh(force: false, priority: .explicit) } + } + + func setMenuBarSourceActive(_ active: Bool) async { + guard menuBarSourceActive != active else { return } + menuBarSourceActive = active + if !active, !visible, requestPriority != .explicit { await cancelRequest() } + updateDisplayTime() + if active { await refresh(force: false, priority: .automatic) } + } + + func setVisible(_ visible: Bool, includeHistory: Bool = true, safetyBuffer: Double = 3) async { + guard !Task.isCancelled else { return } + let historyVisible = visible && includeHistory + let buffer = SafetyBufferPolicy.normalized(safetyBuffer) + guard self.visible != visible || self.historyVisible != historyVisible + || overviewSafetyBuffer != buffer else { return } + self.visible = visible + self.historyVisible = historyVisible + overviewSafetyBuffer = buffer + overviewDemand &+= 1 + overview = nil + if !visible, !menuBarSourceActive, requestPriority != .explicit { await cancelRequest() } + if !historyVisible { + history = [] + historyLoaded = false + } + updateDisplayTime() + if visible { + await refresh(force: false, priority: .visible) + // A shared request may have started before All acquired demand. + if self.visible, !self.historyVisible, overview == nil { + let expected = generation + await coordinator.run(priority: .visible) { @MainActor [weak self] in + await self?.refreshOverview(expected: expected) + } + } + } + } + + func selectExecutable(_ url: URL) async -> Bool { + let initialGeneration = generation + let valid = await Task.detached { GrokBillingClient.isExecutable(url) }.value + guard valid, enabled, generation == initialGeneration else { return false } + selectedExecutableURL = url + let expected = generation &+ 1 + await cancelRequest() + guard enabled, generation == expected else { return false } + error = nil + nextRefreshAt = .distantPast + await refresh(force: true) + return enabled && generation == expected && selectedExecutableURL == url + } + + func refresh(force: Bool = true, priority: IntegrationWorkPriority = .explicit) async { + guard enabled, hasDemand(priority), !Task.isCancelled else { return } + if let request { + if priority == .explicit, requestPriority != .explicit, !isRefreshing { + let expected = generation &+ 1 + await cancelRequest() + guard enabled, generation == expected else { return } + } else { + await request.value + return + } + } + let expected = generation + requestPriority = priority + let task = Task { [weak self] in + guard let self else { return } + await self.coordinator.run(priority: priority) { @MainActor [weak self] in + guard let self, self.isCurrent(expected), self.hasDemand(priority) else { return } + if !self.cacheLoaded { + do { + let cached = try await self.cache.read(now: self.now()) + guard self.isCurrent(expected) else { return } + self.snapshot = cached + self.hasStoredData = cached != nil + if let cached { + self.nextRefreshAt = min(cached.observedAt.addingTimeInterval(600), cached.resetsAt) + } + } catch { + guard self.isCurrent(expected) else { return } + self.storageIssue = "Saved Grok usage couldn’t be read." + } + self.cacheLoaded = true + } + if self.historyVisible, !self.historyLoaded { + do { + let history = try await self.cache.readHistory(now: self.now(), seed: self.snapshot) + guard self.isCurrent(expected) else { return } + if self.historyVisible { + self.history = history + self.historyLoaded = true + self.hasStoredData = self.hasStoredData || !history.isEmpty + self.historyIssue = nil + } + } catch { + guard self.isCurrent(expected) else { return } + self.historyIssue = "Saved Grok history couldn’t be read." + } + } + await self.refreshOverview(expected: expected) + self.updateDisplayTime() + guard force || self.now() >= self.nextRefreshAt, + self.canRefresh else { return } + let selected = self.selectedExecutableURL + let executable = await Task.detached { GrokBillingClient.executableURL(selected: selected) }.value + guard self.isCurrent(expected), self.hasDemand(priority) else { return } + guard let executable else { + self.error = .notFound + self.nextRefreshAt = self.now().addingTimeInterval(600) + return + } + self.isRefreshing = true + self.lastLaunchUptime = self.uptime() + do { + let snapshot = try await self.fetchUsage(executable) + guard self.isCurrent(expected) else { return } + let previous = self.snapshot + self.snapshot = snapshot + self.error = nil + self.failures = 0 + self.nextRefreshAt = self.now().addingTimeInterval(600) + do { + try await self.cache.appendHistory(snapshot, previous: previous) + guard self.isCurrent(expected) else { return } + if self.historyVisible, !self.historyLoaded { + let history = try await self.cache.readHistory(now: self.now(), seed: nil) + guard self.isCurrent(expected) else { return } + if self.historyVisible { + self.history = history + self.historyLoaded = true + } + } else if self.historyVisible { + let observation = snapshot.historyObservation + if observation.isValid, !self.history.contains(observation) { + self.history.append(observation) + } + } + let start = self.now().addingTimeInterval(-84 * 86_400) + self.history.removeAll { $0.observedAt < start } + guard self.history.count <= AllowanceHistory.maximumReadRecords else { + self.history = [] + self.historyLoaded = false + throw AllowanceHistoryError.readLimitExceeded + } + self.historyIssue = nil + self.hasStoredData = true + } catch { + guard self.isCurrent(expected) else { return } + self.historyIssue = "Grok history couldn’t be updated on this Mac." + } + do { + try await self.cache.write(snapshot) + guard self.isCurrent(expected) else { return } + self.hasStoredData = true + self.storageIssue = nil + } catch { + guard self.isCurrent(expected) else { return } + self.storageIssue = "Grok usage couldn’t be saved on this Mac." + } + await self.refreshOverview(expected: expected) + } catch is CancellationError { + return + } catch { + guard self.isCurrent(expected) else { return } + self.error = error as? GrokBillingError ?? .failed + self.failures = min(self.failures + 1, 4) + self.nextRefreshAt = self.now().addingTimeInterval(min(600 * pow(2, Double(self.failures - 1)), 3_600)) + } + } + guard self.generation == expected else { return } + self.isRefreshing = false + self.request = nil + self.requestPriority = nil + self.updateDisplayTime() + } + request = task + await task.value + } + + func deleteData() async { + enabled = false + menuBarSourceActive = false + visible = false + historyVisible = false + overview = nil + overviewDemand &+= 1 + selectedExecutableURL = nil + let expected = generation &+ 1 + await cancelRequest() + await coordinator.run(priority: .explicit) { @MainActor [weak self] in + guard let self, self.generation == expected, !self.enabled else { return } + do { + try await self.cache.delete() + guard self.generation == expected else { return } + self.snapshot = nil + self.history = [] + self.historyLoaded = true + self.historyIssue = nil + self.hasStoredData = false + self.cacheLoaded = true + self.error = nil + self.storageIssue = nil + self.nextRefreshAt = .distantPast + } catch { + guard self.generation == expected else { return } + self.storageIssue = "Grok usage couldn’t be deleted. Try again." + } + self.updateDisplayTime() + } + } + + func updateDisplayTime() { + displayNow = now() + if let overview, overview.range.end <= displayNow { self.overview = nil } + scheduleBoundary() + } + + private func refreshOverview(expected: UInt64) async { + guard isCurrent(expected), visible, !historyVisible else { return } + let demand = overviewDemand + let current = snapshot + let result = await cache.readOverview( + snapshot: current, now: now(), safetyBuffer: overviewSafetyBuffer + ) + guard isCurrent(expected), visible, !historyVisible, + overviewDemand == demand, snapshot == current else { return } + overview = result.snapshot + if result.historyReadFailed { historyIssue = "Saved Grok history couldn’t be read." } + else if current != nil { historyIssue = nil } + } + + private func hasDemand(_ priority: IntegrationWorkPriority) -> Bool { + switch priority { + case .explicit, .settings: true + case .visible: visible + case .automatic: menuBarSourceActive + } + } + + private func isCurrent(_ expected: UInt64) -> Bool { + generation == expected && enabled && !Task.isCancelled + } + + private func cancelRequest() async { + generation &+= 1 + boundaryTask?.cancel() + boundaryTask = nil + let pending = request + request = nil + requestPriority = nil + isRefreshing = false + pending?.cancel() + await pending?.value + } + + private func scheduleBoundary() { + boundaryTask?.cancel() + boundaryTask = nil + guard enabled, menuBarSourceActive || visible || settingsVisible else { return } + let now = now() + let cooldown = lastLaunchUptime.map { max(0, 30 - (uptime() - $0)) } ?? 0 + var dates = [snapshot?.observedAt.addingTimeInterval(30 * 60), snapshot?.resetsAt] + .compactMap { $0 }.filter { $0 > now } + if cooldown > 0 { dates.append(now.addingTimeInterval(cooldown)) } + if menuBarSourceActive, request == nil { + dates.append(max(nextRefreshAt, now.addingTimeInterval(max(cooldown, 0.1)))) + } + guard let boundary = dates.min() else { return } + boundaryTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(boundary.timeIntervalSince(now))) } + catch { return } + guard let self else { return } + self.displayNow = self.now() + if let overview = self.overview, overview.range.end <= self.displayNow { self.overview = nil } + if self.menuBarSourceActive { await self.refresh(force: false, priority: .automatic) } + else { self.scheduleBoundary() } + } + } + + deinit { + request?.cancel() + boundaryTask?.cancel() + } +} diff --git a/Sources/CodexLimits/IntegrationAllowanceChart.swift b/Sources/CodexLimits/IntegrationAllowanceChart.swift new file mode 100644 index 0000000..4577534 --- /dev/null +++ b/Sources/CodexLimits/IntegrationAllowanceChart.swift @@ -0,0 +1,134 @@ +import ClaudeIntegrationCore +import Foundation + +extension GrokAllowanceSnapshot { + var historyObservation: AllowanceObservation { + AllowanceObservation( + metric: period == .weekly ? "grok-weekly" : "grok-monthly", + observedAt: observedAt, + remainingPercent: remainingPercent, + resetsAt: resetsAt, + startsAt: startsAt ?? (period == .weekly + ? resetsAt.addingTimeInterval(-7 * 86_400) : nil), + source: measurementSource + ) + } +} + +/// Provider observations feed the same chart as Codex, without token estimates. +struct IntegrationAllowanceChart: Sendable { + let window: UsageWindow + let chart: UsageChartSnapshot + let evidence: UsageEvidence + let forecastUnavailableReason: String? + + init?( + metric: String, + observations: [AllowanceObservation], + current: AllowanceObservation?, + now: Date, + isStale: Bool, + safetyBuffer: Double + ) { + guard !Task.isCancelled else { return nil } + let values = (observations + [current].compactMap { $0 }).filter { + $0.metric == metric && $0.isValid && $0.observedAt <= now + } + let grouped = Dictionary(grouping: values, by: \.observedAt) + let samples = grouped.values.compactMap { readings -> UsageSample? in + guard let value = readings.last else { return nil } + return UsageSample( + observedAt: value.observedAt, + remainingPercent: value.remainingPercent, + resetsAt: value.resetsAt, + comparisonBreak: readings.contains { + $0.remainingPercent != value.remainingPercent || $0.resetsAt != value.resetsAt + || $0.startsAt != value.startsAt || $0.source != value.source + } + ) + }.sorted { $0.observedAt < $1.observedAt } + guard let latest = samples.last else { return nil } + guard !Task.isCancelled else { return nil } + let latestValue = grouped[latest.observedAt]?.last + let start = latestValue?.startsAt + let first = samples.first { $0.resetsAt == latest.resetsAt } ?? latest + window = UsageWindow( + remainingPercent: latest.remainingPercent, + resetsAt: latest.resetsAt, + durationMinutes: max(1, Int(ceil(latest.resetsAt.timeIntervalSince(start ?? first.observedAt) / 60))) + ) + guard window.isValid else { return nil } + + var segments: [[UsageSample]] = [] + for sample in samples { + guard !Task.isCancelled else { return nil } + if let previous = segments.last?.last, + !sample.comparisonBreak, + !previous.comparisonBreak, + sample.resetsAt == previous.resetsAt, + grouped[sample.observedAt]?.last?.startsAt == grouped[previous.observedAt]?.last?.startsAt, + grouped[sample.observedAt]?.last?.source == grouped[previous.observedAt]?.last?.source, + sample.remainingPercent <= previous.remainingPercent + UsageHistoryPolicy.correctionTolerance, + sample.observedAt.timeIntervalSince(previous.observedAt) <= UsageHistoryPolicy.maximumComparableGap { + segments[segments.count - 1].append(sample) + } else { + segments.append([sample]) + } + } + let windows = Dictionary(grouping: segments, by: { $0[0].resetsAt }) + .map { reset, segments in + UsageAllowanceWindowSeries( + resetsAt: reset, + observedSegments: segments.map { segment in + segment.map { UsageChartPoint(date: $0.observedAt, remaining: $0.remainingPercent) } + } + ) + }.sorted { $0.resetsAt < $1.resetsAt } + + let recent = (segments.last ?? []).filter { $0.observedAt >= now.addingTimeInterval(-86_400) } + var projection: [UsageChartPoint] = [] + let hasCurrent = current.map { + $0.isValid && $0.metric == metric && $0.observedAt == latest.observedAt + && $0.resetsAt == latest.resetsAt && $0.remainingPercent == latest.remainingPercent + } ?? false + if hasCurrent, !isStale, latest.resetsAt > now, + now.timeIntervalSince(latest.observedAt) < UsageHistoryPolicy.maximumComparableGap, + let first = recent.first, recent.count >= 2, + latest.observedAt.timeIntervalSince(first.observedAt) >= 60 { + let days = latest.observedAt.timeIntervalSince(first.observedAt) / 86_400 + let rate = max(0, (first.remainingPercent - latest.remainingPercent) / days) + if rate.isFinite { + projection = UsageIntelligenceEngine.projection( + reading: latest, window: window, rate: rate, + remainingAtReset: max(0, latest.remainingPercent - rate * latest.resetsAt.timeIntervalSince(latest.observedAt) / 86_400) + ) + } + } + let buffer = SafetyBufferPolicy.normalized(safetyBuffer) + let target = start.map { + [UsageChartPoint(date: $0, remaining: 100), UsageChartPoint(date: latest.resetsAt, remaining: buffer)] + } ?? [] + chart = UsageChartSnapshot( + observedSource: .account, + target: target, + currentProjection: projection, + currentAllowanceReset: latest.resetsAt, + allowanceWindows: windows, + currentRunsFaster: projection.last.map { $0.remaining < buffer } ?? false, + accessibilityValue: "Last observed \(Int(latest.remainingPercent.rounded())) percent remaining. " + + (projection.isEmpty ? "A forecast is not available." : "Current estimate follows the observed usage pace.") + ) + evidence = UsageEvidence( + coverage: .partial, + confidence: projection.isEmpty ? .unavailable : .low, + reason: nil, + policyVersion: 1 + ) + forecastUnavailableReason = !projection.isEmpty ? nil + : !hasCurrent || latest.resetsAt <= now + ? "A current usage observation is needed for an estimate." + : isStale || now.timeIntervalSince(latest.observedAt) >= UsageHistoryPolicy.maximumComparableGap + ? "Usage is stale. A new observation is needed for an estimate." + : "An estimate needs at least two recent observations from the current period." + } +} diff --git a/Sources/CodexLimits/IntegrationPreferences.swift b/Sources/CodexLimits/IntegrationPreferences.swift new file mode 100644 index 0000000..8a4be26 --- /dev/null +++ b/Sources/CodexLimits/IntegrationPreferences.swift @@ -0,0 +1,168 @@ +import Foundation + +enum IntegrationID: String, CaseIterable, Codable, Identifiable, Sendable { + case codex + case claudeCode + case grok + + var id: String { rawValue } + + var displayName: String { + switch self { + case .codex: "Codex" + case .claudeCode: "Claude Code" + case .grok: "Grok" + } + } +} + +enum MenuBarMetric: String, CaseIterable, Codable, Identifiable, Sendable { + case none + case codexWeeklyUsageRemaining + case claudeSevenDayUsageRemaining + case grokCurrentPeriodUsageRemaining + + var id: String { rawValue } + + var integration: IntegrationID? { + switch self { + case .none: nil + case .codexWeeklyUsageRemaining: .codex + case .claudeSevenDayUsageRemaining: .claudeCode + case .grokCurrentPeriodUsageRemaining: .grok + } + } + + var displayName: String { + switch self { + case .none: "None" + case .codexWeeklyUsageRemaining: + "Codex — Weekly usage remaining" + case .claudeSevenDayUsageRemaining: + "Claude Code — 7-day usage remaining" + case .grokCurrentPeriodUsageRemaining: + "Grok — Current-period usage remaining" + } + } +} + +@MainActor +final class IntegrationPreferences: ObservableObject { + static let persistenceKey = "integrationPreferences" + + @Published private(set) var enabledIntegrations: Set + @Published private(set) var menuBarMetric: MenuBarMetric + @Published private(set) var codexExecutablePath: String? + @Published private(set) var claudeExecutablePath: String? + @Published private(set) var grokExecutablePath: String? + + private struct Stored: Codable { + let version: Int + let enabledIntegrationIDs: [String] + let menuBarMetricID: String + let codexExecutablePath: String? + let claudeExecutablePath: String? + let grokExecutablePath: String? + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.persistenceKey), + let stored = try? JSONDecoder().decode(Stored.self, from: data), + stored.version == 1 { + enabledIntegrations = Set( + stored.enabledIntegrationIDs.compactMap(IntegrationID.init) + ) + menuBarMetric = MenuBarMetric(rawValue: stored.menuBarMetricID) + ?? .none + if let integration = menuBarMetric.integration, + !enabledIntegrations.contains(integration) { + menuBarMetric = .none + } + codexExecutablePath = stored.codexExecutablePath + claudeExecutablePath = stored.claudeExecutablePath + grokExecutablePath = stored.grokExecutablePath + } else { + enabledIntegrations = [.codex] + menuBarMetric = .codexWeeklyUsageRemaining + codexExecutablePath = nil + claudeExecutablePath = nil + grokExecutablePath = nil + } + } + + func isEnabled(_ integration: IntegrationID) -> Bool { + enabledIntegrations.contains(integration) + } + + func setEnabled(_ enabled: Bool, for integration: IntegrationID) { + if enabled { + enabledIntegrations.insert(integration) + } else { + enabledIntegrations.remove(integration) + if menuBarMetric.integration == integration { + menuBarMetric = .none + } + } + persist() + } + + func selectMenuBarMetric(_ metric: MenuBarMetric) { + guard metric.integration.map(enabledIntegrations.contains) ?? true else { + return + } + menuBarMetric = metric + persist() + } + + var availableMenuBarMetrics: [MenuBarMetric] { + MenuBarMetric.allCases.filter { + $0.integration.map(enabledIntegrations.contains) ?? true + } + } + + var claudeExecutableURL: URL? { + claudeExecutablePath.map(URL.init(fileURLWithPath:)) + } + + var codexExecutableURL: URL? { + codexExecutablePath.map(URL.init(fileURLWithPath:)) + } + + var grokExecutableURL: URL? { + grokExecutablePath.map(URL.init(fileURLWithPath:)) + } + + func selectCodexExecutable(_ url: URL?) { + codexExecutablePath = url?.standardizedFileURL.path + persist() + } + + func selectClaudeExecutable(_ url: URL?) { + claudeExecutablePath = url?.standardizedFileURL.path + persist() + } + + func selectGrokExecutable(_ url: URL?) { + grokExecutablePath = url?.standardizedFileURL.path + persist() + } + + private func persist() { + let stored = Stored( + version: 1, + enabledIntegrationIDs: enabledIntegrations + .map(\.rawValue) + .sorted(), + menuBarMetricID: menuBarMetric.rawValue, + codexExecutablePath: codexExecutablePath, + claudeExecutablePath: claudeExecutablePath, + grokExecutablePath: grokExecutablePath + ) + if let data = try? JSONEncoder().encode(stored) { + defaults.set(data, forKey: Self.persistenceKey) + } + } +} diff --git a/Sources/CodexLimits/LocalCoverageEvaluator.swift b/Sources/CodexLimits/LocalCoverageEvaluator.swift deleted file mode 100644 index 8ba44f5..0000000 --- a/Sources/CodexLimits/LocalCoverageEvaluator.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -enum LocalCoverageUnavailableReason: String, Codable, Equatable, Sendable { - case tokenDefinitionsNotProvenCompatible -} - -struct LocalCoverageEvaluation: Equatable, Sendable { - let comparable: Bool - let numericPercent: Double? - let reason: LocalCoverageUnavailableReason? - - static let unavailable = LocalCoverageEvaluation( - comparable: false, - numericPercent: nil, - reason: .tokenDefinitionsNotProvenCompatible - ) -} diff --git a/Sources/CodexLimits/LocalTokenActivity.swift b/Sources/CodexLimits/LocalTokenActivity.swift index afc8515..ea6f3b8 100644 --- a/Sources/CodexLimits/LocalTokenActivity.swift +++ b/Sources/CodexLimits/LocalTokenActivity.swift @@ -45,7 +45,6 @@ struct LocalTokenActivitySnapshot: Equatable, Sendable { let sourceVersion: String? let observedAt: Date? let points: [LocalTokenActivityPoint] - let accountComparison: LocalCoverageEvaluation static func unavailable( _ reason: String, @@ -58,8 +57,7 @@ struct LocalTokenActivitySnapshot: Equatable, Sendable { reason: reason, sourceVersion: nil, observedAt: nil, - points: [], - accountComparison: .unavailable + points: [] ) } @@ -94,8 +92,7 @@ struct LocalTokenActivitySnapshot: Equatable, Sendable { reason: updatedReason, sourceVersion: source.version, observedAt: source.observedAt, - points: points, - accountComparison: accountComparison + points: points ) } @@ -194,8 +191,7 @@ enum LocalTokenActivityAggregator { reason: "Local token total is invalid", sourceVersion: sourceVersion(observation), observedAt: observedAt(observation), - points: [], - accountComparison: .unavailable + points: [] ) } total = addition.partialValue @@ -238,8 +234,7 @@ enum LocalTokenActivityAggregator { reason: reason, sourceVersion: sourceVersion(observation), observedAt: observedAt(observation), - points: points, - accountComparison: .unavailable + points: points ) } diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index f9dbdd3..7ff8197 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1,20 +1,44 @@ import AppKit import Charts +import ClaudeIntegrationCore import SwiftUI +private enum IntegrationDestination: String, Identifiable { + case all = "All" + case codex = "Codex" + case claudeCode = "Claude Code" + case grok = "Grok" + + var id: String { rawValue } +} + struct MenuContentView: View { @ObservedObject var monitor: UsageMonitor + @ObservedObject var integrations: IntegrationPreferences + @ObservedObject var claudeCode: ClaudeCodeIntegrationStore + @ObservedObject var grok: GrokIntegrationStore @StateObject private var workspace: AnalyticsWorkspaceStore @StateObject private var assistedInsights: CodexAssistedInsightStore @StateObject private var updater = AppUpdater() + @State private var destination: IntegrationDestination = .all + @AppStorage private var safetyBuffer: Double @Environment(\.openSettings) private var openSettings + private let chartDefaults: UserDefaults init( monitor: UsageMonitor, + integrations: IntegrationPreferences, + claudeCode: ClaudeCodeIntegrationStore, + grok: GrokIntegrationStore, defaults: UserDefaults = .standard, assistedInsights: CodexAssistedInsightStore? = nil ) { self.monitor = monitor + self.integrations = integrations + self.claudeCode = claudeCode + self.grok = grok + self.chartDefaults = defaults + _safetyBuffer = AppStorage(wrappedValue: 3, UsageMonitor.safetyBufferKey, store: defaults) _workspace = StateObject( wrappedValue: AnalyticsWorkspaceStore(defaults: defaults) ) @@ -24,39 +48,19 @@ struct MenuContentView: View { } var body: some View { - let layout = currentLayout - VStack(spacing: 0) { - WorkspaceHeader( - reader: monitor.readerSnapshot, - isRefreshing: monitor.isRefreshing, - isCompact: layout.isCompact, - resetReminderState: monitor.resetReminderState, - refresh: { - Task { await monitor.refresh() } - }, - setResetReminderEnabled: { isEnabled in - Task { - await monitor.setResetReminderEnabled(isEnabled) - } - }, - availableUpdateVersion: updater.availableVersion, - showAvailableUpdate: updater.showAvailableUpdate, - settings: showSettings - ) - .padding(.horizontal, 20) - .padding(.vertical, 16) - - Divider() + if availableDestinations.count == 1 { + noIntegrationsWorkspace + } else { + enabledWorkspace + } + } - Picker( - "View", - selection: Binding( - get: { workspace.state.section }, - set: workspace.selectSection - ) - ) { - ForEach(AnalyticsSection.allCases) { section in - Text(section.rawValue).tag(section) + private var enabledWorkspace: some View { + let layout = currentLayout + return VStack(spacing: 0) { + Picker("Integration", selection: $destination) { + ForEach(availableDestinations) { destination in + Text(destination.rawValue).tag(destination) } } .pickerStyle(.segmented) @@ -66,11 +70,16 @@ struct MenuContentView: View { Divider() - ScrollView { - workspaceContent - .padding(20) + switch effectiveDestination { + case .all: + integrationOverview + case .codex: + codexWorkspace + case .claudeCode: + claudeCodeWorkspace + case .grok: + grokWorkspace } - .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() workspaceFooter @@ -81,19 +90,688 @@ struct MenuContentView: View { .task { updater.start() } + .task(id: effectiveDestination) { + await updateVisibleIntegrationWork() + } .task(id: workspace.state) { - let state = workspace.state - await monitor.setLocalAnalyticsVisible( - state.usesLocalAnalytics + guard effectiveDestination == .codex else { return } + await updateCodexWorkspaceWork() + } + .task(id: integrations.enabledIntegrations) { + await updateVisibleIntegrationWork() + } + .task(id: safetyBuffer) { + guard effectiveDestination == .all else { return } + await updateVisibleIntegrationWork() + } + .onChange(of: integrations.enabledIntegrations) { _, _ in + if !availableDestinations.contains(destination) { + destination = .all + } + } + .onDisappear { + Task { + await monitor.setVisible(false) + await monitor.setLocalAnalyticsVisible(false) + await claudeCode.setVisible(false) + await grok.setVisible(false) + } + } + .environment(\.locale, Locale(identifier: "en_US")) + } + + private var codexWorkspace: some View { + VStack(spacing: 0) { + integrationHeader("Codex") + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let weekly = monitor.readerSnapshot.weeklyUsageRemaining { + Text("Usage remaining · \(Int(weekly.window.remainingPercent.rounded()))%") + .font(.title.weight(.semibold)) + .monospacedDigit() + Text("Weekly · Resets \(weekly.window.resetsAt.formatted(date: .abbreviated, time: .shortened))") + .foregroundStyle(.secondary) + } + if workspace.state.section == .graphs, + workspace.state.graph == .usageRemaining, + (workspace.state.timeRange == .twelveWeeks + || monitor.historicalRange != nil) { + historicalRangeControls + } + workspaceContent + Divider() + TimelineView(.periodic(from: .now, by: 60)) { context in + Text(monitor.readerSnapshot.updatedText(at: context.date)) + .font(.callout) + .foregroundStyle(.secondary) + } + HStack(spacing: 10) { + Button("Refresh") { Task { await monitor.refresh() } } + .disabled(monitor.isRefreshing) + if monitor.isRefreshing { + ProgressView().controlSize(.small) + .accessibilityLabel("Checking Codex usage") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func integrationHeader(_ name: String, beta: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(name).font(.title2.weight(.semibold)) + if beta { + Text("Beta").font(.caption).foregroundStyle(.secondary) + } + Spacer() + if name == "Codex" { + Menu("More") { + ForEach(AnalyticsGraph.coreCases) { graph in + Button(graph.rawValue) { + workspace.selectGraph(graph) + workspace.selectSection(.graphs) + } + } + Divider() + Button("Facts & reset reminders") { workspace.selectSection(.facts) } + Button("Insights") { workspace.selectSection(.insights) } + if let version = updater.availableVersion { + Divider() + Button("Upgrade to \(version)", action: updater.showAvailableUpdate) + } + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel("More Codex views") + } + Button("Settings", action: showSettings).buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + + private var integrationOverview: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if integrations.isEnabled(.codex) { + integrationOverviewRow( + name: "Codex", + primary: monitor.readerSnapshot.weeklyUsageRemaining.map { + "Usage remaining · \(Int($0.window.remainingPercent.rounded()))%" + } ?? "Usage is not available", + secondary: monitor.readerSnapshot.sourceMessage + ?? monitor.readerSnapshot.weeklyUsageRemaining.map { + "Resets \($0.window.resetsAt.formatted(date: .abbreviated, time: .shortened))" + }, + status: monitor.isRefreshing ? "Checking" + : (monitor.readerSnapshot.freshness == .stale ? "Stale" : nil), + overview: monitor.readerSnapshot.weeklyUsageRemaining.flatMap { + UsageOverviewSnapshot( + chart: monitor.readerSnapshot.chart, window: $0.window, now: Date() + ) + }, + destination: .codex + ) + } + if integrations.isEnabled(.claudeCode) { + integrationOverviewRow( + name: "Claude Code", + primary: claudeOverviewPrimary, + secondary: claudeOverviewSecondary, + status: claudeOverviewTrailing, + overview: claudeCode.overview, + destination: .claudeCode + ) + } + if integrations.isEnabled(.grok) { + integrationOverviewRow( + name: "Grok", + primary: grokOverviewPrimary, + secondary: grok.error?.localizedDescription + ?? grok.currentSnapshot.map { + "\($0.period == .weekly ? "Weekly" : "Monthly") · Resets \($0.resetsAt.formatted(date: .abbreviated, time: .shortened))" + }, + status: grok.isRefreshing ? "Checking" : (grok.isStale ? "Stale" : nil), + overview: grok.overview, + destination: .grok + ) + } + } + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var historicalRangeControls: some View { + HStack(spacing: 12) { + Button { + Task { + await monitor.loadEarlierHistory( + exploration: workspace.state, + dispositions: workspace.insightDispositions + ) + selectLoadedHistoricalRange() + } + } label: { + Label("Earlier", systemImage: "chevron.left") + } + .disabled( + monitor.isLoadingHistoricalRange + || !monitor.canLoadEarlierHistory ) - if state.section == .graphs, - state.graph == .tokenActivity { - await monitor.refreshAccountIfStale() + + if let range = monitor.historicalRange { + Text( + "\(range.start.formatted(date: .abbreviated, time: .omitted))–\(range.end.formatted(date: .abbreviated, time: .omitted))" + ) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + + Button { + Task { + await monitor.loadLaterHistory( + exploration: workspace.state, + dispositions: workspace.insightDispositions + ) + selectLoadedHistoricalRange() + } + } label: { + Label("Later", systemImage: "chevron.right") + } + .disabled(monitor.isLoadingHistoricalRange) + + Button("Latest") { + monitor.clearHistoricalRange() + workspace.selectTimeRange(.twelveWeeks) + } + } + + if monitor.isLoadingHistoricalRange { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading history range") + } + + Spacer() + + if let issue = monitor.historicalRangeIssue { + Text(issue) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.borderless) + } + + private var claudeCodeWorkspace: some View { + VStack(spacing: 0) { + integrationHeader("Claude Code", beta: true) + + Divider() + + ScrollView { + claudeCodeContent + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var grokOverviewPrimary: String { + if let snapshot = grok.currentSnapshot { + return "Usage remaining · \(Int(snapshot.remainingPercent.rounded()))%" + } + if grok.snapshot != nil { return "New usage observation needed" } + return grok.isRefreshing ? "Checking" : "Usage is not available" + } + + private var grokWorkspace: some View { + VStack(spacing: 0) { + integrationHeader("Grok", beta: true) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(grokOverviewPrimary) + .font(.title.weight(.semibold)) + .monospacedDigit() + if let snapshot = grok.currentSnapshot { + Text("\(snapshot.period == .weekly ? "Weekly" : "Monthly") · Resets \(snapshot.resetsAt.formatted(date: .abbreviated, time: .shortened))") + .foregroundStyle(.secondary) + if snapshot.isUnifiedBilling == true { + Text("Shared across Grok products.") + .font(.callout).foregroundStyle(.secondary) + } + } + IntegrationUsageRemainingView( + title: "Usage remaining", + metric: grok.snapshot?.historyObservation.metric ?? grok.history.last?.metric ?? "grok-weekly", + observations: grok.history, + current: grok.snapshot?.historyObservation, + now: grok.displayNow, + isStale: grok.isStale, + defaults: chartDefaults + ) + .id(grok.snapshot?.historyObservation.metric ?? grok.history.last?.metric) + if let snapshot = grok.snapshot { + if let plan = snapshot.subscriptionTier { + LabeledContent("Plan", value: plan) + } + if let balance = snapshot.prepaidBalanceUSD { + LabeledContent("Prepaid balance", value: balance.formatted(.currency(code: "USD"))) + } + if let used = snapshot.onDemandUsedUSD { + LabeledContent("On-demand usage", value: used.formatted(.currency(code: "USD"))) + } + if let cap = snapshot.onDemandCapUSD { + LabeledContent("On-demand limit", value: cap.formatted(.currency(code: "USD"))) + } + Divider() + VStack(alignment: .leading, spacing: 6) { + Text("Last checked \(snapshot.observedAt.formatted(.relative(presentation: .named)))") + if grok.isStale { + Label("Stale", systemImage: "clock.badge.exclamationmark") + } + if let version = snapshot.sourceVersion { + Text("Grok Build \(version)").foregroundStyle(.secondary) + } + } + .font(.callout) + } + if let error = grok.error { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .font(.callout) + if error == .notFound || error == .authenticationRequired || error == .unsupported { + Button("Open Settings", action: showSettings) + } + } + if let issue = grok.storageIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + if let issue = grok.historyIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + HStack(spacing: 10) { + Button("Refresh") { Task { await grok.refresh() } } + .disabled(!grok.canRefresh) + .help("Checks are at least 30 seconds apart.") + if grok.isRefreshing { + ProgressView().controlSize(.small).accessibilityLabel("Checking Grok usage") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var noIntegrationsWorkspace: some View { + VStack(spacing: 0) { + WorkspaceMessage( + icon: "switch.2", + title: "No integrations enabled", + message: "Enable an integration to show usage." + ) { + Button("Open Settings", action: showSettings) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + workspaceFooter + .padding(.horizontal, 20) + .padding(.vertical, 12) } + .frame(width: 420, height: 460) .environment(\.locale, Locale(identifier: "en_US")) } + private var availableDestinations: [IntegrationDestination] { + [.all] + + (integrations.isEnabled(.codex) ? [.codex] : []) + + (integrations.isEnabled(.claudeCode) ? [.claudeCode] : []) + + (integrations.isEnabled(.grok) ? [.grok] : []) + } + + private var effectiveDestination: IntegrationDestination { + availableDestinations.contains(destination) ? destination : .all + } + + @ViewBuilder + private var claudeCodeContent: some View { + if let snapshot = claudeCode.snapshot { + VStack(alignment: .leading, spacing: 18) { + if let sevenDay = snapshot.sevenDay, + sevenDay.resetsAt > claudeCode.displayNow { + VStack(alignment: .leading, spacing: 8) { + Text( + "Usage remaining · \(Int(sevenDay.remainingPercent.rounded()))%" + ) + .font(.title.weight(.semibold)) + .monospacedDigit() + Text( + "7-day · Resets \(sevenDay.resetsAt.formatted(date: .abbreviated, time: .shortened))" + ) + .foregroundStyle(.secondary) + } + } else { + Text( + snapshot.sevenDay == nil + ? "7-day usage unavailable" + : "New usage observation needed" + ) + .font(.headline) + Text("Use Claude Code to record current usage.") + .foregroundStyle(.secondary) + } + + claudeUsageChart(title: "7-day usage remaining", metric: "claude-seven-day") + + if let fiveHour = snapshot.fiveHour, + fiveHour.resetsAt > claudeCode.displayNow { + LabeledContent( + "5-hour usage remaining", + value: "\(Int(fiveHour.remainingPercent.rounded()))%" + ) + .monospacedDigit() + Text( + "Resets \(fiveHour.resetsAt.formatted(date: .abbreviated, time: .shortened))" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + claudeUsageChart(title: "5-hour usage remaining", metric: "claude-five-hour") + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text( + "Last observed \(snapshot.observedAt.formatted(.relative(presentation: .named)))" + ) + if claudeCode.displayFreshness == .stale { + Label("Stale", systemImage: "clock.badge.exclamationmark") + } + Text("Usage updates during Claude Code activity.") + .foregroundStyle(.secondary) + } + .font(.callout) + + if let issue = claudeSnapshotIssue { + Label(issue, systemImage: "exclamationmark.triangle") + .font(.callout) + Button("Open Settings", action: showSettings) + } + if let issue = claudeCode.historyIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(alignment: .leading, spacing: 18) { + claudeUnavailableContent + claudeUsageChart(title: "7-day usage remaining", metric: "claude-seven-day") + claudeUsageChart(title: "5-hour usage remaining", metric: "claude-five-hour") + if let issue = claudeCode.historyIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + } + } + } + + private func claudeUsageChart(title: String, metric: String) -> some View { + IntegrationUsageRemainingView( + title: title, metric: metric, observations: claudeCode.history, + current: claudeCode.snapshot?.historyObservations.first { $0.metric == metric }, + now: claudeCode.displayNow, isStale: claudeCode.displayFreshness != .fresh, + defaults: chartDefaults + ) + } + + @ViewBuilder + private var claudeUnavailableContent: some View { + switch claudeCode.readiness { + case .checking: + WorkspaceMessage( + icon: "arrow.clockwise", + title: "Checking", + message: "Checking Claude Code setup." + ) { + ProgressView().controlSize(.small) + } + case .notFound: + WorkspaceMessage( + icon: "terminal", + title: "Claude Code not found", + message: "Install Claude Code, then check again in Settings." + ) { + Button("Open Settings", action: showSettings) + } + case .waitingForData: + WorkspaceMessage( + icon: "clock", + title: "Use Claude Code to record usage", + message: "Usage appears after the first response in a session." + ) { + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + } + case .conflict: + WorkspaceMessage( + icon: "exclamationmark.triangle", + title: "Existing status line", + message: "Codex Limits won’t change your Claude Code status line." + ) { + Button("Open Settings", action: showSettings) + } + case .manualCleanupRequired: + WorkspaceMessage( + icon: "exclamationmark.triangle", + title: "Setup changed", + message: "Remove the Codex Limits command from your Claude Code status line." + ) { + Button("Open Settings", action: showSettings) + } + case .setUp, .updateRequired, .failed, .disabled, .ready: + WorkspaceMessage( + icon: "gearshape", + title: "Set up Claude Code", + message: "Finish setup in Settings to record usage." + ) { + Button("Open Settings", action: showSettings) + } + } + } + + private var claudeOverviewPrimary: String { + guard let snapshot = claudeCode.snapshot else { + return claudeReadinessText + } + guard let sevenDay = snapshot.sevenDay else { + return "7-day usage unavailable" + } + guard sevenDay.resetsAt > claudeCode.displayNow else { + return "New usage observation needed" + } + return "Usage remaining · \(Int(sevenDay.remainingPercent.rounded()))%" + } + + private var claudeOverviewSecondary: String? { + guard let snapshot = claudeCode.snapshot else { return nil } + var details: [String] = [] + if let sevenDay = snapshot.sevenDay, sevenDay.resetsAt > claudeCode.displayNow { + details.append("Resets \(sevenDay.resetsAt.formatted(date: .abbreviated, time: .shortened))") + } + if let fiveHour = snapshot.fiveHour, fiveHour.resetsAt > claudeCode.displayNow { + details.append("5-hour remaining · \(Int(fiveHour.remainingPercent.rounded()))%") + } + return details.isEmpty ? nil : details.joined(separator: " · ") + } + + private var claudeOverviewTrailing: String? { + guard let snapshot = claudeCode.snapshot else { return nil } + if let issue = claudeSnapshotIssue { return issue } + return switch claudeCode.displayFreshness { + case .fresh: + "Last observed \(snapshot.observedAt.formatted(.relative(presentation: .named)))" + case .stale: + "Stale" + case .expired: + "Last observed \(snapshot.observedAt.formatted(.relative(presentation: .named)))" + case nil: + nil + } + } + + private var claudeSnapshotIssue: String? { + switch claudeCode.readiness { + case .setUp: + "Set up in Settings" + case .conflict: + "Existing status line" + case .updateRequired: + "Update required" + case .failed: + "Claude Code usage couldn’t be read" + case .notFound: + "Claude Code not found" + default: + nil + } + } + + private var claudeReadinessText: String { + switch claudeCode.readiness { + case .notFound: "Not found" + case .waitingForData: "Waiting for data" + case .conflict: "Existing status line" + case .checking: "Checking" + default: "Set up" + } + } + + private func integrationOverviewRow( + name: String, + primary: String, + secondary: String?, + status: String?, + overview: UsageOverviewSnapshot?, + destination: IntegrationDestination + ) -> some View { + Button { + self.destination = destination + } label: { + HStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text(name).font(.headline) + Text(primary).monospacedDigit() + if let secondary { + Text(secondary) + .font(.caption) + .foregroundStyle(.secondary) + } + if let status { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if let overview { + UsageOverviewChart(snapshot: overview) + .frame(width: currentLayout.isCompact ? 110 : 150, height: 72) + } + } + .padding(14) + .background(.quaternary.opacity(0.65), in: RoundedRectangle(cornerRadius: 10)) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint("Open \(name) usage details.") + } + + private func updateVisibleIntegrationWork() async { + guard !Task.isCancelled else { return } + switch effectiveDestination { + case .all: + monitor.clearHistoricalRange() + await monitor.setVisible(integrations.isEnabled(.codex)) + guard !Task.isCancelled else { return } + await monitor.setLocalAnalyticsVisible(false) + guard !Task.isCancelled else { return } + await claudeCode.setVisible( + integrations.isEnabled(.claudeCode), includeHistory: false, + safetyBuffer: safetyBuffer + ) + guard !Task.isCancelled else { return } + await grok.setVisible( + integrations.isEnabled(.grok), includeHistory: false, safetyBuffer: safetyBuffer + ) + guard !Task.isCancelled else { return } + if integrations.isEnabled(.codex) { + await monitor.refreshAccountIfStale() + } + case .codex: + await monitor.setVisible(true) + guard !Task.isCancelled else { return } + await claudeCode.setVisible(false) + await grok.setVisible(false) + guard !Task.isCancelled else { return } + await updateCodexWorkspaceWork() + case .claudeCode: + monitor.clearHistoricalRange() + await monitor.setVisible(false) + guard !Task.isCancelled else { return } + await monitor.setLocalAnalyticsVisible(false) + guard !Task.isCancelled else { return } + await claudeCode.setVisible(true) + await grok.setVisible(false) + case .grok: + monitor.clearHistoricalRange() + await monitor.setVisible(false) + guard !Task.isCancelled else { return } + await monitor.setLocalAnalyticsVisible(false) + guard !Task.isCancelled else { return } + await claudeCode.setVisible(false) + await grok.setVisible(true) + } + } + + private func updateCodexWorkspaceWork() async { + let state = workspace.state + if state.timeRange != .selected { + monitor.clearHistoricalRange() + } + await monitor.setLocalAnalyticsVisible(state.usesLocalAnalytics) + await monitor.refreshAccountIfStale() + } + + private func selectLoadedHistoricalRange() { + guard let range = monitor.historicalRange, + let snapshot = monitor.historicalReaderSnapshot, + let window = snapshot.account?.mainLimit?.window else { return } + let current = DateInterval( + start: window.startsAt, + end: window.resetsAt + ) + workspace.selectVisibleRange( + range, + within: snapshot.chart.availableRange(including: current) + ) + } + @ViewBuilder private var workspaceContent: some View { let presentation = AnalyticsWorkspacePresentation.resolve( @@ -109,6 +787,7 @@ struct MenuContentView: View { TimelineView(.periodic(from: .now, by: 60)) { context in AnalyticsWorkspaceBody( reader: monitor.readerSnapshot, + historicalReader: monitor.historicalReaderSnapshot, store: workspace, assistedInsights: assistedInsights, now: context.date, @@ -222,6 +901,7 @@ struct AnalyticsWorkspacePresentationView: View { @MainActor struct AnalyticsWorkspaceBody: View { let reader: UsageReaderSnapshot + let historicalReader: UsageReaderSnapshot? @ObservedObject var store: AnalyticsWorkspaceStore @ObservedObject var assistedInsights: CodexAssistedInsightStore let now: Date @@ -232,6 +912,7 @@ struct AnalyticsWorkspaceBody: View { init( reader: UsageReaderSnapshot, + historicalReader: UsageReaderSnapshot? = nil, store: AnalyticsWorkspaceStore, assistedInsights: CodexAssistedInsightStore, now: Date = Date(), @@ -239,13 +920,13 @@ struct AnalyticsWorkspaceBody: View { resetReminderState: ResetReminderState = ResetReminderState( isEnabled: false, leadTime: .hours24, - authorization: .unknown, delivery: .off ), setResetReminderEnabled: @escaping (Bool) -> Void = { _ in }, setResetReminderLeadTime: @escaping (ResetReminderLeadTime) -> Void = { _ in } ) { self.reader = reader + self.historicalReader = historicalReader self.store = store self.assistedInsights = assistedInsights self.now = now @@ -260,7 +941,11 @@ struct AnalyticsWorkspaceBody: View { Group { switch store.state.section { case .graphs: - GraphsWorkspace(reader: reader, store: store, now: now) + GraphsWorkspace( + reader: historicalReader ?? reader, + store: store, + now: now + ) case .facts: FactsWorkspace( reader: reader, @@ -277,204 +962,22 @@ struct AnalyticsWorkspaceBody: View { ) } } - .onChange(of: store.state) { _, _ in - analyticsPreferencesChanged() - } - .onChange(of: store.insightDispositions) { _, _ in - analyticsPreferencesChanged() - } - .onChange(of: now) { _, _ in - guard store.state.section == .graphs, - store.state.graph == .tokenActivity else { return } - switch store.state.timeRange { - case .oneDay, .threeDays, .fourWeeks, .twelveWeeks: - analyticsPreferencesChanged() - case .currentWindow, .selected: - break - } - } - } -} - -private struct WorkspaceHeader: View { - let reader: UsageReaderSnapshot - let isRefreshing: Bool - let isCompact: Bool - let resetReminderState: ResetReminderState - let refresh: () -> Void - let setResetReminderEnabled: (Bool) -> Void - let availableUpdateVersion: String? - let showAvailableUpdate: () -> Void - let settings: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - if let weekly = reader.weeklyUsageRemaining { - Text( - weekly.window.remainingPercent, - format: .number.precision(.fractionLength(0)) - ) - .font(.system(size: 34, weight: .semibold, design: .rounded)) - .monospacedDigit() - Text("% remaining") - .foregroundStyle(.secondary) - } else { - Text(reader.evidence.reason ?? "Weekly usage unavailable") - .font(.headline) - } - - Spacer() - - Button(action: refresh) { - if isRefreshing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .buttonStyle(.borderless) - .help("Refresh") - .accessibilityLabel("Refresh usage") - - if let availableUpdateVersion { - Button(action: showAvailableUpdate) { - Image(systemName: "arrow.down.circle") - } - .buttonStyle(.borderless) - .help("Upgrade to \(availableUpdateVersion)") - .accessibilityLabel("Upgrade Codex Limits") - .accessibilityValue( - "Version \(availableUpdateVersion) is available" - ) - } - - Button(action: settings) { - Image(systemName: "gearshape") - } - .buttonStyle(.borderless) - .help("Settings") - .accessibilityLabel("Settings") - } - - if isCompact { - VStack(alignment: .leading, spacing: 7) { - headerFactRows - } - } else { - HStack(spacing: 22) { - headerFactRows - } - } - - if reader.weeklyUsageRemaining != nil { - VStack(alignment: .leading, spacing: 3) { - Text(reader.guidanceTitle) - .font(.callout.weight(.semibold)) - .foregroundStyle( - reader.guidance.map { statusColor($0.status) } - ?? .secondary - ) - Text(reader.guidanceMessage) - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(isCompact ? 2 : 1) - } - } - } - } - - @ViewBuilder - private var headerFactRows: some View { - if let weekly = reader.weeklyUsageRemaining { - HeaderFact( - label: "Reset", - value: weekly.window.resetsAt.formatted( - date: .abbreviated, - time: .shortened - ) - ) - } - if let summary = reader.bankedResets { - TimelineView(.periodic(from: .now, by: 60)) { context in - HStack(spacing: 5) { - HeaderFact( - label: "Banked resets", - value: summary.headerValue(at: context.date) - ) - .help(summary.inspectionText(at: context.date)) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Banked resets") - .accessibilityValue( - "\(summary.headerValue(at: context.date)) · \(summary.inspectionText(at: context.date))" - ) - - if summary.currentNextKnownExpiry(at: context.date) != nil - || resetReminderState.isEnabled { - Button { - setResetReminderEnabled( - !resetReminderState.isEnabled - ) - } label: { - Image( - systemName: resetReminderState.isEnabled - ? "bell.fill" - : "bell" - ) - } - .buttonStyle(.plain) - .foregroundStyle( - resetReminderState.isEnabled - ? Color.accentColor - : Color.secondary - ) - .help(resetReminderState.controlHelp) - .accessibilityLabel("Reset Reminder") - .accessibilityValue( - "\(resetReminderState.isEnabled ? "On" : "Off"). \(resetReminderState.statusText)" - ) - .accessibilityHint( - resetReminderState.isEnabled - ? "Turn reminder off." - : "Turn reminder on." - ) - } - } - } - } else { - HeaderFact(label: "Banked resets", value: "Unavailable") - } - TimelineView(.periodic(from: .now, by: 60)) { context in - HeaderFact( - label: "Freshness", - value: reader.updatedText(at: context.date) - .replacingOccurrences(of: "Updated ", with: "") - ) + .onChange(of: store.state) { _, _ in + analyticsPreferencesChanged() } - } - - private func statusColor(_ status: PaceStatus) -> Color { - switch status { - case .slowDown: .red - case .onTrack: .green - case .roomToUseMore: .blue + .onChange(of: store.insightDispositions) { _, _ in + analyticsPreferencesChanged() } - } -} - -private struct HeaderFact: View { - let label: String - let value: String - - var body: some View { - HStack(spacing: 5) { - Text(label) - .foregroundStyle(.secondary) - Text(value) - .monospacedDigit() + .onChange(of: now) { _, _ in + guard store.state.section == .graphs, + store.state.graph == .tokenActivity else { return } + switch store.state.timeRange { + case .oneDay, .threeDays, .fourWeeks, .twelveWeeks: + analyticsPreferencesChanged() + case .currentWindow, .selected: + break + } } - .font(.caption) } } @@ -513,37 +1016,16 @@ private struct GraphsWorkspace: View { } private var graphToolbar: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { - graphPicker - rangePicker - scopeControl + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(store.state.graph.rawValue).font(.headline) Spacer() + rangePicker } - VStack(alignment: .leading, spacing: 10) { - graphPicker - HStack(spacing: 12) { - rangePicker - scopeControl - } - } - } - } - - private var graphPicker: some View { - Picker( - "Graph", - selection: Binding( - get: { store.state.graph }, - set: store.selectGraph - ) - ) { - ForEach(AnalyticsGraph.coreCases) { graph in - Text(graph.rawValue).tag(graph) + if !store.state.graph.usesAccountScope { + WorkspaceFilterMenu(reader: reader, store: store) } } - .frame(minWidth: 180) - .accessibilityLabel("Graph") } private var rangePicker: some View { @@ -560,23 +1042,10 @@ private struct GraphsWorkspace: View { Text(range.rawValue).tag(range) } } - .frame(minWidth: 130) + .frame(maxWidth: 200) .accessibilityLabel("Time range") } - @ViewBuilder - private var scopeControl: some View { - if store.state.graph.usesAccountScope { - Label("Account", systemImage: "person.crop.circle") - .font(.caption) - .foregroundStyle(.secondary) - .help("Data from your Codex account.") - .accessibilityLabel("Account scope") - } else { - WorkspaceFilterMenu(reader: reader, store: store) - } - } - @ViewBuilder private var usageRemaining: some View { if let weekly = reader.weeklyUsageRemaining @@ -590,43 +1059,53 @@ private struct GraphsWorkspace: View { now: now ) - Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { - GridRow { - Text("Reset") - .foregroundStyle(.secondary) - Text( - weekly.window.resetsAt.formatted( - date: .abbreviated, - time: .shortened - ) - ) - } - GridRow { - Text("Suggested pace") - .foregroundStyle(.secondary) - Text(reader.suggestedPaceText) - } - GridRow { - Text("Runway") - .foregroundStyle(.secondary) - Text(reader.runwayText) - } - if let gap = reader.guidance?.runway.gapText { - GridRow { - Text("Gap to reset") - .foregroundStyle(.secondary) - Text(gap) + DisclosureGroup("Usage details") { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(reader.guidanceTitle).fontWeight(.semibold) + Text(reader.guidanceMessage).foregroundStyle(.secondary) } - } - if let range = reader.guidance?.remainingAtResetRange { - GridRow { - Text("Range") - .foregroundStyle(.secondary) - Text(range.text) + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { + GridRow { + Text("Reset") + .foregroundStyle(.secondary) + Text( + weekly.window.resetsAt.formatted( + date: .abbreviated, + time: .shortened + ) + ) + } + GridRow { + Text("Suggested pace") + .foregroundStyle(.secondary) + Text(reader.suggestedPaceText) + } + GridRow { + Text("Runway") + .foregroundStyle(.secondary) + Text(reader.runwayText) + } + if let gap = reader.guidance?.runway.gapText { + GridRow { + Text("Gap to reset") + .foregroundStyle(.secondary) + Text(gap) + } + } + if let range = reader.guidance?.remainingAtResetRange { + GridRow { + Text("Range") + .foregroundStyle(.secondary) + Text(range.text) + } + } } + .font(.callout) } + .font(.callout) + .padding(.top, 8) } - .font(.callout) } } else { unavailableCurrentWindow @@ -638,10 +1117,13 @@ private struct GraphsWorkspace: View { } private var unavailableCurrentWindow: some View { - UnavailableGraph( + WorkspaceMessage( + icon: "chart.xyaxis.line", title: reader.evidence.reason ?? "Weekly usage unavailable", message: "Try refreshing to check again." - ) + ) { + EmptyView() + } } } @@ -1572,25 +2054,12 @@ func accountTokenIntervalText( timeZone: TimeZone = .autoupdatingCurrent, locale: Locale = .autoupdatingCurrent ) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.timeZone = timeZone - formatter.locale = locale - return "\(formatter.string(from: interval.start))–\(formatter.string(from: interval.end))" -} - -func accountTokenIntervalAccessibilityValue( - _ interval: AccountTokenActivityInterval, - timeZone: TimeZone = .autoupdatingCurrent, - locale: Locale = .autoupdatingCurrent -) -> String { - let dates = accountTokenIntervalText( - DateInterval(start: interval.start, end: interval.end), - timeZone: timeZone, - locale: locale - ) - return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName)." + Date.IntervalFormatStyle( + date: .abbreviated, + time: .shortened, + locale: locale, + timeZone: timeZone + ).format(interval.start ..< interval.end) } func accountTokenDisplayIntervalAccessibilityValue( @@ -1769,15 +2238,44 @@ private struct TokenActivityWorkspace: View { } private var accountCard: some View { - TokenSourceCard( - title: "Account", - source: reader.accountTokenActivity.sourceDescription, - value: reader.accountTokenActivity.tokens.map(compactTokenCount) - ?? "Not available", - detail: summaryDetail, - freshness: reader.fetchedAt, - freshnessLabel: "Updated", - color: .blue + let source = reader.accountTokenActivity.sourceDescription + let value = reader.accountTokenActivity.tokens.map(compactTokenCount) + ?? "Not available" + return VStack(alignment: .leading, spacing: 9) { + Label("Account", systemImage: "person.crop.circle") + .font(.callout.weight(.semibold)) + .foregroundStyle(.blue) + Text(source) + .font(.caption2) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 26, weight: .semibold, design: .rounded)) + .monospacedDigit() + Text(summaryDetail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + if let freshness = reader.fetchedAt { + Text("Updated " + freshness.formatted( + date: .abbreviated, + time: .shortened + )) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 155, alignment: .topLeading) + .background( + Color.blue.opacity(0.07), + in: RoundedRectangle(cornerRadius: 12) + ) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue.opacity(0.16)) + } + .help( + "Account, \(source). \(value) tokens. \(summaryDetail)" ) } @@ -2019,57 +2517,6 @@ private struct TokenActivityWorkspace: View { } } -private struct TokenSourceCard: View { - let title: String - let source: String - let value: String - let detail: String - let freshness: Date? - let freshnessLabel: String - let color: Color - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - Label(title, systemImage: title == "Account" - ? "person.crop.circle" - : "laptopcomputer") - .font(.callout.weight(.semibold)) - .foregroundStyle(color) - Text(source) - .font(.caption2) - .foregroundStyle(.tertiary) - Text(value) - .font(.system(size: 26, weight: .semibold, design: .rounded)) - .monospacedDigit() - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(3) - if let freshness { - Text(freshnessLabel + " " + freshness.formatted( - date: .abbreviated, - time: .shortened - )) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - .padding(14) - .frame(maxWidth: .infinity, minHeight: 155, alignment: .topLeading) - .background( - color.opacity(0.07), - in: RoundedRectangle(cornerRadius: 12) - ) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(color.opacity(0.16)) - } - .help( - "\(title), \(source). \(value) tokens. \(detail)" - ) - } -} - private struct WorkspaceFilterMenu: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore @@ -2158,6 +2605,152 @@ private struct WorkspaceFilterMenu: View { } } +private struct UsageOverviewChart: View { + let snapshot: UsageOverviewSnapshot + + var body: some View { + Chart { + RuleMark(y: .value("Empty", 0)) + .foregroundStyle(Color.secondary.opacity(0.2)) + ForEach(snapshot.target) { point in + LineMark( + x: .value("Time", point.date), y: .value("Remaining", point.remaining), + series: .value("Series", "Target") + ) + .foregroundStyle(Color.green) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 3])) + } + ForEach(Array(snapshot.observedSegments.enumerated()), id: \.offset) { index, segment in + ForEach(segment) { point in + LineMark( + x: .value("Time", point.date), y: .value("Remaining", point.remaining), + series: .value("Series", "Actual \(index)") + ) + .foregroundStyle(Color.blue) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.stepEnd) + if segment.count == 1 { + PointMark(x: .value("Time", point.date), y: .value("Remaining", point.remaining)) + .foregroundStyle(Color.blue) + .symbolSize(8) + } + } + } + if let point = snapshot.latest { + PointMark(x: .value("Time", point.date), y: .value("Remaining", point.remaining)) + .foregroundStyle(Color.blue) + .symbolSize(28) + } + } + .chartXScale(domain: snapshot.range.start ... snapshot.range.end) + .chartYScale(domain: 0 ... 100) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + .padding(3) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Current usage window") + .accessibilityValue(snapshot.latest.map { + "Last recorded \(Int($0.remaining.rounded()))% remaining, \($0.date.formatted(date: .abbreviated, time: .shortened))" + } ?? "No usage observations") + .help("Current window · Blue: actual usage remaining · Green dashed: target") + } +} + +private struct IntegrationUsageRemainingView: View { + private struct Input: Equatable, Sendable { + let metric: String + let observations: [AllowanceObservation] + let current: AllowanceObservation? + let now: Date + let isStale: Bool + let safetyBuffer: Double + + func chart() -> IntegrationAllowanceChart? { + IntegrationAllowanceChart( + metric: metric, observations: observations, current: current, + now: now, isStale: isStale, safetyBuffer: safetyBuffer + ) + } + } + + let title: String + let metric: String + let observations: [AllowanceObservation] + let current: AllowanceObservation? + let now: Date + let isStale: Bool + @StateObject private var store: AnalyticsWorkspaceStore + @AppStorage private var safetyBuffer: Double + @State private var data: IntegrationAllowanceChart? + @State private var renderedInput: Input? + + init( + title: String, metric: String, observations: [AllowanceObservation], + current: AllowanceObservation?, now: Date, isStale: Bool, defaults: UserDefaults + ) { + self.title = title + self.metric = metric + self.observations = observations + self.current = current + self.now = now + self.isStale = isStale + _store = StateObject(wrappedValue: AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: metric + ".")) + _safetyBuffer = AppStorage(wrappedValue: 3, UsageMonitor.safetyBufferKey, store: defaults) + } + + var body: some View { + let input = Input( + metric: metric, observations: observations, current: current, + now: now, isStale: isStale, safetyBuffer: safetyBuffer + ) + return VStack(alignment: .leading, spacing: 0) { + if let data { + let chart = renderedInput == input ? data.chart : UsageChartSnapshot( + observedSource: data.chart.observedSource, + target: data.chart.target, + currentProjection: [], + currentAllowanceReset: data.chart.currentAllowanceReset, + allowanceWindows: data.chart.allowanceWindows, + currentRunsFaster: false, + accessibilityValue: "Recorded usage history. Updating the estimate." + ) + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(title).font(.headline) + Spacer() + Picker("Range", selection: Binding(get: { store.state.timeRange }, set: store.selectTimeRange)) { + ForEach(AnalyticsTimeRange.allCases.filter { $0.isPreset || store.state.timeRange == .selected }) { + Text($0.rawValue).tag($0) + } + } + .frame(maxWidth: 200) + .accessibilityLabel("\(title) time range") + } + UsageRemainingChart( + window: data.window, chart: chart, evidence: data.evidence, + store: store, now: now + ) + if let reason = data.forecastUnavailableReason { + Text(reason).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .task(id: input) { + let task = Task.detached(priority: .userInitiated) { input.chart() } + let result = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + guard !Task.isCancelled else { return } + data = result + renderedInput = input + } + } +} + private struct UsageRemainingChart: View { let window: UsageWindow let chart: UsageChartSnapshot @@ -2198,7 +2791,9 @@ private struct UsageRemainingChart: View { private var xAxisDates: [Date] { let step: TimeInterval - if visibleRange.duration <= 2 * 86_400 { + if visibleRange.duration <= 6 * 3_600 { + step = 3_600 + } else if visibleRange.duration <= 2 * 86_400 { step = 6 * 3_600 } else if visibleRange.duration <= 10 * 86_400 { step = 86_400 @@ -2240,7 +2835,8 @@ private struct UsageRemainingChart: View { AxisValueLabel { if let date = value.as(Date.self) { if visibleRange.duration <= 2 * 86_400 { - Text(date, format: .dateTime.hour()) + Text(date, format: .dateTime.hour().minute()) + .fixedSize() } else if visibleRange.duration <= 10 * 86_400 { Text( date, @@ -2299,6 +2895,13 @@ private struct UsageRemainingChart: View { } keyboardRangeStart = nil } + .onChange(of: chart) { _, chart in + if let selection { + self.selection = UsageChartSelection.nearest( + to: selection.date, in: chart, within: visibleRange + ) + } + } selectedPointDetail selectedRangeDetail @@ -2359,7 +2962,9 @@ private struct UsageRemainingChart: View { @ViewBuilder private var chartLegend: some View { - ChartLegendItem(label: "Target", color: .green, dash: [3, 3]) + if !chart.target.isEmpty { + ChartLegendItem(label: "Target", color: .green, dash: [3, 3]) + } ChartLegendItem( label: "Actual · \(chart.observedSource.rawValue)", color: .blue @@ -2850,6 +3455,14 @@ private struct FactsWorkspace: View { } } + WorkspaceCard(title: "Active Time") { + activeTimeContent + } + + WorkspaceCard(title: "Usage Receipts") { + receiptContent + } + } } @@ -4469,21 +5082,6 @@ private struct FactRow: View { } } -private struct UnavailableGraph: View { - let title: String - let message: String - - var body: some View { - WorkspaceMessage( - icon: "chart.xyaxis.line", - title: title, - message: message - ) { - EmptyView() - } - } -} - private struct StaleDataNotice: View { let message: String diff --git a/Sources/CodexLimits/ResetReminder.swift b/Sources/CodexLimits/ResetReminder.swift index f87f594..c3ab552 100644 --- a/Sources/CodexLimits/ResetReminder.swift +++ b/Sources/CodexLimits/ResetReminder.swift @@ -34,14 +34,13 @@ enum ResetReminderDelivery: Equatable { case permissionRequired case permissionDenied case scheduled(Date) - case reminderTimePassed(Date) + case reminderTimePassed case failed } struct ResetReminderState: Equatable { let isEnabled: Bool let leadTime: ResetReminderLeadTime - let authorization: ResetReminderAuthorization let delivery: ResetReminderDelivery var statusText: String { @@ -77,9 +76,7 @@ struct ResetReminderTarget: Equatable, Sendable { } struct ResetReminderRequest: Equatable, Sendable { - let resetID: String let firesAt: Date - let expiresAt: Date let title: String let body: String } @@ -125,7 +122,6 @@ final class ResetReminderCoordinator { state = ResetReminderState( isEnabled: isEnabled, leadTime: leadTime, - authorization: .unknown, delivery: isEnabled ? scheduledRecord.map { .scheduled($0.firesAt) } ?? .waitingForExpiry : .off @@ -208,23 +204,18 @@ final class ResetReminderCoordinator { guard await continueIfCurrent(version) else { return } if authorization == .notDetermined || authorization == .unknown { guard mayRequestPermission else { - update( - authorization: authorization, - delivery: .permissionRequired - ) + update(delivery: .permissionRequired) return } do { authorization = try await scheduler.requestAuthorization() } catch { guard await continueIfCurrent(version) else { return } - update(authorization: authorization, delivery: .failed) + update(delivery: .failed) return } guard await continueIfCurrent(version) else { return } } - update(authorization: authorization) - guard authorization == .authorized else { await scheduler.cancel() guard await continueIfCurrent(version) else { return } @@ -259,11 +250,7 @@ final class ResetReminderCoordinator { return } } - update( - delivery: .reminderTimePassed( - existingRecord.firesAt - ) - ) + update(delivery: .reminderTimePassed) return } @@ -286,9 +273,7 @@ final class ResetReminderCoordinator { ? state.leadTime.displayName : Self.shortDuration(target.expiresAt.timeIntervalSince(currentTime)) let request = ResetReminderRequest( - resetID: target.id, firesAt: firesAt, - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in \(bodyTime)." ) @@ -324,13 +309,11 @@ final class ResetReminderCoordinator { private func update( isEnabled: Bool? = nil, leadTime: ResetReminderLeadTime? = nil, - authorization: ResetReminderAuthorization? = nil, delivery: ResetReminderDelivery? = nil ) { state = ResetReminderState( isEnabled: isEnabled ?? state.isEnabled, leadTime: leadTime ?? state.leadTime, - authorization: authorization ?? state.authorization, delivery: delivery ?? state.delivery ) } diff --git a/Sources/CodexLimits/SettingsView.swift b/Sources/CodexLimits/SettingsView.swift index e4f05ee..f311ec8 100644 --- a/Sources/CodexLimits/SettingsView.swift +++ b/Sources/CodexLimits/SettingsView.swift @@ -4,13 +4,148 @@ import SwiftUI struct SettingsView: View { @ObservedObject var monitor: UsageMonitor + @ObservedObject var integrations: IntegrationPreferences + @ObservedObject var claudeCode: ClaudeCodeIntegrationStore + @ObservedObject var grok: GrokIntegrationStore @AppStorage(UsageMonitor.safetyBufferKey) private var safetyBuffer = 3.0 @AppStorage(LoginItem.preferenceKey) private var launchAtLogin = true @State private var loginItemError: String? @State private var confirmsHistoryDeletion = false + @State private var confirmsClaudeSetup = false + @State private var confirmsClaudeDataDeletion = false + @State private var codexExecutableError: String? + @State private var claudeExecutableError: String? + @State private var grokExecutableError: String? + @State private var confirmsGrokDataDeletion = false var body: some View { Form { + Section("Integrations") { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Codex") + if integrations.isEnabled(.codex) { + Text(codexReadiness) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle( + "Codex", + isOn: Binding( + get: { integrations.isEnabled(.codex) }, + set: setCodexEnabled + ) + ) + .labelsHidden() + } + + if integrations.isEnabled(.codex), !monitor.isRefreshing { + if codexNotFound { + Button("Locate…", action: locateCodexExecutable) + } + if monitor.readerSnapshot.account == nil { + Button("Check again") { + Task { await monitor.refresh() } + } + } + } + if let codexExecutableError { + Text(codexExecutableError) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("Claude Code") + Text("Beta") + .font(.caption2) + .foregroundStyle(.secondary) + } + if integrations.isEnabled(.claudeCode) + || claudeCode.readiness == .manualCleanupRequired { + Text(claudeReadiness) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle( + "Claude Code", + isOn: Binding( + get: { + integrations.isEnabled(.claudeCode) + }, + set: setClaudeEnabled + ) + ) + .labelsHidden() + } + + claudeActions + + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("Grok") + Text("Beta") + .font(.caption2) + .foregroundStyle(.secondary) + } + if integrations.isEnabled(.grok) { + Text(grok.statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle("Grok", isOn: Binding( + get: { integrations.isEnabled(.grok) }, + set: setGrokEnabled + )) + .labelsHidden() + } + if integrations.isEnabled(.grok) { + HStack { + Button("Check again") { Task { await grok.refresh() } } + .disabled(!grok.canRefresh) + Button("Locate…", action: locateGrokExecutable) + .disabled(grok.isRefreshing) + } + if grok.error == .notFound || grok.error == .unsupported { + Link("Install or update Grok Build", destination: URL(string: "https://x.ai/cli")!) + } + } + if let grokExecutableError { + Text(grokExecutableError).font(.caption).foregroundStyle(.secondary) + } + if let issue = grok.storageIssue { + Text(issue).font(.caption).foregroundStyle(.secondary) + } + if grok.hasStoredData || integrations.grokExecutablePath != nil { + Button("Delete Grok data…", role: .destructive) { + confirmsGrokDataDeletion = true + } + } + } + + Section("Menu bar") { + Picker( + "Metric", + selection: Binding( + get: { integrations.menuBarMetric }, + set: selectMenuBarMetric + ) + ) { + ForEach(integrations.availableMenuBarMetrics) { metric in + Text(metric.displayName).tag(metric) + } + } + } + Stepper( value: Binding( get: { SafetyBufferPolicy.normalized(safetyBuffer) }, @@ -112,7 +247,50 @@ struct SettingsView: View { } .formStyle(.grouped) .padding() - .frame(width: 380) + .frame(width: 420) + .task { + await claudeCode.settingsPresented() + guard !Task.isCancelled else { return } + await grok.settingsPresented() + } + .onDisappear { grok.settingsDismissed() } + .alert("Delete Grok integration data?", isPresented: $confirmsGrokDataDeletion) { + Button("Cancel", role: .cancel) {} + Button("Delete integration data", role: .destructive) { + integrations.setEnabled(false, for: .grok) + integrations.selectGrokExecutable(nil) + grokExecutableError = nil + Task { await grok.deleteData() } + } + } message: { + Text("This disables Grok and deletes its usage history saved by Codex Limits. It doesn’t delete Grok Build data or change your subscription.") + } + .alert("Set up Claude Code?", isPresented: $confirmsClaudeSetup) { + Button("Cancel", role: .cancel) {} + Button("Set up") { + Task { await claudeCode.setUp() } + } + } message: { + Text( + "This adds a Codex Limits command to Claude Code’s user status line. Claude Code will run it during activity, and its footer will show usage remaining. Project or managed settings can override it. Usage data is available on eligible Pro and Max accounts." + ) + } + .alert( + "Delete Claude Code integration data?", + isPresented: $confirmsClaudeDataDeletion + ) { + Button("Cancel", role: .cancel) {} + Button("Delete integration data", role: .destructive) { + integrations.setEnabled(false, for: .claudeCode) + integrations.selectClaudeExecutable(nil) + claudeExecutableError = nil + Task { await claudeCode.deleteData() } + } + } message: { + Text( + "This disables Claude Code and deletes its usage history and setup records saved by Codex Limits. It doesn’t delete Claude Code data." + ) + } .alert("Delete analytics history?", isPresented: $confirmsHistoryDeletion) { Button("Cancel", role: .cancel) {} Button("Delete analytics history", role: .destructive) { @@ -125,6 +303,179 @@ struct SettingsView: View { } } + private var codexReadiness: String { + if monitor.isRefreshing { return "Checking" } + if let sourceMessage = monitor.readerSnapshot.sourceMessage { + return sourceMessage + } + return monitor.readerSnapshot.account == nil ? "Set up" : "Ready" + } + + private var codexNotFound: Bool { + monitor.readerSnapshot.sourceMessage + == CodexClientError.cliNotFound.localizedDescription + } + + private var claudeReadiness: String { + switch claudeCode.readiness { + case .disabled: + "" + case .checking: + "Checking" + case .notFound: + "Not found" + case .setUp: + "Set up" + case .waitingForData: + "Waiting for data" + case .ready: + "Ready" + case .conflict: + "Existing status line" + case .updateRequired: + "Update required" + case .manualCleanupRequired: + "Remove the Codex Limits command from your Claude Code status line." + case .failed: + "Claude Code usage couldn’t be read." + } + } + + @ViewBuilder + private var claudeActions: some View { + if integrations.isEnabled(.claudeCode) { + switch claudeCode.readiness { + case .notFound: + Button("Locate…", action: locateClaudeExecutable) + Link( + "Install Claude Code", + destination: URL( + string: "https://code.claude.com/docs/en/setup" + )! + ) + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .setUp: + Button("Set up…") { + confirmsClaudeSetup = true + } + case .waitingForData: + Text("Usage data is available on eligible Pro and Max accounts.") + .font(.caption) + .foregroundStyle(.secondary) + Text("It appears after the first response in a session.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + case .ready: + if let observedAt = claudeCode.snapshot?.observedAt { + Text("Last observed \(observedAt.formatted(.relative(presentation: .named)))") + .font(.caption) + .foregroundStyle(.secondary) + } + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + case .conflict: + Text("Codex Limits won’t change your existing status line.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .updateRequired: + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .failed: + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .disabled, .checking, .manualCleanupRequired: + EmptyView() + } + } + if let claudeExecutableError { + Text(claudeExecutableError) + .font(.caption) + .foregroundStyle(.secondary) + } + if claudeCode.hasStoredData + || integrations.claudeExecutablePath != nil { + Button("Delete Claude Code data…", role: .destructive) { + confirmsClaudeDataDeletion = true + } + } + } + + private func setCodexEnabled(_ enabled: Bool) { + if !enabled { codexExecutableError = nil } + integrations.setEnabled(enabled, for: .codex) + let codexSelected = integrations.menuBarMetric + == .codexWeeklyUsageRemaining + Task { + await monitor.setMenuBarSourceActive(codexSelected) + await monitor.setEnabled(enabled) + } + } + + private func setClaudeEnabled(_ enabled: Bool) { + if !enabled { claudeExecutableError = nil } + integrations.setEnabled(enabled, for: .claudeCode) + let claudeSelected = integrations.menuBarMetric + == .claudeSevenDayUsageRemaining + Task { + await claudeCode.setMenuBarSourceActive(claudeSelected) + await claudeCode.setEnabled(enabled) + } + } + + private func selectMenuBarMetric(_ metric: MenuBarMetric) { + integrations.selectMenuBarMetric(metric) + let codexSelected = integrations.menuBarMetric + == .codexWeeklyUsageRemaining + let claudeSelected = integrations.menuBarMetric + == .claudeSevenDayUsageRemaining + let grokSelected = integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining + Task { + // Stop the previous source before a selected source can await a read. + if !codexSelected { await monitor.setMenuBarSourceActive(false) } + guard integrations.menuBarMetric == metric else { return } + if !claudeSelected { await claudeCode.setMenuBarSourceActive(false) } + guard integrations.menuBarMetric == metric else { return } + if !grokSelected { await grok.setMenuBarSourceActive(false) } + guard integrations.menuBarMetric == metric else { return } + if codexSelected { await monitor.setMenuBarSourceActive(true) } + if claudeSelected { await claudeCode.setMenuBarSourceActive(true) } + if grokSelected { await grok.setMenuBarSourceActive(true) } + } + } + + private func setGrokEnabled(_ enabled: Bool) { + if !enabled { grokExecutableError = nil } + integrations.setEnabled(enabled, for: .grok) + let selected = integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining + Task { + await grok.setMenuBarSourceActive(selected) + await grok.setEnabled(enabled) + } + } + + private func locateGrokExecutable() { + guard let url = chooseExecutable(message: "Choose the Grok Build executable.") else { return } + Task { + if await grok.selectExecutable(url) { + integrations.selectGrokExecutable(url) + grokExecutableError = nil + } else { + grokExecutableError = "Choose a regular executable file." + } + } + } + private func updateLaunchAtLogin(_ enabled: Bool) { do { if enabled, SMAppService.mainApp.status != .enabled { @@ -150,6 +501,44 @@ struct SettingsView: View { guard panel.runModal() == .OK, let directory = panel.url else { return } Task { await monitor.connectHistoryFolder(directory) } } + + private func locateClaudeExecutable() { + guard let url = chooseExecutable( + message: "Choose the Claude Code executable." + ) else { return } + Task { + if await claudeCode.selectExecutable(url) { + integrations.selectClaudeExecutable(url) + claudeExecutableError = nil + } else { + claudeExecutableError = "Choose a regular executable file." + } + } + } + + private func locateCodexExecutable() { + guard let url = chooseExecutable( + message: "Choose the Codex executable." + ) else { return } + guard CodexClient.selectExecutable(url) else { + codexExecutableError = "Choose a regular executable file." + return + } + integrations.selectCodexExecutable(url) + codexExecutableError = nil + Task { await monitor.refresh() } + } + + private func chooseExecutable(message: String) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.message = message + guard panel.runModal() == .OK else { return nil } + return panel.url + } } enum LoginItem { diff --git a/Sources/CodexLimits/UsageHistory.swift b/Sources/CodexLimits/UsageHistory.swift index da35ec5..0a0fedd 100644 --- a/Sources/CodexLimits/UsageHistory.swift +++ b/Sources/CodexLimits/UsageHistory.swift @@ -82,6 +82,19 @@ actor UsageHistory { let issue: Issue? } + enum RangeResolution: Equatable, Sendable { + case exact + case downsampled + } + + struct RangeView: Equatable, Sendable { + let retainedBounds: DateInterval? + let coveredInterval: DateInterval? + let samples: [UsageSample] + let resolution: RangeResolution + let hadReadError: Bool + } + private struct Marker: Codable { let version: Int let generation: Int? @@ -123,6 +136,29 @@ actor UsageHistory { let digest: String } + private struct WriterManifest: Codable { + let version: Int + let generation: Int + let oldestDay: String + let newestDay: String + let latestChangedDay: String + let revision: UInt64 + } + + private struct WriterCursor: Codable { + var nextDay: String? + var observedRevision: UInt64? + } + + private struct SyncProgress: Codable { + let version: Int + let lineageID: String + let generation: Int + var publication: WriterCursor + var imports: [String: WriterCursor] + var nextImportWriter: String? + } + private enum HistoryError: Error { case invalidFolder case invalidFile @@ -140,9 +176,50 @@ actor UsageHistory { private static let lineagesName = "lineages" private static let deletionsName = "deletions" private static let accountBindingName = ".codex-limits-account.json" + private static let writerManifestName = ".codex-limits-writer" + private static let syncProgressName = ".codex-limits-sync-progress" private static let maximumFileSize = 1_000_000 private static let maximumGeneration = 1_000_000_000 + private static let maximumAutomaticSyncCandidates = 32 private static let automaticSyncInterval: TimeInterval = 30 * 60 + private static let localMarkerUpdateLock = NSLock() + private static let workingSetFileDays = 90 + private static let workingSetDuration: TimeInterval = 84 * 86_400 + private static let fullResolutionDuration: TimeInterval = 8 * 86_400 + private static let historicalBucketDuration: TimeInterval = 60 * 60 + private static let maximumWorkingSetSamples = 6_000 + private static let maximumWorkingSetWriters = 32 + private static let maximumWorkingSetFileReads = 256 + private static let maximumWorkingSetReadBytes = 8 * 1_024 * 1_024 + + private struct WorkingSetReadBudget { + var files = 0 + var bytes = 0 + var didReachLimit = false + + mutating func admit(_ url: URL) throws -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { + return false + } + let values = try url.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + guard values.isRegularFile == true, + let size = values.fileSize, + size >= 0, + size <= UsageHistory.maximumFileSize else { + throw HistoryError.invalidFile + } + guard files < UsageHistory.maximumWorkingSetFileReads, + size <= UsageHistory.maximumWorkingSetReadBytes - bytes else { + didReachLimit = true + return false + } + files += 1 + bytes += size + return true + } + } private let localDirectory: URL private let installationID: String @@ -204,7 +281,7 @@ actor UsageHistory { } else if marker.pendingDeletionTarget == .localOnly { deletionStatus = .complete } - let local = readAll(from: activeLocalDirectory) + let local = readWorkingSet(from: activeLocalDirectory) knownSamples = local.samples errorMessage = if migrationWarning { "Some usage history couldn’t be migrated." @@ -241,7 +318,7 @@ actor UsageHistory { installationID: installationID, coordinated: false ) - knownSamples = normalized(knownSamples + [sample]) + knownSamples = boundedWorkingSet(knownSamples + [sample]) if let syncDirectory { try prepareRoot( syncDirectory, @@ -272,7 +349,8 @@ actor UsageHistory { to directory: URL, accountIdentity: String? = nil, accountBindingToken: String? = nil, - bindsUnresolvedDeletionTarget: Bool = false + bindsUnresolvedDeletionTarget: Bool = false, + performFullReconciliation: Bool = true ) -> State { do { try prepareLocalStore() @@ -329,7 +407,11 @@ actor UsageHistory { try reconcileGeneration(with: directory) syncDirectory = directory errorMessage = nil - return synchronize() + return synchronize( + at: Date(), + refreshLocalWhenDisconnected: false, + boundedReconciliation: !performFullReconciliation + ) } catch { if case HistoryError.wrongDeletionFolder = error { syncDirectory = nil @@ -378,7 +460,11 @@ actor UsageHistory { } func synchronize() -> State { - synchronize(at: Date()) + synchronize( + at: Date(), + refreshLocalWhenDisconnected: true, + boundedReconciliation: true + ) } func synchronizeIfDue(at now: Date = Date()) -> State { @@ -388,12 +474,114 @@ actor UsageHistory { return state() } } - return synchronize(at: now) + return synchronize( + at: now, + refreshLocalWhenDisconnected: false, + boundedReconciliation: true + ) } - private func synchronize(at now: Date) -> State { + func rangeView(for requestedInterval: DateInterval) -> RangeView? { + guard requestedInterval.duration > 0, + requestedInterval.duration <= Self.workingSetDuration, + requestedInterval.start.timeIntervalSinceReferenceDate.isFinite, + requestedInterval.end.timeIntervalSinceReferenceDate.isFinite else { + return nil + } + do { + try prepareLocalStore() + try prepareRoot( + activeLocalDirectory, + createIfMissing: true, + coordinated: false + ) + } catch { + return RangeView( + retainedBounds: nil, + coveredInterval: nil, + samples: [], + resolution: .exact, + hadReadError: true + ) + } + + let directory = installationsDirectory(in: activeLocalDirectory) + var retainedStart: Date? + var retainedEnd: Date? + var samples: [UsageSample] = [] + var wasDownsampled = false + var hadReadError = false + var budget = WorkingSetReadBudget() + do { + let writers = try boundedWorkingSetWriters(in: directory) + hadReadError = writers.didReachLimit + writerLoop: for writer in writers.values { + guard !Task.isCancelled else { return nil } + do { + if let manifest = try ensureWriterManifest( + in: writer, + generation: 1, + coordinated: false + ), let oldest = date(forDayName: manifest.oldestDay), + let newest = nextDay(after: manifest.newestDay) + .flatMap(date(forDayName:)) { + retainedStart = min(retainedStart ?? oldest, oldest) + retainedEnd = max(retainedEnd ?? newest, newest) + } + } catch { + hadReadError = true + } + + for day in dayNames(in: requestedInterval) { + guard !Task.isCancelled else { return nil } + let file = writer.appendingPathComponent("\(day).json") + do { + guard try budget.admit(file) else { + if budget.didReachLimit { + hadReadError = true + break writerLoop + } + continue + } + let next = try readDailyFileIfPresent( + at: file, + coordinated: false + ).filter { requestedInterval.contains($0.observedAt) } + let bounded = boundedRangeSamples(samples + next) + samples = bounded.samples + wasDownsampled = wasDownsampled || bounded.didDownsample + } catch { + hadReadError = true + } + } + } + } catch { + hadReadError = true + } + + let retainedBounds = retainedStart.flatMap { start in + retainedEnd.flatMap { end in + end > start ? DateInterval(start: start, end: end) : nil + } + } + return RangeView( + retainedBounds: retainedBounds, + coveredInterval: retainedBounds?.intersection(with: requestedInterval), + samples: samples, + resolution: wasDownsampled ? .downsampled : .exact, + hadReadError: hadReadError + ) + } + + private func synchronize( + at now: Date, + refreshLocalWhenDisconnected: Bool, + boundedReconciliation: Bool + ) -> State { lastSynchronizationAttemptAt = now - guard let syncDirectory else { return state(refreshSamples: true) } + guard let syncDirectory else { + return state(refreshSamples: refreshLocalWhenDisconnected) + } do { try prepareLocalStore() try prepareRoot( @@ -416,18 +604,37 @@ actor UsageHistory { } try reconcileGeneration(with: syncDirectory) let generation = try effectiveGeneration(in: syncDirectory) - let hadImportErrors = try importHistory( - from: syncDirectory, - generation: generation - ) - try publishOwnHistory(to: syncDirectory, generation: generation) + let hadImportErrors: Bool + if boundedReconciliation { + let marker = try readMarker( + at: localDirectory.appendingPathComponent(Self.markerName), + coordinated: false + ) + guard let lineageID = marker.syncTarget else { + throw HistoryError.invalidFolder + } + hadImportErrors = try synchronizeBounded( + with: syncDirectory, + generation: generation, + lineageID: lineageID + ) + } else { + hadImportErrors = try importHistory( + from: syncDirectory, + generation: generation + ) + try publishOwnHistory( + to: syncDirectory, + generation: generation + ) + } errorMessage = hadImportErrors ? "Some synced history couldn’t be read." : nil } catch { errorMessage = message(for: error) } - return state(refreshSamples: true) + return state() } func deleteAnalyticsHistory( @@ -621,15 +828,15 @@ actor UsageHistory { refreshSamples: Bool = false ) -> State { if refreshSamples { - let local = readAll(from: activeLocalDirectory) + let local = readWorkingSet(from: activeLocalDirectory) if local.hadError && errorMessage == nil { errorMessage = "Some usage history couldn’t be read." } knownSamples = local.hadError || errorMessage != nil - ? normalized(local.samples + knownSamples + fallback) + ? boundedWorkingSet(local.samples + knownSamples + fallback) : local.samples } else if !fallback.isEmpty { - knownSamples = normalized(knownSamples + fallback) + knownSamples = boundedWorkingSet(knownSamples + fallback) } return State( samples: knownSamples, @@ -718,23 +925,215 @@ actor UsageHistory { .appendingPathComponent(installationID, isDirectory: true) try createDirectory(at: writerDirectory, coordinated: coordinated) - for (day, newSamples) in grouped { + for day in grouped.keys.sorted() { + guard let newSamples = grouped[day] else { continue } let url = writerDirectory.appendingPathComponent("\(day).json") let existing = try readDailyFileIfPresent( at: url, generation: generation, coordinated: coordinated ) + let merged = normalized(existing + newSamples) + guard merged != existing else { continue } try write( - normalized(existing + newSamples), + merged, to: url, generation: generation, coordinated: coordinated ) + try noteChangedDay( + day, + in: writerDirectory, + generation: generation ?? 1, + coordinated: coordinated + ) } } - private func importHistory(from remoteRoot: URL, generation: Int) throws -> Bool { + private func noteChangedDay( + _ day: String, + in writerDirectory: URL, + generation: Int, + coordinated: Bool + ) throws { + guard date(forDayName: day) != nil else { + throw HistoryError.invalidFile + } + let url = writerDirectory.appendingPathComponent( + Self.writerManifestName + ) + let existing: WriterManifest? = if FileManager.default.fileExists( + atPath: url.path + ) { + try JSONDecoder().decode( + WriterManifest.self, + from: readData(at: url, coordinated: coordinated) + ) + } else { + nil + } + if let existing { + guard existing.version == 1, + existing.generation == generation, + date(forDayName: existing.oldestDay) != nil, + date(forDayName: existing.newestDay) != nil, + date(forDayName: existing.latestChangedDay) != nil, + existing.revision < UInt64.max else { + throw HistoryError.invalidFile + } + } + let manifest = WriterManifest( + version: 1, + generation: generation, + oldestDay: min(existing?.oldestDay ?? day, day), + newestDay: max(existing?.newestDay ?? day, day), + latestChangedDay: day, + revision: (existing?.revision ?? 0) + 1 + ) + try writeData( + try JSONEncoder().encode(manifest), + to: url, + coordinated: coordinated + ) + } + + private func writerManifest( + in writerDirectory: URL, + generation: Int, + coordinated: Bool + ) throws -> WriterManifest? { + let url = writerDirectory.appendingPathComponent( + Self.writerManifestName + ) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let manifest = try JSONDecoder().decode( + WriterManifest.self, + from: readData(at: url, coordinated: coordinated) + ) + guard manifest.version == 1, + manifest.generation == generation, + date(forDayName: manifest.oldestDay) != nil, + date(forDayName: manifest.newestDay) != nil, + date(forDayName: manifest.latestChangedDay) != nil, + manifest.oldestDay <= manifest.newestDay else { + throw HistoryError.invalidFile + } + return manifest + } + + private func ensureWriterManifest( + in writerDirectory: URL, + generation: Int, + coordinated: Bool + ) throws -> WriterManifest? { + if let manifest = try writerManifest( + in: writerDirectory, + generation: generation, + coordinated: coordinated + ) { + return manifest + } + let days = try jsonFiles(in: writerDirectory) + .map { $0.deletingPathExtension().lastPathComponent } + .filter { date(forDayName: $0) != nil } + .sorted() + guard let oldestDay = days.first, + let newestDay = days.last else { + return nil + } + let manifest = WriterManifest( + version: 1, + generation: generation, + oldestDay: oldestDay, + newestDay: newestDay, + latestChangedDay: newestDay, + revision: 1 + ) + try writeData( + try JSONEncoder().encode(manifest), + to: writerDirectory.appendingPathComponent( + Self.writerManifestName + ), + coordinated: coordinated + ) + return manifest + } + + private func syncProgress( + lineageID: String, + generation: Int + ) -> SyncProgress { + let url = activeLocalDirectory.appendingPathComponent( + Self.syncProgressName + ) + if let data = try? readData(at: url, coordinated: false), + let progress = try? JSONDecoder().decode( + SyncProgress.self, + from: data + ), progress.version == 1, + progress.lineageID == lineageID, + progress.generation == generation { + return progress + } + return SyncProgress( + version: 1, + lineageID: lineageID, + generation: generation, + publication: WriterCursor( + nextDay: nil, + observedRevision: nil + ), + imports: [:], + nextImportWriter: nil + ) + } + + private func saveSyncProgress(_ progress: SyncProgress) throws { + try writeData( + try JSONEncoder().encode(progress), + to: activeLocalDirectory.appendingPathComponent( + Self.syncProgressName + ), + coordinated: false + ) + } + + private func automaticCandidates( + manifest: WriterManifest, + cursor: inout WriterCursor, + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + var result: [String] = [] + if cursor.observedRevision != manifest.revision { + result.append(manifest.latestChangedDay) + cursor.observedRevision = manifest.revision + } + var next = cursor.nextDay.flatMap { day in + day >= manifest.oldestDay && day <= manifest.newestDay + ? day + : nil + } ?? manifest.oldestDay + var visited: Set = [] + while result.count < limit, visited.insert(next).inserted { + if !result.contains(next) { + result.append(next) + } + guard let following = nextDay(after: next) else { break } + next = following > manifest.newestDay + ? manifest.oldestDay + : following + cursor.nextDay = next + } + return result + } + + private func importHistory( + from remoteRoot: URL, + generation: Int + ) throws -> Bool { let remoteInstallations = installationsDirectory( in: remoteRoot, generation: generation @@ -747,7 +1146,9 @@ actor UsageHistory { isDirectory: true ) try createDirectory(at: localWriter, coordinated: false) - for remoteFile in try jsonFiles(in: remoteWriter) { + for remoteFile in try jsonFiles(in: remoteWriter).sorted(by: { + $0.lastPathComponent < $1.lastPathComponent + }) { do { let localFile = localWriter.appendingPathComponent(remoteFile.lastPathComponent) let remoteSamples = try readDailyFileIfPresent( @@ -756,11 +1157,21 @@ actor UsageHistory { coordinated: true ) let localSamples = try readDailyFileIfPresent(at: localFile, coordinated: false) - try write( - normalized(localSamples + remoteSamples), - to: localFile, - coordinated: false - ) + let merged = normalized(localSamples + remoteSamples) + if merged != localSamples { + try write( + merged, + to: localFile, + coordinated: false + ) + try noteChangedDay( + remoteFile.deletingPathExtension().lastPathComponent, + in: localWriter, + generation: 1, + coordinated: false + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) } catch { hadError = true } @@ -769,7 +1180,10 @@ actor UsageHistory { return hadError } - private func publishOwnHistory(to remoteRoot: URL, generation: Int) throws { + private func publishOwnHistory( + to remoteRoot: URL, + generation: Int + ) throws { let localWriter = installationsDirectory(in: activeLocalDirectory) .appendingPathComponent(installationID, isDirectory: true) guard FileManager.default.fileExists(atPath: localWriter.path) else { return } @@ -779,7 +1193,9 @@ actor UsageHistory { ) .appendingPathComponent(installationID, isDirectory: true) try createDirectory(at: remoteWriter, coordinated: true) - for localFile in try jsonFiles(in: localWriter) { + for localFile in try jsonFiles(in: localWriter).sorted(by: { + $0.lastPathComponent < $1.lastPathComponent + }) { let remoteFile = remoteWriter.appendingPathComponent(localFile.lastPathComponent) let localSamples = try readDailyFileIfPresent(at: localFile, coordinated: false) let remoteSamples = try readDailyFileIfPresent( @@ -788,13 +1204,250 @@ actor UsageHistory { coordinated: true ) let merged = normalized(localSamples + remoteSamples) + let day = localFile.deletingPathExtension().lastPathComponent + if merged != localSamples { + try write(merged, to: localFile, coordinated: false) + try noteChangedDay( + day, + in: localWriter, + generation: 1, + coordinated: false + ) + } + if merged != remoteSamples { + try write( + merged, + to: remoteFile, + generation: generation, + coordinated: true + ) + try noteChangedDay( + day, + in: remoteWriter, + generation: generation, + coordinated: true + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) + } + } + + private func synchronizeBounded( + with remoteRoot: URL, + generation: Int, + lineageID: String + ) throws -> Bool { + var progress = syncProgress( + lineageID: lineageID, + generation: generation + ) + var remaining = Self.maximumAutomaticSyncCandidates + var hadError = false + let remoteInstallations = installationsDirectory( + in: remoteRoot, + generation: generation + ) + let remoteWriters = try directoryContents(of: remoteInstallations) + .filter { $0.lastPathComponent != installationID } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + let localWriter = installationsDirectory(in: activeLocalDirectory) + .appendingPathComponent(installationID, isDirectory: true) + if FileManager.default.fileExists(atPath: localWriter.path), + let manifest = try ensureWriterManifest( + in: localWriter, + generation: 1, + coordinated: false + ) { + var cursor = progress.publication + let limit = remoteWriters.isEmpty + ? remaining + : min(8, remaining) + let days = automaticCandidates( + manifest: manifest, + cursor: &cursor, + limit: limit + ) + let remoteWriter = remoteInstallations.appendingPathComponent( + installationID, + isDirectory: true + ) + try createDirectory(at: remoteWriter, coordinated: true) + for day in days { + hadError = try publishDay( + day, + from: localWriter, + to: remoteWriter, + generation: generation + ) || hadError + } + progress.publication = cursor + remaining -= days.count + } + + let orderedWriters = rotatedWriters( + remoteWriters, + startingAt: progress.nextImportWriter + ) + var lastWriterID: String? + for (index, remoteWriter) in orderedWriters.enumerated() + where remaining > 0 { + let writerID = remoteWriter.lastPathComponent + let writersLeft = orderedWriters.count - index + let limit = max(remaining / max(writersLeft, 1), 1) + guard let manifest = try ensureWriterManifest( + in: remoteWriter, + generation: generation, + coordinated: true + ) else { + lastWriterID = writerID + continue + } + var cursor = progress.imports[writerID] ?? WriterCursor( + nextDay: nil, + observedRevision: nil + ) + let days = automaticCandidates( + manifest: manifest, + cursor: &cursor, + limit: min(limit, remaining) + ) + let localWriter = installationsDirectory(in: activeLocalDirectory) + .appendingPathComponent(writerID, isDirectory: true) + try createDirectory(at: localWriter, coordinated: false) + for day in days { + hadError = try importDay( + day, + from: remoteWriter, + to: localWriter, + generation: generation + ) || hadError + } + progress.imports[writerID] = cursor + remaining -= days.count + lastWriterID = writerID + } + if let lastWriterID, + let index = remoteWriters.firstIndex(where: { + $0.lastPathComponent == lastWriterID + }), !remoteWriters.isEmpty { + progress.nextImportWriter = remoteWriters[ + (index + 1) % remoteWriters.count + ].lastPathComponent + } + try saveSyncProgress(progress) + return hadError + } + + private func rotatedWriters( + _ writers: [URL], + startingAt writerID: String? + ) -> [URL] { + guard let writerID, + let index = writers.firstIndex(where: { + $0.lastPathComponent >= writerID + }), index > 0 else { + return writers + } + return Array(writers[index...]) + Array(writers[.. Bool { + let remoteFile = remoteWriter.appendingPathComponent("\(day).json") + guard FileManager.default.fileExists(atPath: remoteFile.path) else { + return false + } + let localFile = localWriter.appendingPathComponent("\(day).json") + let localSamples: [UsageSample] + let remoteSamples: [UsageSample] + do { + localSamples = try readDailyFileIfPresent( + at: localFile, + coordinated: false + ) + remoteSamples = try readDailyFileIfPresent( + at: remoteFile, + generation: generation, + coordinated: true + ) + } catch { + guard isMalformedHistoryFileError(error) else { throw error } + return true + } + let merged = normalized(localSamples + remoteSamples) + if merged != localSamples { try write(merged, to: localFile, coordinated: false) + try noteChangedDay( + day, + in: localWriter, + generation: 1, + coordinated: false + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) + return false + } + + private func publishDay( + _ day: String, + from localWriter: URL, + to remoteWriter: URL, + generation: Int + ) throws -> Bool { + let localFile = localWriter.appendingPathComponent("\(day).json") + guard FileManager.default.fileExists(atPath: localFile.path) else { + return false + } + let remoteFile = remoteWriter.appendingPathComponent("\(day).json") + let localSamples: [UsageSample] + let remoteSamples: [UsageSample] + do { + localSamples = try readDailyFileIfPresent( + at: localFile, + coordinated: false + ) + remoteSamples = try readDailyFileIfPresent( + at: remoteFile, + generation: generation, + coordinated: true + ) + } catch { + guard isMalformedHistoryFileError(error) else { throw error } + return true + } + let merged = normalized(localSamples + remoteSamples) + if merged != localSamples { + try write(merged, to: localFile, coordinated: false) + } + if merged != remoteSamples { try write( merged, to: remoteFile, generation: generation, coordinated: true ) + try noteChangedDay( + day, + in: remoteWriter, + generation: generation, + coordinated: true + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) + return false + } + + private func isMalformedHistoryFileError(_ error: Error) -> Bool { + if error is DecodingError { return true } + switch error { + case HistoryError.invalidFile, HistoryError.unsupportedFileVersion: + return true + default: + return false } } @@ -822,6 +1475,47 @@ actor UsageHistory { return (normalized(samples), hadError) } + private func readWorkingSet( + from root: URL + ) -> (samples: [UsageSample], hadError: Bool) { + var samples: [UsageSample] = [] + var hadError = false + var budget = WorkingSetReadBudget() + let directory = installationsDirectory(in: root) + do { + let writers = try boundedWorkingSetWriters(in: directory) + hadError = writers.didReachLimit + writerLoop: for writer in writers.values { + do { + for file in try workingSetFiles(in: writer) { + do { + guard try budget.admit(file) else { + if budget.didReachLimit { + hadError = true + break writerLoop + } + continue + } + samples = boundedWorkingSet( + samples + (try readDailyFileIfPresent( + at: file, + coordinated: false + )) + ) + } catch { + hadError = true + } + } + } catch { + hadError = true + } + } + } catch { + hadError = true + } + return (boundedWorkingSet(samples), hadError) + } + private func readDailyFileIfPresent( at url: URL, generation: Int? = nil, @@ -1019,6 +1713,110 @@ actor UsageHistory { } } + private func boundedWorkingSet(_ samples: [UsageSample]) -> [UsageSample] { + let ordered = normalized(samples) + guard let newest = ordered.last?.observedAt else { return [] } + let cutoff = newest.addingTimeInterval(-Self.workingSetDuration) + let fullResolutionStart = newest.addingTimeInterval( + -Self.fullResolutionDuration + ) + var result: [UsageSample] = [] + var bucketFirst: UsageSample? + var bucketLast: UsageSample? + var bucketCount = 0 + var bucketNumber: Int? + var bucketReset: Date? + + func flushBucket() { + guard let first = bucketFirst else { return } + result.append(first) + if bucketCount > 1, let last = bucketLast { + result.append(last) + } + bucketFirst = nil + bucketLast = nil + bucketCount = 0 + bucketNumber = nil + bucketReset = nil + } + + for sample in ordered where sample.observedAt >= cutoff { + if sample.observedAt >= fullResolutionStart || sample.comparisonBreak { + flushBucket() + result.append(sample) + continue + } + let number = Int(floor( + sample.observedAt.timeIntervalSinceReferenceDate + / Self.historicalBucketDuration + )) + if bucketNumber != number || bucketReset != sample.resetsAt { + flushBucket() + bucketFirst = sample + bucketNumber = number + bucketReset = sample.resetsAt + } + bucketLast = sample + bucketCount += 1 + } + flushBucket() + return Array(result.suffix(Self.maximumWorkingSetSamples)) + } + + private func boundedRangeSamples( + _ samples: [UsageSample] + ) -> (samples: [UsageSample], didDownsample: Bool) { + let ordered = normalized(samples) + guard ordered.count > Self.maximumWorkingSetSamples else { + return (ordered, false) + } + var reduced: [UsageSample] = [] + var bucket: [UsageSample] = [] + var bucketNumber: Int? + var bucketReset: Date? + + func flushBucket() { + guard let first = bucket.first else { return } + reduced.append(first) + if let last = bucket.last, last != first { + reduced.append(last) + } + bucket = [] + bucketNumber = nil + bucketReset = nil + } + + for sample in ordered { + if sample.comparisonBreak { + flushBucket() + reduced.append(sample) + continue + } + let number = Int(floor( + sample.observedAt.timeIntervalSinceReferenceDate + / Self.historicalBucketDuration + )) + if bucketNumber != number || bucketReset != sample.resetsAt { + flushBucket() + bucketNumber = number + bucketReset = sample.resetsAt + } + bucket.append(sample) + } + flushBucket() + guard reduced.count > Self.maximumWorkingSetSamples else { + return (reduced, true) + } + let lastIndex = reduced.count - 1 + let capped = (0 ..< Self.maximumWorkingSetSamples).map { index in + reduced[Int( + (Double(index) * Double(lastIndex) + / Double(Self.maximumWorkingSetSamples - 1)).rounded() + )] + } + return (capped, true) + } + private func installationsDirectory( in root: URL, generation: Int? = nil @@ -1159,6 +1957,35 @@ actor UsageHistory { at url: URL, _ update: (Marker) throws -> Marker ) throws -> Marker { + func updateAtURL(_ target: URL) throws -> Marker { + try self.beforeCoordinatedMarkerRead?(target) + let current = try JSONDecoder().decode( + Marker.self, + from: self.checkedData(at: target) + ) + guard current.version == Self.folderFormatVersion, + let currentGeneration = current.generation, + (1 ... Self.maximumGeneration).contains(currentGeneration) else { + throw current.version == Self.folderFormatVersion + ? HistoryError.invalidFile + : HistoryError.unsupportedFolderVersion + } + let updated = try update(current) + guard updated.version == Self.folderFormatVersion, + let updatedGeneration = updated.generation, + (1 ... Self.maximumGeneration).contains(updatedGeneration) else { + throw updated.version == Self.folderFormatVersion + ? HistoryError.invalidFile + : HistoryError.unsupportedFolderVersion + } + try JSONEncoder().encode(updated).write(to: target, options: .atomic) + return updated + } + guard isUbiquitousItem(url) else { + return try Self.localMarkerUpdateLock.withLock { + try updateAtURL(url) + } + } var result: Result? var coordinationError: NSError? NSFileCoordinator(filePresenter: nil).coordinate( @@ -1166,31 +1993,7 @@ actor UsageHistory { options: .forReplacing, error: &coordinationError ) { coordinatedURL in - result = Result { - try beforeCoordinatedMarkerRead?(coordinatedURL) - let current = try JSONDecoder().decode( - Marker.self, - from: checkedData(at: coordinatedURL) - ) - guard current.version == Self.folderFormatVersion, - let currentGeneration = current.generation, - (1 ... Self.maximumGeneration).contains(currentGeneration) else { - throw current.version == Self.folderFormatVersion - ? HistoryError.invalidFile - : HistoryError.unsupportedFolderVersion - } - let updated = try update(current) - guard updated.version == Self.folderFormatVersion, - let updatedGeneration = updated.generation, - (1 ... Self.maximumGeneration).contains(updatedGeneration) else { - throw updated.version == Self.folderFormatVersion - ? HistoryError.invalidFile - : HistoryError.unsupportedFolderVersion - } - let data = try JSONEncoder().encode(updated) - try data.write(to: coordinatedURL, options: .atomic) - return updated - } + result = Result { try updateAtURL(coordinatedURL) } } if let coordinationError { throw coordinationError } guard let result else { throw HistoryError.unavailableFolder } @@ -1684,6 +2487,46 @@ actor UsageHistory { } } + private func boundedWorkingSetWriters( + in directory: URL + ) throws -> (values: [URL], didReachLimit: Bool) { + var values: [URL] = [] + let preferred = directory.appendingPathComponent( + installationID, + isDirectory: true + ) + var isDirectory: ObjCBool = false + if FileManager.default.fileExists( + atPath: preferred.path, + isDirectory: &isDirectory + ), isDirectory.boolValue { + values.append(preferred) + } + + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + return (values, false) + } + for case let url as URL in enumerator { + guard url.standardizedFileURL != preferred.standardizedFileURL, + (try? url.resourceValues( + forKeys: [.isDirectoryKey] + ).isDirectory) == true else { + continue + } + guard values.count < Self.maximumWorkingSetWriters else { + // ponytail: cap cold fan-out; add a compact partition summary + // if real histories exceed 32 contributing installations. + return (values, true) + } + values.append(url) + } + return (values, false) + } + private func jsonFiles(in directory: URL) throws -> [URL] { try FileManager.default.contentsOfDirectory( at: directory, @@ -1695,6 +2538,23 @@ actor UsageHistory { } } + private func workingSetFiles(in writerDirectory: URL) throws -> [URL] { + guard let manifest = try ensureWriterManifest( + in: writerDirectory, + generation: 1, + coordinated: false + ), let newest = date(forDayName: manifest.newestDay) else { + return [] + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return (0 ..< Self.workingSetFileDays).reversed().compactMap { offset in + calendar.date(byAdding: .day, value: -offset, to: newest).map { + writerDirectory.appendingPathComponent("\(dayName(for: $0)).json") + } + } + } + private func dayName(for date: Date) -> String { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! @@ -1702,6 +2562,48 @@ actor UsageHistory { return String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!) } + private func date(forDayName day: String) -> Date? { + let parts = day.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let dayOfMonth = Int(parts[2]) else { + return nil + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + guard let date = calendar.date(from: DateComponents( + year: year, + month: month, + day: dayOfMonth + )), dayName(for: date) == day else { + return nil + } + return date + } + + private func nextDay(after day: String) -> String? { + guard let date = date(forDayName: day) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(byAdding: .day, value: 1, to: date).map(dayName) + } + + private func dayNames(in interval: DateInterval) -> [String] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + var date = calendar.startOfDay(for: interval.start) + var result: [String] = [] + while date <= interval.end, result.count <= Self.workingSetFileDays { + result.append(dayName(for: date)) + guard let next = calendar.date(byAdding: .day, value: 1, to: date) else { + break + } + date = next + } + return result + } + private func message(for error: Error) -> String { switch error { case HistoryError.invalidFolder: diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 92c7099..bc38883 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -860,9 +860,10 @@ enum UsageIntelligenceEngine { sourceState: input.sourceState, now: input.now ) - let weeklyAccountTokenActivity = accountTokenActivity( + let localActivityInterval = tokenActivityInterval( account: input.account, - samples: currentSamples + samples: currentSamples, + accountEpochStartedAt: input.accountEpochStartedAt ) let selectedTokenRange: DateInterval? switch input.analyticsExploration.timeRange { @@ -902,11 +903,7 @@ enum UsageIntelligenceEngine { ) } let localTokenActivity: LocalTokenActivitySnapshot - if let interval = tokenActivityInterval( - account: input.account, - accountActivity: weeklyAccountTokenActivity, - accountEpochStartedAt: input.accountEpochStartedAt - ) { + if let interval = localActivityInterval { if let cached = reusableLocalAggregates?.localTokenActivity, cached.interval.start == interval.start, !tokenFactsAffectIntervalChange( @@ -1133,7 +1130,9 @@ enum UsageIntelligenceEngine { account: input.account, accountSource: .account, interval: observedInterval, - menuBarText: input.account?.mainLimit.map { + menuBarText: observedInterval.flatMap { _ in + input.account?.mainLimit + }.map { "\(Int($0.window.remainingPercent.rounded()))%" } ?? "—", sourceState: input.sourceState, @@ -1256,50 +1255,17 @@ enum UsageIntelligenceEngine { samples: [UsageSample], accountEpochStartedAt: Date? = nil ) -> DateInterval? { - tokenActivityInterval( - account: account, - accountActivity: accountTokenActivity( - account: account, - samples: samples - ), - accountEpochStartedAt: accountEpochStartedAt - ) - } - - private static func tokenActivityInterval( - account: UsageSnapshot?, - accountActivity: AccountTokenActivitySnapshot, - accountEpochStartedAt: Date? - ) -> DateInterval? { - if let interval = accountActivity.interval { - let start = max( - interval.start, - accountEpochStartedAt ?? interval.start - ) - guard interval.end >= start else { return nil } - return DateInterval(start: start, end: interval.end) - } guard let account, let window = account.mainLimit?.window else { return nil } - let start = max( - window.startsAt, - accountEpochStartedAt ?? window.startsAt + let fallbackEnd = min(account.fetchedAt, window.resetsAt) + guard fallbackEnd >= window.startsAt else { return nil } + let fallback = DateInterval( + start: window.startsAt, + end: fallbackEnd ) - let end = min(account.fetchedAt, window.resetsAt) - guard end >= start else { return nil } - return DateInterval(start: start, end: end) - } - - private static func accountTokenActivity( - account: UsageSnapshot?, - samples: [UsageSample] - ) -> AccountTokenActivitySnapshot { - guard let account, let weeklyLimit = account.mainLimit else { - return .unavailable("Account token readings are unavailable") - } - let start = weeklyLimit.window.startsAt + let start = window.startsAt let boundary = samples .filter { $0.lifetimeTokens != nil @@ -1310,58 +1276,55 @@ enum UsageIntelligenceEngine { abs($0.observedAt.timeIntervalSince(start)) < abs($1.observedAt.timeIntervalSince(start)) } + let interval: DateInterval if let currentTokens = account.accountFacts?.lifetimeTokens, let boundary, - let boundaryTokens = boundary.lifetimeTokens { + let boundaryTokens = boundary.lifetimeTokens, + currentTokens >= 0, + boundaryTokens >= 0 { let currentObservedAt = account.accountFacts? .lifetimeTokensObservedAt ?? account.fetchedAt - guard currentObservedAt >= boundary.observedAt else { - return .unavailable( - "No lifetime token reading after the weekly boundary" + if currentObservedAt >= boundary.observedAt { + interval = DateInterval( + start: boundary.observedAt, + end: currentObservedAt ) + } else { + interval = fallback } - guard currentTokens >= 0, boundaryTokens >= 0 else { - return .unavailable("Lifetime token reading is invalid") + } else { + let completeDays = account.tokenHistory + .filter { day in + let end = day.date.addingTimeInterval(86_400) + return day.completeness == .complete + && day.tokens >= 0 + && day.date >= window.startsAt + && end <= fallbackEnd + } + .sorted { $0.date < $1.date } + var total: Int64 = 0 + let hasValidTotal = completeDays.allSatisfy { day in + let sum = total.addingReportingOverflow(day.tokens) + total = sum.partialValue + return !sum.overflow } - guard currentTokens >= boundaryTokens else { - return .unavailable( - "Lifetime token counter decreased", - interval: DateInterval( - start: boundary.observedAt, - end: currentObservedAt - ) + if let first = completeDays.first, + let last = completeDays.last, + hasValidTotal { + interval = DateInterval( + start: first.date, + end: last.date.addingTimeInterval(86_400) ) + } else { + interval = fallback } - let delta = currentTokens.subtractingReportingOverflow( - boundaryTokens - ) - guard !delta.overflow else { - return .unavailable("Lifetime token reading is invalid") - } - return AccountTokenActivitySnapshot( - state: .exact, - tokens: delta.partialValue, - method: .lifetimeDelta, - interval: DateInterval( - start: boundary.observedAt, - end: currentObservedAt - ), - reason: nil - ) - } - - if let dailyActivity = dailyTokenActivity( - account: account, - window: weeklyLimit.window - ) { - return dailyActivity } - - return .unavailable( - account.accountFacts?.lifetimeTokens == nil - ? "Lifetime token readings are unavailable" - : "No lifetime token reading at the weekly boundary" + let clampedStart = max( + interval.start, + accountEpochStartedAt ?? interval.start ) + guard interval.end >= clampedStart else { return nil } + return DateInterval(start: clampedStart, end: interval.end) } private static func selectedRangeAccountTokenActivity( @@ -1621,57 +1584,6 @@ enum UsageIntelligenceEngine { ) } - private static func dailyTokenActivity( - account: UsageSnapshot, - window: UsageWindow - ) -> AccountTokenActivitySnapshot? { - let intervalEnd = min( - account.fetchedAt, - window.resetsAt - ) - let completeDays = account.tokenHistory - .filter { day in - let end = day.date.addingTimeInterval(24 * 60 * 60) - return day.completeness == .complete - && day.tokens >= 0 - && day.date >= window.startsAt - && end <= intervalEnd - } - .sorted { $0.date < $1.date } - guard let first = completeDays.first, - let last = completeDays.last else { - return nil - } - var total: Int64 = 0 - for day in completeDays { - let result = total.addingReportingOverflow(day.tokens) - guard !result.overflow else { return nil } - total = result.partialValue - } - let lastDayEnd = last.date.addingTimeInterval(24 * 60 * 60) - let isContiguous = zip( - completeDays, - completeDays.dropFirst() - ).allSatisfy { previous, next in - next.date == previous.date.addingTimeInterval(24 * 60 * 60) - } - let exactlyMatchesInterval = isContiguous - && first.date == window.startsAt - && lastDayEnd == intervalEnd - return AccountTokenActivitySnapshot( - state: exactlyMatchesInterval ? .exact : .partial, - tokens: total, - method: .dailyBuckets, - interval: DateInterval( - start: first.date, - end: lastDayEnd - ), - reason: exactlyMatchesInterval - ? nil - : "Only complete daily token totals are available" - ) - } - private static func chart( account: UsageSnapshot?, samples: [UsageSample], @@ -1876,7 +1788,7 @@ enum UsageIntelligenceEngine { .sorted { $0.resetsAt < $1.resetsAt } } - private static func projection( + static func projection( reading: UsageSample, window: UsageWindow, rate: Double, @@ -1931,7 +1843,11 @@ enum UsageIntelligenceEngine { sourceState: UsageSourceState, now: Date ) -> UsageFreshness { - guard let account else { return .unavailable } + guard let account, + let reset = account.mainLimit?.window.resetsAt, + reset > now else { + return .unavailable + } if case .failed = sourceState { return .stale } return now.timeIntervalSince(account.fetchedAt) > CurrentUsagePolicy.tightBoundary ? .stale diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index c52c021..ab4f773 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -2,6 +2,67 @@ import AppKit import Combine import Foundation +enum IntegrationWorkPriority: Int, Sendable { + case explicit + case visible + case automatic + case settings +} + +actor IntegrationWorkCoordinator { + private struct Waiter { + let priority: IntegrationWorkPriority + let order: UInt64 + let continuation: CheckedContinuation + } + + private var isAvailable = true + private var nextOrder: UInt64 = 0 + private var waiters: [Waiter] = [] + + func run( + priority: IntegrationWorkPriority, + operation: @Sendable () async -> Void + ) async { + await enter(priority: priority) + if !Task.isCancelled { + await operation() + } + leave() + } + + private func enter(priority: IntegrationWorkPriority) async { + if isAvailable { + isAvailable = false + return + } + let order = nextOrder + nextOrder &+= 1 + // ponytail: cancelled waiters drain without source work; add waiter IDs + // only if bounded-operation metrics show meaningful queue churn. + await withCheckedContinuation { continuation in + waiters.append(Waiter( + priority: priority, + order: order, + continuation: continuation + )) + } + } + + private func leave() { + guard let index = waiters.indices.min(by: { + let lhs = waiters[$0] + let rhs = waiters[$1] + return (lhs.priority.rawValue, lhs.order) + < (rhs.priority.rawValue, rhs.order) + }) else { + isAvailable = true + return + } + waiters.remove(at: index).continuation.resume() + } +} + enum SafetyBufferPolicy { static let defaultValue = 3.0 static let range = 1.0 ... 10.0 @@ -14,6 +75,11 @@ enum SafetyBufferPolicy { @MainActor final class UsageMonitor: ObservableObject { + private struct AccountRefreshRequest: Equatable { + let generation: UInt64 + let priority: IntegrationWorkPriority + } + private static let accountRefreshInterval: TimeInterval = 600 static let safetyBufferKey = "safetyBuffer" @@ -29,6 +95,12 @@ final class UsageMonitor: ObservableObject { ) @Published private(set) var samples: [UsageSample] = [] @Published private(set) var isRefreshing = false + @Published private(set) var isEnabled: Bool + @Published private(set) var historicalReaderSnapshot: UsageReaderSnapshot? + @Published private(set) var historicalRange: DateInterval? + @Published private(set) var historicalRetainedBounds: DateInterval? + @Published private(set) var historicalRangeIssue: String? + @Published private(set) var isLoadingHistoricalRange = false @Published private(set) var syncFolderName: String? @Published private(set) var syncErrorMessage: String? @Published private(set) var historyDeletionStatus: UsageHistory.DeletionStatus = .none @@ -53,15 +125,22 @@ final class UsageMonitor: ObservableObject { private let history: UsageHistory private let codexAssistedHistory: CodexAssistedHistory? private let fetchUsage: () async throws -> CodexFetchResult + private let cancelFetchUsage: @Sendable () async -> Void private let evaluateUsage: @Sendable (UsageIntelligenceInput) -> UsageReaderSnapshot private let localActivityCollector: LocalActivityCollector? private let resetReminderCoordinator: ResetReminderCoordinator + private let integrationWorkCoordinator: IntegrationWorkCoordinator private var historyPartition: AccountHistoryPartition private var accountSnapshot: UsageSnapshot? private var sourceState: UsageSourceState = .available private var previousStatus: PaceStatus? private var cancellables: Set = [] + private var accountRefreshCancellable: AnyCancellable? + private var accountFetchTask: Task? + private var accountCollectionGeneration: UInt64 = 0 + private var accountRefreshRequest: AccountRefreshRequest? + private var menuBarSourceActive: Bool private var started = false private var historyPrepared = false private var historyUsesFiles = false @@ -79,14 +158,25 @@ final class UsageMonitor: ObservableObject { ) private var evaluationGeneration: UInt64 = 0 private var evaluationTask: Task? + private var readerBoundaryTask: Task? + private var historicalRangeGeneration: UInt64 = 0 + private var historicalRangeTask: Task? private var localImportGeneration: UInt64 = 0 private var localImportTask: Task? private var localAnalyticsVisible = false private var localAnalyticsNeedsLoad = false + private var visible = false - convenience init() { + convenience init( + isEnabled: Bool = true, + menuBarSourceActive: Bool = true, + integrationWorkCoordinator: IntegrationWorkCoordinator = + IntegrationWorkCoordinator() + ) { self.init( defaults: .standard, + isEnabled: isEnabled, + menuBarSourceActive: menuBarSourceActive, localActivityCollector: LocalActivityCollector( projectionSource: ReadOnlyThreadProjectionSource { request in try await CodexClient.shared.threadProjectionResponse( @@ -97,7 +187,8 @@ final class UsageMonitor: ObservableObject { try? await CodexClient.shared.installedCLIVersion() } ), - codexAssistedHistory: CodexAssistedHistory.shared + codexAssistedHistory: CodexAssistedHistory.shared, + integrationWorkCoordinator: integrationWorkCoordinator ) } @@ -105,11 +196,17 @@ final class UsageMonitor: ObservableObject { defaults: UserDefaults, historyDirectory: URL? = nil, startsAutomatically: Bool = true, + isEnabled: Bool = true, + menuBarSourceActive: Bool = true, localActivityCollector: LocalActivityCollector? = nil, resetReminderScheduler: (any ResetReminderScheduling)? = nil, resetReminderNow: @escaping () -> Date = Date.init, codexAssistedHistory: CodexAssistedHistory? = nil, + integrationWorkCoordinator: IntegrationWorkCoordinator = + IntegrationWorkCoordinator(), fetchUsage: @escaping () async throws -> CodexFetchResult = CodexClient.fetch, + cancelFetchUsage: @escaping @Sendable () async -> Void = + { await CodexClient.cancelFetch() }, evaluateUsage: @escaping @Sendable ( UsageIntelligenceInput ) -> UsageReaderSnapshot = { @@ -117,10 +214,14 @@ final class UsageMonitor: ObservableObject { } ) { self.defaults = defaults + self.isEnabled = isEnabled + self.menuBarSourceActive = menuBarSourceActive self.fetchUsage = fetchUsage + self.cancelFetchUsage = cancelFetchUsage self.evaluateUsage = evaluateUsage self.localActivityCollector = localActivityCollector self.codexAssistedHistory = codexAssistedHistory + self.integrationWorkCoordinator = integrationWorkCoordinator let storedSafetyBuffer = defaults.object( forKey: Self.safetyBufferKey ) as? Double @@ -192,8 +293,11 @@ final class UsageMonitor: ObservableObject { guard !started else { return } started = true await resetReminderCoordinator.restore() + if !isEnabled { + await resetReminderCoordinator.reconcile(target: nil) + } publishResetReminderState() - if let cutoff = defaults.object( + if isEnabled, let cutoff = defaults.object( forKey: Self.localHistoryDeletionCutoffKey ) as? Date { await localActivityCollector?.restorePendingHistoryDeletion( @@ -201,43 +305,43 @@ final class UsageMonitor: ObservableObject { ) } - Timer.publish( - every: Self.accountRefreshInterval, - on: .main, - in: .common - ) - .autoconnect() - .sink { [weak self] _ in - Task { - @MainActor in await self?.automaticRefresh() - } - } - .store(in: &cancellables) + startAccountRefreshTimerIfNeeded() NSWorkspace.shared.notificationCenter .publisher(for: NSWorkspace.didWakeNotification) .sink { [weak self] _ in Task { - @MainActor in await self?.automaticRefresh() + @MainActor in + await self?.refreshSelectedMenuBarSourceIfStale() } } .store(in: &cancellables) - await automaticRefresh() + await refreshSelectedMenuBarSourceIfStale() } func automaticRefresh() async { + guard isEnabled, menuBarSourceActive else { return } await refresh( forceHistorySync: false, - includeLocalActivity: false + includeLocalActivity: false, + priority: .automatic ) } - func refreshAccountIfStale(now: Date = Date()) async { + func refreshAccountIfStale( + now: Date = Date(), + priority: IntegrationWorkPriority = .visible + ) async { + guard isEnabled else { return } guard let fetchedAt = accountSnapshot?.fetchedAt, now.timeIntervalSince(fetchedAt) < Self.accountRefreshInterval else { - await automaticRefresh() + await refresh( + forceHistorySync: false, + includeLocalActivity: false, + priority: priority + ) return } } @@ -246,23 +350,76 @@ final class UsageMonitor: ObservableObject { forceHistorySync: Bool = true, includeLocalActivity: Bool = true ) async { - guard !isRefreshing else { return } + await refresh( + forceHistorySync: forceHistorySync, + includeLocalActivity: includeLocalActivity, + priority: .explicit + ) + } + + private func refresh( + forceHistorySync: Bool, + includeLocalActivity: Bool, + priority: IntegrationWorkPriority + ) async { + guard isEnabled else { return } + if let current = accountRefreshRequest { + guard priority.rawValue < current.priority.rawValue else { return } + accountFetchTask?.cancel() + await cancelFetchUsage() + } + accountCollectionGeneration &+= 1 + let request = AccountRefreshRequest( + generation: accountCollectionGeneration, + priority: priority + ) + accountRefreshRequest = request isRefreshing = true - defer { isRefreshing = false } + await integrationWorkCoordinator.run(priority: priority) { + @MainActor [weak self] in + guard let self, self.isEnabled, + self.accountRefreshRequest == request else { + return + } + await self.performRefresh( + forceHistorySync: forceHistorySync, + includeLocalActivity: includeLocalActivity, + generation: request.generation + ) + } + if accountRefreshRequest == request { + accountRefreshRequest = nil + isRefreshing = false + } + } + + private func performRefresh( + forceHistorySync: Bool, + includeLocalActivity: Bool, + generation: UInt64 + ) async { + defer { + if generation == accountCollectionGeneration { + accountFetchTask = nil + } + } if includeLocalActivity { cancelLocalImport() } await restoreHistoryIfAvailable() let fetchTask = Task { try await fetchUsage() } + accountFetchTask = fetchTask do { let result = try await fetchTask.value + guard canPublishAccountWork(generation) else { return } guard let account = result.account else { historyMatchesCurrentSnapshot = false await exchangeRestoredHistoryIfAvailable( force: forceHistorySync ) + guard canPublishAccountWork(generation) else { return } accountSnapshot = result.snapshot sourceState = .available if localAnalyticsVisible, @@ -272,6 +429,7 @@ final class UsageMonitor: ObservableObject { observedAt: result.snapshot.fetchedAt, identityVerified: false ) + guard canPublishAccountWork(generation) else { return } } let published = await recalculate( now: result.snapshot.fetchedAt @@ -290,13 +448,17 @@ final class UsageMonitor: ObservableObject { planType: result.planType, observedAt: result.snapshot.fetchedAt ) + guard canPublishAccountWork(generation) else { return } await prepareHistory(legacySamples: legacySamples) + guard canPublishAccountWork(generation) else { return } if !historyUsesFiles { let historyState = await history.load(legacySamples: samples) + guard canPublishAccountWork(generation) else { return } apply(historyState) historyUsesFiles = historyState.errorMessage == nil } let historyState = await exchangeHistory(force: forceHistorySync) + guard canPublishAccountWork(generation) else { return } apply(historyState, configuredFolderName: configuredSyncDirectory?.lastPathComponent) repairInitialAccountEpochIfNeeded() let exchangeErrorMessage = historyState.errorMessage @@ -311,6 +473,7 @@ final class UsageMonitor: ObservableObject { accountEpochStartedAt == newSnapshot.fetchedAt ) let recordedState = await history.record(sample) + guard canPublishAccountWork(generation) else { return } apply( recordedState, configuredFolderName: configuredSyncDirectory?.lastPathComponent @@ -329,6 +492,7 @@ final class UsageMonitor: ObservableObject { for: newSnapshot, observedAt: newSnapshot.fetchedAt ) + guard canPublishAccountWork(generation) else { return } } let published = await recalculate(now: newSnapshot.fetchedAt) persist() @@ -336,9 +500,11 @@ final class UsageMonitor: ObservableObject { await reconcileResetReminder() } } catch { + guard canPublishAccountWork(generation) else { return } await exchangeRestoredHistoryIfAvailable( force: forceHistorySync ) + guard canPublishAccountWork(generation) else { return } sourceState = .failed( (error as? CodexClientError)?.localizedDescription ?? "Couldn’t read Codex usage. Try refreshing again." @@ -351,6 +517,7 @@ final class UsageMonitor: ObservableObject { observedAt: Date(), identityVerified: false ) + guard canPublishAccountWork(generation) else { return } } _ = await recalculate() persist() @@ -359,6 +526,7 @@ final class UsageMonitor: ObservableObject { func setLocalAnalyticsVisible(_ isVisible: Bool) async { if isVisible { + guard isEnabled else { return } if !localAnalyticsVisible { localAnalyticsVisible = true localAnalyticsNeedsLoad = true @@ -384,17 +552,273 @@ final class UsageMonitor: ObservableObject { } guard localAnalyticsVisible, localAnalyticsNeedsLoad, - let accountSnapshot else { + accountSnapshot != nil else { + return + } + await integrationWorkCoordinator.run(priority: .visible) { + @MainActor [weak self] in + guard let self, + self.isEnabled, + self.localAnalyticsVisible, + self.localAnalyticsNeedsLoad, + let accountSnapshot = self.accountSnapshot else { + return + } + let identityVerified = self.sourceState == .available + && self.historyAccountIdentity != nil + await self.refreshLocalActivity( + for: accountSnapshot, + observedAt: identityVerified + ? accountSnapshot.fetchedAt + : Date(), + identityVerified: identityVerified + ) + _ = await self.recalculate() + } + } + + func setVisible(_ visible: Bool) async { + guard visible != self.visible else { return } + self.visible = visible + scheduleReaderBoundary() + if !visible, !menuBarSourceActive { + await cancelAccountCollection() + } + if !visible { + clearHistoricalRange() + } + } + + func setEnabled(_ enabled: Bool) async { + guard enabled != isEnabled else { return } + isEnabled = enabled + accountCollectionGeneration &+= 1 + accountFetchTask?.cancel() + accountFetchTask = nil + accountRefreshRequest = nil + isRefreshing = false + + if enabled { + startAccountRefreshTimerIfNeeded() + scheduleReaderBoundary() + while isRefreshing { + guard !Task.isCancelled else { return } + await Task.yield() + } + await refreshSelectedMenuBarSourceIfStale() return } - let identityVerified = sourceState == .available - && historyAccountIdentity != nil - await refreshLocalActivity( - for: accountSnapshot, - observedAt: identityVerified ? accountSnapshot.fetchedAt : Date(), - identityVerified: identityVerified + + accountRefreshCancellable?.cancel() + accountRefreshCancellable = nil + await cancelFetchUsage() + readerBoundaryTask?.cancel() + readerBoundaryTask = nil + clearHistoricalRange() + cancelLocalImport() + evaluationGeneration &+= 1 + evaluationTask?.cancel() + evaluationTask = nil + localAnalyticsVisible = false + localAnalyticsNeedsLoad = false + localActivityCollection = .unavailable( + "Codex local records are unavailable" ) - _ = await recalculate() + await localActivityCollector?.releaseCachedFacts() + _ = await history.disconnect() + await resetReminderCoordinator.reconcile(target: nil) + publishResetReminderState() + } + + func setMenuBarSourceActive(_ active: Bool) async { + guard active != menuBarSourceActive else { return } + menuBarSourceActive = active + if !active { + accountRefreshCancellable?.cancel() + accountRefreshCancellable = nil + scheduleReaderBoundary() + if !visible { + await cancelAccountCollection() + } + return + } + guard isEnabled else { return } + scheduleReaderBoundary() + startAccountRefreshTimerIfNeeded() + await refreshAccountIfStale(priority: .automatic) + } + + private func refreshSelectedMenuBarSourceIfStale() async { + guard menuBarSourceActive else { return } + await refreshAccountIfStale(priority: .automatic) + } + + private func startAccountRefreshTimerIfNeeded() { + guard started, isEnabled, menuBarSourceActive, + accountRefreshCancellable == nil else { + return + } + accountRefreshCancellable = Timer.publish( + every: Self.accountRefreshInterval, + on: .main, + in: .common + ) + .autoconnect() + .sink { [weak self] _ in + Task { + @MainActor in await self?.automaticRefresh() + } + } + } + + private func canPublishAccountWork(_ generation: UInt64) -> Bool { + isEnabled + && generation == accountCollectionGeneration + && !Task.isCancelled + } + + private func cancelAccountCollection() async { + accountCollectionGeneration &+= 1 + accountFetchTask?.cancel() + accountFetchTask = nil + accountRefreshRequest = nil + isRefreshing = false + await cancelFetchUsage() + } + + var canLoadEarlierHistory: Bool { + guard !samples.isEmpty else { return false } + guard let historicalRange, + let retained = historicalRetainedBounds else { + return true + } + return retained.start < historicalRange.start + } + + func loadEarlierHistory( + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition] + ) async { + guard let end = historicalRange?.start + ?? samples.first?.observedAt else { return } + await loadHistoricalRange( + DateInterval( + start: end.addingTimeInterval(-84 * 86_400), + end: end + ), + exploration: exploration, + dispositions: dispositions + ) + } + + func loadLaterHistory( + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition] + ) async { + guard let historicalRange else { return } + guard let latestStart = samples.first?.observedAt, + historicalRange.end < latestStart else { + clearHistoricalRange() + return + } + let end = min( + historicalRange.end.addingTimeInterval(84 * 86_400), + latestStart + ) + await loadHistoricalRange( + DateInterval( + start: end.addingTimeInterval(-84 * 86_400), + end: end + ), + exploration: exploration, + dispositions: dispositions + ) + } + + func clearHistoricalRange() { + historicalRangeGeneration &+= 1 + historicalRangeTask?.cancel() + historicalRangeTask = nil + historicalReaderSnapshot = nil + historicalRange = nil + historicalRetainedBounds = nil + historicalRangeIssue = nil + isLoadingHistoricalRange = false + } + + private func loadHistoricalRange( + _ interval: DateInterval, + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition] + ) async { + guard isEnabled, visible else { return } + historicalRangeGeneration &+= 1 + let generation = historicalRangeGeneration + historicalRangeTask?.cancel() + isLoadingHistoricalRange = true + historicalRangeIssue = nil + var rangeExploration = exploration + rangeExploration.timeRange = .selected + rangeExploration.visibleRange = interval + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performHistoricalRangeLoad( + interval, + exploration: rangeExploration, + dispositions: dispositions, + generation: generation + ) + } + historicalRangeTask = task + await task.value + if historicalRangeGeneration == generation { + historicalRangeTask = nil + isLoadingHistoricalRange = false + } + } + + private func performHistoricalRangeLoad( + _ interval: DateInterval, + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition], + generation: UInt64 + ) async { + await integrationWorkCoordinator.run(priority: .visible) { + @MainActor [weak self] in + guard let self, self.isEnabled, self.visible, + self.historicalRangeGeneration == generation, + !Task.isCancelled, + let view = await self.history.rangeView(for: interval) else { + return + } + let input = self.evaluationInput( + analyticsExploration: exploration, + insightDispositions: dispositions, + historySamples: view.samples + ) + let evaluateUsage = self.evaluateUsage + let evaluation = Task.detached(priority: .userInitiated) { + Task.isCancelled ? nil : evaluateUsage(input) + } + let snapshot = await withTaskCancellationHandler { + await evaluation.value + } onCancel: { + evaluation.cancel() + } + guard let snapshot, self.isEnabled, self.visible, + self.historicalRangeGeneration == generation, + !Task.isCancelled else { return } + self.historicalReaderSnapshot = snapshot + self.historicalRange = interval + self.historicalRetainedBounds = view.retainedBounds + self.historicalRangeIssue = if view.hadReadError { + "Some history couldn’t be read." + } else if view.samples.isEmpty { + "No usage observations in this range." + } else { + nil + } + } } func updateSafetyBuffer(_ value: Double) { @@ -854,7 +1278,8 @@ final class UsageMonitor: ObservableObject { safetyBuffer: Double? = nil, now: Date = Date(), analyticsExploration: AnalyticsExplorationState? = nil, - insightDispositions: [String: InsightDisposition]? = nil + insightDispositions: [String: InsightDisposition]? = nil, + historySamples: [UsageSample]? = nil ) -> UsageIntelligenceInput { let storedBuffer = defaults.object(forKey: Self.safetyBufferKey) as? Double let buffer = SafetyBufferPolicy.normalized( @@ -862,7 +1287,9 @@ final class UsageMonitor: ObservableObject { ) return UsageIntelligenceInput( account: accountSnapshot, - samples: historyMatchesCurrentSnapshot ? samples : [], + samples: historyMatchesCurrentSnapshot + ? (historySamples ?? samples) + : [], safetyBuffer: buffer, sourceState: sourceState, now: now, @@ -908,12 +1335,44 @@ final class UsageMonitor: ObservableObject { return false } readerSnapshot = snapshot + scheduleReaderBoundary() if let status = snapshot.guidance?.status { previousStatus = status } return true } + private func scheduleReaderBoundary() { + readerBoundaryTask?.cancel() + guard isEnabled, menuBarSourceActive || visible, + let account = readerSnapshot.account, + let reset = account.mainLimit?.window.resetsAt else { + readerBoundaryTask = nil + return + } + let now = Date() + let candidates = [ + account.fetchedAt.addingTimeInterval(15 * 60), + reset + ].filter { $0 > now }.sorted() + guard let boundary = candidates.first else { + readerBoundaryTask = nil + return + } + readerBoundaryTask = Task { [weak self] in + do { + try await Task.sleep( + for: .seconds(boundary.timeIntervalSinceNow) + ) + } catch { + return + } + guard let self else { return } + readerBoundaryTask = nil + _ = await recalculate() + } + } + func analyticsPreferencesDidChange( exploration: AnalyticsExplorationState, dispositions: [String: InsightDisposition] @@ -1126,7 +1585,8 @@ final class UsageMonitor: ObservableObject { accountBindingToken: defaults.string( forKey: Self.historySyncAccountBindingKey ), - bindsUnresolvedDeletionTarget: true + bindsUnresolvedDeletionTarget: true, + performFullReconciliation: false ) historyConnectionActive = connectedState.folderName != nil apply(connectedState, configuredFolderName: directory.lastPathComponent) @@ -1198,7 +1658,8 @@ final class UsageMonitor: ObservableObject { accountIdentity: historyAccountIdentity, accountBindingToken: defaults.string( forKey: Self.historySyncAccountBindingKey - ) + ), + performFullReconciliation: false ) historyConnectionActive = state.folderName != nil return state diff --git a/Sources/CodexLimits/UsageOverviewSnapshot.swift b/Sources/CodexLimits/UsageOverviewSnapshot.swift new file mode 100644 index 0000000..aa4e50b --- /dev/null +++ b/Sources/CodexLimits/UsageOverviewSnapshot.swift @@ -0,0 +1,90 @@ +import ClaudeIntegrationCore +import Foundation + +/// A bounded view of real observations in the current allowance period. +struct UsageOverviewSnapshot: Equatable, Sendable { + static let maximumPoints = 256 + + let range: DateInterval + let target: [UsageChartPoint] + let observedSegments: [[UsageChartPoint]] + let latest: UsageChartPoint? + + init?(chart: UsageChartSnapshot, window: UsageWindow, now: Date) { + guard chart.observedSource == .account, window.isValid, now.isSupportedUsageDate, + window.startsAt <= now, window.resetsAt > now, + chart.currentAllowanceReset == window.resetsAt else { return nil } + range = DateInterval(start: window.startsAt, end: window.resetsAt) + target = Self.compact(chart.target.filter { + Self.isValid($0) && $0.date >= window.startsAt && $0.date <= window.resetsAt + }, limit: 2) + + var segments: [[UsageChartPoint]] = [] + for original in chart.observedSegments { + var segment: [UsageChartPoint] = [] + for point in original { + guard Self.isValid(point), point.date >= window.startsAt, point.date <= now else { + if !segment.isEmpty { segments.append(segment); segment = [] } + continue + } + if let previous = segment.last, point.date <= previous.date { + segments.append(segment) + segment = [] + } + segment.append(point) + } + if !segment.isEmpty { segments.append(segment) } + } + segments.sort { $0[0].date < $1[0].date } + latest = segments.flatMap { $0 }.max { $0.date < $1.date } + let budget = Self.maximumPoints - target.count - (latest == nil ? 0 : 1) + // Preserve both ends of every retained segment; omit older segments if + // the boundary points alone exceed the thumbnail's fixed display budget. + var endpointCount = segments.reduce(0) { $0 + min(2, $1.count) } + var firstKept = 0 + while endpointCount > budget { + endpointCount -= min(2, segments[firstKept].count) + firstKept += 1 + } + let retained = segments.dropFirst(firstKept) + var remainingInterior = retained.reduce(0) { $0 + max(0, $1.count - 2) } + var extraBudget = budget - endpointCount + observedSegments = retained.map { segment in + let interior = max(0, segment.count - 2) + let extra = remainingInterior > 0 + ? min(interior, extraBudget * interior / remainingInterior) : 0 + extraBudget -= extra + remainingInterior -= interior + return Self.compact(segment, limit: min(2, segment.count) + extra) + } + } + + init?(observations: [AllowanceObservation], current: AllowanceObservation?, now: Date, safetyBuffer: Double) { + guard let current, Self.historyReadStart(current: current, now: now) != nil, + let content = IntegrationAllowanceChart( + metric: current.metric, + observations: observations.filter { $0.resetsAt == current.resetsAt }, + current: current, now: now, isStale: true, safetyBuffer: safetyBuffer + ) else { return nil } + self.init(chart: content.chart, window: content.window, now: now) + } + + /// A 31-day elapsed range touches at most 32 UTC daily files. + static func historyReadStart(current: AllowanceObservation?, now: Date) -> Date? { + guard let current, current.isValid, now.isSupportedUsageDate, + current.observedAt <= now, current.resetsAt > now else { return nil } + let earliest = now.addingTimeInterval(-31 * 86_400) + return max(current.startsAt ?? earliest, earliest) + } + + private static func isValid(_ point: UsageChartPoint) -> Bool { + point.date.isSupportedUsageDate && point.remaining.isFinite + && (0 ... 100).contains(point.remaining) + } + + private static func compact(_ points: [UsageChartPoint], limit: Int) -> [UsageChartPoint] { + guard points.count > limit else { return points } + guard limit > 1 else { return Array(points.suffix(limit)) } + return (0 ..< limit).map { points[$0 * (points.count - 1) / (limit - 1)] } + } +} diff --git a/Sources/CodexLimits/UsageReceipts.swift b/Sources/CodexLimits/UsageReceipts.swift index 525bc1a..b51b903 100644 --- a/Sources/CodexLimits/UsageReceipts.swift +++ b/Sources/CodexLimits/UsageReceipts.swift @@ -1206,97 +1206,6 @@ struct UsageReceiptSnapshot: Equatable, Sendable { date >= interval.start && date < interval.end } - private static func filterGapReason( - in contributions: [Contribution], - filters: WorkspaceFilters - ) -> String? { - filterGapReason( - in: contributions.map { - FilterMetadata( - projectID: $0.projectLabel, - taskTreeID: $0.rootTaskID, - model: $0.context?.effectiveModel, - reasoning: $0.context?.reasoning - ) - }, - filters: filters, - subject: "Some local activity", - verb: "has" - ) - } - - private static func filterGapReason( - in diagnostics: [DiagnosticContribution], - filters: WorkspaceFilters - ) -> String? { - filterGapReason( - in: diagnostics.map { - FilterMetadata( - projectID: $0.projectLabel, - taskTreeID: $0.rootTaskID, - model: $0.context?.effectiveModel, - reasoning: $0.context?.reasoning - ) - }, - filters: filters, - subject: "Some local diagnostics", - verb: "have" - ) - } - - private static func filterGapReason( - in metadata: [FilterMetadata], - filters: WorkspaceFilters, - subject: String, - verb: String - ) -> String? { - if filters.projectID != nil, - metadata.contains(where: { - $0.projectID == nil - && couldMatch( - $0, - filters: filters, - ignoring: .project - ) - }) { - return "\(subject) \(verb) no Project metadata" - } - if filters.taskTreeID != nil, - metadata.contains(where: { - $0.taskTreeID == nil - && couldMatch( - $0, - filters: filters, - ignoring: .taskTree - ) - }) { - return "\(subject) \(verb) no Task metadata" - } - if filters.model != nil, - metadata.contains(where: { - $0.model == nil - && couldMatch( - $0, - filters: filters, - ignoring: .model - ) - }) { - return "\(subject) \(verb) no model metadata" - } - if filters.reasoning != nil, - metadata.contains(where: { - $0.reasoning == nil - && couldMatch( - $0, - filters: filters, - ignoring: .reasoning - ) - }) { - return "\(subject) \(verb) no reasoning metadata" - } - return nil - } - private static func couldMatch( _ metadata: FilterMetadata, filters: WorkspaceFilters, diff --git a/Sources/CodexLimitsClaudeRelay/main.swift b/Sources/CodexLimitsClaudeRelay/main.swift new file mode 100644 index 0000000..82ae936 --- /dev/null +++ b/Sources/CodexLimitsClaudeRelay/main.swift @@ -0,0 +1,40 @@ +import ClaudeIntegrationCore +import Foundation + +let receivedAt = Date() +let arguments = CommandLine.arguments +guard arguments.count == 5, + arguments[1] == "--cache", + arguments[3] == "--enabled-marker" else { + exit(64) +} +let cacheURL = URL(fileURLWithPath: arguments[2]) +let markerURL = URL(fileURLWithPath: arguments[4]) + +do { + let input = try ClaudeRelay.readBoundedInput(from: .standardInput) + let snapshot = try ClaudeRelay.decode(input, observedAt: receivedAt) + let didWrite = try ClaudeRelay.storeIfNewer( + snapshot, + at: cacheURL, + enabledMarkerURL: markerURL + ) + if didWrite { + DistributedNotificationCenter.default().postNotificationName( + Notification.Name(ClaudeRelay.snapshotChangedNotificationName), + object: nil, + deliverImmediately: true + ) + } + FileHandle.standardOutput.write( + Data((ClaudeRelay.statusLine(for: snapshot) + "\n").utf8) + ) +} catch ClaudeRelayError.disabled { + exit(0) +} catch ClaudeRelayError.noAllowance { + FileHandle.standardOutput.write( + Data((ClaudeRelay.unavailableStatusLine + "\n").utf8) + ) +} catch { + exit(65) +} diff --git a/Tests/CodexLimitsTests/AllowanceHistoryTests.swift b/Tests/CodexLimitsTests/AllowanceHistoryTests.swift new file mode 100644 index 0000000..341d629 --- /dev/null +++ b/Tests/CodexLimitsTests/AllowanceHistoryTests.swift @@ -0,0 +1,310 @@ +import ClaudeIntegrationCore +import Combine +import Darwin +import Foundation +import XCTest +@testable import CodexLimits + +final class AllowanceHistoryTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + func testDailyAppendRepairsAnInterruptedTailAndDeduplicatesCacheSeeds() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let first = point(now.addingTimeInterval(-600), remaining: 90) + let second = point(now.addingTimeInterval(-300), remaining: 80) + let third = point(now.addingTimeInterval(-60), remaining: 70) + try AllowanceHistory.append([first, second], in: directory) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + let original = try Data(contentsOf: file) + try AllowanceHistory.append([first, second], in: directory) + XCTAssertEqual(try Data(contentsOf: file), original) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data(#"{"metric":"interrupted"#.utf8)) + try handle.close() + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [first, second]) + try AllowanceHistory.append([third], in: directory) + + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [first, second, third]) + XCTAssertEqual(try Data(contentsOf: file).last, 10) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber, 0o700) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber, 0o600) + } + + func testReadUsesExactRollingBoundsWithoutDeletingOlderHistory() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let old = point(now.addingTimeInterval(-90 * 86_400), remaining: 90) + let boundary = point(now.addingTimeInterval(-84 * 86_400), remaining: 80) + let current = point(now.addingTimeInterval(-60), remaining: 70) + let future = point(now.addingTimeInterval(60), remaining: 60) + try AllowanceHistory.append([old, boundary, current, future], in: directory) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).sorted { $0.lastPathComponent < $1.lastPathComponent } + let oldestFile = try XCTUnwrap(files.first) + // Out-of-range archives are neither enumerated nor decoded by the reader. + try Data("unread old archive".utf8).write(to: oldestFile) + + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [boundary, current]) + XCTAssertTrue(FileManager.default.fileExists(atPath: oldestFile.path)) + try AllowanceHistory.delete(in: directory) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path)) + } + + func testInvalidCommittedRecordsAndReadBoundsAreReported() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + try AllowanceHistory.append([point(now, remaining: 90)], in: directory) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + try Data("not-json\n".utf8).write(to: file) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now)) { + XCTAssertEqual($0 as? AllowanceHistoryError, .invalidRecord) + } + try Data(repeating: 0x20, count: AllowanceHistory.maximumFileBytes + 1).write(to: file) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now)) { + XCTAssertEqual($0 as? AllowanceHistoryError, .readLimitExceeded) + } + XCTAssertThrowsError(try AllowanceHistory.append([ + AllowanceObservation(metric: "grok-weekly", observedAt: now, remainingPercent: .nan, resetsAt: now.addingTimeInterval(60)) + ], in: directory)) + XCTAssertThrowsError(try AllowanceHistory.append([ + AllowanceObservation(metric: "grok-weekly", observedAt: now, remainingPercent: 50, resetsAt: now.addingTimeInterval(60), startsAt: now.addingTimeInterval(1)) + ], in: directory)) + XCTAssertFalse(AllowanceObservation( + metric: "grok-weekly", observedAt: now, remainingPercent: 50, + resetsAt: now.addingTimeInterval(60), source: "raw\nresponse" + ).isValid) + let expiredDirectory = temporaryDirectory().appendingPathComponent("History") + try AllowanceHistory.append([ + AllowanceObservation(metric: "grok-weekly", observedAt: now, remainingPercent: 50, resetsAt: now) + ], in: expiredDirectory) + XCTAssertFalse(FileManager.default.fileExists(atPath: expiredDirectory.path)) + } + + func testCurrentPeriodReadSkipsOlderDailyFilesAndKeepsExistingReadBounds() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let old = point(now.addingTimeInterval(-20 * 86_400), remaining: 90) + let current = point(now.addingTimeInterval(-60), remaining: 70) + try AllowanceHistory.append([old, current], in: directory) + let oldestFile = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent }.first) + try Data("invalid older archive\n".utf8).write(to: oldestFile) + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now, since: now.addingTimeInterval(-7 * 86_400)), [current]) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now)) + XCTAssertTrue(try AllowanceHistory.read(in: directory, now: now, since: now.addingTimeInterval(1)).isEmpty) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now, since: Date(timeIntervalSince1970: .nan))) + } + + func testContendedDayFileReturnsWithoutAnUnboundedWait() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let first = point(now.addingTimeInterval(-60), remaining: 90) + try AllowanceHistory.append([first], in: directory) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + let descriptor = open(file.path, O_RDWR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + let started = ProcessInfo.processInfo.systemUptime + + XCTAssertThrowsError(try AllowanceHistory.append([point(now, remaining: 80)], in: directory)) { + XCTAssertEqual($0 as? AllowanceHistoryError, .writeFailed) + } + + XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - started, 1) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [first]) + } + + @MainActor + func testHiddenClaudeRelayRecordsHistoryAndVisibleStoreMigratesAndDeletesIt() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let first = claudeSnapshot(at: Date().addingTimeInterval(-180), remaining: 90) + let second = claudeSnapshot(at: first.observedAt.addingTimeInterval(60), remaining: 80) + let third = claudeSnapshot(at: second.observedAt.addingTimeInterval(60), remaining: 70) + try JSONEncoder().encode(first).write(to: fixture.cacheURL) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + for snapshot in [second, third] { + XCTAssertTrue(try ClaudeRelay.storeIfNewer(snapshot, at: fixture.cacheURL, enabledMarkerURL: fixture.enabledMarkerURL)) + } + XCTAssertTrue(store.history.isEmpty) + XCTAssertNil(store.snapshot) + + await store.setVisible(true) + + XCTAssertEqual(store.snapshot, third) + XCTAssertEqual(store.history.count, 6) + XCTAssertEqual(Set(store.history.map(\.metric)), ["claude-five-hour", "claude-seven-day"]) + XCTAssertEqual(Set(store.history.compactMap(\.source)), ["statusLine.rate_limits"]) + XCTAssertEqual(store.history.first?.observedAt, first.observedAt) + XCTAssertNil(store.historyIssue) + await store.setVisible(false) + XCTAssertTrue(store.history.isEmpty) + await store.setVisible(true) + XCTAssertEqual(store.history.count, 6) + + await store.deleteData() + + XCTAssertTrue(store.history.isEmpty) + XCTAssertNil(store.snapshot) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.historyDirectory.path)) + XCTAssertFalse(store.hasStoredData) + XCTAssertThrowsError(try ClaudeRelay.storeIfNewer(third, at: fixture.cacheURL, enabledMarkerURL: fixture.enabledMarkerURL)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.historyDirectory.path)) + } + + @MainActor + func testClaudeHistoryFailureKeepsTheLatestAllowanceAndReportsTheIssue() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + try Data("blocked history directory".utf8).write(to: fixture.historyDirectory) + let snapshot = claudeSnapshot(at: Date().addingTimeInterval(-60), remaining: 65) + + XCTAssertTrue(try ClaudeRelay.storeIfNewer(snapshot, at: fixture.cacheURL, enabledMarkerURL: fixture.enabledMarkerURL)) + let persisted = try ClaudeRelay.readSnapshot(at: fixture.cacheURL) + XCTAssertEqual(persisted.sevenDay, snapshot.sevenDay) + XCTAssertEqual(persisted.historyWriteFailed, true) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + await store.setVisible(true) + XCTAssertEqual(store.snapshot?.sevenDay, snapshot.sevenDay) + XCTAssertNotNil(store.historyIssue) + XCTAssertTrue(store.history.isEmpty) + } + + @MainActor + func testOverviewDoesNotReadHistoryAndDetailStillLoadsWithoutLatestCache() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let snapshot = claudeSnapshot(at: Date().addingTimeInterval(-60), remaining: 70) + try AllowanceHistory.append(snapshot.historyObservations, in: fixture.historyDirectory) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + + await store.setVisible(true, includeHistory: false) + XCTAssertTrue(store.history.isEmpty) + XCTAssertNil(store.overview) + XCTAssertNil(store.historyIssue) + await store.setVisible(true) + + XCTAssertNil(store.snapshot) + XCTAssertEqual(store.history.count, 2) + XCTAssertNil(store.historyIssue) + await store.setVisible(false) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil).first) + try Data("invalid history\n".utf8).write(to: file) + await store.setVisible(true, includeHistory: false) + XCTAssertNil(store.historyIssue) + await store.setVisible(true) + XCTAssertNotNil(store.historyIssue) + } + + @MainActor + func testClaudeOverviewUsesOnlyCurrentPeriodAndReleasesItsCompactSnapshot() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let snapshot = claudeSnapshot(at: Date().addingTimeInterval(-60), remaining: 70) + try JSONEncoder().encode(snapshot).write(to: fixture.cacheURL) + let previous = snapshot.historyObservations.map { + AllowanceObservation(metric: $0.metric, observedAt: $0.observedAt.addingTimeInterval(-600), + remainingPercent: 80, resetsAt: $0.resetsAt, startsAt: $0.startsAt, source: $0.source) + } + try AllowanceHistory.append(previous, in: fixture.historyDirectory) + let old = claudeSnapshot(at: Date().addingTimeInterval(-20 * 86_400), remaining: 90) + let oldPoint = AllowanceObservation(metric: "claude-seven-day", observedAt: old.observedAt, + remainingPercent: 90, resetsAt: old.observedAt.addingTimeInterval(60)) + try AllowanceHistory.append([oldPoint], in: fixture.historyDirectory) + let oldestFile = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent }.first) + try Data("invalid old archive\n".utf8).write(to: oldestFile) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + + await store.setVisible(true, includeHistory: false, safetyBuffer: 5) + + XCTAssertTrue(store.history.isEmpty) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 2) + XCTAssertEqual(store.overview?.latest?.remaining, 70) + XCTAssertEqual(store.overview?.target.last?.remaining, 5) + XCTAssertNil(store.historyIssue) + await store.setVisible(true) + XCTAssertNil(store.overview) + XCTAssertNotNil(store.historyIssue) + await store.setVisible(false) + let newestFile = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent }.last) + try Data("invalid current archive\n".utf8).write(to: newestFile) + await store.setVisible(true, includeHistory: false) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 1) + XCTAssertNotNil(store.historyIssue) + await store.setEnabled(false) + XCTAssertNil(store.overview) + await store.deleteData() + XCTAssertNil(store.overview) + } + + @MainActor + func testNotificationDuringHistoryReadTriggersOneLatestCacheRead() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let observedAt = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970 / 86_400) * 86_400 - 43_200) + let first = claudeSnapshot(at: observedAt, remaining: 90) + let second = claudeSnapshot(at: observedAt.addingTimeInterval(60), remaining: 80) + try JSONEncoder().encode(first).write(to: fixture.cacheURL) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + await store.setVisible(true) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil).first) + let descriptor = open(file.path, O_RDWR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + var cacheWasRead = false + let observation = store.$snapshot.dropFirst().sink { _ in cacheWasRead = true } + defer { observation.cancel() } + let reading = Task { await store.checkForNewObservation(priority: .automatic) } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while !cacheWasRead, ContinuousClock.now < deadline { await Task.yield() } + XCTAssertTrue(cacheWasRead) + try await Task.sleep(for: .milliseconds(10)) + + try JSONEncoder().encode(second).write(to: fixture.cacheURL, options: .atomic) + await store.checkForNewObservation(priority: .automatic) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + await reading.value + while store.snapshot?.observedAt != second.observedAt, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + + XCTAssertEqual(store.snapshot?.observedAt, second.observedAt) + XCTAssertEqual(store.snapshot?.sevenDay?.remainingPercent, 80) + } + + private func point(_ observedAt: Date, remaining: Double) -> AllowanceObservation { + AllowanceObservation( + metric: "grok-weekly", observedAt: observedAt, + remainingPercent: remaining, resetsAt: observedAt.addingTimeInterval(86_400), + startsAt: observedAt.addingTimeInterval(-6 * 86_400), source: "creditUsagePercent" + ) + } + + private func claudeSnapshot(at observedAt: Date, remaining: Double) -> ClaudeAllowanceSnapshot { + let reset = Date().addingTimeInterval(3_600) + return ClaudeAllowanceSnapshot( + observedAt: observedAt, cliVersion: "2.1.231", + fiveHour: ClaudeAllowanceWindowSnapshot(remainingPercent: remaining, resetsAt: reset), + sevenDay: ClaudeAllowanceWindowSnapshot(remainingPercent: remaining, resetsAt: reset) + ) + } + + private func claudeFixture() throws -> ClaudeCodeIntegrationPaths { + let root = temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let executable = root.appendingPathComponent("claude") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + return ClaudeCodeIntegrationPaths( + executableCandidates: [executable], settingsURL: root.appendingPathComponent("settings.json"), + dataDirectory: root.appendingPathComponent("data"), helperURL: executable + ) + } +} diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index a7736ba..f8d4c5c 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -116,11 +116,16 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertFalse(AnalyticsGraph.concurrency.usesAccountScope) } - func testLightweightCoreOffersOnlyAccountGraphs() { - XCTAssertEqual( - AnalyticsGraph.coreCases, - [.usageRemaining, .tokenActivity] - ) + func testLocalAnalyticsVisibilityMatchesTheSelectedSurface() { + var state = AnalyticsExplorationState.initial + XCTAssertFalse(state.usesLocalAnalytics) + + state.graph = .usagePerToken + XCTAssertTrue(state.usesLocalAnalytics) + + state.section = .facts + state.graph = .usageRemaining + XCTAssertTrue(state.usesLocalAnalytics) } func testRestoredLocalGraphFallsBackToUsageRemaining() throws { @@ -440,152 +445,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testAccountTokenIntervalSelectionPreservesFactualIdentity() { - let range = DateInterval( - start: Date(timeIntervalSince1970: 1_000), - end: Date(timeIntervalSince1970: 10_000) - ) - let positive = AccountTokenActivityInterval( - start: Date(timeIntervalSince1970: 2_000), - end: Date(timeIntervalSince1970: 3_000), - tokenDelta: 300, - method: .lifetimeDelta, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: range.end - ) - let zero = AccountTokenActivityInterval( - start: Date(timeIntervalSince1970: 5_000), - end: Date(timeIntervalSince1970: 6_000), - tokenDelta: 0, - method: .lifetimeDelta, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: range.end - ) - let daily = AccountTokenActivityInterval( - start: Date(timeIntervalSince1970: 7_000), - end: Date(timeIntervalSince1970: 8_000), - tokenDelta: 900, - method: .dailyBuckets, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: nil - ) - let intervals = [daily, zero, positive] - - XCTAssertEqual( - accountTokenInterval( - at: Date(timeIntervalSince1970: 2_500), - in: intervals, - within: range - ), - positive - ) - XCTAssertEqual( - accountTokenInterval( - at: Date(timeIntervalSince1970: 5_500), - in: intervals, - within: range - ), - zero - ) - XCTAssertNil(accountTokenInterval( - at: Date(timeIntervalSince1970: 4_000), - in: intervals, - within: range - )) - XCTAssertNil(accountTokenInterval( - at: Date(timeIntervalSince1970: 9_000), - in: intervals, - within: range - )) - XCTAssertEqual( - steppedAccountTokenInterval( - in: intervals, - from: nil, - by: 1 - ), - positive - ) - XCTAssertEqual( - steppedAccountTokenInterval( - in: intervals, - from: positive, - by: 1 - ), - zero - ) - XCTAssertEqual( - steppedAccountTokenInterval( - in: intervals, - from: zero, - by: 1 - ), - daily - ) - XCTAssertEqual( - retainedAccountTokenInterval( - daily, - in: intervals, - range: range - ), - daily - ) - XCTAssertNil(retainedAccountTokenInterval( - daily, - in: [positive, zero], - range: range - )) - XCTAssertNil(retainedAccountTokenInterval( - daily, - in: intervals, - range: DateInterval(start: range.start, end: daily.start) - )) - XCTAssertEqual(zero.method.displayName, "Lifetime counter interval") - XCTAssertEqual(daily.method.displayName, "UTC daily bucket") - XCTAssertTrue( - accountTokenIntervalAccessibilityValue(zero) - .contains("0 account tokens. Account.") - ) - XCTAssertTrue( - accountTokenIntervalAccessibilityValue(daily) - .contains("UTC daily bucket") - ) - } - - func testSelectedTokenIntervalFormattingDoesNotChangeIdentity() throws { - let formatter = ISO8601DateFormatter() - let interval = AccountTokenActivityInterval( - start: try XCTUnwrap(formatter.date(from: "2026-07-01T00:00:00Z")), - end: try XCTUnwrap(formatter.date(from: "2026-07-02T00:00:00Z")), - tokenDelta: 900, - method: .dailyBuckets, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: nil - ) - let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) - let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) - let locale = Locale(identifier: "en_US_POSIX") - let dateInterval = DateInterval(start: interval.start, end: interval.end) - - XCTAssertNotEqual( - accountTokenIntervalText( - dateInterval, - timeZone: utc, - locale: locale - ), - accountTokenIntervalText( - dateInterval, - timeZone: berlin, - locale: locale - ) - ) - XCTAssertEqual(interval.tokenDelta, 900) - XCTAssertEqual(interval.id, interval) - } - func testTokenDisplayAggregationPreservesTotalsGapsAndBreaks() { let base = Date(timeIntervalSince1970: 1_000) let reset = base.addingTimeInterval(20_000) @@ -1566,76 +1425,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testPresentationViewsRenderEveryStateAtLargeAndSmallSizes() { - let presentations: [AnalyticsWorkspacePresentation] = [ - .loading, - .valid, - .stale("Showing the last update."), - .empty, - .sourceError("Couldn’t read Codex usage.") - ] - let sizes = [ - CGSize(width: 390, height: 430), - CGSize(width: 640, height: 780) - ] - - for presentation in presentations { - for size in sizes { - XCTAssertTrue( - renders( - AnalyticsWorkspacePresentationView( - presentation: presentation, - refresh: {} - ) { - Text("Workspace content") - }, - size: size - ), - "\(presentation) did not render at \(size)" - ) - } - } - } - - func testWorkspaceBodyRendersGraphsFactsAndInsights() { - let defaults = UserDefaults( - suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" - )! - let store = AnalyticsWorkspaceStore(defaults: defaults) - let reader = reader(fetchedAt: Date(timeIntervalSince1970: 10_000)) - - for section in AnalyticsSection.allCases { - store.selectSection(section) - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: store, - assistedInsights: CodexAssistedInsightStore() - ), - size: CGSize(width: 640, height: 620) - ), - "\(section.rawValue) did not render" - ) - } - - store.selectSection(.graphs) - for graph in AnalyticsGraph.allCases { - store.selectGraph(graph) - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: store, - assistedInsights: CodexAssistedInsightStore() - ), - size: CGSize(width: 640, height: 620) - ), - "\(graph.rawValue) did not render" - ) - } - } - func testExpiredCurrentWindowRendersUnavailableAndHistoricalUsage() throws { let start = try date("2026-08-01T12:13:00Z") let reset = try date("2026-08-08T12:13:00Z") @@ -1701,48 +1490,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testUsagePerTokenComparisonRendersAtSmallAndLargeSizes() { - let suiteName = - "AnalyticsWorkspaceTests-usage-per-token-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let store = AnalyticsWorkspaceStore(defaults: defaults) - store.selectTimeRange(.fourWeeks) - let current = comparisonWeek( - index: 5, - movement: 40, - tokens: 10_000_000 - ) - let history = (0 ... 3).map { - comparisonWeek( - index: $0, - movement: Double(($0 + 1) * 10), - tokens: 10_000_000 - ) - } - let snapshot = UsagePerTokenEngine.evaluate( - current: current, - history: history, - pinnedBaselineID: nil - ) - - for size in [ - CGSize(width: 390, height: 430), - CGSize(width: 640, height: 780) - ] { - XCTAssertTrue( - renders( - UsagePerTokenWorkspace( - sourceSnapshot: snapshot, - store: store - ), - size: size - ), - "Usage per token comparison did not render at \(size)" - ) - } - } - private func date(_ value: String) throws -> Date { try XCTUnwrap(ISO8601DateFormatter().date(from: value)) } @@ -1801,42 +1548,4 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - private func comparisonWeek( - index: Int, - movement: Double, - tokens: Int64 - ) -> WeeklyUsageEvidence { - let start = Date(timeIntervalSince1970: 10_000) - .addingTimeInterval(Double(index) * 7 * 86_400) - return WeeklyUsageEvidence( - id: "week-\(index)", - accountPartitionID: "account-a", - limitID: "weekly", - windowDurationMinutes: 10_080, - allowanceResetsAt: start.addingTimeInterval(7 * 86_400), - interval: DateInterval(start: start, duration: 7 * 86_400), - isComplete: index < 5, - accountMovementPoints: movement, - accountTokenActivity: tokens, - localTokenActivity: Int64(Double(tokens) * 0.9), - localCoveragePercent: 90, - boundaryQuality: .tight, - maximumAccountGap: 15 * 60, - modelShares: [ - "gpt-5.6-sol": 0.8, - "gpt-5.6-luna": 0.2 - ], - modelAttributionPercent: 100, - reasoningShares: ["high": 0.8, "medium": 0.2], - reasoningAttributionPercent: 100, - cachedInputShare: 0.4, - containsUnknownCorrection: false, - containsAccountChange: false, - containsCounterDecrease: false, - tokenDefinitionsAlign: true, - localSourceContinuous: true, - localSourceReason: nil - ) - } - } diff --git a/Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift b/Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift new file mode 100644 index 0000000..be09cbd --- /dev/null +++ b/Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift @@ -0,0 +1,522 @@ +import ClaudeIntegrationCore +import Combine +import Darwin +import Foundation +import XCTest +@testable import CodexLimits + +final class ClaudeCodeSetupServiceTests: XCTestCase { + func testQAPathsStayInsideTheQABaseAndBundle() { + let base = URL(fileURLWithPath: "/qa-data", isDirectory: true) + let bundle = URL(fileURLWithPath: "/qa-app", isDirectory: true) + + let paths = ClaudeCodeIntegrationPaths.isolatedQA( + base: base, + bundleURL: bundle + ) + + XCTAssertEqual( + paths.executableCandidates, + [bundle.appendingPathComponent( + "Contents/Helpers/CodexLimitsClaudeRelay" + )] + ) + XCTAssertEqual( + paths.settingsURL, + base.appendingPathComponent("Fixtures/ClaudeCode/settings.json") + ) + XCTAssertEqual( + paths.dataDirectory, + base.appendingPathComponent( + "Integrations/ClaudeCode", + isDirectory: true + ) + ) + XCTAssertEqual(paths.helperURL, paths.executableCandidates[0]) + } + + func testSevenDayResetExpiresThePrimaryMetricEvenWhenFiveHourIsStillValid() { + let now = Date(timeIntervalSince1970: 10_000) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: now.addingTimeInterval(-60), + cliVersion: "2.1.92", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 80, + resetsAt: now.addingTimeInterval(60) + ), + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 40, + resetsAt: now + ) + ) + + XCTAssertEqual(snapshot.displayFreshness(now: now), .expired) + } + + func testFreshnessFallsBackToFiveHourWhenSevenDayIsMissing() { + let now = Date(timeIntervalSince1970: 10_000) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: now.addingTimeInterval(-31 * 60), + cliVersion: "2.1.92", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 80, + resetsAt: now.addingTimeInterval(60) + ), + sevenDay: nil + ) + + XCTAssertEqual(snapshot.displayFreshness(now: now), .stale) + } + + @MainActor + func testDisableSuppressesAnInFlightReadinessResult() async throws { + let fixture = try fixture(settings: [ + "payload": Array(repeating: "x", count: 200_000) + ]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + let store = ClaudeCodeIntegrationStore( + isEnabled: false, + service: service + ) + var readinessValues: [ClaudeCodeReadiness] = [] + let observation = store.$readiness.sink { + readinessValues.append($0) + } + defer { observation.cancel() } + + let enabling = Task { @MainActor in + await store.setEnabled(true) + } + while store.readiness != .checking { + await Task.yield() + } + try await Task.sleep(for: .milliseconds(1)) + let probe = ClaudeServiceProbe() + let queuedProbe = Task { + _ = await service.hasStoredData() + await probe.complete() + } + try await Task.sleep(for: .milliseconds(1)) + let probeCompletedEarly = await probe.isComplete + XCTAssertFalse(probeCompletedEarly) + + await store.setEnabled(false) + await enabling.value + await queuedProbe.value + + XCTAssertEqual(store.readiness, .disabled) + XCTAssertFalse(readinessValues.contains(.setUp)) + } + + @MainActor + func testUnselectedHiddenClaudeDefersCacheReadUntilVisible() async throws { + let fixture = try fixture(settings: [:]) + try FileManager.default.createDirectory( + at: fixture.paths.dataDirectory, + withIntermediateDirectories: true + ) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 80, + resetsAt: Date().addingTimeInterval(3_600) + ), + sevenDay: nil + ) + try JSONEncoder().encode(snapshot).write(to: fixture.paths.cacheURL) + let store = ClaudeCodeIntegrationStore( + isEnabled: true, + menuBarSourceActive: false, + service: ClaudeCodeSetupService(paths: fixture.paths) + ) + + try await Task.sleep(for: .milliseconds(20)) + XCTAssertNil(store.snapshot) + XCTAssertEqual(store.readiness, .checking) + + await store.setVisible(true) + + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.readiness, .setUp) + } + + @MainActor + func testHidingClaudeSuppressesQueuedVisibleRead() async throws { + let fixture = try fixture(settings: [:]) + let coordinator = IntegrationWorkCoordinator() + let gate = ClaudeCoordinatorGate() + let blocker = Task { + await coordinator.run(priority: .explicit) { + await gate.hold() + } + } + while !(await gate.started) { + await Task.yield() + } + let store = ClaudeCodeIntegrationStore( + isEnabled: true, + menuBarSourceActive: false, + service: ClaudeCodeSetupService(paths: fixture.paths), + integrationWorkCoordinator: coordinator + ) + let showing = Task { @MainActor in + await store.setVisible(true) + } + try await Task.sleep(for: .milliseconds(10)) + + await store.setVisible(false) + await gate.release() + await blocker.value + await showing.value + XCTAssertEqual(store.readiness, .checking) + + await store.setVisible(true) + XCTAssertEqual(store.readiness, .setUp) + } + + func testSetupPreservesSettingsAndExactDeactivateRemovesOnlyOwnedStatusLine() async throws { + let fixture = try fixture(settings: ["model": "sonnet"]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + + let installed = try await service.setUp() + XCTAssertEqual(installed.readiness, .waitingForData) + var settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNotNil(settings["statusLine"]) + + let didDeactivate = await service.deactivate() + XCTAssertTrue(didDeactivate) + settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNil(settings["statusLine"]) + } + + func testDeactivateRemovesASettingsFileCreatedBySetup() async throws { + let fixture = try fixture(settings: nil) + let service = ClaudeCodeSetupService(paths: fixture.paths) + + _ = try await service.setUp() + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.paths.settingsURL.path + )) + + let didDeactivate = await service.deactivate() + + XCTAssertTrue(didDeactivate) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.paths.settingsURL.path + )) + } + + func testDeactivateKeepsSettingsAddedAfterSetupCreatedTheFile() async throws { + let fixture = try fixture(settings: nil) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + var settings = try settingsObject(at: fixture.paths.settingsURL) + settings["model"] = "sonnet" + try JSONSerialization.data(withJSONObject: settings).write( + to: fixture.paths.settingsURL, + options: .atomic + ) + + let didDeactivate = await service.deactivate() + + XCTAssertTrue(didDeactivate) + settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNil(settings["statusLine"]) + } + + func testSetupRefusesUserOwnedStatusLineWithoutChangingIt() async throws { + let owned: [String: Any] = [ + "type": "command", + "command": "user-status", + "padding": 2 + ] + let fixture = try fixture(settings: ["statusLine": owned]) + let before = try Data(contentsOf: fixture.paths.settingsURL) + let service = ClaudeCodeSetupService(paths: fixture.paths) + + let inspection = await service.inspect() + XCTAssertEqual(inspection.readiness, .conflict) + do { + _ = try await service.setUp() + XCTFail("Expected a status-line conflict") + } catch ClaudeCodeSetupService.SetupError.conflict { + } + + XCTAssertEqual(try Data(contentsOf: fixture.paths.settingsURL), before) + } + + func testDeactivateLeavesModifiedOwnedConfigurationAndStopsWrites() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + var settings = try settingsObject(at: fixture.paths.settingsURL) + var statusLine = try XCTUnwrap(settings["statusLine"] as? [String: Any]) + statusLine["padding"] = 1 + settings["statusLine"] = statusLine + try JSONSerialization.data(withJSONObject: settings).write( + to: fixture.paths.settingsURL, + options: .atomic + ) + + let didDeactivate = await service.deactivate() + XCTAssertFalse(didDeactivate) + XCTAssertNotNil( + try settingsObject(at: fixture.paths.settingsURL)["statusLine"] + ) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.paths.enabledMarkerURL.path + )) + } + + func testDeleteDataRemovesOnlyOwnedSettingsAndFiles() async throws { + let fixture = try fixture(settings: ["model": "sonnet"]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 50, + resetsAt: Date().addingTimeInterval(3_600) + ) + ) + try JSONEncoder().encode(snapshot).write(to: fixture.paths.cacheURL) + let readyInspection = await service.inspect() + XCTAssertEqual(readyInspection.readiness, .ready) + + let deleted = await service.deleteData() + XCTAssertTrue(deleted) + let settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNil(settings["statusLine"]) + let hasStoredData = await service.hasStoredData() + XCTAssertFalse(hasStoredData) + } + + func testDeletionWaitsForAnActiveRelayAndKeepsItsLockInode() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let lockURL = fixture.paths.cacheURL.appendingPathExtension("lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + let inode = try FileManager.default.attributesOfItem(atPath: lockURL.path)[.systemFileNumber] as? NSNumber + let deleting = Task { await service.deleteData() } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path), + ContinuousClock.now < deadline { + await Task.yield() + } + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path)) + + // A relay past the marker check can still finish its write under this lock. + try Data("in-flight snapshot".utf8).write(to: fixture.paths.cacheURL) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + let deleted = await deleting.value + + XCTAssertTrue(deleted) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.paths.cacheURL.path)) + XCTAssertEqual( + try FileManager.default.attributesOfItem(atPath: lockURL.path)[.systemFileNumber] as? NSNumber, + inode + ) + } + + func testSetupSupersedesDeletionWaitingForARelay() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let lockURL = fixture.paths.cacheURL.appendingPathExtension("lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + let deleting = Task { await service.deleteData() } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path), + ContinuousClock.now < deadline { + await Task.yield() + } + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path)) + + _ = try await service.setUp() + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + let deleted = await deleting.value + + XCTAssertFalse(deleted) + XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path)) + XCTAssertNotNil(try settingsObject(at: fixture.paths.settingsURL)["statusLine"]) + } + + @MainActor + func testReadFailureAndMissingCLIPreserveTheLastSnapshot() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 50, + resetsAt: Date().addingTimeInterval(3_600) + ) + ) + let data = try JSONEncoder().encode(snapshot) + try data.write(to: fixture.paths.cacheURL) + let store = ClaudeCodeIntegrationStore( + isEnabled: true, + menuBarSourceActive: false, + service: service + ) + await store.setVisible(true) + XCTAssertEqual(store.displayFreshness, .fresh) + + try Data("invalid cache".utf8).write(to: fixture.paths.cacheURL) + await store.checkForNewObservation() + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.readiness, .failed) + XCTAssertEqual(store.displayFreshness, .stale) + await store.settingsPresented() + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.displayFreshness, .stale) + + try data.write(to: fixture.paths.cacheURL) + await store.checkForNewObservation() + XCTAssertEqual(store.readiness, .ready) + XCTAssertEqual(store.displayFreshness, .fresh) + + try FileManager.default.removeItem(at: fixture.paths.executableCandidates[0]) + await store.settingsPresented() + XCTAssertEqual(store.readiness, .notFound) + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.displayFreshness, .stale) + } + + func testSelectedExecutableMustBeARegularExecutableFile() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService( + paths: ClaudeCodeIntegrationPaths( + executableCandidates: [], + settingsURL: fixture.paths.settingsURL, + dataDirectory: fixture.paths.dataDirectory, + helperURL: fixture.paths.helperURL + ) + ) + let plainFile = fixture.root.appendingPathComponent("not-executable") + try Data().write(to: plainFile) + + let missingInspection = await service.inspect() + XCTAssertEqual(missingInspection.readiness, .notFound) + let rejected = await service.selectExecutable(plainFile) + XCTAssertNil(rejected) + let inspection = await service.selectExecutable( + fixture.paths.executableCandidates[0] + ) + XCTAssertEqual(inspection?.readiness, .setUp) + } + + func testUpdateRequiredPreservesACompatibleSnapshot() async throws { + let fixture = try fixture(settings: [:]) + try FileManager.default.createDirectory( + at: fixture.paths.dataDirectory, + withIntermediateDirectories: true + ) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 75, + resetsAt: Date().addingTimeInterval(3_600) + ), + sevenDay: nil + ) + try JSONEncoder().encode(snapshot).write(to: fixture.paths.cacheURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: fixture.paths.helperURL.path + ) + + let inspection = await ClaudeCodeSetupService( + paths: fixture.paths + ).inspect() + + XCTAssertEqual(inspection.readiness, .updateRequired) + XCTAssertEqual(inspection.snapshot, snapshot) + } + + private func fixture( + settings: [String: Any]? + ) throws -> (paths: ClaudeCodeIntegrationPaths, root: URL) { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let executable = root.appendingPathComponent("claude") + let helper = root.appendingPathComponent("relay") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try Data("#!/bin/sh\n".utf8).write(to: helper) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: helper.path + ) + let settingsURL = root.appendingPathComponent("settings.json") + if let settings { + try JSONSerialization.data(withJSONObject: settings).write( + to: settingsURL + ) + } + return ( + ClaudeCodeIntegrationPaths( + executableCandidates: [executable], + settingsURL: settingsURL, + dataDirectory: root.appendingPathComponent("data", isDirectory: true), + helperURL: helper + ), + root + ) + } + + private func settingsObject(at url: URL) throws -> [String: Any] { + try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(contentsOf: url) + ) as? [String: Any] + ) + } +} + +private actor ClaudeServiceProbe { + private(set) var isComplete = false + + func complete() { + isComplete = true + } +} + +private actor ClaudeCoordinatorGate { + private(set) var started = false + private var continuation: CheckedContinuation? + + func hold() async { + started = true + await withCheckedContinuation { continuation = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + } +} diff --git a/Tests/CodexLimitsTests/ClaudeRelayTests.swift b/Tests/CodexLimitsTests/ClaudeRelayTests.swift new file mode 100644 index 0000000..a4a3fec --- /dev/null +++ b/Tests/CodexLimitsTests/ClaudeRelayTests.swift @@ -0,0 +1,185 @@ +import ClaudeIntegrationCore +import XCTest + +final class ClaudeRelayTests: XCTestCase { + func testBoundedReaderConsumesMultipleChunksAndStopsAtTheCap() throws { + let root = temporaryDirectory() + let inputURL = root.appendingPathComponent("input.json") + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + var input = Data(repeating: 0x20, count: 70 * 1_024) + input.append(Data( + #"{"rate_limits":{"seven_day":{"used_percentage":40,"resets_at":2200000}}}"#.utf8 + )) + try input.write(to: inputURL) + + var handle = try FileHandle(forReadingFrom: inputURL) + var read = try ClaudeRelay.readBoundedInput(from: handle) + try handle.close() + XCTAssertEqual(read, input) + XCTAssertNoThrow(try ClaudeRelay.decode(read, observedAt: Date())) + + try Data( + repeating: 0x20, + count: ClaudeRelay.maximumInputBytes + 100 + ).write(to: inputURL) + handle = try FileHandle(forReadingFrom: inputURL) + read = try ClaudeRelay.readBoundedInput(from: handle) + try handle.close() + XCTAssertEqual(read.count, ClaudeRelay.maximumInputBytes + 1) + } + + func testUnavailableStatusLineIsUsefulAndNeutral() { + XCTAssertEqual(ClaudeRelay.unavailableStatusLine, "Usage unavailable") + } + + func testDecodesOnlyBoundedAllowanceWindows() throws { + let observedAt = Date(timeIntervalSince1970: 2_000_000) + let snapshot = try ClaudeRelay.decode( + Data(#"{"version":"2.1.92","session_id":"private","model":{"display_name":"Private"},"rate_limits":{"five_hour":{"used_percentage":25,"resets_at":2100000},"seven_day":{"used_percentage":40,"resets_at":2200000}}}"#.utf8), + observedAt: observedAt + ) + + XCTAssertEqual(snapshot.observedAt, observedAt) + XCTAssertEqual(snapshot.cliVersion, "2.1.92") + XCTAssertEqual(snapshot.fiveHour?.remainingPercent, 75) + XCTAssertEqual(snapshot.sevenDay?.remainingPercent, 60) + XCTAssertEqual( + ClaudeRelay.statusLine(for: snapshot), + "7d 60% remaining · 5h 75% remaining" + ) + let encoded = String( + data: try JSONEncoder().encode(snapshot), + encoding: .utf8 + )! + XCTAssertFalse(encoded.contains("session")) + XCTAssertFalse(encoded.contains("model")) + XCTAssertFalse(encoded.contains("Private")) + } + + func testRejectsInvalidOrUnboundedInput() { + XCTAssertThrowsError(try ClaudeRelay.decode( + Data(#"{"rate_limits":{"seven_day":{"used_percentage":101,"resets_at":2200000}}}"#.utf8), + observedAt: Date() + )) + XCTAssertThrowsError(try ClaudeRelay.decode( + Data(repeating: 0x20, count: ClaudeRelay.maximumInputBytes + 1), + observedAt: Date() + )) + XCTAssertThrowsError(try ClaudeRelay.decode( + Data(#"{"rate_limits":{}}"#.utf8), + observedAt: Date() + )) + } + + func testOlderWriterCannotReplaceNewerSnapshot() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let cache = root.appendingPathComponent("snapshot.json") + let marker = root.appendingPathComponent("enabled") + try Data().write(to: marker) + let newer = snapshot(observedAt: 200, remaining: 60) + let older = snapshot(observedAt: 100, remaining: 80) + + _ = try ClaudeRelay.storeIfNewer( + newer, + at: cache, + enabledMarkerURL: marker + ) + _ = try ClaudeRelay.storeIfNewer( + older, + at: cache, + enabledMarkerURL: marker + ) + + XCTAssertEqual(try ClaudeRelay.readSnapshot(at: cache), newer) + } + + func testEquivalentObservationWithinThirtySecondsDoesNotRewriteCache() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let cache = root.appendingPathComponent("snapshot.json") + let marker = root.appendingPathComponent("enabled") + try Data().write(to: marker) + let first = snapshot(observedAt: 100, remaining: 80) + let equivalent = snapshot(observedAt: 110, remaining: 80) + let changed = snapshot(observedAt: 111, remaining: 79) + + XCTAssertTrue(try ClaudeRelay.storeIfNewer( + first, + at: cache, + enabledMarkerURL: marker + )) + XCTAssertFalse(try ClaudeRelay.storeIfNewer( + equivalent, + at: cache, + enabledMarkerURL: marker + )) + XCTAssertTrue(try ClaudeRelay.storeIfNewer( + changed, + at: cache, + enabledMarkerURL: marker + )) + XCTAssertEqual(try ClaudeRelay.readSnapshot(at: cache), changed) + } + + func testReaderRejectsTamperedCache() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let cache = root.appendingPathComponent("snapshot.json") + try JSONEncoder().encode( + snapshot(observedAt: 100, remaining: 200) + ).write(to: cache) + + XCTAssertThrowsError(try ClaudeRelay.readSnapshot(at: cache)) + + try JSONEncoder().encode(ClaudeAllowanceSnapshot( + observedAt: Date(timeIntervalSince1970: 100), + cliVersion: "private\nvalue", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 50, + resetsAt: Date(timeIntervalSince1970: 1_000) + ) + )).write(to: cache) + XCTAssertThrowsError(try ClaudeRelay.readSnapshot(at: cache)) + } + + func testMissingEnabledMarkerPreventsPersistence() { + let root = temporaryDirectory() + let cache = root.appendingPathComponent("snapshot.json") + + XCTAssertThrowsError(try ClaudeRelay.storeIfNewer( + snapshot(observedAt: 100, remaining: 80), + at: cache, + enabledMarkerURL: root.appendingPathComponent("missing") + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + } + + private func snapshot( + observedAt: TimeInterval, + remaining: Double + ) -> ClaudeAllowanceSnapshot { + ClaudeAllowanceSnapshot( + observedAt: Date(timeIntervalSince1970: observedAt), + cliVersion: "2.1.92", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: remaining, + resetsAt: Date(timeIntervalSince1970: 1_000) + ) + ) + } +} diff --git a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift index d978441..6c3842e 100644 --- a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift +++ b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift @@ -1,5 +1,3 @@ -import AppKit -import SwiftUI import XCTest @testable import CodexLimits @@ -379,8 +377,6 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertFalse(failedStore.showsAnalyzeAction) XCTAssertEqual(missingCalls.analysisCalls, 0) XCTAssertEqual(failedCalls.analysisCalls, 0) - XCTAssertFalse(missingStore.showsCard) - XCTAssertFalse(failedStore.showsCard) } func testCancelledAvailabilityCheckCanRunAgain() async { @@ -401,7 +397,6 @@ final class CodexAssistedInsightTests: XCTestCase { let calls = await service.snapshot() XCTAssertEqual(calls.catalogCalls, 2) XCTAssertTrue(store.showsAnalyzeAction) - XCTAssertTrue(store.showsCard) } func testExplicitAnalysisClickPublishesMarkedResultAndOverhead() async { @@ -562,7 +557,6 @@ final class CodexAssistedInsightTests: XCTestCase { let calls = await service.snapshot() XCTAssertEqual(calls.catalogCalls, 2) XCTAssertFalse(store.showsAnalyzeAction) - XCTAssertFalse(store.showsCard) XCTAssertNil(store.result) } @@ -1367,108 +1361,6 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertEqual(fixture.snapshot().turnStartCount, 0) } - func testActionProgressFailureAndResultRenderAtSmallAndLargeSizes() async { - let reader = UsageIntelligenceEngine.evaluate( - UsageIntelligenceInput( - account: nil, - samples: [], - safetyBuffer: 3, - sourceState: .available, - now: Date(timeIntervalSince1970: 2_000), - previousStatus: nil - ) - ) - let defaults = UserDefaults( - suiteName: "CodexAssistedInsightTests-\(UUID().uuidString)" - )! - let workspace = AnalyticsWorkspaceStore(defaults: defaults) - workspace.selectSection(.insights) - - let successService = AssistedServiceFixture( - catalogResult: .success(eligibleProfile()), - analysisResult: .succeeded(analysisResult()) - ) - let successStore = CodexAssistedInsightStore(service: successService) - await successStore.checkAvailability() - for size in [ - CGSize(width: 420, height: 620), - CGSize(width: 720, height: 780) - ] { - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: successStore - ), - size: size - ) - ) - } - - successStore.startAnalysis( - payload: metadataPayload(), - scope: analysisScope() - ) - await successStore.waitForAnalysis() - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: successStore - ), - size: CGSize(width: 520, height: 720) - ) - ) - - let delayedService = AssistedServiceFixture( - catalogResult: .success(eligibleProfile()), - analysisResult: .delayed - ) - let delayedStore = CodexAssistedInsightStore(service: delayedService) - await delayedStore.checkAvailability() - delayedStore.startAnalysis( - payload: metadataPayload(), - scope: analysisScope() - ) - await Task.yield() - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: delayedStore - ), - size: CGSize(width: 520, height: 720) - ) - ) - await delayedStore.cancelAnalysis() - await delayedStore.waitForAnalysis() - - let failedService = AssistedServiceFixture( - catalogResult: .success(eligibleProfile()), - analysisResult: .failed(failedOverhead()) - ) - let failedStore = CodexAssistedInsightStore(service: failedService) - await failedStore.checkAvailability() - failedStore.startAnalysis( - payload: metadataPayload(), - scope: analysisScope() - ) - await failedStore.waitForAnalysis() - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: failedStore - ), - size: CGSize(width: 520, height: 720) - ) - ) - } - private func profile( id: String, model: String? = nil, @@ -1687,17 +1579,6 @@ final class CodexAssistedInsightTests: XCTestCase { try XCTUnwrap(request["params"] as? [String: Any]) } - private func renders( - _ view: V, - size: CGSize - ) -> Bool { - let renderer = ImageRenderer( - content: view.frame(width: size.width, height: size.height) - ) - renderer.proposedSize = ProposedViewSize(size) - return renderer.nsImage != nil - } - private func waitUntil( timeout: Duration = .seconds(1), condition: @escaping @Sendable () async -> Bool diff --git a/Tests/CodexLimitsTests/CodexClientTests.swift b/Tests/CodexLimitsTests/CodexClientTests.swift index b86bd38..db5c984 100644 --- a/Tests/CodexLimitsTests/CodexClientTests.swift +++ b/Tests/CodexLimitsTests/CodexClientTests.swift @@ -2,6 +2,30 @@ import XCTest @testable import CodexLimits final class CodexClientTests: XCTestCase { + func testSelectedExecutableMustBeARegularExecutableFile() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + _ = CodexClient.selectExecutable(nil) + try? FileManager.default.removeItem(at: directory) + } + let executable = directory.appendingPathComponent("codex") + let plainFile = directory.appendingPathComponent("plain") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try Data().write(to: plainFile) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + + XCTAssertFalse(CodexClient.selectExecutable(plainFile)) + XCTAssertTrue(CodexClient.selectExecutable(executable)) + } + func testIsolatedHomeLinksCredentialsAndRemovesTheLink() throws { let root = temporaryDirectory() let source = root.appendingPathComponent("source", isDirectory: true) @@ -112,6 +136,22 @@ final class CodexClientTests: XCTestCase { XCTAssertEqual(server.initializationCount, 1) } + func testIdleConnectionClosesAndTheNextReadReconnects() async throws { + let server = PersistentAppServerFixture() + let client = CodexClient( + makeConnection: server.makeConnection, + timeout: 1, + connectionIdleTimeout: 0.01 + ) + + _ = try await client.installedCLIVersion() + try await Task.sleep(nanoseconds: 30_000_000) + _ = try await client.installedCLIVersion() + + XCTAssertEqual(server.connectionCount, 2) + XCTAssertEqual(server.initializationCount, 2) + } + func testClosedServerOutputReportsConnectionLost() async { let client = CodexClient( makeConnection: { diff --git a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift index 54844e9..ddab8ef 100644 --- a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift +++ b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift @@ -1,5 +1,4 @@ import Foundation -import SwiftUI import XCTest @testable import CodexLimits @@ -883,33 +882,6 @@ final class CodexSourceAnalysisTests: XCTestCase { XCTAssertNil(properties["insightKind"]) } - func testPreflightRendersWithNativeCategoryControls() { - let draft = CodexSourceContentDraft( - selection: sourceSelection(), - values: [ - .prompts: ["Build the report"], - .responses: ["Done"], - .code: ["+let answer = 42"], - .paths: ["/synthetic/atlas/App.swift"], - .commands: ["swift test"], - .toolOutput: ["All tests passed"] - ] - ) - let renderer = ImageRenderer( - content: SourceAnalysisPreflightView( - draft: draft, - cancel: {}, - analyze: { _ in } - ) - ) - renderer.proposedSize = ProposedViewSize( - width: 460, - height: 640 - ) - - XCTAssertNotNil(renderer.nsImage) - } - func testSourceContentNeverEntersAnalyticsHistory() async throws { let fileURL = temporaryDirectory() .appendingPathComponent("history.json") diff --git a/Tests/CodexLimitsTests/GrokBillingClientTests.swift b/Tests/CodexLimitsTests/GrokBillingClientTests.swift new file mode 100644 index 0000000..ac5b64b --- /dev/null +++ b/Tests/CodexLimitsTests/GrokBillingClientTests.swift @@ -0,0 +1,226 @@ +import Darwin +import XCTest +@testable import CodexLimits + +final class GrokBillingClientTests: XCTestCase { + private let observedAt = Date(timeIntervalSince1970: 1_700_000_000) + private let current = #"{"config":{"creditUsagePercent":25.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2030-01-01T00:00:00+00:00","end":"2030-01-08T00:00:00.123456+00:00"},"prepaidBalance":{},"onDemandUsed":{"val":125},"onDemandCap":{"val":2500},"isUnifiedBillingUser":true},"subscription_tier":"Super\u0007Grok","private":"do-not-retain"}"# + + func testCurrentAndLegacyAllowancesKeepOnlyValidatedFacts() throws { + let decoded = try decode(current) + XCTAssertEqual(decoded.remainingPercent, 74.5) + XCTAssertEqual(decoded.period, .weekly) + XCTAssertEqual(decoded.subscriptionTier, "SuperGrok") + XCTAssertEqual(decoded.prepaidBalanceUSD, 0) + XCTAssertEqual(decoded.onDemandUsedUSD, 1.25) + XCTAssertEqual(decoded.onDemandCapUSD, 25) + XCTAssertEqual(decoded.isUnifiedBilling, true) + XCTAssertTrue(decoded.isValid) + XCTAssertEqual(decoded.measurementSource, "creditUsagePercent") + XCTAssertEqual(try XCTUnwrap(decoded.startsAt).timeIntervalSince1970, 1_893_456_000) + XCTAssertEqual(decoded.resetsAt.timeIntervalSince1970, 1_894_060_800.123456, accuracy: 0.001) + let encoded = try JSONEncoder().encode(decoded) + XCTAssertFalse(String(decoding: encoded, as: UTF8.self).contains("do-not-retain")) + XCTAssertEqual(try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: encoded), decoded) + + let legacy = try decode(#"{"config":{"monthlyLimit":{"val":10000},"used":{},"billingPeriodEnd":"2030-02-01T00:00:00Z"}}"#) + XCTAssertEqual(legacy.period, .monthly) + XCTAssertEqual(legacy.remainingPercent, 100) + XCTAssertEqual(legacy.measurementSource, "legacyCredits") + XCTAssertNil(legacy.prepaidBalanceUSD) + XCTAssertNil(legacy.startsAt) + let above = try decode(current.replacingOccurrences(of: "25.5", with: "120")) + XCTAssertEqual(above.reportedUsedPercent, 120) + XCTAssertEqual(above.remainingPercent, 0) + XCTAssertTrue(above.isValid) + let monthly = try decode(current.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_MONTHLY")) + XCTAssertEqual(monthly.period, .monthly) + } + + func testPeriodStartIsRetainedWhenSuppliedAndNeverInferredWhenMissing() throws { + let legacy = try decode(#"{"config":{"monthlyLimit":{"val":10000},"used":{"val":100},"billingPeriodStart":"2030-01-03T12:00:00+02:00","billingPeriodEnd":"2030-02-03T10:00:00Z"}}"#) + XCTAssertEqual(try XCTUnwrap(legacy.startsAt).timeIntervalSince1970, 1_893_664_800) + XCTAssertTrue(legacy.isValid) + + let monthly = current.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_MONTHLY") + for fixture in [ + monthly.replacingOccurrences(of: #""start":"2030-01-01T00:00:00+00:00","#, with: ""), + monthly.replacingOccurrences(of: #""start":"2030-01-01T00:00:00+00:00""#, with: #""start":null"#) + ] { + let snapshot = try decode(fixture) + XCTAssertNil(snapshot.startsAt) + XCTAssertEqual(snapshot.period, .monthly) + XCTAssertTrue(snapshot.isValid) + } + } + + func testOldLatestSnapshotDecodesWithoutInventingStartAndCorruptDatesAreInvalid() throws { + let original = try decode(current) + var saved = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(original)) as? [String: Any]) + saved.removeValue(forKey: "startsAt") + let migrated = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONSerialization.data(withJSONObject: saved)) + XCTAssertNil(migrated.startsAt) + XCTAssertEqual(migrated.remainingPercent, original.remainingPercent) + XCTAssertEqual(migrated.observedAt, original.observedAt) + XCTAssertEqual(migrated.resetsAt, original.resetsAt) + XCTAssertTrue(migrated.isValid) + + for (key, invalidDate) in [ + ("startsAt", original.resetsAt), + ("startsAt", Date.distantPast.addingTimeInterval(-1)), + ("resetsAt", Date.distantFuture.addingTimeInterval(1)), + ("observedAt", Date.distantFuture.addingTimeInterval(1)) + ] { + var corrupted = saved + corrupted[key] = invalidDate.timeIntervalSinceReferenceDate + let snapshot = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONSerialization.data(withJSONObject: corrupted)) + XCTAssertFalse(snapshot.isValid, key) + } + } + + func testInvalidCurrentFieldsNeverFallBackToLegacyOrInventZero() throws { + let fixtures: [(String, GrokBillingError)] = [ + (#"{"config":null}"#, .missingAllowance), + (#"{"config":{"monthlyLimit":{},"used":{},"billingPeriodEnd":"2030-02-01T00:00:00Z"}}"#, .missingAllowance), + (#"{"config":{"creditUsagePercent":null,"monthlyLimit":{"val":100},"used":{},"billingPeriodEnd":"2030-02-01T00:00:00Z"}}"#, .missingAllowance), + (current.replacingOccurrences(of: "25.5", with: "true"), .missingAllowance), + (current.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_DAILY"), .unknownPeriod), + (current.replacingOccurrences(of: "2030-01-08T00:00:00.123456+00:00", with: "bad"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-01T00:00:00+00:00", with: "2030-02-01T00:00:00Z"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-01T00:00:00+00:00", with: "2030-01-08T00:00:00.123456+00:00"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-01T00:00:00+00:00", with: "bad"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-08T00:00:00.123456+00:00", with: "5000-01-08T00:00:00Z"), .invalidReset) + ] + for (fixture, error) in fixtures { + XCTAssertThrowsError(try decode(fixture)) { + XCTAssertEqual($0 as? GrokBillingError, error) + } + } + var saved = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(decode(current))) as? [String: Any]) + saved["subscriptionTier"] = "bad\u{001b}text" + let corrupted = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONSerialization.data(withJSONObject: saved)) + XCTAssertFalse(corrupted.isValid) + XCTAssertThrowsError(try GrokAllowanceSnapshot.decode( + Data(current.utf8), observedAt: .distantFuture.addingTimeInterval(1), sourceVersion: nil + )) { + XCTAssertEqual($0 as? GrokBillingError, .invalidResponse) + } + } + + func testTransportUsesPrefixedBillingAfterInitializeAndCleansItsWorkingDirectory() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let record = directory.appendingPathComponent("requests") + let cwd = directory.appendingPathComponent("cwd") + let executable = try script(in: directory, body: """ + printf '%s\\n' "$*" > '\(record.path)' + pwd > '\(cwd.path)' + IFS= read -r line + printf '%s\\n' "$line" >> '\(record.path)' + printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"_meta":{"agentVersion":"9.8.7"}}}' + IFS= read -r line + printf '%s\\n' "$line" >> '\(record.path)' + case "$line" in + *'"method":"_x.ai/billing"'*) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":\(current)}' ;; + *) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"error":{"code":-32601}}' ;; + esac + """) + let result = try await GrokBillingClient().fetch(executableURL: executable) + XCTAssertEqual(result.sourceVersion, "9.8.7") + XCTAssertEqual(result.remainingPercent, 74.5) + let lines = try String(contentsOf: record).split(separator: "\n") + XCTAssertEqual(lines.count, 3) + XCTAssertEqual(lines[0], "agent --no-leader stdio") + let initRequest = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(lines[1].utf8)) as? [String: Any]) + XCTAssertEqual(initRequest["method"] as? String, "initialize") + let params = try XCTUnwrap(initRequest["params"] as? [String: Any]) + let capabilities = try XCTUnwrap(params["clientCapabilities"] as? [String: Any]) + XCTAssertEqual(capabilities["terminal"] as? Bool, false) + let fs = try XCTUnwrap(capabilities["fs"] as? [String: Bool]) + XCTAssertEqual(fs, ["readTextFile": false, "writeTextFile": false]) + let workingDirectory = try String(contentsOf: cwd).trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertTrue(workingDirectory.contains("CodexLimits-Grok-")) + XCTAssertFalse(FileManager.default.fileExists(atPath: workingDirectory)) + XCTAssertFalse(GrokBillingClient.isExecutable(directory)) + } + + func testTransportRejectsProtocolErrorsAndBoundsTotalOutput() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + for (code, expected) in [(-32601, GrokBillingError.unsupported), (-32000, .authenticationRequired), (-32603, .failed)] { + let executable = try script(in: directory, body: """ + IFS= read -r line + printf '%s\\n' '{"jsonrpc":"2.0","id":1,"error":{"code":\(code),"message":"PRIVATE"}}' + """) + do { + _ = try await GrokBillingClient().fetch(executableURL: executable) + XCTFail("Expected an error") + } catch { + XCTAssertEqual(error as? GrokBillingError, expected) + XCTAssertFalse(error.localizedDescription.contains("PRIVATE")) + } + } + let oversized = try script(in: directory, body: """ + IFS= read -r line + /usr/bin/head -c 1048577 /dev/zero + """) + do { + _ = try await GrokBillingClient().fetch(executableURL: oversized) + XCTFail("Expected output rejection") + } catch { + XCTAssertEqual(error as? GrokBillingError, .invalidResponse) + } + } + + func testDeadlineAndCancellationTerminateTheOwnedProcessGroup() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + for cancellation in [false, true] { + let record = directory.appendingPathComponent(UUID().uuidString) + let executable = try script(in: directory, body: """ + /bin/sleep 60 & + child=$! + printf '%s %s\\n' "$$" "$child" > '\(record.path)' + trap 'kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; exit 0' TERM + wait "$child" + """) + let started = Date() + let task = Task { + try await GrokBillingClient(timeout: cancellation ? 10 : 0.2) + .fetch(executableURL: executable) + } + if cancellation { + for _ in 0..<100 where !FileManager.default.fileExists(atPath: record.path) { + try await Task.sleep(for: .milliseconds(10)) + } + task.cancel() + } + do { + _ = try await task.value + XCTFail("Expected interruption") + } catch { + if cancellation { XCTAssertTrue(error is CancellationError) } + else { XCTAssertEqual(error as? GrokBillingError, .timedOut) } + } + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + let pids = try String(contentsOf: record).split(whereSeparator: \.isWhitespace).compactMap { Int32($0) } + XCTAssertEqual(pids.count, 2) + for pid in pids { + XCTAssertEqual(kill(pid, 0), -1) + XCTAssertEqual(errno, ESRCH) + } + } + } + + private func decode(_ json: String) throws -> GrokAllowanceSnapshot { + try GrokAllowanceSnapshot.decode(Data(json.utf8), observedAt: observedAt, sourceVersion: "1.2.3") + } + + private func script(in directory: URL, body: String) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let executable = directory.appendingPathComponent("fake-grok") + try Data(("#!/bin/sh\n" + body + "\n").utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + return executable + } +} diff --git a/Tests/CodexLimitsTests/GrokIntegrationTests.swift b/Tests/CodexLimitsTests/GrokIntegrationTests.swift new file mode 100644 index 0000000..ae05006 --- /dev/null +++ b/Tests/CodexLimitsTests/GrokIntegrationTests.swift @@ -0,0 +1,324 @@ +import XCTest +import ClaudeIntegrationCore +@testable import CodexLimits + +@MainActor +final class GrokIntegrationTests: XCTestCase { + func testOverviewReadsCurrentPeriodWithoutRetainingDetailAndFallsBackToLatest() async throws { + let clock = GrokTestClock() + let current = fixture(at: clock.now()) + let source = GrokFetchProbe(snapshot: current) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + let historyDirectory = cache.deletingLastPathComponent().appendingPathComponent("History") + let previous = AllowanceObservation( + metric: current.historyObservation.metric, observedAt: current.observedAt.addingTimeInterval(-600), + remainingPercent: 80, resetsAt: current.resetsAt, + startsAt: current.historyObservation.startsAt, source: current.measurementSource + ) + let old = fixture(at: clock.now().addingTimeInterval(-20 * 86_400)).historyObservation + try AllowanceHistory.append([old, previous], in: historyDirectory) + try JSONEncoder().encode(current).write(to: cache) + let files = try FileManager.default.contentsOfDirectory(at: historyDirectory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent } + let oldest = try XCTUnwrap(files.first) + try Data("invalid old archive\n".utf8).write(to: oldest) + let store = makeStore(clock: clock, source: source, cache: cache) + await store.refresh(force: false) + XCTAssertNil(store.overview) + XCTAssertNil(store.historyIssue) + + await store.setVisible(true, includeHistory: false, safetyBuffer: 5) + + XCTAssertTrue(store.history.isEmpty) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 2) + XCTAssertEqual(store.overview?.latest?.remaining, 75) + XCTAssertEqual(store.overview?.target.last?.remaining, 5) + XCTAssertNil(store.historyIssue) + var calls = await source.calls + XCTAssertEqual(calls, 0, "Overview demand must reuse a fresh cache") + await store.setVisible(true) + XCTAssertNil(store.overview) + XCTAssertNotNil(store.historyIssue) + await store.setVisible(false) + XCTAssertNil(store.overview) + let newest = try XCTUnwrap(files.last) + try Data("invalid current archive\n".utf8).write(to: newest) + await store.setVisible(true, includeHistory: false) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 1) + XCTAssertEqual(store.overview?.latest?.remaining, 75) + XCTAssertNotNil(store.historyIssue) + calls = await source.calls + XCTAssertEqual(calls, 0) + await store.setEnabled(false) + XCTAssertNil(store.overview) + await store.deleteData() + XCTAssertNil(store.overview) + } + + func testSwitchingToOverviewDuringSharedFetchPublishesOnlyCompactHistory() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let store = makeStore(clock: clock, source: source) + let detail = Task { await store.setVisible(true) } + await waitForCall(source) + let overview = Task { await store.setVisible(true, includeHistory: false) } + await Task.yield() + await source.release() + await detail.value + await overview.value + XCTAssertTrue(store.history.isEmpty) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 1) + let calls = await source.calls + XCTAssertEqual(calls, 1) + await store.deleteData() + XCTAssertNil(store.overview) + } + + func testRecordedHistoryMigratesLatestCacheAndSurvivesRelaunchUntilDeletion() async throws { + let clock = GrokTestClock() + let first = fixture(at: clock.now()) + let source = GrokFetchProbe(snapshot: first) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(first).write(to: cache) + let store = makeStore(clock: clock, source: source, cache: cache) + await store.setVisible(true) + XCTAssertEqual(store.history, [first.historyObservation], "Migrate one real cached observation only") + clock.advance(600) + let second = fixture(at: clock.now()) + await source.setSnapshot(second) + await store.refresh() + XCTAssertEqual(store.history, [first.historyObservation, second.historyObservation]) + await store.setEnabled(false) + let restored = makeStore(clock: clock, source: source, cache: cache) + await restored.setVisible(true) + XCTAssertEqual(restored.history, [first.historyObservation, second.historyObservation]) + await restored.setVisible(false) + XCTAssertTrue(restored.history.isEmpty, "Hidden detail releases resident history") + await restored.setVisible(true) + XCTAssertEqual(restored.history.count, 2) + await restored.deleteData() + XCTAssertTrue(restored.history.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.deletingLastPathComponent().appendingPathComponent("History").path)) + } + + func testOnlyEnabledDemandFetchesAndTheCacheSurvivesRelaunch() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + let store = makeStore(enabled: false, clock: clock, source: source, cache: cache) + await store.settingsPresented() + await store.refresh() + await store.setVisible(true) + var calls = await source.calls + XCTAssertEqual(calls, 0) + + await store.setEnabled(true) + XCTAssertEqual(store.menuBarText, "75%") + calls = await source.calls + XCTAssertEqual(calls, 1) + let attributes = try FileManager.default.attributesOfItem(atPath: cache.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + await store.setVisible(false) + await store.setEnabled(false) + + let restored = makeStore(enabled: true, clock: clock, source: source, cache: cache) + await restored.settingsPresented() + XCTAssertNil(restored.snapshot) + await restored.setVisible(true) + XCTAssertEqual(restored.snapshot, store.snapshot) + calls = await source.calls + XCTAssertEqual(calls, 1, "Opening a fresh cache must not start a provider process") + await restored.setVisible(false) + clock.advance(700) + await restored.refresh(force: false, priority: .visible) + calls = await source.calls + XCTAssertEqual(calls, 1, "Hidden and unselected Grok has no source demand") + await restored.deleteData() + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + XCTAssertNil(restored.snapshot) + XCTAssertFalse(restored.hasStoredData) + } + + func testThrottledFailuresKeepUsageUntilResetAndBackOff() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let store = makeStore(clock: clock, source: source) + await store.refresh() + clock.advance(31) + await source.fail(with: .failed) + await store.refresh() + XCTAssertEqual(store.menuBarText, "75%") + XCTAssertTrue(store.isStale) + await store.refresh() + var calls = await source.calls + XCTAssertEqual(calls, 2, "Explicit refresh must honor the 30-second floor") + clock.advance(600) + await store.refresh(force: false) + calls = await source.calls + XCTAssertEqual(calls, 3) + clock.advance(600) + await store.refresh(force: false) + calls = await source.calls + XCTAssertEqual(calls, 3, "A second failure backs off for 20 minutes") + clock.advance(3_000) + store.updateDisplayTime() + XCTAssertEqual(store.menuBarText, "—") + XCTAssertNotNil(store.snapshot) + XCTAssertNil(store.currentSnapshot) + XCTAssertFalse(store.isStale, "Reset expiration takes precedence over stale usage") + } + + func testWallClockChangesCannotBypassTheLaunchFloor() async { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let store = makeStore(clock: clock, source: source) + await store.refresh() + clock.advance(3_600, uptime: 0) + await store.refresh() + let calls = await source.calls + XCTAssertEqual(calls, 1) + XCTAssertEqual(store.menuBarText, "—") + } + + func testDeletionWaitsForLateWorkAndSupersedesExecutableSelection() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + let store = makeStore(clock: clock, source: source, cache: cache) + let selection = Task { await store.selectExecutable(URL(fileURLWithPath: "/usr/bin/true")) } + await waitForCall(source) + let deletion = Task { await store.deleteData() } + while store.isRefreshing { await Task.yield() } + await source.release() + let selected = await selection.value + await deletion.value + XCTAssertFalse(selected, "Settings must not restore an executable after deletion") + XCTAssertNil(store.snapshot) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + await store.refresh() + let calls = await source.calls + XCTAssertEqual(calls, 1) + } + + func testExplicitRefreshPromotesQueuedFreshCacheWork() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let coordinator = IntegrationWorkCoordinator() + let gate = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let blocker = Task { + await coordinator.run(priority: .explicit) { _ = try? await gate.fetch() } + } + await waitForCall(gate) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(fixture(at: clock.now())).write(to: cache) + let store = makeStore(clock: clock, source: source, cache: cache, coordinator: coordinator) + let visible = Task { await store.setVisible(true) } + await Task.yield() + let explicit = Task { await store.refresh() } + await Task.yield() + await gate.release() + await blocker.value + await visible.value + await explicit.value + let calls = await source.calls + XCTAssertEqual(calls, 1) + await store.setVisible(false) + } + + func testDismissedSettingsDoesNotPublishQueuedReadiness() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let coordinator = IntegrationWorkCoordinator() + let gate = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let blocker = Task { + await coordinator.run(priority: .explicit) { _ = try? await gate.fetch() } + } + await waitForCall(gate) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(fixture(at: clock.now())).write(to: cache) + let store = makeStore(clock: clock, source: source, cache: cache, coordinator: coordinator) + let settings = Task { await store.settingsPresented() } + await Task.yield() + store.settingsDismissed() + await gate.release() + await blocker.value + await settings.value + XCTAssertFalse(store.hasStoredData) + let calls = await source.calls + XCTAssertEqual(calls, 0) + } + + private func makeStore( + enabled: Bool = true, clock: GrokTestClock, source: GrokFetchProbe, + cache: URL? = nil, coordinator: IntegrationWorkCoordinator = IntegrationWorkCoordinator() + ) -> GrokIntegrationStore { + GrokIntegrationStore( + isEnabled: enabled, + selectedExecutableURL: URL(fileURLWithPath: "/usr/bin/true"), + cacheURL: cache ?? temporaryDirectory().appendingPathComponent("snapshot.json"), + integrationWorkCoordinator: coordinator, + fetchUsage: { _ in try await source.fetch() }, + now: { clock.now() }, uptime: { clock.uptime() } + ) + } + + private func fixture(at now: Date) -> GrokAllowanceSnapshot { + GrokAllowanceSnapshot( + reportedUsedPercent: 25, period: .weekly, + resetsAt: now.addingTimeInterval(3_600), observedAt: now, + sourceVersion: "1.2.3", subscriptionTier: nil, + prepaidBalanceUSD: nil, onDemandUsedUSD: nil, onDemandCapUSD: nil, + isUnifiedBilling: true, measurementSource: "creditUsagePercent" + ) + } + + private func waitForCall(_ source: GrokFetchProbe) async { + for _ in 0..<1_000 { + if await source.calls > 0 { return } + try? await Task.sleep(for: .milliseconds(1)) + } + XCTFail("Source work did not start") + } +} + +private final class GrokTestClock: @unchecked Sendable { + private let lock = NSLock() + private var date = Date(timeIntervalSince1970: 1_800_000_000) + private var elapsed: TimeInterval = 0 + func now() -> Date { lock.withLock { date } } + func uptime() -> TimeInterval { lock.withLock { elapsed } } + func advance(_ seconds: TimeInterval, uptime: TimeInterval? = nil) { + lock.withLock { + date.addTimeInterval(seconds) + elapsed += uptime ?? seconds + } + } +} + +private actor GrokFetchProbe { + private var snapshot: GrokAllowanceSnapshot + private var error: GrokBillingError? + private var held: Bool + private var continuation: CheckedContinuation? + private(set) var calls = 0 + init(snapshot: GrokAllowanceSnapshot, held: Bool = false) { + self.snapshot = snapshot + self.held = held + } + func fail(with error: GrokBillingError) { self.error = error } + func setSnapshot(_ snapshot: GrokAllowanceSnapshot) { self.snapshot = snapshot } + func fetch() async throws -> GrokAllowanceSnapshot { + calls += 1 + if held { await withCheckedContinuation { continuation = $0 } } + if let error { throw error } + return snapshot + } + func release() { + held = false + continuation?.resume() + continuation = nil + } +} diff --git a/Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift b/Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift new file mode 100644 index 0000000..36630ea --- /dev/null +++ b/Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift @@ -0,0 +1,81 @@ +import ClaudeIntegrationCore +import XCTest +@testable import CodexLimits + +final class IntegrationAllowanceChartTests: XCTestCase { + private let date = Date(timeIntervalSince1970: 1_800_000_000) + + func testOnePointHasNoInventedHistoryAndFreshCompatiblePointsForecast() throws { + let first = point(0, 80) + let second = point(600, 75) + let single = try chart([first], now: 0) + XCTAssertEqual(single.chart.observed.count, 1) + XCTAssertTrue(single.chart.currentProjection.isEmpty) + let recorded = try chart([first, second], now: 600) + XCTAssertEqual(recorded.chart.observed.map(\.remaining), [80, 75]) + XCTAssertEqual(recorded.chart.currentProjection.first?.date, second.observedAt) + XCTAssertEqual(recorded.chart.currentProjection.last?.date, second.resetsAt) + XCTAssertEqual(recorded.chart.currentProjection.last?.remaining, 50) + XCTAssertEqual(recorded.chart.target.first?.date, first.startsAt) + XCTAssertNil(recorded.chart.reference) + XCTAssertTrue(try chart([first, point(30, 79)], now: 30).chart.currentProjection.isEmpty) + } + + func testGapsCorrectionsResetsStalenessAndConflictsBreakForecasts() throws { + let first = point(0, 80) + for next in [point(1_801, 75), point(600, 90), point(600, 75, reset: 7_200)] { + let value = try chart([first, next], now: next.observedAt.timeIntervalSince(date)) + XCTAssertTrue(value.chart.currentProjection.isEmpty) + XCTAssertEqual(value.chart.allObservedSegments.count, 2) + } + XCTAssertTrue(try chart([first, point(600, 75)], now: 600, stale: true).chart.currentProjection.isEmpty) + XCTAssertTrue(try chart([first, point(600, 75)], now: 2_400).chart.currentProjection.isEmpty) + XCTAssertTrue(try chart([first, point(600, 75)], now: 3_601).chart.currentProjection.isEmpty) + XCTAssertTrue(try chart([first, point(0, 90), point(600, 75)], now: 600).chart.currentProjection.isEmpty) + } + + func testMetricsStaySeparateAndUnknownMonthlyStartHasNoTarget() throws { + let monthly = AllowanceObservation(metric: "grok-monthly", observedAt: date, remainingPercent: 25, resetsAt: date.addingTimeInterval(30 * 86_400)) + let other = AllowanceObservation(metric: "claude-five-hour", observedAt: date, remainingPercent: 99, resetsAt: date.addingTimeInterval(3_600)) + let data = try XCTUnwrap(IntegrationAllowanceChart(metric: monthly.metric, observations: [monthly, other], current: nil, now: date, isStale: false, safetyBuffer: 3)) + XCTAssertEqual(data.chart.observed.map(\.remaining), [25]) + XCTAssertTrue(data.chart.target.isEmpty) + XCTAssertTrue(data.chart.currentProjection.isEmpty) + XCTAssertNil(IntegrationAllowanceChart(metric: "missing", observations: [monthly], current: nil, now: date, isStale: false, safetyBuffer: 3)) + } + + func testMissingCurrentWindowOrChangedMeasurementCannotExtendOldForecast() throws { + let first = point(0, 80) + let second = point(600, 75) + let noCurrent = try XCTUnwrap(IntegrationAllowanceChart(metric: first.metric, observations: [first, second], current: nil, now: second.observedAt, isStale: false, safetyBuffer: 3)) + XCTAssertEqual(noCurrent.chart.observed.count, 2) + XCTAssertTrue(noCurrent.chart.currentProjection.isEmpty) + for changed in [ + AllowanceObservation(metric: first.metric, observedAt: second.observedAt, remainingPercent: 75, resetsAt: first.resetsAt, startsAt: first.startsAt, source: "new-source"), + AllowanceObservation(metric: first.metric, observedAt: second.observedAt, remainingPercent: 75, resetsAt: first.resetsAt, startsAt: first.startsAt?.addingTimeInterval(60)) + ] { + let result = try chart([first, changed], now: 600) + XCTAssertTrue(result.chart.currentProjection.isEmpty) + XCTAssertEqual(result.chart.allObservedSegments.count, 2) + } + } + + @MainActor + func testChartRangeIsIsolatedFromCodexAndOtherProviderMetrics() { + let name = "IntegrationAllowanceChartTests-\(UUID())" + let defaults = UserDefaults(suiteName: name)! + defer { defaults.removePersistentDomain(forName: name) } + AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: "grok-weekly.").selectTimeRange(.fourWeeks) + XCTAssertEqual(AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: "grok-weekly.").state.timeRange, .fourWeeks) + XCTAssertEqual(AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: "claude-seven-day.").state.timeRange, .currentWindow) + XCTAssertEqual(AnalyticsWorkspaceStore(defaults: defaults).state.timeRange, .currentWindow) + } + + private func point(_ seconds: TimeInterval, _ remaining: Double, reset: TimeInterval = 3_600) -> AllowanceObservation { + AllowanceObservation(metric: "grok-weekly", observedAt: date.addingTimeInterval(seconds), remainingPercent: remaining, resetsAt: date.addingTimeInterval(reset), startsAt: date.addingTimeInterval(reset - 7 * 86_400)) + } + + private func chart(_ observations: [AllowanceObservation], now: TimeInterval, stale: Bool = false) throws -> IntegrationAllowanceChart { + try XCTUnwrap(IntegrationAllowanceChart(metric: "grok-weekly", observations: observations, current: observations.last, now: date.addingTimeInterval(now), isStale: stale, safetyBuffer: 3)) + } +} diff --git a/Tests/CodexLimitsTests/IntegrationPreferencesTests.swift b/Tests/CodexLimitsTests/IntegrationPreferencesTests.swift new file mode 100644 index 0000000..c766b1f --- /dev/null +++ b/Tests/CodexLimitsTests/IntegrationPreferencesTests.swift @@ -0,0 +1,157 @@ +import XCTest +@testable import CodexLimits + +@MainActor +final class IntegrationPreferencesTests: XCTestCase { + func testExistingInstallDefaultsToCodexAndItsWeeklyMenuMetric() { + let preferences = IntegrationPreferences(defaults: defaults()) + + XCTAssertEqual(preferences.enabledIntegrations, [.codex]) + XCTAssertEqual( + preferences.menuBarMetric, + .codexWeeklyUsageRemaining + ) + XCTAssertEqual( + preferences.availableMenuBarMetrics, + [.none, .codexWeeklyUsageRemaining] + ) + } + + func testDisablingTheSelectedIntegrationSelectsNoneAndPersists() { + let defaults = defaults() + let preferences = IntegrationPreferences(defaults: defaults) + + preferences.setEnabled(false, for: .codex) + let restored = IntegrationPreferences(defaults: defaults) + + XCTAssertTrue(restored.enabledIntegrations.isEmpty) + XCTAssertEqual(restored.menuBarMetric, .none) + XCTAssertEqual(restored.availableMenuBarMetrics, [.none]) + } + + func testMenuMetricRequiresItsIntegrationToBeEnabled() { + let preferences = IntegrationPreferences(defaults: defaults()) + + preferences.selectMenuBarMetric(.claudeSevenDayUsageRemaining) + XCTAssertEqual( + preferences.menuBarMetric, + .codexWeeklyUsageRemaining + ) + + preferences.setEnabled(true, for: .claudeCode) + preferences.selectMenuBarMetric(.claudeSevenDayUsageRemaining) + XCTAssertEqual( + preferences.menuBarMetric, + .claudeSevenDayUsageRemaining + ) + } + + func testDeferredIntegrationCannotReturnFromStoredPreferences() { + let defaults = defaults() + defaults.set( + Data( + """ + {"version":1,"enabledIntegrationIDs":["openCode"],"menuBarMetricID":"openCodeSevenDayLocalTokens"} + """.utf8 + ), + forKey: IntegrationPreferences.persistenceKey + ) + + let preferences = IntegrationPreferences(defaults: defaults) + + XCTAssertTrue(preferences.enabledIntegrations.isEmpty) + XCTAssertEqual(preferences.menuBarMetric, .none) + XCTAssertEqual(preferences.availableMenuBarMetrics, [.none]) + } + + func testExecutableSelectionsPersistAndCanBeDeleted() { + let defaults = defaults() + let preferences = IntegrationPreferences(defaults: defaults) + let codex = URL(fileURLWithPath: "/custom/bin/codex") + let claude = URL(fileURLWithPath: "/custom/bin/claude") + let grok = URL(fileURLWithPath: "/custom/bin/grok") + + preferences.selectCodexExecutable(codex) + preferences.selectClaudeExecutable(claude) + preferences.selectGrokExecutable(grok) + preferences.setEnabled(true, for: .grok) + preferences.selectMenuBarMetric(.grokCurrentPeriodUsageRemaining) + var restored = IntegrationPreferences(defaults: defaults) + XCTAssertEqual(restored.codexExecutableURL, codex) + XCTAssertEqual(restored.claudeExecutableURL, claude) + XCTAssertEqual(restored.grokExecutableURL, grok) + XCTAssertEqual(restored.menuBarMetric, .grokCurrentPeriodUsageRemaining) + + preferences.selectCodexExecutable(nil) + preferences.selectClaudeExecutable(nil) + preferences.selectGrokExecutable(nil) + preferences.setEnabled(false, for: .grok) + restored = IntegrationPreferences(defaults: defaults) + XCTAssertNil(restored.codexExecutableURL) + XCTAssertNil(restored.claudeExecutableURL) + XCTAssertNil(restored.grokExecutableURL) + XCTAssertEqual(restored.menuBarMetric, .none) + } + + func testWorkCoordinatorSerializesAndPrioritizesExplicitWork() async { + let coordinator = IntegrationWorkCoordinator() + let probe = IntegrationWorkProbe() + let first = Task { + await coordinator.run(priority: .automatic) { + await probe.begin("automatic") + try? await Task.sleep(for: .milliseconds(80)) + await probe.end() + } + } + while await probe.startedCount == 0 { + await Task.yield() + } + let settings = Task { + await coordinator.run(priority: .settings) { + await probe.begin("settings") + await probe.end() + } + } + let explicit = Task { + await coordinator.run(priority: .explicit) { + await probe.begin("explicit") + await probe.end() + } + } + + await first.value + await settings.value + await explicit.value + let result = await probe.result + XCTAssertEqual(result.order, ["automatic", "explicit", "settings"]) + XCTAssertEqual(result.maximumActive, 1) + } + + private func defaults() -> UserDefaults { + let suite = "IntegrationPreferencesTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } +} + +private actor IntegrationWorkProbe { + private var active = 0 + private var maximumActive = 0 + private var order: [String] = [] + + var startedCount: Int { order.count } + var result: (order: [String], maximumActive: Int) { + (order, maximumActive) + } + + func begin(_ name: String) { + active += 1 + maximumActive = max(maximumActive, active) + order.append(name) + } + + func end() { + active -= 1 + } +} diff --git a/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift b/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift index 2803429..4820a29 100644 --- a/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift +++ b/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift @@ -260,116 +260,6 @@ final class LocalActivityPerformanceTests: XCTestCase { XCTAssertLessThan(residentDelta, 256 * 1_024 * 1_024) } - func testRepresentativeFixtureMetrics() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let fileURL = directory.appendingPathComponent("representative.jsonl") - - let recordCount = 20_000 - var fixture = - #"{"timestamp":"2026-07-27T10:00:00.000Z","ordinal":0,"type":"session_meta","payload":{"id":"task-benchmark","cli_version":"0.145.0","history_mode":"paginated"}}"# - + "\n" - fixture.reserveCapacity(recordCount * 180) - for ordinal in 1...recordCount { - fixture += - #"{"timestamp":"2026-07-27T10:00:01.000Z","ordinal":\#(ordinal),"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":\#(ordinal * 100)}}}}"# - + "\n" - } - let fixtureByteCount = fixture.utf8.count - try Data(fixture.utf8).write(to: fileURL) - fixture.removeAll(keepingCapacity: false) - - let source = IncrementalRolloutTailSource() - let residentBeforeInitialRead = currentResidentBytes() - let initialStart = ProcessInfo.processInfo.systemUptime - let initial = try source.read( - fileURL: fileURL, - cursor: nil, - observedAt: Date(timeIntervalSince1970: 100) - ) - let initialMilliseconds = - (ProcessInfo.processInfo.systemUptime - initialStart) * 1_000 - let residentAfterInitialRead = currentResidentBytes() - let initialResidentDelta = residentAfterInitialRead >= residentBeforeInitialRead - ? residentAfterInitialRead - residentBeforeInitialRead - : 0 - - let idleRefreshCount = 1_000 - let residentBeforeIdleRefreshes = currentResidentBytes() - let idleCPUStart = clock() - let idleWallStart = ProcessInfo.processInfo.systemUptime - var idleBytesRead: UInt64 = 0 - var idleRecords = 0 - for _ in 0..= residentBeforeIdleRefreshes - ? residentAfterIdleRefreshes - residentBeforeIdleRefreshes - : 0 - - let appended = - #"{"timestamp":"2026-07-27T10:00:02.000Z","ordinal":20001,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":2000100}}}}"# - + "\n" - let handle = try FileHandle(forWritingTo: fileURL) - try handle.seekToEnd() - try handle.write(contentsOf: Data(appended.utf8)) - try handle.close() - let appendedStart = ProcessInfo.processInfo.systemUptime - let incremental = try source.read( - fileURL: fileURL, - cursor: initial.cursor, - observedAt: Date(timeIntervalSince1970: 300) - ) - let appendedMilliseconds = - (ProcessInfo.processInfo.systemUptime - appendedStart) * 1_000 - var usage = rusage() - getrusage(RUSAGE_SELF, &usage) - - print( - [ - "LOCAL_ACTIVITY_METRICS", - "fixture_bytes=\(fixtureByteCount)", - "fixture_records=\(initial.records.count)", - String(format: "initial_ms=%.3f", initialMilliseconds), - "initial_resident_delta_bytes=\(initialResidentDelta)", - "idle_refreshes=\(idleRefreshCount)", - String(format: "idle_wall_ms=%.3f", idleWallMilliseconds), - String(format: "idle_cpu_ms=%.3f", idleCPUMilliseconds), - "idle_resident_delta_bytes=\(idleResidentDelta)", - "idle_bytes=\(idleBytesRead)", - "idle_records=\(idleRecords)", - "incremental_bytes=\(incremental.bytesRead)", - "incremental_records=\(incremental.records.count)", - String(format: "incremental_ms=%.3f", appendedMilliseconds), - "max_rss_bytes=\(usage.ru_maxrss)" - ].joined(separator: " ") - ) - - XCTAssertEqual(initial.records.count, recordCount + 1) - XCTAssertEqual(idleBytesRead, 0) - XCTAssertEqual(idleRecords, 0) - XCTAssertEqual( - incremental.bytesRead, - UInt64(appended.utf8.count) - + (initial.cursor.checkpoint?.byteLength ?? 0) - ) - XCTAssertEqual(incremental.records.count, 1) - } - private func currentResidentBytes() -> UInt64 { var info = mach_task_basic_info() var count = mach_msg_type_number_t( diff --git a/Tests/CodexLimitsTests/LocalTokenActivityTests.swift b/Tests/CodexLimitsTests/LocalTokenActivityTests.swift index f9b9009..41882c5 100644 --- a/Tests/CodexLimitsTests/LocalTokenActivityTests.swift +++ b/Tests/CodexLimitsTests/LocalTokenActivityTests.swift @@ -28,8 +28,6 @@ final class LocalTokenActivityTests: XCTestCase { XCTAssertEqual(activity.sourceVersion, "0.145.0") XCTAssertEqual(activity.observedAt, Date(timeIntervalSince1970: 2_000)) XCTAssertEqual(activity.points.map(\.tokens), [100, 350]) - XCTAssertNil(activity.accountComparison.numericPercent) - XCTAssertFalse(activity.accountComparison.comparable) } func testReadsFractionalSecondTimestampsFromRealRollouts() { @@ -294,8 +292,6 @@ final class LocalTokenActivityTests: XCTestCase { reader.localTokenActivity.interval, reader.accountTokenActivity.interval ) - XCTAssertFalse(reader.localTokenActivity.accountComparison.comparable) - XCTAssertNil(reader.localTokenActivity.accountComparison.numericPercent) } func testOffDeviceActivityDoesNotTurnTheAccountLocalGapIntoCoverage() { @@ -334,8 +330,6 @@ final class LocalTokenActivityTests: XCTestCase { XCTAssertEqual(reader.accountTokenActivity.tokens, 1_000) XCTAssertEqual(reader.localTokenActivity.tokens, 100) - XCTAssertNil(reader.localTokenActivity.accountComparison.numericPercent) - XCTAssertFalse(reader.localTokenActivity.accountComparison.comparable) } func testZeroAccountAndLocalActivityRemainFactual() { diff --git a/Tests/CodexLimitsTests/ResetReminderTests.swift b/Tests/CodexLimitsTests/ResetReminderTests.swift index 4e8c5ac..9bcd656 100644 --- a/Tests/CodexLimitsTests/ResetReminderTests.swift +++ b/Tests/CodexLimitsTests/ResetReminderTests.swift @@ -27,9 +27,7 @@ final class ResetReminderTests: XCTestCase { fixture.scheduler.events.last, .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(48 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -56,7 +54,6 @@ final class ResetReminderTests: XCTestCase { ) XCTAssertTrue(fixture.coordinator.state.isEnabled) - XCTAssertEqual(fixture.coordinator.state.authorization, .denied) XCTAssertEqual(fixture.coordinator.state.delivery, .permissionDenied) XCTAssertEqual( fixture.scheduler.events, @@ -80,9 +77,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(42 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 6 hours." ) @@ -126,9 +121,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(48 * 60 * 60), - expiresAt: changed.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -213,9 +206,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(24 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -259,10 +250,7 @@ final class ResetReminderTests: XCTestCase { XCTAssertTrue(scheduler.events.isEmpty) XCTAssertEqual( restarted.state.delivery, - .reminderTimePassed( - Date(timeIntervalSince1970: 1_800_000_000) - .addingTimeInterval(24 * 60 * 60) - ) + .reminderTimePassed ) } @@ -297,10 +285,7 @@ final class ResetReminderTests: XCTestCase { XCTAssertEqual(coordinator.state.leadTime, .hours6) XCTAssertEqual( coordinator.state.delivery, - .reminderTimePassed( - Date(timeIntervalSince1970: 1_800_000_000) - .addingTimeInterval(24 * 60 * 60) - ) + .reminderTimePassed ) } @@ -331,9 +316,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(24 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -408,9 +391,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(1), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 1 hour." ) diff --git a/Tests/CodexLimitsTests/UsageHistoryTests.swift b/Tests/CodexLimitsTests/UsageHistoryTests.swift index 444dc16..c770905 100644 --- a/Tests/CodexLimitsTests/UsageHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageHistoryTests.swift @@ -1407,13 +1407,21 @@ final class UsageHistoryTests: XCTestCase { _ = await receiver.load() _ = await receiver.connect(to: shared) - let corruptWriter = shared - .appendingPathComponent("installations", isDirectory: true) - .appendingPathComponent("a-corrupt", isDirectory: true) - try FileManager.default.createDirectory(at: corruptWriter, withIntermediateDirectories: true) - try Data("broken".utf8).write( - to: corruptWriter.appendingPathComponent("0000-broken.json") + let corruptWriter = UsageHistory( + localDirectory: root.appendingPathComponent("corrupt", isDirectory: true), + installationID: "a-corrupt" + ) + _ = await corruptWriter.load() + _ = await corruptWriter.connect(to: shared) + _ = await corruptWriter.record(UsageSample( + observedAt: now.addingTimeInterval(-60), + remainingPercent: 81, + resetsAt: now.addingTimeInterval(86_400) + )) + let corruptFile = try XCTUnwrap( + jsonFiles(for: "a-corrupt", in: shared).first ) + try Data("broken".utf8).write(to: corruptFile) let sample = UsageSample( observedAt: now, @@ -1463,6 +1471,349 @@ final class UsageHistoryTests: XCTestCase { XCTAssertEqual(jsonFiles(for: "writer-a", in: root).count, 1) } + func testTenYearRetentionPublishesABoundedWorkingSet() async throws { + let root = temporaryDirectory() + let start = Date(timeIntervalSince1970: 1_600_000_000) + let dayCount = 10 * 365 + let history = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + _ = await history.load() + for day in 0 ..< dayCount { + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + _ = await history.record(UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - day % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + )) + } + + let reloaded = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + let loadStartedAt = ProcessInfo.processInfo.systemUptime + let state = await reloaded.load() + let loadMilliseconds = ( + ProcessInfo.processInfo.systemUptime - loadStartedAt + ) * 1_000 + + XCTAssertEqual(jsonFiles(for: "writer-a", in: root).count, dayCount) + XCTAssertLessThanOrEqual(state.samples.count, 90) + XCTAssertEqual( + state.samples.last?.observedAt, + start.addingTimeInterval(Double(dayCount - 1) * 86_400) + ) + let newestFile = try XCTUnwrap( + jsonFiles(for: "writer-a", in: root).max { + $0.lastPathComponent < $1.lastPathComponent + } + ) + try Data("broken".utf8).write(to: newestFile) + let refreshStartedAt = ProcessInfo.processInfo.systemUptime + let automatic = await reloaded.synchronizeIfDue() + let refreshMilliseconds = ( + ProcessInfo.processInfo.systemUptime - refreshStartedAt + ) * 1_000 + XCTAssertNil(automatic.errorMessage) + XCTAssertEqual(automatic.samples, state.samples) + let explicit = await reloaded.synchronize() + XCTAssertEqual( + explicit.errorMessage, + "Some usage history couldn’t be read." + ) + print( + String( + format: "BOUNDED_HISTORY days=%d samples=%d cold_load_ms=%.3f automatic_refresh_ms=%.3f", + dayCount, + state.samples.count, + loadMilliseconds, + refreshMilliseconds + ) + ) + } + + func testDenseHistoryCannotExceedTheHardWorkingSetCap() async throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let samples = (0 ..< 14_400).map { minute in + let observedAt = start.addingTimeInterval(Double(minute) * 60) + return UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - minute % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + ) + } + let state = await UsageHistory( + localDirectory: temporaryDirectory(), + installationID: "writer-a" + ).load(legacySamples: samples) + + XCTAssertEqual(state.samples.count, 6_000) + XCTAssertEqual(state.samples.last?.observedAt, samples.last?.observedAt) + } + + func testColdWorkingSetReadCapsInstallationFanOut() async { + let root = temporaryDirectory() + let start = Date(timeIntervalSince1970: 1_700_000_000) + for index in 0 ..< 33 { + let history = UsageHistory( + localDirectory: root, + installationID: String(format: "writer-%02d", index) + ) + _ = await history.load() + _ = await history.record(UsageSample( + observedAt: start.addingTimeInterval(Double(index)), + remainingPercent: Double(index), + resetsAt: start.addingTimeInterval(86_400) + )) + } + + let state = await UsageHistory( + localDirectory: root, + installationID: "writer-00" + ).load() + + XCTAssertEqual( + state.errorMessage, + "Some usage history couldn’t be read." + ) + XCTAssertLessThan(state.samples.count, 33) + } + + func testOlderRangeLoadsASeparateBoundedView() async throws { + let root = temporaryDirectory() + let start = Date(timeIntervalSince1970: 1_600_000_000) + let samples = (0 ..< 200).map { day in + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + return UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - day % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + ) + } + let history = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + let defaultState = await history.load(legacySamples: samples) + let requested = DateInterval( + start: samples[20].observedAt, + end: samples[103].observedAt.addingTimeInterval(1) + ) + let loadedView = await history.rangeView(for: requested) + let view = try XCTUnwrap(loadedView) + + XCTAssertGreaterThan(defaultState.samples.first!.observedAt, requested.end) + XCTAssertEqual( + view.samples, + samples.filter { requested.contains($0.observedAt) } + ) + XCTAssertEqual(view.resolution, .exact) + XCTAssertFalse(view.hadReadError) + XCTAssertEqual(view.coveredInterval, requested) + XCTAssertLessThanOrEqual( + view.retainedBounds!.start, + samples.first!.observedAt + ) + XCTAssertGreaterThan( + view.retainedBounds!.end, + samples.last!.observedAt + ) + } + + func testDenseOlderRangeIsDownsampledAndRejectsAnUnboundedRequest() async throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let samples = (0 ..< 7_000).map { minute in + let observedAt = start.addingTimeInterval(Double(minute) * 60) + return UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - minute % 100), + resetsAt: start.addingTimeInterval(7 * 86_400) + ) + } + let history = UsageHistory( + localDirectory: temporaryDirectory(), + installationID: "writer-a" + ) + _ = await history.load(legacySamples: samples) + let requested = DateInterval( + start: start, + end: samples.last!.observedAt.addingTimeInterval(1) + ) + let loadedView = await history.rangeView(for: requested) + let view = try XCTUnwrap(loadedView) + + XCTAssertEqual(view.resolution, .downsampled) + XCTAssertLessThanOrEqual(view.samples.count, 6_000) + XCTAssertEqual(view.samples.first?.observedAt, samples.first?.observedAt) + XCTAssertEqual(view.samples.last?.observedAt, samples.last?.observedAt) + let unboundedView = await history.rangeView(for: DateInterval( + start: start, + end: start.addingTimeInterval(85 * 86_400) + )) + XCTAssertNil(unboundedView) + } + + func testExplicitAndAutomaticSyncBoundAndPersistOfflineBackfill() async throws { + let root = temporaryDirectory() + let shared = root.appendingPathComponent("shared", isDirectory: true) + let senderRoot = root.appendingPathComponent("sender", isDirectory: true) + let receiverRoot = root.appendingPathComponent("receiver", isDirectory: true) + try FileManager.default.createDirectory( + at: shared, + withIntermediateDirectories: true + ) + let receiver = UsageHistory( + localDirectory: receiverRoot, + installationID: "receiver" + ) + _ = await receiver.load() + _ = await receiver.connect(to: shared) + + let sender = UsageHistory( + localDirectory: senderRoot, + installationID: "sender" + ) + _ = await sender.load() + let start = Date(timeIntervalSince1970: 1_700_000_000) + for day in 0 ..< 100 { + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + _ = await sender.record(UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - day % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + )) + } + _ = await sender.connect(to: shared) + + _ = await receiver.synchronize() + var importedCount = jsonFiles( + for: "sender", + in: receiverRoot + ).count + XCTAssertLessThanOrEqual(importedCount, 32) + let newestSharedFile = try XCTUnwrap( + jsonFiles(for: "sender", in: shared) + .map(\.lastPathComponent) + .max() + ) + XCTAssertTrue( + jsonFiles(for: "sender", in: receiverRoot).contains { + $0.lastPathComponent == newestSharedFile + } + ) + + let reloaded = UsageHistory( + localDirectory: receiverRoot, + installationID: "receiver" + ) + _ = await reloaded.load() + _ = await reloaded.connect( + to: shared, + performFullReconciliation: false + ) + var nextCount = jsonFiles(for: "sender", in: receiverRoot).count + XCTAssertLessThanOrEqual(nextCount - importedCount, 32) + importedCount = nextCount + + for pass in 2 ... 6 where importedCount < 100 { + _ = await reloaded.synchronizeIfDue( + at: Date().addingTimeInterval(Double(pass) * 3_600) + ) + nextCount = jsonFiles(for: "sender", in: receiverRoot).count + XCTAssertLessThanOrEqual(nextCount - importedCount, 32) + importedCount = nextCount + } + XCTAssertEqual(importedCount, 100) + } + + func testAutomaticSyncDoesNotRewriteUnchangedDailyFiles() async throws { + let root = temporaryDirectory() + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory( + at: shared, + withIntermediateDirectories: true + ) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "writer-a" + ) + _ = await history.load() + _ = await history.record(UsageSample( + observedAt: Date(timeIntervalSince1970: 1_700_000_000), + remainingPercent: 75, + resetsAt: Date(timeIntervalSince1970: 1_700_604_800) + )) + _ = await history.connect(to: shared) + let before = try writerManifestRevision(for: "writer-a", in: shared) + + _ = await history.synchronizeIfDue( + at: Date().addingTimeInterval(3_600) + ) + + XCTAssertEqual( + try writerManifestRevision(for: "writer-a", in: shared), + before + ) + } + + func testAutomaticSyncContinuesPastMalformedHistoryAndRevisitsItAfterRepair() async throws { + let root = temporaryDirectory() + let shared = root.appendingPathComponent("shared", isDirectory: true) + let receiverRoot = root.appendingPathComponent("receiver", isDirectory: true) + let senderRoot = root.appendingPathComponent("sender", isDirectory: true) + try FileManager.default.createDirectory( + at: shared, + withIntermediateDirectories: true + ) + let receiver = UsageHistory( + localDirectory: receiverRoot, + installationID: "receiver" + ) + _ = await receiver.load() + _ = await receiver.connect(to: shared) + + let sender = UsageHistory( + localDirectory: senderRoot, + installationID: "sender" + ) + _ = await sender.load() + let start = Date(timeIntervalSince1970: 1_700_000_000) + for day in 0 ..< 3 { + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + _ = await sender.record(UsageSample( + observedAt: observedAt, + remainingPercent: Double(80 - day), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + )) + } + _ = await sender.connect(to: shared) + let damaged = try XCTUnwrap( + jsonFiles(for: "sender", in: shared).min { + $0.lastPathComponent < $1.lastPathComponent + } + ) + let original = try Data(contentsOf: damaged) + try Data("broken".utf8).write(to: damaged) + + let first = await receiver.synchronizeIfDue( + at: Date().addingTimeInterval(3_600) + ) + XCTAssertEqual( + first.errorMessage, + "Some synced history couldn’t be read." + ) + XCTAssertEqual(jsonFiles(for: "sender", in: receiverRoot).count, 2) + + try original.write(to: damaged, options: .atomic) + let second = await receiver.synchronizeIfDue( + at: Date().addingTimeInterval(7_200) + ) + XCTAssertNil(second.errorMessage) + XCTAssertEqual(jsonFiles(for: "sender", in: receiverRoot).count, 3) + } + func testMalformedFileKeepsValidHistoryAndReportsWarning() async throws { let root = temporaryDirectory() let now = Date(timeIntervalSince1970: 1_900_000) @@ -1471,18 +1822,24 @@ final class UsageHistoryTests: XCTestCase { remainingPercent: 80, resetsAt: now.addingTimeInterval(86_400) ) + let validLaterSample = UsageSample( + observedAt: now.addingTimeInterval(86_400), + remainingPercent: 70, + resetsAt: now.addingTimeInterval(2 * 86_400) + ) let history = UsageHistory( localDirectory: root, installationID: "writer-a" ) _ = await history.load() _ = await history.record(sample) - let writerDirectory = try XCTUnwrap( - writerDirectories(for: "writer-a", in: root).first - ) - try Data("broken".utf8).write( - to: writerDirectory.appendingPathComponent("broken.json") + _ = await history.record(validLaterSample) + let storedFile = try XCTUnwrap( + jsonFiles(for: "writer-a", in: root).min { + $0.lastPathComponent < $1.lastPathComponent + } ) + try Data("broken".utf8).write(to: storedFile) let reloaded = UsageHistory( localDirectory: root, @@ -1490,7 +1847,7 @@ final class UsageHistoryTests: XCTestCase { ) let state = await reloaded.load() - XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.samples, [validLaterSample]) XCTAssertEqual(state.errorMessage, "Some usage history couldn’t be read.") } @@ -1643,6 +2000,22 @@ final class UsageHistoryTests: XCTestCase { } } + private func writerManifestRevision( + for installationID: String, + in root: URL + ) throws -> UInt64 { + let writer = try XCTUnwrap( + writerDirectories(for: installationID, in: root).first + ) + let data = try Data( + contentsOf: writer.appendingPathComponent(".codex-limits-writer") + ) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + return try XCTUnwrap((object["revision"] as? NSNumber)?.uint64Value) + } + private func markerData(generation: Int, syncTarget: String) throws -> Data { try JSONSerialization.data(withJSONObject: [ "version": 2, diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 7b2790c..d52d8a6 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -3241,6 +3241,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.weeklyUsageRemaining) XCTAssertNil(reader.interval) + XCTAssertEqual(reader.menuBarText, "—") + XCTAssertEqual(reader.freshness, .unavailable) XCTAssertEqual(reader.evidence.reason, "Current allowance window unavailable") XCTAssertEqual(reader.guidanceTitle, "Current allowance window unavailable") XCTAssertTrue(reader.chart.allObserved.contains { diff --git a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift index 1f2ea65..97f830e 100644 --- a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift @@ -4,6 +4,171 @@ import XCTest @MainActor final class UsageMonitorHistoryTests: XCTestCase { + func testUnselectedMenuSourceWaitsForDemandAndStopsAutomaticRefresh() async throws { + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ), + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_600), + remaining: 63 + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + isEnabled: false, + menuBarSourceActive: false, + fetchUsage: { try await source.next() } + ) + + await monitor.start() + var callCount = await source.callCount + XCTAssertEqual(callCount, 0) + + await monitor.setEnabled(true) + callCount = await source.callCount + XCTAssertEqual(callCount, 0) + + await monitor.refreshAccountIfStale() + callCount = await source.callCount + XCTAssertEqual(callCount, 1) + + await monitor.setMenuBarSourceActive(true) + callCount = await source.callCount + XCTAssertEqual(callCount, 2) + + await monitor.setMenuBarSourceActive(false) + await monitor.automaticRefresh() + callCount = await source.callCount + XCTAssertEqual(callCount, 2) + } + + func testDisabledMonitorDoesNoSourceWorkUntilEnabled() async throws { + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + isEnabled: false, + fetchUsage: { try await source.next() } + ) + + await monitor.start() + await monitor.refresh() + let disabledCallCount = await source.callCount + XCTAssertEqual(disabledCallCount, 0) + + await monitor.setEnabled(true) + let enabledCallCount = await source.callCount + XCTAssertEqual(enabledCallCount, 1) + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "64%") + } + + func testDisplayBoundaryExpiresCodexWithoutAnotherSourceRead() async throws { + let now = Date() + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: now, + remaining: 64, + resetsAt: now.addingTimeInterval(0.15) + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + fetchUsage: { try await source.next() } + ) + + await monitor.start() + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "64%") + let deadline = Date().addingTimeInterval(2) + while monitor.readerSnapshot.menuBarText != "—", Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "—") + XCTAssertEqual(monitor.readerSnapshot.freshness, .unavailable) + let callCount = await source.callCount + XCTAssertEqual(callCount, 1) + } + + func testDisablingMonitorCancelsAnInFlightFetchBeforePublication() async throws { + let source = DelayedFetchSource( + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ) + ) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + fetchUsage: { try await source.next() } + ) + let refresh = Task { await monitor.refresh() } + while await source.callCount == 0 { + await Task.yield() + } + + await monitor.setEnabled(false) + await refresh.value + + XCTAssertFalse(monitor.isEnabled) + XCTAssertNil(monitor.readerSnapshot.account) + } + + func testHidingUnselectedCodexCancelsVisibleAccountWork() async { + let source = DelayedFetchSource( + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ) + ) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + menuBarSourceActive: false, + fetchUsage: { try await source.next() } + ) + await monitor.setVisible(true) + let refresh = Task { await monitor.refreshAccountIfStale() } + while await source.callCount == 0 { + await Task.yield() + } + + await monitor.setVisible(false) + await refresh.value + + XCTAssertNil(monitor.readerSnapshot.account) + XCTAssertFalse(monitor.isRefreshing) + } + func testSafetyBufferPolicyNormalizesInvalidValues() { XCTAssertEqual(SafetyBufferPolicy.normalized(nil), 3) XCTAssertEqual(SafetyBufferPolicy.normalized(.nan), 3) @@ -77,7 +242,7 @@ final class UsageMonitorHistoryTests: XCTestCase { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let fetchedAt = Date(timeIntervalSince1970: 1_900_000) + let fetchedAt = Date() defaults.set( try JSONEncoder().encode( StoredStateFixture( @@ -591,6 +756,50 @@ final class UsageMonitorHistoryTests: XCTestCase { ) } + func testExplicitRefreshSupersedesQueuedAutomaticRefresh() async { + let coordinator = IntegrationWorkCoordinator() + let gate = UsageMonitorCoordinatorGate() + let blocker = Task { + await coordinator.run(priority: .explicit) { + await gate.hold() + } + } + while !(await gate.started) { + await Task.yield() + } + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 80 + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + integrationWorkCoordinator: coordinator, + fetchUsage: { try await source.next() } + ) + let automatic = Task { await monitor.automaticRefresh() } + while !monitor.isRefreshing { + await Task.yield() + } + let explicit = Task { await monitor.refresh() } + try? await Task.sleep(for: .milliseconds(10)) + + await gate.release() + await blocker.value + await explicit.value + await automatic.value + + let callCount = await source.callCount + XCTAssertEqual(callCount, 1) + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "80%") + } + func testTokenActivityRefreshesOnlyWhenAccountDataIsStale() async throws { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -638,11 +847,13 @@ final class UsageMonitorHistoryTests: XCTestCase { description: "first account evaluation started" ) let evaluator = BlockingUsageEvaluator(started: evaluationStarted) + let fetchedAt = Date() let source = FetchSequence([ makeFetchResult( identity: "user@example.com", - fetchedAt: Date(timeIntervalSince1970: 1_900_000), - remaining: 80 + fetchedAt: fetchedAt, + remaining: 80, + resetsAt: fetchedAt.addingTimeInterval(7 * 86_400) ) ]) let monitor = UsageMonitor( @@ -686,11 +897,13 @@ final class UsageMonitorHistoryTests: XCTestCase { description: "account evaluation started" ) let evaluator = BlockingUsageEvaluator(started: evaluationStarted) + let fetchedAt = Date() let source = FetchSequence([ makeFetchResult( identity: "user@example.com", - fetchedAt: Date(timeIntervalSince1970: 1_900_000), - remaining: 80 + fetchedAt: fetchedAt, + remaining: 80, + resetsAt: fetchedAt.addingTimeInterval(7 * 86_400) ) ]) let monitor = UsageMonitor( @@ -774,11 +987,13 @@ final class UsageMonitorHistoryTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let root = temporaryDirectory() + let fetchedAt = Date() let source = FetchSequence([ makeFetchResult( identity: "user@example.com", - fetchedAt: Date(timeIntervalSince1970: 1_900_000), - remaining: 80 + fetchedAt: fetchedAt, + remaining: 80, + resetsAt: fetchedAt.addingTimeInterval(7 * 86_400) ) ]) let monitor = UsageMonitor( @@ -969,7 +1184,7 @@ final class UsageMonitorHistoryTests: XCTestCase { requestCount = await requests.count XCTAssertEqual(requestCount, 0) - for graph in AnalyticsGraph.coreCases { + for graph in [AnalyticsGraph.usageRemaining, .tokenActivity] { var state = AnalyticsExplorationState.initial state.graph = graph await monitor.setLocalAnalyticsVisible( @@ -1772,6 +1987,59 @@ final class UsageMonitorHistoryTests: XCTestCase { ) } + func testEarlierHistoryLoadsOnlyAfterVisibleUserDemand() async throws { + let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let root = temporaryDirectory() + let currentAt = Date(timeIntervalSince1970: 9_000_000) + let oldAt = currentAt.addingTimeInterval(-30 * 86_400) + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: currentAt, + remaining: 80, + resetsAt: currentAt.addingTimeInterval(7 * 86_400) + ) + ]) + let monitor = UsageMonitor( + defaults: defaults, + historyDirectory: root, + startsAutomatically: false, + fetchUsage: { try await source.next() } + ) + await monitor.refresh() + let partition = try JSONDecoder().decode( + AccountHistoryPartition.self, + from: XCTUnwrap(defaults.data(forKey: "historyAccountPartition")) + ) + let olderWriter = UsageHistory( + localDirectory: root, + installationID: "older-fixture", + partition: partition + ) + _ = await olderWriter.load() + _ = await olderWriter.record(UsageSample( + observedAt: oldAt, + remainingPercent: 90, + resetsAt: oldAt.addingTimeInterval(7 * 86_400) + )) + + XCTAssertNil(monitor.historicalReaderSnapshot) + await monitor.setVisible(true) + await monitor.loadEarlierHistory( + exploration: .initial, + dispositions: [:] + ) + + XCTAssertEqual( + monitor.historicalReaderSnapshot?.chart.allObserved.first?.date, + oldAt + ) + await monitor.setVisible(false) + XCTAssertNil(monitor.historicalReaderSnapshot) + } + func testUnresolvableSavedSyncTargetKeepsDeletionPending() async throws { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -2018,6 +2286,7 @@ final class UsageMonitorHistoryTests: XCTestCase { identity: String, fetchedAt: Date, remaining: Double, + resetsAt: Date = Date(timeIntervalSince1970: 2_000_000), lifetimeTokens: Int64? = nil, lifetimeTokensObservedAt: Date? = nil, planType: String? = nil @@ -2026,6 +2295,7 @@ final class UsageMonitorHistoryTests: XCTestCase { account: .stable(identity: identity), fetchedAt: fetchedAt, remaining: remaining, + resetsAt: resetsAt, lifetimeTokens: lifetimeTokens, lifetimeTokensObservedAt: lifetimeTokensObservedAt, planType: planType @@ -2036,6 +2306,7 @@ final class UsageMonitorHistoryTests: XCTestCase { account: CodexAccountObservation, fetchedAt: Date, remaining: Double, + resetsAt: Date = Date(timeIntervalSince1970: 2_000_000), lifetimeTokens: Int64? = nil, lifetimeTokensObservedAt: Date? = nil, planType: String? = nil @@ -2047,7 +2318,7 @@ final class UsageMonitorHistoryTests: XCTestCase { name: "Codex", window: UsageWindow( remainingPercent: remaining, - resetsAt: Date(timeIntervalSince1970: 2_000_000), + resetsAt: resetsAt, durationMinutes: 10_080 ) ), @@ -2148,6 +2419,21 @@ private actor DelayedFetchSource { } } +private actor UsageMonitorCoordinatorGate { + private(set) var started = false + private var continuation: CheckedContinuation? + + func hold() async { + started = true + await withCheckedContinuation { continuation = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + private final class BlockingUsageEvaluator: @unchecked Sendable { private let lock = NSLock() private let gate = DispatchSemaphore(value: 0) diff --git a/Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift b/Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift new file mode 100644 index 0000000..715a5a9 --- /dev/null +++ b/Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift @@ -0,0 +1,114 @@ +import ClaudeIntegrationCore +import Foundation +import XCTest +@testable import CodexLimits + +final class UsageOverviewSnapshotTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + func testOverviewContainsOnlyCurrentPeriodActualObservations() throws { + let window = window() + let current = point(-60, 65) + let chart = chart(window: window, segments: [[point(-8 * 86_400, 95), point(-600, 80), current, point(60, 60)]]) + let overview = try XCTUnwrap(UsageOverviewSnapshot(chart: chart, window: window, now: now)) + + XCTAssertEqual(overview.range, DateInterval(start: window.startsAt, end: window.resetsAt)) + XCTAssertEqual(overview.observedSegments, [[point(-600, 80), current]]) + XCTAssertEqual(overview.latest, current) + XCTAssertEqual(overview.target, chart.target) + XCTAssertFalse(overview.observedSegments.flatMap { $0 }.contains(point(3_600, 0))) + } + + func testCompactionKeepsSegmentEndpointsAndLatestWithinFixedBudget() throws { + let window = window() + let segments: [[UsageChartPoint]] = (0 ..< 20).map { segment in + let start = -10_000 + segment * 400 + return (0 ..< 100).map { index in + point(Double(start + index), Double(100 - index)) + } + } + let overview = try XCTUnwrap(UsageOverviewSnapshot(chart: chart(window: window, segments: segments), window: window, now: now)) + XCTAssertEqual(overview.observedSegments.count, segments.count) + for (actual, original) in zip(overview.observedSegments, segments) { + XCTAssertEqual(actual.first, original.first) + XCTAssertEqual(actual.last, original.last) + XCTAssertTrue(actual.allSatisfy { original.contains($0) }) + } + XCTAssertEqual(overview.latest, segments.last?.last) + XCTAssertLessThanOrEqual(overview.observedSegments.flatMap { $0 }.count + overview.target.count + 1, 256) + + let isolated = (0 ..< 500).map { [point(Double(-600 + $0), 50)] } + let manyBreaks = try XCTUnwrap(UsageOverviewSnapshot(chart: chart(window: window, segments: isolated), window: window, now: now)) + XCTAssertTrue(manyBreaks.observedSegments.allSatisfy { $0.count == 1 }) + XCTAssertEqual(manyBreaks.latest, isolated.last?.last) + XCTAssertLessThanOrEqual(manyBreaks.observedSegments.count + manyBreaks.target.count + 1, 256) + } + + func testInvalidObservationsBreakLinesAndDerivedValuesAreRejected() throws { + let window = window() + let segments = [[point(-600, 80), point(-500, .nan), point(-400, 70), point(-300, 101), point(-200, 60)]] + let overview = try XCTUnwrap(UsageOverviewSnapshot(chart: chart(window: window, segments: segments), window: window, now: now)) + XCTAssertEqual(overview.observedSegments, [[point(-600, 80)], [point(-400, 70)], [point(-200, 60)]]) + XCTAssertNil(UsageOverviewSnapshot(chart: chart(window: window, segments: segments, source: .derivedEstimate), window: window, now: now)) + XCTAssertNil(UsageOverviewSnapshot(chart: chart(window: window, segments: segments), window: window, now: window.resetsAt)) + } + + func testLatestOnlyOverviewNeverFabricatesEarlierHistoryOrUnknownMonthlyTarget() throws { + let current = observation(at: now.addingTimeInterval(-60)) + let overview = try XCTUnwrap(UsageOverviewSnapshot(observations: [], current: current, now: now, safetyBuffer: 7)) + XCTAssertEqual(overview.observedSegments, [[point(-60, 75)]]) + XCTAssertEqual(overview.latest, point(-60, 75)) + XCTAssertEqual(overview.target.last?.remaining, 7) + XCTAssertNil(UsageOverviewSnapshot(observations: [current], current: nil, now: now, safetyBuffer: 3)) + XCTAssertNil(UsageOverviewSnapshot(observations: [current], current: current, now: current.resetsAt, safetyBuffer: 3)) + + let monthly = observation(at: now.addingTimeInterval(-60), startsAt: nil) + let unknownStart = try XCTUnwrap(UsageOverviewSnapshot(observations: [], current: monthly, now: now, safetyBuffer: 3)) + XCTAssertTrue(unknownStart.target.isEmpty) + XCTAssertEqual(unknownStart.observedSegments, [[point(-60, 75)]]) + XCTAssertEqual(UsageOverviewSnapshot.historyReadStart(current: monthly, now: now), now.addingTimeInterval(-31 * 86_400)) + } + + func testProviderCorrectionSourceAndGapBoundariesSurviveOverview() throws { + let observations = [ + observation(at: now.addingTimeInterval(-90_000), remaining: 90), + observation(at: now.addingTimeInterval(-3_600), remaining: 80), + observation(at: now.addingTimeInterval(-3_000), remaining: 85), + observation(at: now.addingTimeInterval(-1_600), remaining: 70, source: "legacyCredits"), + observation(at: now.addingTimeInterval(-60), remaining: 60, source: "legacyCredits") + ] + let overview = try XCTUnwrap(UsageOverviewSnapshot(observations: observations, current: observations.last, now: now, safetyBuffer: 3)) + XCTAssertEqual(overview.observedSegments.map(\.count), [1, 1, 1, 2]) + } + + private func window() -> UsageWindow { + UsageWindow(remainingPercent: 65, resetsAt: now.addingTimeInterval(3_600), durationMinutes: 7 * 24 * 60) + } + + private func point(_ offset: TimeInterval, _ remaining: Double) -> UsageChartPoint { + UsageChartPoint(date: now.addingTimeInterval(offset), remaining: remaining) + } + + private func observation( + at date: Date, remaining: Double = 75, + startsAt: Date? = Date(timeIntervalSince1970: 1_800_000_000 - 6 * 86_400), + source: String = "creditUsagePercent" + ) -> AllowanceObservation { + AllowanceObservation(metric: "grok-weekly", observedAt: date, remainingPercent: remaining, + resetsAt: now.addingTimeInterval(3_600), startsAt: startsAt, source: source) + } + + private func chart(window: UsageWindow, segments: [[UsageChartPoint]], source: UsageValueSource = .account) -> UsageChartSnapshot { + UsageChartSnapshot( + observedSource: source, + target: [UsageChartPoint(date: window.startsAt, remaining: 100), UsageChartPoint(date: window.resetsAt, remaining: 3)], + currentProjection: [point(-60, 65), point(3_600, 0)], + reference: UsageChartReferenceSeries(source: .tokenEstimate, points: [point(-3_600, 99)]), + currentAllowanceReset: window.resetsAt, + allowanceWindows: [ + UsageAllowanceWindowSeries(resetsAt: window.startsAt, observedSegments: [[point(-86_400, 99)]]), + UsageAllowanceWindowSeries(resetsAt: window.resetsAt, observedSegments: segments) + ], currentRunsFaster: false, accessibilityValue: "" + ) + } +} diff --git a/docs/MEASUREMENT-CONTRACT.md b/docs/MEASUREMENT-CONTRACT.md new file mode 100644 index 0000000..a212606 --- /dev/null +++ b/docs/MEASUREMENT-CONTRACT.md @@ -0,0 +1,343 @@ +# Measurement contract + +This contract defines how Codex Limits labels facts, Freshness, Coverage, Confidence, and comparable work across supported Integrations. It applies to the reader snapshot, charts, Facts, Insights, tooltips, notifications, and tests. + +The product prefers no estimate to a weak estimate. + +## Source classes + +Every value has one source class: + +1. **Account fact** — returned by an Integration's supported account source. +2. **Local fact** — observed in an Integration's supported local records on this Mac. +3. **Derived estimate** — calculated from named account and local facts. + +The UI never merges these classes into one unexplained value. + +## Integration separation + +Every fact and Integration Snapshot carries its Integration ID, capability, observed time, source kind, and source version when available. + +- Never add, average, rank, or otherwise combine allowance percentages from different Integrations. +- Never convert OpenCode Local Activity into Account Allowance. +- Never infer support for one Integration Capability from another capability. +- Omit an Unsupported Capability. Use unavailable only when a supported capability cannot currently produce a value. +- Keep the existing Codex analytics engine isolated from Claude Code and Grok snapshots and histories, and from any future OpenCode snapshot. + +The `All` overview uses separate current-period thumbnails. Each includes only recorded account observations, the latest actual point, and a target when the period start is known. It excludes forecasts and token estimates, preserves gaps, and retains at most 256 display points. Claude/Grok overview reads cover at most 31 elapsed days (32 UTC files); detail history keeps its existing 84-day read bound. A single reading remains one point. + +## Account Allowance normalization + +An Integration may publish Account Allowance only when the source identifies the percentage orientation and allowance period. + +- A percentage must be finite and source-valid. Missing, null, malformed, or semantically unknown values are unavailable, never zero. +- Claude Code `used_percentage` is valid only inside `0...100`; convert it to `remaining = 100 - used`. Reject an out-of-range value. +- Grok `creditUsagePercent` is a used percentage. Preserve the finite original as `reportedUsedPercent`, apply the official `0...100` clamp, and convert it to remaining. Record `measurementSource` as `creditUsagePercent` or `legacyCredits`, with the CLI version when available. Use legacy `100 * used.val / monthlyLimit.val` only when both current fields are absent; a zero or missing legacy limit is unavailable. A present empty Cent object means zero, while a missing monetary field remains unavailable. The collector sends `_x.ai/billing` on the ACP wire; the internal handler name omits the underscore. +- A direct remaining percentage is validated without changing its orientation. +- Every allowance window carries its provider window ID or period type, observed time, reset time, and duration when known. +- Weekly and monthly Grok periods remain distinct. Retain a supplied `currentPeriod.start` or legacy `billingPeriodStart` only after supported finite-date and start-before-reset validation. A missing start remains absent; never invent a monthly start from a fixed duration. An unknown period type is unavailable rather than relabeled as weekly. +- A missing secondary window does not invalidate another independently valid window. +- OpenCode Local Token Activity is not Account Allowance and never passes through this normalization. + +## Freshness and allowance lifetime + +Freshness describes age and current source availability, not accuracy or Confidence. + +The OpenCode row is a future contract. Freshness windows do not define polling schedules; Grok polls every ten minutes only while selected for the menu bar, with failure backoff and a thirty-second launch floor. + +| Integration | Fresh window | +|---|---:| +| Codex | At most 15 minutes old | +| Claude Code | At most 30 minutes old | +| Grok | At most 30 minutes old | +| OpenCode | At most 15 minutes old | + +Reader-facing source states are: + +| State | Meaning | +|---|---| +| `Fresh` | A valid observation inside its Freshness window and, for allowance, before its known reset. | +| `Stale` | A valid observation outside its Freshness window but still before the same known reset. | +| `Expired` | A valid allowance observation whose known reset passed without a post-reset observation. Its old percentage is withheld. | +| `Unavailable` | No compatible valid observation exists. | + +Claude Code always shows `Last observed` because its `statusLine` source is event-driven. When a supported source fails, retain the last valid snapshot only while it remains meaningful, show the current actionable source error, and never replace a failed or absent reading with zero. A known reset boundary takes precedence over the Freshness window: no numeric allowance crosses it. + +Concurrent event-driven observations are ordered by receive time captured before parsing. Atomic persistence must not allow an older Claude relay process that finishes later to replace a newer observation. + +## Claude Code and Grok allowance history + +Each provider has its own Usage remaining chart backed only by recorded allowance observations. Claude's seven-day and five-hour windows are separate series; Grok's weekly and monthly periods are separate series. Every point retains its observation time, period or window, reset, source provenance, and provider-reported start when available. A cached snapshot from before history support may seed one real point at its original observation time. It never creates earlier points or a complete past period. + +A source or period change, reset, or detected correction breaks a comparable interval. An increase in remaining percentage beyond the shared 0.1 percentage-point rounding tolerance is a correction. Do not connect across these breaks. Missing observations remain gaps; token activity, local sessions, costs, and monetary balances never act as allowance proxies. + +A current allowance estimate requires at least two compatible observations spanning at least 60 seconds, a latest observation inside the provider's 30-minute Freshness window and before reset, and no intervening gap over 30 minutes, reset, source change, or correction. The estimate stays within the same known allowance period and is labeled as an estimate. When these gates fail, show recorded facts and the reason more observations are needed. Do not borrow Codex token-based guidance, workload comparisons, or Confidence from another Integration. + +## Primary Codex allowance + +The weekly Codex allowance is the primary allowance. + +- Select the Codex window whose `windowDurationMins` is `10080`. +- Show its Usage remaining in the menu bar, current-state header, Runway, Suggested Pace, and default Usage remaining chart. +- Do not replace it with a five-hour window because that window has a lower percentage. +- Show five-hour and model-specific windows as Other limits in Facts. +- If no weekly window is returned, show `Weekly usage unavailable`. Do not substitute another window without naming it. + +Every allowance-derived metric carries the selected limit ID, duration, start, and reset time. + +## Account Token Activity + +Account Token Activity is the primary weekly token total. Use the strongest available method in this order: + +1. **Observed lifetime delta** — subtract two monotonic `summary.lifetimeTokens` readings that bound the same account and interval. +2. **Exact daily sum** — sum complete account daily buckets only when their calendar boundaries match the selected interval. +3. **Partial daily sum** — show complete daily buckets inside the interval as a factual partial value. Do not scale partial days or call the result a weekly total. +4. **Unavailable** — withhold the total when no method above applies. + +An observed lifetime delta is valid only when: + +- both readings belong to the same local account partition; +- the counter did not decrease; +- both interval boundaries meet the boundary rules below; +- no account change occurred between the readings. + +Daily buckets may seed a historical chart, but they never become observed allowance readings. + +## Account facts + +Facts may show these values when the account API returns them: + +- Lifetime tokens +- Peak daily tokens +- Longest running turn +- Current streak +- Longest streak +- Credits balance or unlimited credits +- Spend-control limit, Usage remaining, and reset time + +These are Account facts. They do not need Confidence. They do need source, fetched time, and an unavailable state. + +## Local Activity source boundary + +Issue `Prove read-only Local Activity ingestion and Coverage` owns the source decision before Local Token Activity ships. + +Until that spike is complete: + +- do not assume that a separate app-server connection receives live events from Tasks owned by another Codex process; +- do not resume, load, start, stop, or take ownership of a user Task to observe it; +- treat supported read-only app-server projections as the preferred metadata source; +- treat incrementally tailed local Codex records as a candidate source for token, turn, tool, and timing facts; +- record source capability and CLI version with every normalized event. + +If no safe read-only source exists for a fact, the fact is unavailable. + +## OpenCode Local Activity + +OpenCode Local Activity is deferred from v1 because the tested supported local-server process failed the memory and initialization-write budgets. The rules below are the acceptance contract for a future supported source; they do not authorize the rejected server collector. A future source never requests messages or parts. + +- The rolling seven-day interval is exactly 604,800 seconds ending now. +- Local Token Activity is the sum of finite, non-negative input, output, reasoning, cache-read, and cache-write counters only after fixtures prove these categories are disjoint cumulative values. +- A root session has no parent ID. Overview session count includes roots only; detail may show bounded descendants separately. +- Parent and child token or cost totals are not added until fixtures prove that parent totals exclude descendants. +- Repeated cumulative snapshots deduplicate by `(Integration ID, session ID)` and retain the newest compatible counter state. +- A counter decrease after compaction, fork, archive, deletion, or schema change creates a source break; do not produce a negative delta or silently join both sides. +- Missing or invalid cost is unavailable, not zero. Valid cost is labeled `OpenCode local estimated cost` and remains distinct from provider billing. +- Session provider/model describes only the currently saved session selection. It does not prove per-response or whole-session model attribution. +- Production collection remains unavailable until a supported source enforces accepted range and count bounds before returning session data and passes the process, memory, disk-write, and endurance budgets. + +## Time boundaries + +### Rolling ranges + +`24 hours`, `3 days`, `7 days`, `4 weeks`, and `12 weeks` end at the current instant and use exact elapsed durations of 86,400, 259,200, 604,800, 2,419,200, and 7,257,600 seconds. A delayed observation does not move a rolling range into the past. + +### Machine-local time + +Reader-facing dates and clock labels use the Mac's current time zone when rendered. A time-zone or daylight-saving change changes local labels, not the underlying elapsed interval. + +An interval is: + +- **Tightly bounded** when the closest account readings are no more than 15 minutes from both boundaries. +- **Loosely bounded** when both readings are no more than 60 minutes from the boundaries. +- **Unbounded** when either reading is farther away or missing. + +For allowance movement: + +- a gap of no more than 30 minutes between account readings supports High Coverage; +- a gap over 30 minutes and no more than 6 hours lowers Coverage to Partial; +- a gap over 6 hours makes comparable allowance movement unavailable; +- any gap that may contain an unknown reset or correction makes the interval unbounded. + +A known scheduled reset, banked reset, account change, or detected correction always splits the interval. + +## Coverage + +Coverage says how much of the required source data was observed. It does not mean accuracy. + +Reader-facing Coverage states are: + +| State | Meaning | +|---|---| +| `Complete` | Every required source and boundary is present, with no known gap or ambiguity. | +| `High` | At least 80% of aligned activity is represented and every required boundary is tight. | +| `Partial` | Useful evidence exists, but coverage is between 50% and 79%, a boundary is loose, or a named source is missing. | +| `Low` | Less than 50% is represented or a material gap prevents a dependable conclusion. | +| `Unavailable` | The required source, identity, token definition, or time boundary cannot be reconciled. | +| `Not applicable` | The metric has no meaningful coverage denominator, such as an interval with no activity. | + +Every state other than Complete names at least one reason, such as: + +- `Account boundary is 42 minutes late` +- `Local Tasks are missing` +- `Activity from another device is possible` +- `Token definitions do not align` +- `Unknown reset or correction` +- `Codex version does not expose this field` + +### Numeric Local Coverage + +Numeric Local Coverage is shown only when the source spike proves that Account Token Activity and Local Token Activity use compatible token definitions for the active Codex version and both values cover the same interval. + +For aligned values: + +`Local Coverage = Local Token Activity / Account Token Activity` + +Rules: + +- When both totals are zero, Coverage is Not applicable. +- When account activity is zero but local activity is positive, numeric Coverage is unavailable. +- When local activity is more than 2% above account activity, numeric Coverage is unavailable and the UI says `Account and local totals do not align`. +- A difference of at most 2% may be treated as rounding and clamped to 100%. +- Numeric Coverage describes the share of Account Token Activity visible in local records. It does not prove that local records explain account billing. + +### Reset Detail Coverage + +Reset Detail Coverage uses the authoritative reset count and returned available detail: + +- `Complete` when detail count equals the authoritative count. +- `Partial` when detail count is greater than zero and lower than the count. +- `Unavailable` when the count is greater than zero and no detail is returned. +- `Not applicable` when the authoritative count is zero. + +## Confidence + +Confidence says how strongly the observed evidence supports a derived estimate or Insight. + +| State | Product behavior | +|---|---| +| `High` | Show the estimate or Insight. Coverage is Complete or High, the interval is tightly bounded, and no material comparability warning applies. | +| `Medium` | Show the estimate with its range and named caveat. The interval is still bounded and the conclusion remains useful. | +| `Low` | Withhold the estimate or Insight. Show the observed facts and the reason more evidence is needed. | +| `Unavailable` | Do not calculate the result. | + +Direct Account facts and Local facts show provenance and freshness instead of artificial Confidence. + +The engine, not the view, owns Confidence and its reasons. Thresholds are versioned policy values and have deterministic tests. + +## Comparable work + +Two intervals are comparable only when all these gates pass: + +- both intervals belong to the same account partition; +- both use the weekly Codex allowance; +- both are bounded; +- neither contains a reset, account change, unknown correction, or counter decrease; +- both have non-zero Account Token Activity; +- Local Coverage is at least 50% when workload mix is part of the comparison; +- the dominant model family and reasoning level are known; +- model, reasoning, and cached-input shares differ by no more than 20 percentage points; +- the product can name every reason that lowers comparability. + +Comparability is: + +- **High** when Local Coverage is at least 80%, both intervals are tightly bounded, and each observed workload-mix share differs by no more than 10 percentage points. +- **Medium** when Local Coverage is at least 50%, the intervals are at least loosely bounded, and each share differs by no more than 20 percentage points. +- **Not comparable** otherwise. + +Low-comparability conclusions are withheld. + +## Reference Baseline + +The default Reference Baseline is the median Allowance Intensity of the previous four complete, High-comparability weekly windows. + +- Use exactly four eligible windows. +- If fewer than four exist, show `Not enough comparable weeks`. +- A user-pinned period must pass at least Medium comparability. +- Pinning a period does not override reset, identity, boundary, or token-definition failures. +- Store the baseline interval IDs and policy version with the derived result. + +Allowance Intensity divides observed weekly Account Movement by aligned Account Token Activity. Equivalent Capacity extrapolates from that intensity and always remains an estimate. + +## Account partitions + +Analytics History never mixes signed-in accounts. + +- Read account state before joining new observations to history. +- When an email is available, derive an on-device keyed fingerprint and never persist the email as the partition key. +- When stable identity is unavailable, start an isolated unknown-account partition after every observed auth transition. +- A plan change does not create a new partition, but it splits comparable intervals. + +Claude Code and Grok snapshots and observation histories remain in separate Local Installation Partitions and are never joined or synchronized. Neither source supplies a stable account identity, so these histories describe observations on this installation and cannot establish continuity through an unobserved provider account change. Current snapshots remain bounded at 64 KiB; retained history is separate. A future OpenCode integration follows the same partition rule and keeps only the bounded rolling seven-day cache defined by the multi-integration PRD. + +## Retention and Bounded Working Set + +Retention describes the canonical records kept on disk. It never authorizes loading every retained record into resident memory. + +- Reader snapshots contain only the current value, the selected visible range, and bounded summaries required by the visible surface. +- Ordinary refresh and periodic sync do not enumerate or decode all retained Codex history. +- A range query returns a bounded point count or a bounded aggregate resolution. +- Source detail caches are released when their capability becomes hidden. +- Claude Code and Grok retain compact provider-local allowance observations until explicit deletion. Their active history view is bounded to the latest 84 days; this bound does not delete older canonical records or authorize eager full-history reads. Disabling preserves history, and a prior latest-only snapshot seeds at most its own observation. +- The provider-local reader opens at most 85 direct UTC daily journal paths, reads at most 4 MiB per file and 32 MiB total, limits each record to 512 bytes and the decoded working set to 200,000 records, and reports a history failure for malformed committed records or exceeded bounds. It does not silently downsample. Appends are cross-process locked, contain at most 64 records, and inspect only a bounded tail for interrupted-write recovery and duplicate suppression. +- Tests compare short and multi-year fixtures and fail when resident memory or ordinary refresh time grows proportionally with retained history. +- The Codex reader working set contains at most 6,000 samples from the latest 84 days. The latest eight days remain full resolution; older data keeps the first and last sample per hourly/reset bucket plus explicit comparison breaks. +- The default 84-day working set does not redefine retained-history bounds. Selecting an older interval reads at most 84 days and returns at most 6,000 exact or explicitly downsampled samples; it never requires all retained samples to become resident. A cold default or range read considers at most 32 writer partitions, reads at most 256 daily files and 8 MiB, and exposes a partial-history issue when a bound is reached. +- A bounded Codex sync uses the durable manifest and cursor contract in ADR-0014. One periodic pass examines at most 32 calendar-day candidates and reads at most 32 daily files across all writers, prioritizes recent changes, persists its round-robin backlog position, and eventually merges every changed daily file. Truncating to the newest files without eventual backfill is forbidden. An explicit new-folder connection may do one full reconciliation outside the main actor. + +## Delete non-Codex Integration data + +`Delete integration data` disables the selected non-Codex Integration, cancels its source work, and removes every app-owned snapshot, retained allowance observation, Derived Record, cache, and Codex Limits-owned source configuration for that Integration. It preserves source records owned by the integrated product and does not rebuild automatically. Re-enabling the Integration explicitly starts a new local collection boundary. + +## Delete analytics history + +`Delete analytics history` means all Codex Analytics History owned by Codex Limits; Claude Code and Grok use their separate Integration deletion actions: + +- all local Derived Records; +- Codex-assisted Insight results; +- Analytics Overhead records; +- account usage samples in the selected sync folder; +- records written by every installation in that sync folder. + +Preferences, notification settings, and Codex source records remain. + +Deletion creates a new empty sync generation so another Mac cannot republish older history. Each installation that observes the generation discards older local analytics before it publishes again. + +If the selected sync folder is unavailable, the product must not claim that deletion completed. It prevents older synced records from being imported, keeps a pending deletion state, and offers retry. + +The app does not rebuild deleted history automatically. A separate explicit `Rebuild available history` action may read only source data that still exists. New observations after deletion belong to the new generation. + +## Codex-assisted availability + +`Analyze with Codex` is visible only when `model/list` advertises: + +- GPT-5.6 Luna; +- Medium reasoning for that exact model; +- an account state that can run the request. + +If any condition is missing or model availability cannot be checked, hide the action. Do not fall back to GPT-5.5, Terra, Sol, another reasoning level, or the analyzed Task model. + +Metadata-only Analysis uses a closed payload allowlist. Source-backed Analysis sends only the categories and scope accepted in its current preflight. The analysis Task cannot use tools, read additional files, or change the workspace. + +## Reader rules + +- Show the source beside a value when sources may disagree. +- Show the observed interval for every derived value. +- Show raw facts before estimates. +- Use `Not enough data` or a specific reason instead of a Low-confidence number. +- Never call Coverage accuracy. +- Never call Confidence certainty. +- Never call Account Token Activity a token allowance. +- Never call Local Coverage billing coverage. +- Never show a pre-reset allowance percentage as current after its known reset. +- Never call an event-driven cache refresh a live account refresh. +- Never call a missing or invalid OpenCode cost zero. diff --git a/docs/PRODUCT-LANGUAGE.md b/docs/PRODUCT-LANGUAGE.md new file mode 100644 index 0000000..b054a7f --- /dev/null +++ b/docs/PRODUCT-LANGUAGE.md @@ -0,0 +1,125 @@ +# Product language + +Codex Limits uses clear, direct English. These rules apply to every label, tooltip, chart, notification, and insight. + +## Orwell’s six rules + +1. Use literal words. Avoid familiar metaphors and figures of speech. +2. Use a short word when it says the same thing as a long word. +3. Cut every word that adds no meaning. +4. Use active voice. +5. Prefer everyday English to jargon or foreign phrases. +6. Break a rule when following it would make the text harsh, false, or unclear. + +## Product rules + +- Name the quantity: `remaining`, `used`, `tokens`, or `percentage points`. +- Use Codex’s label `Usage remaining` for the primary allowance percentage. +- Separate account facts, local facts, and estimates. +- State uncertainty instead of hiding it. +- Name the source when two sources can disagree. +- Describe what changed; do not invent a cause. +- Use one canonical domain term for one concept. +- Put the action first in buttons. +- Keep tooltips to one fact or consequence. +- Do not call local activity billing, cost, waste, or efficiency. +- Do not claim that OpenAI changed a limit when the product only observed a change in intensity. +- Describe a usage deviation in `Insights`; do not call it an anomaly or send an alert. +- Use the weekly Codex window for the primary `Usage remaining`; name every other window. +- Withhold a Low-confidence estimate and say what data is missing. +- Do not call a partial sum of daily token buckets a weekly total. + +## Time labels + +Rolling ranges end now. Show their dates and clock labels in the Mac's current time zone; daylight-saving and time-zone changes do not change the elapsed range. + +## Navigation labels + +- `All` — strongest supported facts from every Enabled Integration. +- `Codex`, `Claude Code`, `Grok` — v1 Integration detail destinations shown only while enabled. +- `OpenCode` — reserved future destination; do not show them until a supported collector ships and the Integration is enabled. +- `Graphs` — Usage remaining, Token activity, Usage per token, and Concurrency charts. +- `Facts` — account facts, banked resets, other limits, and Usage receipts. +- `Insights` — structured observations and recommendations. + +The current-state header remains visible while these views change. + +## Integration settings + +Use only the state that tells the user what can happen next: + +- `Checking` — one explicitly requested compatibility or setup check is running. +- `Not found` — the user-managed Integration is not available on this Mac. +- `Set up` — the Integration is available but needs an explicit setup action. +- `Waiting for data` — setup succeeded but the source has not produced its first observation. +- `Ready` — the Integration can produce its supported facts; keep this status inside Settings. +- `Update required` — the installed CLI cannot provide the accepted source contract. +- A provider-specific error such as `Billing unavailable` — a shipped supported source failed and the user can retry or change setup. Do not expose errors for deferred Integrations. + +Use `Last observed` for Claude Code and any other event-driven source. Use `Stale` only after the source-specific Freshness window and before the same known reset. Use `Expired` internally; reader copy should say `New usage observation needed` rather than exposing the implementation term. Use `Beta` in Settings and the Integration detail header, not beside every value. + +## Menu bar metric labels + +The shipped v1 picker names both source and quantity: + +- `None` +- `Codex — Weekly usage remaining` +- `Claude Code — 7-day usage remaining` +- `Grok — Current-period usage remaining` + +Reserved future label, shown only after the corresponding collector ships: + +- `OpenCode — 7-day local tokens` + +Do not shorten picker labels to a bare Integration name. The compact menu bar itself may use `%`, `K`, or `M` once the picker and accessibility label establish the quantity. + +## Source-specific actions + +- Codex and Grok may use `Refresh` when the action starts a source read. Grok respects its thirty-second launch floor, including explicit actions. A future OpenCode surface may use it after its collector ships. +- Claude Code uses `Check for new observation` only to re-read the relay cache. Supporting copy says `Usage updates during Claude Code activity`. +- Use `Delete Claude Code data…` for the destructive Settings action and `Delete integration data` in its confirmation. The message must say that Claude Code's own data is not deleted. +- Use `Delete Grok data…` for Grok’s Settings action; explain that it removes only Codex Limits data and preserves Grok Build’s files and login. +- Use `Check again` for compatibility or setup recovery. +- Use `Locate…` when the user needs to choose an executable. +- Never show `Refresh all` in v1. + +Grok names the returned weekly or monthly period. When the source marks unified billing, describe a shared Grok usage pool; never imply that its percentage measures only Grok Build activity. Optional plan, prepaid, and pay-as-you-go facts keep their own labels. Show CLI-version provenance in the Grok detail. + +Claude Code and Grok use `Usage remaining` for their recorded burndown charts. Name Claude's `7-day` and `5-hour` windows separately and name Grok's returned weekly or monthly period. Describe points as `Recorded` and projections as estimates; never imply that a one-point cache reconstructs an earlier period. An empty or single-observation chart explains that more observations are needed. Retained history describes this Mac's observations and does not imply a verified account identity or cross-device coverage. Deletion copy includes recorded usage history. + +In Claude Settings and the setup confirmation, `Waiting for data` explains that usage data is available on eligible Pro and Max accounts and appears after the first response in a session. The setup confirmation says Codex Limits changes the user status line and that project or managed settings can override it. Do not repeat plan eligibility in the menu bar or beside every value. + +## Empty and expired states + +- No Enabled Integrations: `No integrations enabled` with `Open Settings`. +- Enabled but not configured: use the Integration's specific setup state and one action. +- Claude without a first observation: the workspace uses `Use Claude Code to record usage` and `Usage appears after the first response in a session`; Settings adds the eligible Pro/Max constraint. +- Known reset passed: `New usage observation needed`; do not repeat the old percentage. +- A future OpenCode surface with missing cost omits the cost or says `Estimated cost unavailable`; it never displays `$0`. + +Background refresh does not replace valid content with `Loading`, `Reading usage`, or a global progress message. Keep cached values visible and attach progress only to an explicit action. + +History sync may show `Backfilling history` in Settings while its bounded cursor is visiting older daily files. Do not show that state in the menu bar or over cached charts, and do not say `Up to date` unless the current generation has no known backlog. + +## Examples + +| Avoid | Use | +|---|---| +| `37% left` or `37% allowance remaining` | `Usage remaining · 37%` | +| `You're burning through your quota` | `Usage increased faster than your baseline` | +| `Token efficiency` | `Allowance used per 1M local tokens` | +| `Workload cost` as a visible chart label | `Usage per token` | +| `Oldest reset expires` | `Next known expiry` | +| `3 resets available` when only one expiry is known | `3 banked resets · 1 expiry known` | +| `AI-powered analysis` | `Analyze with Codex` | +| `We detected hidden usage` | `Account and local totals differ` | +| `Your limit got worse` | `Comparable work used 1.3× more allowance` | +| `Usage anomaly detected` | `Usage increased faster than your baseline` | +| `Low confidence · 3.2 days` | `Not enough data · Account gap over 6 hours` | +| `Combined usage · 42%` | Separate Integration values | +| `OpenCode billing cost` | `OpenCode local estimated cost` | +| `Claude live usage` | `Claude Code · Last observed 8 min ago` | +| `Claude Code · Refresh` | `Usage updates during Claude Code activity` | +| `37% · Stale` after its reset | `New usage observation needed` | +| `OpenCode cost · $0` when absent | `Estimated cost unavailable` | +| `Menu bar source · Claude Code` | `Claude Code — 7-day usage remaining` | diff --git a/docs/adr/0006-user-selected-folder-for-shared-usage-history.md b/docs/adr/0006-user-selected-folder-for-shared-usage-history.md index c01385e..39410cf 100644 --- a/docs/adr/0006-user-selected-folder-for-shared-usage-history.md +++ b/docs/adr/0006-user-selected-folder-for-shared-usage-history.md @@ -1,5 +1,7 @@ # User-selected folder for shared usage history -The app stores usage history locally as versioned daily JSON files and optionally replicates those files across Macs through a dedicated user-selected folder, such as one in iCloud Drive. This keeps the same lightweight format on both sides; a shared SQLite database is rejected because file synchronization cannot coordinate its transactions and auxiliary files safely, while a separate local database would add a second persistence model without benefiting the small 90-day dataset. CloudKit is unavailable to the app's ad-hoc local builds. +The app stores account usage history locally as versioned daily JSON files and optionally replicates those files across Macs through a dedicated user-selected folder, such as one in iCloud Drive. This keeps the same lightweight format on both sides; a shared SQLite database is rejected because file synchronization cannot coordinate its transactions and auxiliary files safely, while a separate local database would add a second persistence model without benefiting the compact dataset. CloudKit is unavailable to the app's ad-hoc local builds. ADR-0010 supersedes this ADR's original 90-day retention and disconnect-only deletion behavior. -The app initializes only an empty folder or joins a folder carrying its supported format marker, so it never treats an arbitrary nonempty directory as sync data. Each sync folder represents one Codex account and contains usage history only; preferences, credentials, and device state remain local. The folder must be private and not shared with other people. The JSON remains readable and has no app-level encryption because it contains no credentials or content, while synchronizing and recovering an encryption key would add a new data-loss risk. When a Mac joins, its existing 90-day history is merged with the history already in the folder without replacing either side. An exact tuple of observation time, remaining percentage, and window reset identifies a usage sample; exact copies are deduplicated, while readings made at different times remain distinct. Each installation has a random local identifier and writes only files belonging to that identifier, preventing concurrent Macs from overwriting one another without exposing hardware identity. The app ignores samples older than 90 days; an installation removes only its own expired daily files and never deletes another installation's files. Stopping sync only disconnects the folder: the merged local history and the folder's contents remain intact. Folder failures never replace valid local history or interrupt the main usage display; the app keeps the folder selected, retries later, and reports sync status only in settings. Valid files continue to merge when one history file is malformed, while that file is left untouched and reported in settings. An unsupported folder-format marker prevents all writes so an older app cannot damage newer data. Sync runs only during the app's existing refresh events and may therefore lag by up to the ten-minute background refresh interval. Each Mac imports independently; the app neither tracks delivery to another Mac nor claims that both devices are up to date. Reading current usage from Codex and exchanging history produce independent results, so either can succeed when the other fails. +The app initializes only an empty folder or joins a folder carrying its supported format marker, so it never treats an arbitrary nonempty directory as sync data. Each sync folder represents one Codex account and contains account usage history only; preferences, credentials, device state, local Task analytics, and Source Content remain local. The folder must be private and not shared with other people. The JSON remains readable and has no app-level encryption because it contains no credentials or content, while synchronizing and recovering an encryption key would add a new data-loss risk. When a Mac joins, its existing history is merged with the current sync generation without replacing either side. An exact tuple of observation time, remaining percentage, and window reset identifies a usage sample; exact copies are deduplicated, while readings made at different times remain distinct. Each installation has a random local identifier and writes only files belonging to that identifier, preventing concurrent Macs from overwriting one another without exposing hardware identity. History has no automatic age cutoff. + +Stopping sync only disconnects the folder: the merged local history and the folder's contents remain intact. The separate `Delete analytics history` action defined in ADR-0010 removes Codex Limits history from both locations and advances the sync generation so older files cannot be imported again. Folder failures never replace valid local history or interrupt the main usage display; the app keeps the folder selected, retries later, and reports sync status only in settings. Valid files continue to merge when one history file is malformed, while that file is left untouched and reported in settings. An unsupported folder-format marker prevents all writes so an older app cannot damage newer data. Sync runs only during the app's existing refresh events and may therefore lag by up to the ten-minute background refresh interval. Each Mac imports independently; the app neither tracks delivery to another Mac nor claims that both devices are up to date. Reading current usage from Codex and exchanging history produce independent results, so either can succeed when the other fails. diff --git a/docs/adr/0007-local-only-analytics.md b/docs/adr/0007-local-only-analytics.md new file mode 100644 index 0000000..7f097f4 --- /dev/null +++ b/docs/adr/0007-local-only-analytics.md @@ -0,0 +1,3 @@ +# Local-only analytics + +Codex Limits may analyze Codex information already accessible on the user’s machine without a separate analytics opt-in, but Codex-derived data, computation, and derived history remain on-device and are not transmitted as product telemetry. Raw prompts, responses, code, paths, commands, and tool output may be read locally when needed, but are not duplicated into the analytics store; only compact derived records are retained. This preserves complete cross-task analytics without introducing a cloud data boundary; unrelated operating-system permissions, such as notification authorization, remain explicit. diff --git a/docs/adr/0008-user-initiated-codex-assisted-insights.md b/docs/adr/0008-user-initiated-codex-assisted-insights.md new file mode 100644 index 0000000..ebd3b5d --- /dev/null +++ b/docs/adr/0008-user-initiated-codex-assisted-insights.md @@ -0,0 +1,7 @@ +# User-initiated Codex-assisted insights + +Deterministic analytics remains Local-only, but a user may explicitly invoke a separately labeled `Analyze with Codex` action to generate a Codex-assisted Insight from bounded evidence. The action must disclose that it sends a request to Codex and consumes allowance, and it must never run automatically. Metadata-only Analysis starts directly after the explicit action; Source-backed Analysis first shows a short preflight identifying the content categories that will be sent. + +The product reads `model/list` before it exposes the action. It shows `Analyze with Codex` only when the catalog advertises the exact GPT-5.6 Luna Medium profile. If that profile is missing or catalog lookup fails, the action is absent. The product never falls back to GPT-5.5 Medium, Terra, Sol, another reasoning level, or the analyzed Task model. A stronger retry requires a new user action and an explicitly advertised profile. + +The analysis Task receives only the bounded payload. It has no tools, cannot read more files, cannot change the workspace, and cannot control another Task. This explicit exception to ADR-0007 preserves user intent, privacy expectations, and control over analytics overhead. diff --git a/docs/adr/0009-keep-account-control-read-only.md b/docs/adr/0009-keep-account-control-read-only.md new file mode 100644 index 0000000..aa32af3 --- /dev/null +++ b/docs/adr/0009-keep-account-control-read-only.md @@ -0,0 +1,3 @@ +# Keep account control read-only + +Codex Limits reads account and activity data, calculates metrics, shows guidance, schedules local reminders, and runs user-requested Codex analysis, but it does not redeem resets, change Codex settings, or control tasks. Although the app-server exposes reset redemption, the product uses a Reset Reminder instead; this avoids hidden account changes and preserves user control. diff --git a/docs/adr/0010-retain-analytics-history-until-deletion.md b/docs/adr/0010-retain-analytics-history-until-deletion.md new file mode 100644 index 0000000..7d992e8 --- /dev/null +++ b/docs/adr/0010-retain-analytics-history-until-deletion.md @@ -0,0 +1,9 @@ +# Retain analytics history until user deletion + +Codex Limits keeps compact, account-partitioned Derived Records without a time limit until the user chooses `Delete analytics history`; Source Content is never copied into this store. This supersedes the 90-day retention rule in ADR-0006 for local account samples because long baselines, comparable-workload analysis, and personal trends lose value when old observations disappear. The existing user-selected folder remains limited to account usage samples; deep Task Tree, agent, model, Source Content-derived, and Codex-assisted records stay on the current Mac unless a separate decision expands that boundary. + +Unlimited retention applies to the canonical on-disk store, not to resident memory or ordinary refresh work. Reader snapshots, chart queries, sync reconciliation, and analytics calculations use bounded ranges, indexes, or summaries; they do not keep or repeatedly decode every retained record. Long retention must not make steady-state RSS or ordinary refresh time grow proportionally with history age. + +`Delete analytics history` removes the whole history owned by Codex Limits: every local Derived Record and every supported account-history generation in the selected sync folder, including records written by another installation. It preserves preferences. Deletion advances an empty sync generation so an offline Mac cannot restore an older generation later. If the selected folder is unavailable, the app keeps deletion pending, blocks imports from older generations, and does not claim that deletion is complete. + +The app does not rebuild deleted history automatically. A separate explicit rebuild action may read only Codex sources that still exist. The product must not promise full recovery. diff --git a/docs/adr/0011-weekly-allowance-is-primary.md b/docs/adr/0011-weekly-allowance-is-primary.md new file mode 100644 index 0000000..34a6e11 --- /dev/null +++ b/docs/adr/0011-weekly-allowance-is-primary.md @@ -0,0 +1,5 @@ +# Weekly allowance is primary + +The `10080`-minute Codex allowance window is the product's primary window. It owns the menu-bar percentage, current-state header, Runway, Suggested Pace, default `Usage remaining` graph, and weekly Account Token Activity. Five-hour and model-specific windows remain visible as named Other limits. + +If the account source does not return a weekly window, Codex Limits shows the weekly state as unavailable. It never substitutes another window or relabels another percentage as weekly. This preserves the meaning of every value and keeps historical comparisons on the same boundary. diff --git a/docs/adr/0012-demand-driven-bounded-integration-collection.md b/docs/adr/0012-demand-driven-bounded-integration-collection.md new file mode 100644 index 0000000..b7f8fbf --- /dev/null +++ b/docs/adr/0012-demand-driven-bounded-integration-collection.md @@ -0,0 +1,17 @@ +# Demand-driven bounded Integration collection + +Codex Limits publishes cached state first and serializes availability probes, setup checks, process launches, network reads, file reads, and imports across Integrations. Disabled Integrations do no automatic source work; an explicit bounded Settings probe is the only exception. Local Activity collectors run only for a visible capability, repeated requests coalesce, superseded results cannot publish, and explicit refresh targets one Integration. + +Every source has wall-time or local-pass count bounds, payload, output, process-lifetime, and working-set bounds. Child processes are reaped after success, cancellation, timeout, hide, disable, sleep/wake recovery, and app termination. The Codex app-server may be shared inside a protocol burst but closes after five seconds idle, so the ten-minute account timer does not make its process resident. Claude is event-driven and has no recurring collection timer; Codex, Claude, and Grok each keep at most one demanded, cancellable one-shot task that advances display Freshness or reset state without source I/O. Unlimited on-disk allowance-history retention does not imply an unlimited in-memory history: ordinary refresh and periodic sync operate on bounded ranges or summaries. + +The OpenCode `serve --pure` collector is excluded from v1. A validation run of OpenCode 1.18.11 measured approximately 736 MiB RSS, about 66 MiB of isolated initialization writes, and a fixed port despite requesting port zero. Correct termination does not make that cost acceptable for passive analytics. OpenCode remains disabled and absent until a supported lighter source passes this ADR's budgets. + +Grok returns as an opt-in Beta after the 2026-09-10 protocol correction: ACP custom methods require an underscore on the wire. Official Grok Build 1.0.25 accepts `_x.ai/billing`; the earlier bare `x.ai/billing` probe did not prove the route absent. The collector uses a temporary empty working directory and a short-lived `grok agent --no-leader stdio` process, sends only initialization and billing requests, and leaves login and service communication to the CLI. It reads no credentials, calls no private backend directly, and parses no TUI. + +Grok collection runs every ten minutes only while its menu metric is selected, or when due for a visible or explicit demand. A monotonic thirty-second launch floor also applies to explicit actions. Automatic failures back off for 600, 1,200, 2,400, then at most 3,600 seconds; success restores the normal cadence. An unselected, hidden Grok integration performs no polling. Each fetch has one ten-second total deadline and a one-second graceful process-group termination bound, rejects total stdout above 1 MiB, discards stderr, and completes cleanup before another coordinated operation can start. Its latest allowlisted snapshot retains a 64 KiB limit. + +The authorized history extension adds separate provider-local Claude and Grok allowance journals retained until explicit Integration deletion. It adds no polling cadence. The active reader covers the latest 84 days through at most 85 direct UTC daily paths, bounded at 4 MiB per file, 32 MiB total, 512 bytes per record, and 200,000 decoded observations. Writes are locked and bounded, and an older latest-only snapshot seeds only its recorded observation. No token activity or provider session history is imported to reconstruct missing allowance points. Hidden detail histories are released while canonical files remain on disk. See the [measurement contract](../MEASUREMENT-CONTRACT.md) for estimate and deletion rules. + +The successful authenticated probe measured 77,578,240 bytes maximum transient child RSS and 0.208 seconds child CPU. These observations do not satisfy the idle or endurance gates. The all-enabled idle comparison, provider-owned startup-write measurement, and eight-hour soak remain pending; the earlier Codex-plus-Claude comparison does not cover Grok. See [current Grok validation](../research/grok-build-validation-2026-09-10.md). + +We accept temporarily different freshness, stale menu values, and provider-specific explicit actions instead of parallel polling, persistent hidden provider processes, eager history scans, or misleading live-refresh controls. Passive analytics must remain lighter than the coding work it observes, and release acceptance includes CPU, RSS, wakeup, process-count, and endurance budgets. diff --git a/docs/adr/0013-capability-driven-integration-surfaces.md b/docs/adr/0013-capability-driven-integration-surfaces.md new file mode 100644 index 0000000..a118aee --- /dev/null +++ b/docs/adr/0013-capability-driven-integration-surfaces.md @@ -0,0 +1,9 @@ +# Capability-driven Integration surfaces + +Codex Limits models Account Allowance, Account Facts, Local Activity, Guidance, and Analysis as independent Integration Capabilities instead of requiring provider parity. The user chooses Enabled Integrations and one fixed, explicitly named Menu Bar Metric. `All` and Integration detail contents remain curated per Integration in v1; individual capability and card switches are deferred. + +The `All` view uses cohesive but asymmetric rows with one primary fact, at most one secondary fact, and freshness only when it changes interpretation. Codex retains its deeper workspace and no combined allowance is calculated. Claude Code and Grok appear as opt-in Beta Integrations with their supported allowance facts. Grok shows the returned weekly or monthly usage period, reset, CLI provenance, and available plan/prepaid/PAYG facts; its menu metric is `Grok — Current-period usage remaining`. OpenCode retains its future Local Activity contract but remains absent until a supported lighter collector passes its gates. + +Claude Code and Grok detail surfaces also show Usage remaining burndown history from actual recorded allowance observations. Claude's seven-day and five-hour windows and Grok's weekly and monthly periods stay separate. Their bounded active view covers the latest 84 days, while canonical provider-local history remains until explicitly deleted. A previous latest-only cache seeds one observation, so initial charts may have one point. An estimate requires fresh, compatible observations over at least one minute and no gap over thirty minutes, reset, or correction. Codex's token-based analytics and cross-device history are not implied by these allowance charts. + +We accept a less uniform interface and less customization because forcing every Integration into the Codex data model would create empty UI, misleading estimates, unnecessary collection work, and settings complexity. We also accept an em dash or stale marker in the menu bar when the selected source cannot refresh passively instead of silently substituting another Integration. diff --git a/docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md b/docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md new file mode 100644 index 0000000..a031b8e --- /dev/null +++ b/docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md @@ -0,0 +1,17 @@ +# Bounded range and eventual history reconciliation + +Status: Accepted and implemented on 2026-08-22 + +`UsageHistory` remains the single module and seam for canonical Codex allowance history. Callers request either the cached default view or one explicit date interval; they do not learn daily-file, manifest, writer, or cursor details. A returned view includes retained bounds, the covered interval, at most 6,000 ordered samples, and whether it is exact or downsampled. The default view covers the latest 84 days. An older request covers at most 84 days and runs off the main actor only after the visible graph's `Earlier` action. A cold default or range read considers at most 32 writer partitions, reads at most 256 daily files and 8 MiB, reports a partial result when a ceiling is reached, and releases the range when hidden. + +Each writer keeps a small atomic manifest containing format version, sync generation, oldest day, newest day, latest changed day, and a monotonic revision. It contains no usage values. Each local installation keeps atomic import and publication cursors keyed by sync lineage, generation, and writer. A periodic or explicit refresh prioritizes today and the manifest's latest changed day, then continues a calendar-day round robin from the persisted cursor. Across all writers it examines at most 32 calendar-day candidates and therefore reads at most 32 daily files. A candidate advances after a successful merge, a confirmed missing day, or a recorded malformed-file result; loss of access to the folder stops the pass without advancing unseen candidates. Relaunch resumes the same backlog; reaching the newest day wraps to the oldest, so an old changed or repaired file is eventually revisited without an unbounded scan or change journal. + +Only an explicit first connection may perform one complete reconciliation outside the main actor and initializes manifests and cursors. Later explicit refreshes use the same bounded pass as periodic refreshes. A pre-manifest writer uses a one-time filename-only manifest bootstrap; it does not decode historical payloads beyond the bounded candidates. Generation changes discard incompatible cursors before any import. Malformed files remain untouched and reported, valid later candidates continue, and a repaired file is picked up on a later round-robin visit; malformed data never causes valid canonical files to be deleted or skipped permanently. + +Current observations keep the existing direct write when the selected folder is available, so normal cross-device freshness remains tied to the current refresh cadence. The round robin is backlog recovery, not a claim that years of offline history become current in one pass. Settings may show `Backfilling history`; the main allowance and cached charts remain usable. Hiding an older range cancels its read and releases its samples. + +Acceptance uses ten-year and sparse-history fixtures to prove: no periodic or later explicit refresh examines more than 32 calendar-day candidates or reads more than 32 daily files; recent changes appear on the first due pass; every older offline day converges across repeated passes; cursor progress survives relaunch; generation/deletion prevents stale republish; malformed data remains untouched while later valid files converge and a repaired file is later merged; an older 84-day view returns at most 6,000 points; and ordinary default publication does not read range data. + +We reject newest-file truncation because it can omit offline history forever, a full scan on every refresh because work grows with retention, and a separate public history repository interface because `UsageHistory` already provides the correct deep module seam. + +Implementation validation uses a 3,650-day fixture while retaining every canonical daily file. The final 2026-08-22 Release run measured a 12.169 ms default load and a 0.007 ms disconnected automatic refresh. Focused tests prove the 32-candidate bound for both explicit and automatic refresh, relaunch-safe convergence over 100 offline days, newest-day priority, no rewrite or revision bump for unchanged data, continuation past a malformed day, later pickup after repair, cold-read writer/file/byte ceilings, visible-demand older-range loading, and bounded exact/downsampled older-range views. The complete Release suite passed 601 tests with zero failures in 26.632 seconds. diff --git a/docs/images/codex-limits-dashboard.png b/docs/images/codex-limits-dashboard.png deleted file mode 100644 index 12b86a760b23a094e6b5949e7cf9dd7bcdf54eb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59846 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