Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Sources/CodeCaps/MonitorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ final class MonitorModel: ObservableObject {
private enum PushOutcome {
case success(QuotaPublishResult)
case failure(String)
case noPublishableWindows
case cancelled
}

Expand Down Expand Up @@ -1591,6 +1592,8 @@ final class MonitorModel: ObservableObject {
return (true, result.message)
case let .failure(message):
return (false, message)
case .noPublishableWindows:
return (false, "No quota windows with a known remaining percentage to publish.")
case .cancelled:
return (false, "Push cancelled because sharing was disabled or its settings changed.")
}
Expand Down Expand Up @@ -1660,6 +1663,13 @@ final class MonitorModel: ObservableObject {
self.isCurrentPushSettings(generation, endpoint: targetEndpoint, format: targetFormat) else {
return .cancelled
}
// Unknown/credits-only readings are a valid local state, not
// a failed sync. The publisher rejects this batch before any
// request; preserve prior status and never claim an ACK.
if let publishError = error as? QuotaPublisherError,
publishError == .noPublishableWindows {
return .noPublishableWindows
}
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
self.lastSyncStatus = "Error: \(message)"
self.lastSyncError = message
Expand Down
66 changes: 64 additions & 2 deletions Sources/QuotaCore/QuotaPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public struct QuotaPublishResult: Sendable, Equatable {
public enum QuotaPublisherError: Error, Equatable, Sendable, LocalizedError {
case invalidEndpoint
case emptyWindows
case noPublishableWindows
case unauthorized
case httpStatus(Int, String?)
case timedOut
Expand All @@ -41,6 +42,8 @@ public enum QuotaPublisherError: Error, Equatable, Sendable, LocalizedError {
return "The sync endpoint URL is invalid or not allowed (must be HTTPS or localhost)."
case .emptyWindows:
return "No quota windows to publish."
case .noPublishableWindows:
return "No quota windows with a known remaining percentage to publish."
case .unauthorized:
return "Unauthorized (HTTP 401)." + sentenceGap + "Check your Ingest Token."
case let .httpStatus(status, detail):
Expand Down Expand Up @@ -113,14 +116,21 @@ public actor QuotaPublisher {
}

let bodyData: Data
let eventCount: Int
let now = Date()
let occurredAtIso = ISO8601DateFormatter().string(from: now)

switch format {
case .usageMonitorV2:
bodyData = try buildUsageMonitorV2Payload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName)
let payload = try makeUsageMonitorV2Payload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName)
guard payload.eventCount > 0 else {
throw QuotaPublisherError.noPublishableWindows
}
bodyData = payload.data
eventCount = payload.eventCount
Comment thread
kody-ai[bot] marked this conversation as resolved.
case .genericWebhook:
bodyData = try buildGenericWebhookPayload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName)
eventCount = windows.count
}

var request = URLRequest(url: endpoint)
Expand All @@ -144,6 +154,11 @@ public actor QuotaPublisher {
}

if (200..<300).contains(http.statusCode) {
if format == .usageMonitorV2 {
let acknowledgment = try validateUsageMonitorAcknowledgment(data, eventCount: eventCount)
let message = "Synced \(acknowledgment.received) quotas (\(acknowledgment.persisted) persisted, \(acknowledgment.duplicates) duplicates, \(acknowledgment.pruned) pruned)."
return QuotaPublishResult(statusCode: http.statusCode, count: eventCount, message: message)
}
var message = "Pushed \(windows.count) quota windows successfully."
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let persisted = json["persisted"] as? Int, let received = json["received"] as? Int {
Expand All @@ -170,7 +185,54 @@ public actor QuotaPublisher {
}
}

private struct UsageMonitorAcknowledgment: Decodable {
let ok: Bool
let schemaVersion: Int
let received: Int
let persisted: Int
let duplicates: Int
let pruned: Int
let rejected: Int
}

private func validateUsageMonitorAcknowledgment(_ data: Data, eventCount: Int) throws -> UsageMonitorAcknowledgment {
// Typed decoding rejects booleans, strings, fractions and out-of-range
// counters instead of Foundation's permissive NSNumber bridging.
guard let acknowledgment = try? JSONDecoder().decode(UsageMonitorAcknowledgment.self, from: data),
acknowledgment.ok, acknowledgment.schemaVersion == 2 else {
throw QuotaPublisherError.serverError("Usage Monitor returned an invalid v2 acknowledgment.")
}
let counts = [acknowledgment.persisted, acknowledgment.duplicates, acknowledgment.pruned, acknowledgment.rejected]
guard acknowledgment.received >= 0, counts.allSatisfy({ $0 >= 0 }) else {
throw QuotaPublisherError.serverError("Usage Monitor returned invalid acknowledgment counts.")
}
guard acknowledgment.received == eventCount else {
throw QuotaPublisherError.serverError("Usage Monitor acknowledged \(acknowledgment.received) quota events; the request contained \(eventCount).")
}
// Subtract from the bounded sent count, so hostile counts cannot
// overflow while checking the complete disposition of the batch.
var unaccounted = acknowledgment.received
for count in counts {
guard count <= unaccounted else {
throw QuotaPublisherError.serverError("Usage Monitor acknowledgment counts do not add up to the received total.")
}
unaccounted -= count
}
guard unaccounted == 0 else {
throw QuotaPublisherError.serverError("Usage Monitor acknowledgment counts do not add up to the received total.")
}
guard acknowledgment.rejected == 0 else {
// Include only validated counters, never raw server error details.
throw QuotaPublisherError.serverError("Usage Monitor rejected \(acknowledgment.rejected) of \(acknowledgment.received) quota events (\(acknowledgment.persisted) persisted, \(acknowledgment.duplicates) duplicates, \(acknowledgment.pruned) pruned).")
}
return acknowledgment
}

public nonisolated func buildUsageMonitorV2Payload(windows: [QuotaWindow], occurredAtIso: String, machineName: String? = nil, producerInstanceId: String = QuotaPublisher.producerInstanceId) throws -> Data {
try makeUsageMonitorV2Payload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName, producerInstanceId: producerInstanceId).data
}

private nonisolated func makeUsageMonitorV2Payload(windows: [QuotaWindow], occurredAtIso: String, machineName: String?, producerInstanceId: String = QuotaPublisher.producerInstanceId) throws -> (data: Data, eventCount: Int) {
let machine = machineName ?? Self.machineName
let events: [[String: Any]] = windows.compactMap { window in
guard let remaining = window.boundedRemainingPercent ?? window.remainingPercent else { return nil }
Expand Down Expand Up @@ -241,7 +303,7 @@ public actor QuotaPublisher {
"producerInstanceId": producerInstanceId,
"events": events
]
return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])
return (try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]), events.count)
}

public nonisolated func buildGenericWebhookPayload(windows: [QuotaWindow], occurredAtIso: String, machineName: String?, producerInstanceId: String = QuotaPublisher.producerInstanceId) throws -> Data {
Expand Down
72 changes: 72 additions & 0 deletions Tests/CodeCapsTests/DataModeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,78 @@ final class DataModeTests: XCTestCase {
XCTAssertEqual(published, 0)
}

func testAutomaticPushWithNoPublishableWindowsIsNeutral() async {
let model = noPublishableModel()
var attempts = 0
model.syncTokenReadForTesting = { UUID().uuidString }
model.pushForTesting = { windows, _, _, _ in
attempts += 1
XCTAssertTrue(windows.allSatisfy { $0.remainingPercent == nil })
throw QuotaPublisherError.noPublishableWindows
}

model.refresh()
let finished = await waitForRefreshToFinish(model)

XCTAssertTrue(finished)
XCTAssertEqual(attempts, 1)
XCTAssertNil(model.lastSyncTime)
XCTAssertNil(model.lastSyncStatus)
XCTAssertNil(model.lastSyncError)
XCTAssertFalse(model.isSyncing)
}

func testManualPushWithNoPublishableWindowsReportsNeutralNoOp() async {
let model = noPublishableModel()
var attempts = 0
model.pushForTesting = { windows, _, _, _ in
attempts += 1
XCTAssertTrue(windows.allSatisfy { $0.remainingPercent == nil })
throw QuotaPublisherError.noPublishableWindows
}

let outcome = await model.testAndPushSync(token: UUID().uuidString)

XCTAssertFalse(outcome.success, "A no-op must not claim a delivery receipt")
XCTAssertEqual(outcome.message, "No quota windows with a known remaining percentage to publish.")
XCTAssertEqual(attempts, 1)
XCTAssertNil(model.lastSyncTime)
XCTAssertNil(model.lastSyncStatus)
XCTAssertNil(model.lastSyncError)
XCTAssertFalse(model.isSyncing)
}

func testManualPushKeepsAcknowledgmentFailuresVisible() async {
let model = noPublishableModel()
model.pushForTesting = { _, _, _, _ in
throw QuotaPublisherError.serverError("Invalid acknowledgment.")
}

let outcome = await model.testAndPushSync(token: UUID().uuidString)

XCTAssertFalse(outcome.success)
XCTAssertEqual(outcome.message, "Server error: Invalid acknowledgment.")
XCTAssertEqual(model.lastSyncStatus, "Error: Server error: Invalid acknowledgment.")
XCTAssertEqual(model.lastSyncError, "Server error: Invalid acknowledgment.")
XCTAssertNil(model.lastSyncTime)
XCTAssertFalse(model.isSyncing)
}

private func noPublishableModel() -> MonitorModel {
defaults.set(true, forKey: "localEnabled")
defaults.set(false, forKey: "serverEnabled")
defaults.set(true, forKey: "syncEnabled")
defaults.set("https://quota.example.test/api/ingest/usage", forKey: "syncEndpoint")
defaults.set(QuotaSyncFormat.usageMonitorV2.rawValue, forKey: "syncFormat")
let model = MonitorModel(defaults: defaults)
model.skipsSnapshotIOForTesting = true
var unknown = window("credits-only")
unknown.remainingPercent = nil
unknown.remainingUnknown = true
model.localResultForTesting = LocalQuotaResult(windows: [unknown])
return model
}

private func waitForRefreshToFinish(_ model: MonitorModel) async -> Bool {
for _ in 0..<10_000 where model.isRefreshing {
try? await Task.sleep(nanoseconds: 100_000)
Expand Down
Loading
Loading