diff --git a/Cargo.lock b/Cargo.lock index 473c2b33..d79021fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -506,6 +507,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -1614,11 +1624,15 @@ dependencies = [ "base64", "chrono", "dirs", + "fs2", + "hmac", "parking_lot", "rayon", "reqwest", + "security-framework", "serde", "serde_json", + "sha2", "tokio", "tokscale-core", ] diff --git a/Fixtures/CrossCheck/provider-quota-pace-v3.json b/Fixtures/CrossCheck/provider-quota-pace-v3.json new file mode 100644 index 00000000..7270a122 --- /dev/null +++ b/Fixtures/CrossCheck/provider-quota-pace-v3.json @@ -0,0 +1,201 @@ +{ + "schemaVersion": 3, + "payload": { + "generatedAt": "2026-07-10T12:00:00.000Z", + "agents": [ + { + "clientId": "provider-fixture.invalid", + "source": "fixture.invalid", + "updatedAt": "2026-07-10T12:00:00.000Z", + "identity": null, + "windows": [ + { + "cardId": "ahead.invalid", + "label": "Ahead quota", + "usedPercent": 72.0, + "remainingPercent": 28.0, + "resetsAt": "2026-07-10T15:00:00Z", + "windowMinutes": 300, + "paceStatus": { + "state": "available", + "windowKey": "quota.ahead.invalid", + "durationSeconds": 18000, + "durationSource": "provider", + "completeCycles": 5 + }, + "historicalPace": { + "expectedUsedPercent": 32.0, + "etaSeconds": 3600.0, + "willLastToReset": false, + "runOutProbability": 0.75 + } + }, + { + "cardId": "behind.invalid", + "label": "Behind quota", + "usedPercent": 28.0, + "remainingPercent": 72.0, + "resetsAt": "2026-07-15T12:00:00Z", + "windowMinutes": 10080, + "paceStatus": { + "state": "available", + "windowKey": "quota.behind.invalid", + "durationSeconds": 604800, + "durationSource": "contract", + "completeCycles": 7 + }, + "historicalPace": { + "expectedUsedPercent": 56.0, + "willLastToReset": true, + "runOutProbability": 0.2 + } + }, + { + "cardId": "learning-history.invalid", + "label": "Learning history", + "usedPercent": 40.0, + "remainingPercent": 60.0, + "resetsAt": "2026-07-10T15:00:00Z", + "windowMinutes": 300, + "paceStatus": { + "state": "learningHistory", + "windowKey": "quota.learning-history.invalid", + "durationSeconds": 18000, + "durationSource": "provider", + "completeCycles": 2 + } + }, + { + "cardId": "learning-duration.invalid", + "label": "Learning duration", + "usedPercent": 40.0, + "remainingPercent": 60.0, + "resetsAt": "2026-07-10T15:00:00Z", + "paceStatus": { + "state": "learningDuration", + "windowKey": "quota.learning-duration.invalid", + "durationSource": "observed", + "completeCycles": 0 + } + }, + { + "cardId": "missing-reset.invalid", + "label": "Missing reset", + "usedPercent": 50.0, + "remainingPercent": 50.0, + "paceStatus": { + "state": "unavailable", + "windowKey": "quota.missing-reset.invalid", + "completeCycles": 0, + "reason": "missingReset" + } + }, + { + "cardId": "shared-first.invalid", + "label": "Shared label", + "usedPercent": 10.0, + "remainingPercent": 90.0, + "resetsAt": "2026-07-10T15:00:00Z", + "windowMinutes": 300, + "paceStatus": { + "state": "learningHistory", + "windowKey": "quota.shared-first.invalid", + "durationSeconds": 18000, + "durationSource": "provider", + "completeCycles": 2 + } + }, + { + "cardId": "shared-second.invalid", + "label": "Shared label", + "usedPercent": 20.0, + "remainingPercent": 80.0, + "resetsAt": "2026-07-10T15:00:00Z", + "windowMinutes": 300, + "paceStatus": { + "state": "learningHistory", + "windowKey": "quota.shared-second.invalid", + "durationSeconds": 18000, + "durationSource": "provider", + "completeCycles": 2 + } + } + ], + "credits": null, + "error": null + } + ] + }, + "cases": [ + { + "name": "available-ahead-historical", + "kind": "pace", + "clientId": "provider-fixture.invalid", + "cardId": "ahead.invalid", + "mode": "historical", + "now": "2026-07-10T12:00:00Z" + }, + { + "name": "available-behind-historical", + "kind": "pace", + "clientId": "provider-fixture.invalid", + "cardId": "behind.invalid", + "mode": "historical", + "now": "2026-07-10T12:00:00Z" + }, + { + "name": "learning-history-historical-uses-linear", + "kind": "pace", + "clientId": "provider-fixture.invalid", + "cardId": "learning-history.invalid", + "mode": "historical", + "now": "2026-07-10T12:00:00Z" + }, + { + "name": "selection-exact-card-id", + "kind": "selection", + "selection": "provider-fixture.invalid|shared-second.invalid" + }, + { + "name": "selection-unique-legacy-label", + "kind": "selection", + "selection": "provider-fixture.invalid|Ahead quota" + }, + { + "name": "selection-ambiguous-legacy-label-preserved", + "kind": "selection", + "selection": "provider-fixture.invalid|Shared label" + }, + { + "name": "legacy-missing-pace-status", + "kind": "legacy", + "now": "2026-07-10T12:00:00Z", + "rawWindow": "{\"cardId\":\"legacy.invalid\",\"label\":\"Legacy\",\"usedPercent\":20,\"remainingPercent\":80,\"resetsAt\":\"2026-07-10T15:00:00Z\",\"windowMinutes\":300}" + }, + { + "name": "malformed-pace-status-null", + "kind": "malformed", + "rawWindow": "{\"cardId\":\"malformed-null.invalid\",\"label\":\"Malformed null\",\"usedPercent\":20,\"remainingPercent\":80,\"resetsAt\":\"2026-07-10T15:00:00Z\",\"windowMinutes\":300,\"paceStatus\":null}" + }, + { + "name": "malformed-unknown-state", + "kind": "malformed", + "rawWindow": "{\"cardId\":\"malformed-state.invalid\",\"label\":\"Malformed state\",\"usedPercent\":20,\"remainingPercent\":80,\"resetsAt\":\"2026-07-10T15:00:00Z\",\"windowMinutes\":300,\"paceStatus\":{\"state\":\"unknownState\",\"windowKey\":\"quota.malformed-state.invalid\",\"durationSeconds\":18000,\"durationSource\":\"provider\",\"completeCycles\":0}}" + }, + { + "name": "malformed-available-without-historical", + "kind": "malformed", + "rawWindow": "{\"cardId\":\"malformed-available.invalid\",\"label\":\"Malformed available\",\"usedPercent\":20,\"remainingPercent\":80,\"resetsAt\":\"2026-07-10T15:00:00Z\",\"windowMinutes\":300,\"paceStatus\":{\"state\":\"available\",\"windowKey\":\"quota.malformed-available.invalid\",\"durationSeconds\":18000,\"durationSource\":\"provider\",\"completeCycles\":5}}" + }, + { + "name": "malformed-duration-window-minutes-mismatch", + "kind": "malformed", + "rawWindow": "{\"cardId\":\"malformed-duration.invalid\",\"label\":\"Malformed duration\",\"usedPercent\":20,\"remainingPercent\":80,\"resetsAt\":\"2026-07-10T15:00:00Z\",\"windowMinutes\":301,\"paceStatus\":{\"state\":\"learningHistory\",\"windowKey\":\"quota.malformed-duration.invalid\",\"durationSeconds\":18000,\"durationSource\":\"provider\",\"completeCycles\":0}}" + }, + { + "name": "malformed-percentages-not-complementary", + "kind": "malformed", + "rawWindow": "{\"cardId\":\"malformed-percent.invalid\",\"label\":\"Malformed percentages\",\"usedPercent\":80,\"remainingPercent\":80,\"resetsAt\":\"2026-07-10T15:00:00Z\",\"windowMinutes\":300}" + } + ] +} diff --git a/Sources/CTB/include/ctb.h b/Sources/CTB/include/ctb.h index 17ca06e4..207d8f20 100644 --- a/Sources/CTB/include/ctb.h +++ b/Sources/CTB/include/ctb.h @@ -11,14 +11,18 @@ // {"ok":false,"err":"..."} on failure // Payload fields use the Tauri frontend's camelCase contract. In particular, // AgentUsagePayload is `{generatedAt, agents, opencodeSubscriptions}` (the -// subscription array is omitted when empty) and each quota window uses -// `{label, usedPercent, remainingPercent, resetsAt, -// resetText, windowMinutes, historicalPace}`. When historicalPace is present, -// it is one nested result with `{expectedUsedPercent, etaSeconds, -// willLastToReset, runOutProbability}`; missing/null historicalPace means the -// Swift presentation layer uses its linear pace fallback. The nested result is -// optional and its ETA/risk fields may be omitted or null. Other report -// payloads retain their existing camelCase shapes from the Tauri contract. +// subscription array is omitted when empty) and each v3 quota window uses +// `{cardId, label, usedPercent, remainingPercent, resetsAt, resetText, +// windowMinutes, paceStatus, historicalPace}`. `paceStatus` is required and +// carries `{state, windowKey, durationSeconds, durationSource, completeCycles, +// reason}`; positive durationSeconds is the pace calculation source of truth, +// while windowMinutes is compatibility output derived by integer division. +// historicalPace is present only for `available` and carries one coherent Rust +// result: `{expectedUsedPercent, etaSeconds, willLastToReset, +// runOutProbability}`. A legacy payload missing the entire paceStatus key is +// not eligible for an implicit Linear fallback. ETA/risk remain optional inside +// an available historical result. Other report payloads retain their existing +// camelCase shapes from the Tauri contract. // tb_probe keeps its Phase 0 shape: {"ok":true,"messages":N} / {"ok":false,...}. // // `year` parameters may be NULL or "" for the all-time view, otherwise a diff --git a/Sources/CrossCheckHarness/main.swift b/Sources/CrossCheckHarness/main.swift index 42e64da1..f5c5cf16 100644 --- a/Sources/CrossCheckHarness/main.swift +++ b/Sources/CrossCheckHarness/main.swift @@ -17,12 +17,13 @@ guard ProcessInfo.processInfo.environment["TZ"] == "Asia/Taipei" else { } let args = CommandLine.arguments -guard args.count == 3 else { - FileHandle.standardError.write(Data("usage: crosscheck-harness \n".utf8)) +guard (3...4).contains(args.count) else { + FileHandle.standardError.write(Data("usage: crosscheck-harness [format|usage-pace|provider-quota-pace-v3]\n".utf8)) exit(2) } let fixturesDir = URL(fileURLWithPath: args[1], isDirectory: true) let outDir = URL(fileURLWithPath: args[2], isDirectory: true) +let selector = args.count == 4 ? args[3] : "all" try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) // Harness-side strict RFC3339 parser for `now` (NOT the module under test — the @@ -71,32 +72,49 @@ func write(_ obj: [String: Any], to name: String) throws { } // ---------------- usage-pace.json ---------------- -struct PaceCase: Decodable { - let name: String - let kind: String - let mode: String? - let now: String? - let window: UsageWindow // production DTO, production decoder -} -struct PaceFile: Decodable { let cases: [PaceCase] } - func runUsagePace() throws { let data = try Data(contentsOf: fixturesDir.appendingPathComponent("usage-pace.json")) - let file = try decoder.decode(PaceFile.self, from: data) + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let cases = root["cases"] as? [[String: Any]] + else { + throw NSError(domain: "CrossCheckHarness", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "usage-pace.json must contain a cases array" + ]) + } + var out: [String: Any] = [:] - for c in file.cases { - switch c.kind { + for (index, c) in cases.enumerated() { + guard let name = c["name"] as? String, + let kind = c["kind"] as? String, + let windowObject = c["window"], + JSONSerialization.isValidJSONObject(windowObject) + else { + out["invalid-case-\(index)"] = ["error": "invalid case metadata"] + continue + } + let windowData = try JSONSerialization.data(withJSONObject: windowObject) + guard let window = try? decoder.decode(UsageWindow.self, from: windowData) else { + // Preserve the complete legacy baseline run while using the current + // production decoder: old payloads that are now invalid are explicit + // intended mismatches until the Windows v3 wire port lands. + out[name] = ["rejected": true] + continue + } + + switch kind { case "compute": - guard let modeRaw = c.mode, let mode = PaceMode(rawValue: modeRaw) else { - out[c.name] = ["error": "unknown pace mode \(c.mode ?? "nil")"]; continue + let modeRaw = c["mode"] as? String + guard let modeRaw, let mode = PaceMode(rawValue: modeRaw) else { + out[name] = ["error": "unknown pace mode \(modeRaw ?? "nil")"]; continue } - guard let nowRaw = c.now, let now = parseNow(nowRaw) else { - out[c.name] = ["error": "unparseable now \(c.now ?? "nil")"]; continue + let nowRaw = c["now"] as? String + guard let nowRaw, let now = parseNow(nowRaw) else { + out[name] = ["error": "unparseable now \(nowRaw ?? "nil")"]; continue } - guard let pace = UsagePace.compute(window: c.window, mode: mode, now: now) else { - out[c.name] = NSNull(); continue + guard let pace = UsagePace.compute(window: window, mode: mode, now: now) else { + out[name] = NSNull(); continue } - out[c.name] = [ + out[name] = [ "stage": stageName(pace.stage), "deltaPercent": pace.deltaPercent, "expectedUsedPercent": pace.expectedUsedPercent, @@ -107,9 +125,9 @@ func runUsagePace() throws { "etaText": pace.etaText.map { $0 as Any } ?? NSNull(), ] case "runOutRisk": - out[c.name] = runOutRiskLabel(window: c.window).map { $0 as Any } ?? NSNull() + out[name] = runOutRiskLabel(window: window).map { $0 as Any } ?? NSNull() default: - out[c.name] = ["error": "unknown kind \(c.kind)"] + out[name] = ["error": "unknown kind \(kind)"] } } try write(out, to: "usage-pace.actual.json") @@ -167,5 +185,185 @@ func runFormat() throws { print("format.actual.json: \(out.count) cases") } -try runUsagePace() -try runFormat() +// ---------------- provider-quota-pace-v3.json ---------------- +struct ProviderQuotaPaceCase: Decodable { + let name: String + let kind: String + let clientId: String? + let cardId: String? + let mode: String? + let now: String? + let selection: String? + let rawWindow: String? +} + +struct ProviderQuotaPaceFile: Decodable { + let schemaVersion: Int + let payload: AgentUsagePayload // production DTO, production decoder + let cases: [ProviderQuotaPaceCase] +} + +func paceStateName(_ state: UsagePaceState) -> String { + state.rawValue +} + +func durationSourceName(_ source: UsagePaceDurationSource?) -> Any { + source?.rawValue ?? NSNull() +} + +func basisName(_ basis: UsagePaceBasis) -> String { + switch basis { + case .linear: return "linear" + case .historical: return "historical" + } +} + +func quotaWindow( + payload: AgentUsagePayload, clientId: String, cardId: String +) -> UsageWindow? { + payload.agents.first(where: { $0.clientId == clientId })? + .uniqueCardWindows.first(where: { $0.cardId == cardId }) +} + +func lifecycleRows(_ payload: AgentUsagePayload) -> [[String: Any]] { + payload.agents.flatMap { agent in + agent.windows.map { window in + [ + "clientId": agent.clientId, + "cardId": window.cardId, + "label": window.label, + "state": paceStateName(window.paceStatus.state), + "reason": window.paceStatus.reason.map { $0.rawValue as Any } ?? NSNull(), + "durationSeconds": window.paceStatus.durationSeconds.map { NSNumber(value: $0) } ?? NSNull(), + "durationSource": durationSourceName(window.paceStatus.durationSource), + "completeCycles": window.paceStatus.completeCycles, + "hasHistorical": window.historicalPace != nil, + ] + } + } +} + +func paceOutput( + window: UsageWindow, mode: PaceMode, pace: UsagePace +) -> [String: Any] { + let presentation = UsagePace.presentation(window: window, mode: mode, pace: pace) + return [ + "basis": basisName(pace.basis), + "stage": stageName(pace.stage), + "deltaPercent": pace.deltaPercent, + "expectedUsedPercent": pace.expectedUsedPercent, + "actualUsedPercent": pace.actualUsedPercent, + "etaSeconds": pace.etaSeconds.map { NSNumber(value: $0) } ?? NSNull(), + "willLastToReset": pace.willLastToReset, + "label": pace.label, + "etaText": presentation.etaText.map { $0 as Any } ?? NSNull(), + "riskText": presentation.riskText.map { $0 as Any } ?? NSNull(), + "isHistoricalDeficit": pace.isHistoricalDeficit, + ] +} + +func decodeRawWindow(_ raw: String) throws -> UsageWindow { + try decoder.decode(UsageWindow.self, from: Data(raw.utf8)) +} + +func runProviderQuotaPaceV3() throws { + let data = try Data(contentsOf: fixturesDir.appendingPathComponent("provider-quota-pace-v3.json")) + let file = try decoder.decode(ProviderQuotaPaceFile.self, from: data) + guard file.schemaVersion == 3 else { + throw NSError(domain: "CrossCheckHarness", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "provider-quota-pace-v3 schemaVersion must be 3" + ]) + } + + var casesOut: [String: Any] = [:] + for c in file.cases { + switch c.kind { + case "pace": + guard let clientId = c.clientId, + let cardId = c.cardId, + let modeRaw = c.mode, + let mode = PaceMode(rawValue: modeRaw), + let nowRaw = c.now, + let now = parseNow(nowRaw), + let window = quotaWindow(payload: file.payload, clientId: clientId, cardId: cardId) + else { + casesOut[c.name] = ["error": "invalid pace case metadata"] + continue + } + guard let pace = UsagePace.compute(window: window, mode: mode, now: now) else { + casesOut[c.name] = NSNull() + continue + } + casesOut[c.name] = paceOutput(window: window, mode: mode, pace: pace) + + case "selection": + guard let selection = c.selection else { + casesOut[c.name] = ["error": "selection case needs selection"] + continue + } + let canonical = QuotaResolver.canonicalSelection( + payload: file.payload, selection: selection) + let resolved = QuotaResolver.resolve(payload: file.payload, selection: selection) + casesOut[c.name] = [ + "selection": selection, + "canonicalSelection": canonical, + "resolvedClientId": resolved.map { $0.clientId as Any } ?? NSNull(), + "resolvedCardId": resolved.map { $0.window.cardId as Any } ?? NSNull(), + ] + + case "legacy": + guard let raw = c.rawWindow, let nowRaw = c.now, let now = parseNow(nowRaw) else { + casesOut[c.name] = ["error": "legacy case needs rawWindow and now"] + continue + } + do { + let window = try decodeRawWindow(raw) + casesOut[c.name] = [ + "rejected": false, + "state": paceStateName(window.paceStatus.state), + "reason": window.paceStatus.reason.map { $0.rawValue as Any } ?? NSNull(), + "durationSeconds": window.paceStatus.durationSeconds.map { NSNumber(value: $0) } ?? NSNull(), + "durationSource": durationSourceName(window.paceStatus.durationSource), + "completeCycles": window.paceStatus.completeCycles, + "windowMinutes": window.windowMinutes.map { NSNumber(value: $0) } ?? NSNull(), + "historicalPace": UsagePace.compute(window: window, mode: .historical, now: now) != nil, + "linearPace": UsagePace.compute(window: window, mode: .linear, now: now) != nil, + ] + } catch { + casesOut[c.name] = ["rejected": true] + } + + case "malformed": + guard let raw = c.rawWindow else { + casesOut[c.name] = ["error": "malformed case needs rawWindow"] + continue + } + casesOut[c.name] = ["rejected": (try? decodeRawWindow(raw)) == nil] + + default: + casesOut[c.name] = ["error": "unknown kind \(c.kind)"] + } + } + + try write([ + "schemaVersion": file.schemaVersion, + "lifecycle": lifecycleRows(file.payload), + "cases": casesOut, + ], to: "provider-quota-pace-v3.actual.json") + print("provider-quota-pace-v3.actual.json: \(casesOut.count) cases") +} + +switch selector { +case "all": + try runUsagePace() + try runFormat() +case "usage-pace": + try runUsagePace() +case "format": + try runFormat() +case "provider-quota-pace-v3": + try runProviderQuotaPaceV3() +default: + FileHandle.standardError.write(Data("error: unknown selector \(selector)\n".utf8)) + exit(2) +} diff --git a/Sources/TokenBar/DemoData.swift b/Sources/TokenBar/DemoData.swift index f390942a..15f5b9bc 100644 --- a/Sources/TokenBar/DemoData.swift +++ b/Sources/TokenBar/DemoData.swift @@ -364,33 +364,115 @@ enum DemoData { } private static func makeAgentUsage() -> AgentUsagePayload { - let updated = "\(Format.todayKey())T00:00:00Z" + let now = Date() + let formatter = ISO8601DateFormatter() + let updated = formatter.string(from: now) + let sessionDuration: Int64 = 18_000 + let weeklyDuration: Int64 = 604_800 + + func learningHistoryWindow( + cardId: String, label: String, used: Double, duration: Int64 + ) -> [String: Any] { + [ + "cardId": cardId, + "label": label, + "usedPercent": used, + "remainingPercent": 100 - used, + "resetsAt": formatter.string( + from: now.addingTimeInterval(TimeInterval(duration / 2))), + "resetText": duration == sessionDuration ? "in 2h 30m" : "in 3d 12h", + "windowMinutes": duration / 60, + "paceStatus": [ + "state": "learningHistory", + "windowKey": cardId, + "durationSeconds": duration, + "durationSource": "contract", + "completeCycles": 0, + ], + ] + } + let agents = ClientRegistry.allIds.enumerated().map { index, id in let sessionUsed = Double(12 + (index * 7) % 76) let weeklyUsed = max(5, sessionUsed * 0.58) - return [ - "clientId": id, - "source": "demo", - "updatedAt": updated, - "identity": ["email": "demo@\(id).local", "plan": "Demo"], - "windows": [ + let windows: [[String: Any]] + switch index { + case 0: + windows = [ [ - "label": "Session", - "usedPercent": sessionUsed, - "remainingPercent": 100 - sessionUsed, - "resetsAt": "\(Format.todayKey())T23:59:59Z", - "resetText": "today", - "windowMinutes": 300, + "cardId": "session.v1", + "label": "Session · Learning duration", + "usedPercent": 18.0, + "remainingPercent": 82.0, + "resetsAt": formatter.string( + from: now.addingTimeInterval(TimeInterval(sessionDuration / 2))), + "resetText": "in 2h 30m", + "paceStatus": [ + "state": "learningDuration", + "windowKey": "session.v1", + "durationSource": "observed", + "completeCycles": 0, + ], ], + learningHistoryWindow( + cardId: "weekly.v1", label: "Weekly · Learning history", + used: 35, duration: weeklyDuration), + ] + case 1: + windows = [ [ - "label": "Weekly", - "usedPercent": weeklyUsed, - "remainingPercent": 100 - weeklyUsed, - "resetsAt": "\(Format.todayKey())T23:59:59Z", - "resetText": "this week", - "windowMinutes": 10080, + "cardId": "session.v1", + "label": "Session · Historical ahead", + "usedPercent": 72.0, + "remainingPercent": 28.0, + "resetsAt": formatter.string( + from: now.addingTimeInterval(TimeInterval(sessionDuration / 2))), + "resetText": "in 2h 30m", + "windowMinutes": sessionDuration / 60, + "paceStatus": [ + "state": "available", + "windowKey": "session.v1", + "durationSeconds": sessionDuration, + "durationSource": "contract", + "completeCycles": 6, + ], + "historicalPace": [ + "expectedUsedPercent": 35.0, + "etaSeconds": 1_800.0, + "willLastToReset": false, + "runOutProbability": 0.42, + ], ], - ], + [ + "cardId": "weekly.v1", + "label": "Weekly · Typed unavailable", + "usedPercent": 20.0, + "remainingPercent": 80.0, + "resetText": "reset unavailable", + "paceStatus": [ + "state": "unavailable", + "windowKey": "weekly.v1", + "completeCycles": 0, + "reason": "missingReset", + ], + ], + ] + default: + windows = [ + learningHistoryWindow( + cardId: "session.v1", label: "Session", + used: sessionUsed, duration: sessionDuration), + learningHistoryWindow( + cardId: "weekly.v1", label: "Weekly", + used: weeklyUsed, duration: weeklyDuration), + ] + } + return [ + "clientId": id, + "source": "fixture", + "updatedAt": updated, + "identity": ["email": "demo@\(id).local", "plan": "Demo pace fixture"], + "windows": windows, ] as [String: Any] } return decode( diff --git a/Sources/TokenBar/QuotaSelectionPolicy.swift b/Sources/TokenBar/QuotaSelectionPolicy.swift index bd9febad..e1db5a99 100644 --- a/Sources/TokenBar/QuotaSelectionPolicy.swift +++ b/Sources/TokenBar/QuotaSelectionPolicy.swift @@ -1,37 +1,37 @@ import TokenBarCore -/// Shared quota-selection policy for the tray and Settings preview. Live mode -/// preserves an explicit persisted selection exactly; demo mode may locally -/// substitute Auto when a dynamic provider label is absent from its fixture. +/// Shared quota-selection policy for the tray and Settings preview. Both live +/// and demo callers use the same payload-aware canonical migration. enum QuotaSelectionPolicy { static func effectiveSelection( payload: AgentUsagePayload?, persistedSelection: String, - excluding: Set, - fallbackUnknownExplicit: Bool + excluding: Set ) -> String { - guard fallbackUnknownExplicit, - !persistedSelection.isEmpty, - persistedSelection != QuotaResolver.auto, - QuotaResolver.resolve( - payload: payload, selection: persistedSelection, excluding: excluding) == nil - else { - return persistedSelection - } - return QuotaResolver.auto + QuotaResolver.canonicalSelection(payload: payload, selection: persistedSelection) + } + + /// Returns a stable card-id selection only when the current payload proves + /// that a persisted pre-v3 label has one unambiguous migration target. + static func migrationToPersist( + payload: AgentUsagePayload?, + persistedSelection: String + ) -> String? { + let canonical = QuotaResolver.canonicalSelection( + payload: payload, selection: persistedSelection) + guard canonical != QuotaResolver.auto, canonical != persistedSelection else { return nil } + return canonical } static func resolve( payload: AgentUsagePayload?, persistedSelection: String, - excluding: Set, - fallbackUnknownExplicit: Bool + excluding: Set ) -> (clientId: String, window: UsageWindow)? { let selection = effectiveSelection( payload: payload, persistedSelection: persistedSelection, - excluding: excluding, - fallbackUnknownExplicit: fallbackUnknownExplicit) + excluding: excluding) return QuotaResolver.resolve( payload: payload, selection: selection, excluding: excluding) } diff --git a/Sources/TokenBar/SelfTest.swift b/Sources/TokenBar/SelfTest.swift index 00572a3c..4868ddbb 100644 --- a/Sources/TokenBar/SelfTest.swift +++ b/Sources/TokenBar/SelfTest.swift @@ -82,126 +82,364 @@ enum SelfTest { let s3 = Streaks.compute(perDayMap: [:], rangeStart: "2026-06-10", rangeEnd: "2026-06-01") expect(s3.longest == 0 && s3.current == 0, "inverted range is empty") - // UsagePace: expected-vs-actual classification, ETA projection, modes. - // Fixture: 60-minute window, 30 minutes elapsed (linear expected 50%). + // UsagePace: explicit v3 state fixtures, exact duration timing, and + // mode/basis policy. No pace assertion uses the legacy constructor. let now = Date(timeIntervalSince1970: 1_750_000_000) - func window( - used: Double, minutes: Int64 = 60, untilReset: TimeInterval = 1800, - historicalPace: HistoricalPace? = nil + func v3Window( + used: Double, + durationSeconds: Int64 = 3_600, + untilReset: TimeInterval = 1_800, + state: UsagePaceState = .learningHistory, + historicalPace: HistoricalPace? = nil, + windowMinutes: Int64? = nil ) -> UsageWindow { - UsageWindow( + let duration: Int64? = state == .learningDuration || state == .unavailable + ? nil : durationSeconds + let durationSource: UsagePaceDurationSource? = duration == nil + ? (state == .learningDuration ? .observed : nil) : .contract + let status = PaceStatus( + state: state, windowKey: "session.v3", durationSeconds: duration, + durationSource: durationSource, + completeCycles: state == .available ? 5 : 0, + reason: state == .unavailable ? .nonRecurring : nil) + return UsageWindow( label: "Session", usedPercent: used, remainingPercent: 100 - used, resetsAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(untilReset)), - windowMinutes: minutes, historicalPace: historicalPace) + windowMinutes: windowMinutes ?? duration.map { $0 / 60 }, + historicalPace: historicalPace, + cardId: "session.v3", durationSeconds: duration, paceStatus: status) } - let onPace = UsagePace.compute(window: window(used: 50), now: now) - expect(onPace?.stage == .onTrack && onPace?.label == "On pace", "pace on track at 50%/50%") - let ahead = UsagePace.compute(window: window(used: 80), now: now) - expect(ahead?.stage == .farAhead && ahead?.label == "30% in deficit", "pace far ahead label") + + let onPace = UsagePace.compute(window: v3Window(used: 50), now: now) + expect(onPace?.stage == .onTrack && onPace?.basis == .linear + && onPace?.label == "On pace", "pace on track at 50%/50%") + let ahead = UsagePace.compute(window: v3Window(used: 80), now: now) + expect(ahead?.stage == .farAhead && ahead?.label == "30% in deficit" + && ahead?.basis == .linear, "pace far ahead label") // 80% in 30min → 100% in 37.5min, before the 30min reset → ETA 7.5min. expect(ahead?.willLastToReset == false && abs((ahead?.etaSeconds ?? 0) - 450) < 1, "pace eta 450s") expect(ahead?.etaText == "Projected empty in 8m", "pace eta text") - let reserve = UsagePace.compute(window: window(used: 40), now: now) + let reserve = UsagePace.compute(window: v3Window(used: 40), now: now) expect(reserve?.stage == .behind && reserve?.label == "10% in reserve", "pace reserve label") expect(reserve?.willLastToReset == true && reserve?.etaText == "Lasts until reset", "slow burn lasts") - expect(UsagePace.compute(window: window(used: 50, minutes: 0), now: now) == nil, "no window length, no pace") - expect(UsagePace.compute(window: window(used: 50, untilReset: -10), now: now) == nil, "past reset, no pace") - // Modes: off → nil; a missing/null historical result falls back to - // linear; nested historical values replace only expected/stage/delta - // while ETA and lasts-to-reset remain backend-owned. - expect(UsagePace.compute(window: window(used: 50), mode: .off, now: now) == nil, "pace mode off") + let learningDurationWindow = v3Window(used: 50, state: .learningDuration) + expect(UsagePace.compute(window: learningDurationWindow, now: now) == nil, + "learning duration has no pace") + expect(UsagePace.compute( + window: learningDurationWindow, mode: .historical, now: now) == nil, + "historical learningDuration has no pace") + expect(UsagePace.compute( + window: learningDurationWindow, mode: .linear, now: now) == nil, + "linear learningDuration has no pace") + expect(UsagePace.compute(window: v3Window(used: 50, untilReset: -10), now: now) == nil, + "past reset, no pace") + expect(UsagePace.compute(window: v3Window(used: 50, untilReset: 3_600), now: now) == nil, + "elapsed-zero positive usage has no pace") + + // A non-minute duration proves timing uses exact v3 seconds rather than + // the compatibility windowMinutes field. + let exactDuration = UsagePace.compute( + window: v3Window(used: 50, durationSeconds: 3_601, untilReset: 1_800), now: now) + let exactExpected = (Double(3_601 - 1_800) / Double(3_601)) * 100 + expect(exactDuration?.expectedUsedPercent == exactExpected + && exactDuration?.expectedUsedPercent != 50, + "pace uses exact duration seconds") + let historicalLasts = HistoricalPace( expectedUsedPercent: 80, etaSeconds: nil, willLastToReset: true, runOutProbability: nil) + let availableWindow = v3Window( + used: 50, state: .available, historicalPace: historicalLasts) let hist = UsagePace.compute( - window: window(used: 50, historicalPace: historicalLasts), mode: .historical, now: now) - expect(hist?.expectedUsedPercent == 80 && hist?.stage == .farBehind, "historical expected override") - expect(hist?.willLastToReset == true && hist?.etaSeconds == nil, "historical lasts result is trusted") - let risky = UsagePace.compute( - window: window( - used: 90, - historicalPace: HistoricalPace( - expectedUsedPercent: 50, etaSeconds: 120, - willLastToReset: false, runOutProbability: 0.8)), - mode: .historical, now: now) - expect( - risky?.willLastToReset == false && risky?.etaSeconds == 120, - "historical projected empty trusts backend eta") + window: availableWindow, mode: .historical, now: now) + expect(hist?.expectedUsedPercent == 80 && hist?.stage == .farBehind + && hist?.basis == .historical, "historical available uses backend expected") + expect(hist?.willLastToReset == true && hist?.etaSeconds == nil, + "historical lasts result is trusted") + expect(hist?.isHistoricalDeficit == false, "historical reserve is not a deficit") + + let riskyWindow = v3Window( + used: 90, state: .available, + historicalPace: HistoricalPace( + expectedUsedPercent: 50, etaSeconds: 120, + willLastToReset: false, runOutProbability: 0.8)) + let risky = UsagePace.compute(window: riskyWindow, mode: .historical, now: now) + expect(risky?.willLastToReset == false && risky?.etaSeconds == 120 + && risky?.basis == .historical && risky?.isHistoricalDeficit == true, + "historical projected empty trusts backend eta and deficit gate") expect(risky?.etaText == "Projected empty in 2m", "historical projected empty text") - let fallback = UsagePace.compute(window: window(used: 50), mode: .historical, now: now) - expect(fallback?.expectedUsedPercent == 50, "missing historical result falls back to linear") - let linear = UsagePace.compute( - window: window(used: 50, historicalPace: historicalLasts), mode: .linear, now: now) - expect(linear?.expectedUsedPercent == 50, "linear mode ignores historical") - expect( - UsagePace.compute(window: window(used: 50, historicalPace: historicalLasts), now: now)? - .expectedUsedPercent == 50, + + let learningHistoryWindow = v3Window(used: 80, state: .learningHistory) + let learningEstimate = UsagePace.compute( + window: learningHistoryWindow, mode: .historical, now: now) + expect(learningEstimate?.basis == .linear + && learningEstimate?.stage.isDeficit == true + && learningEstimate?.isHistoricalDeficit == false, + "historical learningHistory is identifiable Linear estimate") + expect(learningEstimate?.expectedUsedPercent == 50, + "learningHistory historical mode uses Linear estimate") + + // Stage 5D UI presentation: typed state copy and mode gates are pure + // helper behavior, so these contracts do not depend on SwiftUI layout. + for mode in [PaceMode.historical, PaceMode.linear] { + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .learningDuration, reason: nil, mode: mode) + == "Learning reset duration", + "learningDuration copy in \(mode.rawValue) mode") + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .legacyMissing, reason: nil, mode: mode) + == "Pace unavailable · legacy data", + "legacy pace copy in \(mode.rawValue) mode") + } + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .learningHistory, reason: nil, mode: .historical) + == "Learning history · Linear estimate", + "learningHistory uses exact Linear estimate copy") + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .learningHistory, reason: nil, mode: .linear) == "Linear" + && AgentLimitsCard.PacePresentation.statusText( + state: .available, reason: nil, mode: .linear) == "Linear", + "linear mode labels duration-ready cards") + + let unavailableCopies: [(UsagePaceUnavailableReason, String)] = [ + (.windowIdentity, "Pace unavailable · unknown quota window"), + (.missingReset, "Pace unavailable · missing reset"), + (.invalidEvidence, "Pace unavailable · invalid quota data"), + (.accountScope, "Pace unavailable · account identity unavailable"), + (.storeCapacity, "Pace unavailable · history storage full"), + (.history, "Pace unavailable · history unavailable"), + (.nonRecurring, "Pace unavailable · non-recurring quota"), + ] + for (reason, copy) in unavailableCopies { + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .unavailable, reason: reason, mode: .historical) == copy, + "typed unavailable \(reason.rawValue) copy") + } + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .available, reason: nil, mode: .historical) == nil, + "available has no learning status copy") + expect( + AgentLimitsCard.PacePresentation.statusText( + state: .learningHistory, reason: nil, mode: .off) == nil + && AgentLimitsCard.PacePresentation.statusText( + state: .unavailable, reason: .history, mode: .off) == nil + && AgentLimitsCard.PacePresentation.statusText( + state: .legacyMissing, reason: nil, mode: .off) == nil, + "off hides pace status") + + expect(UsagePace.compute(window: availableWindow, mode: .off, now: now) == nil, + "pace mode off") + let linear = UsagePace.compute(window: availableWindow, mode: .linear, now: now) + expect(linear?.expectedUsedPercent == 50 && linear?.basis == .linear, + "linear mode ignores available historical") + expect(UsagePace.compute(window: availableWindow, now: now)?.basis == .linear, "direct pace compute stays linear") - expect(runOutRiskLabel(window: window(used: 50)) == nil, "missing historical risk is nil") - let lastingRisk = HistoricalPace( - expectedUsedPercent: 80, etaSeconds: nil, - willLastToReset: true, runOutProbability: 0.2) - let lastingRiskWindow = window(used: 50, historicalPace: lastingRisk) + expect( + AgentLimitsCard.PacePresentation.isHistoricalDeficit(risky) + && !AgentLimitsCard.PacePresentation.isHistoricalDeficit(learningEstimate) + && !AgentLimitsCard.PacePresentation.isHistoricalDeficit(linear), + "UI warning color requires historical-basis deficit") + let unavailableWindow = v3Window(used: 50, state: .unavailable) + expect(UsagePace.compute( + window: unavailableWindow, mode: .historical, now: now) == nil, + "historical unavailable has no silent Linear fallback") + expect(UsagePace.compute( + window: unavailableWindow, mode: .linear, now: now) == nil, + "linear unavailable has no pace") + + // Stage 0 old-fail/new-pass baseline: legacy windowMinutes cannot + // restore pace when the typed paceStatus key is absent. + let legacyWindow = try? JSONDecoder().decode( + UsageWindow.self, + from: Data(#"{"label":"Weekly","usedPercent":80,"remainingPercent":20,"windowMinutes":60}"#.utf8)) + expect(legacyWindow.flatMap { + UsagePace.compute(window: $0, mode: .historical, now: now) + } == nil, "stage0 legacy payload has no silent historical Linear fallback") + expect(legacyWindow.flatMap { + UsagePace.compute(window: $0, now: now) + } == nil, "legacy windowMinutes cannot revive direct pace") + + let lastingRiskWindow = v3Window( + used: 50, state: .available, + historicalPace: HistoricalPace( + expectedUsedPercent: 80, etaSeconds: nil, + willLastToReset: true, runOutProbability: 0.2)) let lastingRiskPace = UsagePace.compute( window: lastingRiskWindow, mode: .historical, now: now)! let lastingRiskPresentation = UsagePace.presentation( window: lastingRiskWindow, mode: .historical, pace: lastingRiskPace) - expect( - lastingRiskPace.etaText == "Lasts until reset" && - lastingRiskPresentation.etaText == nil && - lastingRiskPresentation.riskText == "≈ 20% run-out risk", - "visible historical risk suppresses lasts text") - let lastingNoRiskPresentation = UsagePace.presentation( - window: window(used: 50, historicalPace: historicalLasts), - mode: .historical, pace: hist!) - expect( - lastingNoRiskPresentation.etaText == "Lasts until reset" && - lastingNoRiskPresentation.riskText == nil, - "historical lasting without risk keeps lasts text") - let exhaustedWindow = window( - used: 100, + expect(lastingRiskPresentation.etaText == nil + && lastingRiskPresentation.riskText == "≈ 20% run-out risk", + "historical available risk suppresses lasts text") + expect(runOutRiskLabel(window: riskyWindow) == "≈ 80% run-out risk", + "risk belongs to historical available") + expect(runOutRiskLabel(window: riskyWindow, pace: linear) == nil, + "Linear basis cannot display nested risk") + expect(UsagePace.presentation( + window: riskyWindow, mode: .linear, pace: linear!).riskText == nil, + "linear presentation cannot display nested risk") + expect(UsagePace.presentation( + window: learningHistoryWindow, mode: .historical, pace: learningEstimate!).riskText == nil, + "learningHistory Linear estimate cannot display nested risk") + expect(runOutRiskLabel(window: v3Window(used: 50, state: .learningHistory)) == nil, + "learningHistory has no historical risk") + + let exhaustedWindow = v3Window( + used: 100, state: .available, historicalPace: HistoricalPace( expectedUsedPercent: 80, etaSeconds: 0, willLastToReset: false, runOutProbability: 1)) let exhausted = UsagePace.compute( window: exhaustedWindow, mode: .historical, now: now) - expect( - exhausted?.etaSeconds == 0 && exhausted?.willLastToReset == false && - exhausted?.etaText == "Projected empty now" && - runOutRiskLabel(window: exhaustedWindow) == "≈ 100% run-out risk", + expect(exhausted?.etaSeconds == 0 && exhausted?.willLastToReset == false + && exhausted?.etaText == "Projected empty now" + && runOutRiskLabel(window: exhaustedWindow) == "≈ 100% run-out risk", "historical exhausted result is coherent") expect(UsagePace.durationText(130 * 60) == "2h 10m", "duration text h m") expect(UsagePace.durationText(26 * 3600) == "1d 2h", "duration text d h") - // Production decoder shape: nested result decodes as one object; - // missing/null historicalPace stays nil, and legacy top-level scalar - // fields are ignored rather than becoming a second source of truth. - let nestedWindowJSON = """ - {"label":"Weekly","usedPercent":50,"remainingPercent":50, - "resetsAt":"2025-05-15T01:13:20Z","windowMinutes":60, - "historicalPace":{"expectedUsedPercent":80,"etaSeconds":120, - "willLastToReset":false,"runOutProbability":0.8}} + // Stage 5A production decoder: v3 pace states are typed and strict; + // only an entirely missing paceStatus key takes the internal legacy path. + func decodeWindow(_ json: String) -> UsageWindow? { + try? JSONDecoder().decode(UsageWindow.self, from: Data(json.utf8)) + } + let learningDurationJSON = """ + {"cardId":"session.v1","label":"Session","usedPercent":20,"remainingPercent":80, + "resetsAt":"2026-07-17T05:00:00Z", + "paceStatus":{"state":"learningDuration","windowKey":"session.v1", + "durationSource":"observed","completeCycles":0}} """ - let nestedDecoded = try! JSONDecoder().decode( - UsageWindow.self, from: Data(nestedWindowJSON.utf8)) - expect( - nestedDecoded.historicalPace?.expectedUsedPercent == 80 && - nestedDecoded.historicalPace?.etaSeconds == 120 && - nestedDecoded.historicalPace?.willLastToReset == false && - nestedDecoded.historicalPace?.runOutProbability == 0.8, - "nested historical pace decodes") - let missingDecoded = try! JSONDecoder().decode( - UsageWindow.self, - from: Data("{\"label\":\"Weekly\",\"usedPercent\":50,\"remainingPercent\":50}".utf8)) - let nullDecoded = try! JSONDecoder().decode( - UsageWindow.self, - from: Data("{\"label\":\"Weekly\",\"usedPercent\":50,\"remainingPercent\":50,\"historicalPace\":null}".utf8)) - let legacyDecoded = try! JSONDecoder().decode( - UsageWindow.self, - from: Data("{\"label\":\"Weekly\",\"usedPercent\":50,\"remainingPercent\":50,\"historicalExpectedPercent\":80,\"runOutProbability\":0.8}".utf8)) - expect(missingDecoded.historicalPace == nil, "missing historical pace decodes as nil") - expect(nullDecoded.historicalPace == nil, "null historical pace decodes as nil") - expect(legacyDecoded.historicalPace == nil, "legacy scalar fields are not a fallback") + let learningHistoryJSON = """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":35,"remainingPercent":65, + "resetsAt":"2026-07-24T00:00:00Z","windowMinutes":300, + "paceStatus":{"state":"learningHistory","windowKey":"weekly.v1", + "durationSeconds":18000,"durationSource":"contract","completeCycles":2}} + """ + let availableJSON = """ + {"cardId":"daily.v1","label":"Daily","usedPercent":60,"remainingPercent":40, + "resetsAt":"2026-07-24T00:00:00Z","windowMinutes":300, + "paceStatus":{"state":"available","windowKey":"daily.v1", + "durationSeconds":18000,"durationSource":"contract","completeCycles":4}, + "historicalPace":{"expectedUsedPercent":55,"etaSeconds":900, + "willLastToReset":false,"runOutProbability":0.25}} + """ + let unavailableJSON = """ + {"cardId":"extra_usage.v1","label":"Extra usage","usedPercent":70,"remainingPercent":30, + "paceStatus":{"state":"unavailable","windowKey":"extra_usage.v1", + "completeCycles":0,"reason":"nonRecurring"}} + """ + let learningDuration = decodeWindow(learningDurationJSON) + let learningHistory = decodeWindow(learningHistoryJSON) + let available = decodeWindow(availableJSON) + let unavailable = decodeWindow(unavailableJSON) + expect( + learningDuration?.paceStatus.state == UsagePaceState.learningDuration && + learningDuration?.durationSeconds == nil && + learningDuration?.paceStatus.durationSource == .observed, + "v3 learningDuration decodes with observed source") + expect( + learningHistory?.paceStatus.state == UsagePaceState.learningHistory && + learningHistory?.durationSeconds == 18_000 && + learningHistory?.historicalPace == nil, + "v3 learningHistory decodes with exact duration") + expect( + available?.paceStatus.state == UsagePaceState.available && + available?.durationSeconds == 18_000 && + available?.historicalPace?.expectedUsedPercent == 55, + "v3 available decodes with historical result") + expect( + unavailable?.paceStatus.state == UsagePaceState.unavailable && + unavailable?.paceStatus.reason == .nonRecurring && + unavailable?.durationSeconds == nil, + "v3 unavailable decodes with typed reason") + + let legacyDecoded = decodeWindow( + "{\"label\":\"Weekly\",\"usedPercent\":50,\"remainingPercent\":50,\"windowMinutes\":60}") + expect( + legacyDecoded?.paceStatus.state == UsagePaceState.legacyMissing && + legacyDecoded?.cardId == "legacy.missing.v1" && + legacyDecoded?.durationSeconds == nil && legacyDecoded?.windowMinutes == 60, + "missing whole paceStatus uses fixed legacy identity without duration inference") + + let invalidFixtures: [(String, String)] = [ + ("present null paceStatus", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "paceStatus":null} + """), + ("unknown state", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "paceStatus":{"state":"futureState","windowKey":"weekly.v1","completeCycles":0}} + """), + ("unknown source", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "paceStatus":{"state":"learningHistory","windowKey":"weekly.v1", + "durationSeconds":18000,"durationSource":"calendar","completeCycles":0}} + """), + ("unknown reason", """ + {"cardId":"extra_usage.v1","label":"Extra usage","usedPercent":50,"remainingPercent":50, + "paceStatus":{"state":"unavailable","windowKey":"extra_usage.v1", + "completeCycles":0,"reason":"unsupported"}} + """), + ("missing cardId", """ + {"label":"Weekly","usedPercent":50,"remainingPercent":50, + "paceStatus":{"state":"learningDuration","windowKey":"weekly.v1","completeCycles":0}} + """), + ("contradictory percentages", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":80,"remainingPercent":80, + "paceStatus":{"state":"learningDuration","windowKey":"weekly.v1","completeCycles":0}} + """), + ("available without historicalPace", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "windowMinutes":300,"paceStatus":{"state":"available","windowKey":"weekly.v1", + "durationSeconds":18000,"durationSource":"contract","completeCycles":0}} + """), + ("learningHistory with historicalPace", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "windowMinutes":300,"paceStatus":{"state":"learningHistory","windowKey":"weekly.v1", + "durationSeconds":18000,"durationSource":"contract","completeCycles":0}, + "historicalPace":{"expectedUsedPercent":50,"willLastToReset":true}} + """), + ("windowKey and reason contradiction", """ + {"cardId":"unknown.v1","label":"Unknown","usedPercent":50,"remainingPercent":50, + "paceStatus":{"state":"unavailable","windowKey":null,"completeCycles":0, + "reason":"accountScope"}} + """), + ("duration and windowMinutes contradiction", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "windowMinutes":301,"paceStatus":{"state":"learningHistory","windowKey":"weekly.v1", + "durationSeconds":18000,"durationSource":"contract","completeCycles":0}} + """), + ("duration without derived windowMinutes", """ + {"cardId":"weekly.v1","label":"Weekly","usedPercent":50,"remainingPercent":50, + "paceStatus":{"state":"learningHistory","windowKey":"weekly.v1", + "durationSeconds":18000,"durationSource":"contract","completeCycles":0}} + """), + ] + for (label, json) in invalidFixtures { + expect(decodeWindow(json) == nil, "v3 rejects \(label)") + } + + let productionPayloadJSON = """ + {"generatedAt":"2026-07-17T00:00:00Z","agents":[ + {"clientId":"codex","source":"oauth","updatedAt":"2026-07-17T00:00:00Z", + "identity":{"email":"fixture@example.invalid","plan":"plus"}, + "windows":[\(learningDurationJSON),\(learningHistoryJSON),\(availableJSON),\(unavailableJSON)], + "credits":{"remaining":12.5,"unlimited":false},"error":null} + ],"opencodeSubscriptions":["Codex"]} + """ + let productionPayload = try? JSONDecoder().decode( + AgentUsagePayload.self, from: Data(productionPayloadJSON.utf8)) + expect( + productionPayload?.agents.count == 1 && + productionPayload?.agents.first?.windows.count == 4 && + productionPayload?.agents.first?.windows[2].paceStatus.state == .available, + "complete AgentUsagePayload v3 shape decodes") // Contribution grid: GitHub layout, col 0 row 0 = Sunday on/before // Jan 1; out-of-year cells are never active; max tracks active only. @@ -278,19 +516,19 @@ enum SelfTest { expect(AgentLimitsCard.normalizeTraceClient("codex-cli") == "codex", "limits wrapper applies explicit alias") expect(AgentLimitsCard.normalizeTraceClient("antigravity-cli") == "antigravity", "limits wrapper folds generic -cli for quota attribution") - // Quota resolver: auto picks the tightest window across agents, - // erroring agents are skipped, explicit selections parse. The payload - // builds via JSON (the snapshot types have no memberwise inits). + // Quota resolver: card IDs are explicit and missing paceStatus remains + // a valid legacy fixture. Selection tests intentionally read only + // identity and percentage fields. let quotaJSON = """ {"generatedAt":"now","agents":[ {"clientId":"codex","source":"oauth","updatedAt":"now", - "windows":[{"label":"Session","usedPercent":20,"remainingPercent":80}, - {"label":"Weekly","usedPercent":65,"remainingPercent":35}]}, + "windows":[{"cardId":"session.v1","label":"Session","usedPercent":20,"remainingPercent":80}, + {"cardId":"weekly.v1","label":"Weekly","usedPercent":65,"remainingPercent":35}]}, {"clientId":"claude","source":"oauth","updatedAt":"now", - "windows":[{"label":"Session","usedPercent":88,"remainingPercent":12}, - {"label":"Weekly","usedPercent":10,"remainingPercent":90}]}, + "windows":[{"cardId":"session.v1","label":"Session","usedPercent":88,"remainingPercent":12}, + {"cardId":"weekly.v1","label":"Weekly","usedPercent":10,"remainingPercent":90}]}, {"clientId":"broken","source":"oauth","updatedAt":"now", - "windows":[{"label":"Session","usedPercent":99,"remainingPercent":1}], + "windows":[{"cardId":"session.v1","label":"Session","usedPercent":99,"remainingPercent":1}], "error":"401"} ]} """ @@ -298,26 +536,101 @@ enum SelfTest { AgentUsagePayload.self, from: Data(quotaJSON.utf8)) let tightest = QuotaResolver.resolve(payload: quotaPayload, selection: "auto") expect( - tightest?.clientId == "claude" && tightest?.window.label == "Session", - "auto resolves the tightest healthy window") + tightest?.clientId == "claude" && tightest?.window.cardId == "session.v1", + "auto resolves the tightest healthy card") + expect( + QuotaResolver.selection(clientId: "codex", cardId: "weekly.v1") == "codex|weekly.v1", + "canonical selection stores cardId") + expect( + QuotaResolver.canonicalSelection(payload: quotaPayload, selection: "codex|Weekly") + == "codex|weekly.v1" + && QuotaResolver.resolve(payload: quotaPayload, selection: "codex|Weekly")? + .window.cardId == "weekly.v1", + "unique legacy label migrates to cardId") + expect( + QuotaSelectionPolicy.migrationToPersist( + payload: quotaPayload, persistedSelection: "codex|Weekly") == "codex|weekly.v1" + && QuotaSelectionPolicy.migrationToPersist( + payload: quotaPayload, persistedSelection: "codex|weekly.v1") == nil + && QuotaSelectionPolicy.migrationToPersist( + payload: quotaPayload, persistedSelection: "codex|stale") == nil, + "selection policy persists only a proven legacy migration") + expect( + QuotaResolver.canonicalSelection(payload: quotaPayload, selection: "codex|stale") + == "codex|stale" + && QuotaResolver.resolve(payload: quotaPayload, selection: "codex|stale") == nil, + "temporarily absent explicit card stays selected instead of following Auto") expect( - QuotaResolver.resolve(payload: quotaPayload, selection: "codex|Weekly")? - .window.remainingPercent == 35, - "explicit quota selection resolves") + QuotaResolver.canonicalSelection(payload: quotaPayload, selection: "nope|Session") + == "nope|Session" + && QuotaResolver.resolve(payload: quotaPayload, selection: "nope|Session") == nil, + "temporarily absent explicit client stays selected instead of following Auto") expect( - QuotaResolver.resolve(payload: quotaPayload, selection: "nope|Session") == nil, - "unknown quota selection is nil") + QuotaResolver.canonicalSelection(payload: quotaPayload, selection: "codex|Weekly|extra") + == QuotaResolver.auto, + "malformed selection normalizes to Auto") + expect( + QuotaResolver.canonicalSelection(payload: nil, selection: "future|legacy-card.v1") + == "future|legacy-card.v1" + && QuotaResolver.canonicalSelection(payload: nil, selection: "future|legacy|extra") + == QuotaResolver.auto, + "payload nil preserves well-formed explicit selection") expect(QuotaResolver.resolve(payload: nil, selection: "auto") == nil, "no payload, no quota") + + let duplicateJSON = """ + {"generatedAt":"now","agents":[ + {"clientId":"dupe","source":"fixture","updatedAt":"now", + "windows":[ + {"cardId":"same.v1","label":"Ambiguous","usedPercent":20,"remainingPercent":80}, + {"cardId":"same.v1","label":"Ambiguous","usedPercent":99,"remainingPercent":1}, + {"cardId":"other.v1","label":"Ambiguous","usedPercent":70,"remainingPercent":30}, + {"cardId":"Session","label":"Other","usedPercent":90,"remainingPercent":10}, + {"cardId":"other-session.v1","label":"Session","usedPercent":75,"remainingPercent":25} + ]} + ]} + """ + let duplicatePayload = try! JSONDecoder().decode( + AgentUsagePayload.self, from: Data(duplicateJSON.utf8)) + let duplicateAgent = duplicatePayload.agents[0] + expect( + duplicateAgent.uniqueCardWindows.map(\.cardId) + == ["same.v1", "other.v1", "Session", "other-session.v1"], + "unique card view keeps first occurrence order") + expect( + duplicateAgent.uniqueCardWindows.allSatisfy { $0.cardId != "same.v1" || $0.remainingPercent == 80 } + && duplicateAgent.uniqueCardWindows.count == 4, + "duplicate card later occurrence fails closed") + expect( + QuotaResolver.canonicalSelection(payload: duplicatePayload, selection: "dupe|Ambiguous") + == "dupe|Ambiguous", + "ambiguous legacy label cannot be migrated") + expect( + QuotaResolver.resolve(payload: duplicatePayload, selection: "dupe|Ambiguous") == nil, + "ambiguous legacy label stays explicit instead of following Auto") + expect( + QuotaResolver.resolve(payload: duplicatePayload, selection: "dupe|same.v1")? + .window.remainingPercent == 80 + && QuotaResolver.resolve(payload: duplicatePayload, selection: "auto")?.window.cardId + == "Session", + "duplicate card is not rendered or considered by Auto") + expect( + QuotaResolver.canonicalSelection(payload: duplicatePayload, selection: "dupe|Other") + == "dupe|Session" + && QuotaResolver.canonicalSelection(payload: duplicatePayload, selection: "dupe|Session") + == "dupe|Session", + "exact cardId wins over same-named legacy label") + // Auto pick excludes hidden clients (issue #36): hiding the tightest - // (claude|Session, 12%) makes auto fall to the next healthy window + // (claude|Session, 12%) makes auto fall to the next healthy card // (codex|Weekly, 35%); an EXPLICIT pick of a hidden client is honored; // empty exclusion is byte-identical to the default. let autoExClaude = QuotaResolver.resolve( payload: quotaPayload, selection: "auto", excluding: ["claude"]) - expect(autoExClaude?.clientId == "codex" && autoExClaude?.window.label == "Weekly", + expect(autoExClaude?.clientId == "codex" && autoExClaude?.window.cardId == "weekly.v1", "auto skips a hidden tightest-window client") expect( - QuotaResolver.resolve(payload: quotaPayload, selection: "claude|Session", excluding: ["claude"])? + QuotaResolver.resolve( + payload: quotaPayload, selection: "claude|session.v1", excluding: ["claude"])? .window.remainingPercent == 12, "explicit selection of a hidden client still resolves") expect( @@ -336,12 +649,16 @@ enum SelfTest { !QuotaResolver.excludedAllCandidates( payload: quotaPayload, selection: "auto", excluding: ["claude"]), "excludedAllCandidates false while a visible candidate survives") + expect( + !QuotaResolver.excludedAllCandidates( + payload: duplicatePayload, selection: "dupe|Ambiguous", excluding: ["dupe"]), + "unresolved explicit selection does not acquire Auto exclusion semantics") expect( !QuotaResolver.excludedAllCandidates(payload: nil, selection: "auto", excluding: ["claude"]), "excludedAllCandidates false with no payload (fetch-failure keeps the cache)") expect( !QuotaResolver.excludedAllCandidates( - payload: quotaPayload, selection: "claude|Session", excluding: ["claude"]), + payload: quotaPayload, selection: "claude|session.v1", excluding: ["claude"]), "excludedAllCandidates false for an explicit selection") expect( !QuotaResolver.excludedAllCandidates(payload: quotaPayload, selection: "auto", excluding: []), @@ -756,30 +1073,76 @@ enum SelfTest { summaryClients == registryClients && contributionClients == registryClients && quotaClients == registryClients, "demo summary contributions and quota share the client set") - let dynamicLabelSelection = "\(ClientRegistry.allIds.first ?? "claude")|Sonnet" - let demoQuotaSelection = QuotaSelectionPolicy.effectiveSelection( - payload: quota, - persistedSelection: dynamicLabelSelection, - excluding: [], - fallbackUnknownExplicit: demoSource.fallsBackUnknownQuotaSelectionToAuto) - let liveQuotaSelection = QuotaSelectionPolicy.effectiveSelection( - payload: quota, - persistedSelection: dynamicLabelSelection, - excluding: [], - fallbackUnknownExplicit: liveSource.fallsBackUnknownQuotaSelectionToAuto) - expect( - demoQuotaSelection == QuotaResolver.auto && liveQuotaSelection == dynamicLabelSelection - && QuotaSelectionPolicy.resolve( - payload: quota, - persistedSelection: dynamicLabelSelection, - excluding: [], - fallbackUnknownExplicit: true) != nil - && QuotaSelectionPolicy.resolve( - payload: quota, - persistedSelection: dynamicLabelSelection, - excluding: [], - fallbackUnknownExplicit: false) == nil, - "demo unknown quota labels fall back locally while live stays exact") + expect( + quota.agents.count == ClientRegistry.allIds.count + && quota.agents.allSatisfy { agent in + let windows = agent.uniqueCardWindows + return windows.count == 2 + && windows[0].cardId == "session.v1" + && windows[1].cardId == "weekly.v1" + }, + "demo quota cards use unique canonical window identities") + + let firstDemoWindows = quota.agents.first?.uniqueCardWindows ?? [] + let secondDemoWindows = quota.agents.dropFirst().first?.uniqueCardWindows ?? [] + let demoLearningDuration = firstDemoWindows.first + let demoLearningHistory = firstDemoWindows.dropFirst().first + let demoAvailable = secondDemoWindows.first + let demoUnavailable = secondDemoWindows.dropFirst().first + expect( + firstDemoWindows.count == 2 + && demoLearningDuration?.paceStatus.state == .learningDuration + && demoLearningDuration?.durationSeconds == nil + && demoLearningDuration?.windowMinutes == nil + && demoLearningDuration?.paceStatus.durationSource == .observed + && demoLearningHistory?.paceStatus.state == .learningHistory + && demoLearningHistory?.durationSeconds == 604_800 + && demoLearningHistory?.windowMinutes == 10_080 + && demoLearningHistory?.historicalPace == nil, + "demo fixture exposes learning-duration and learning-history rows") + expect( + secondDemoWindows.count == 2 + && demoAvailable?.paceStatus.state == .available + && demoAvailable?.durationSeconds == 18_000 + && demoAvailable?.historicalPace?.expectedUsedPercent == 35 + && demoUnavailable?.paceStatus.state == .unavailable + && demoUnavailable?.paceStatus.reason == .missingReset + && demoUnavailable?.resetsAt == nil, + "demo fixture exposes historical-available and typed-unavailable rows") + + let demoLearningEstimate = demoLearningHistory.flatMap { + UsagePace.compute(window: $0, mode: .historical) + } + let demoHistoricalAhead = demoAvailable.flatMap { + UsagePace.compute(window: $0, mode: .historical) + } + expect( + demoLearningEstimate?.basis == .linear + && demoLearningEstimate?.isHistoricalDeficit == false, + "demo learning-history estimate cannot trigger historical warning color") + expect( + demoHistoricalAhead?.basis == .historical + && demoHistoricalAhead?.stage.isDeficit == true + && demoHistoricalAhead?.isHistoricalDeficit == true, + "demo available row is a historical deficit acceptance fixture") + expect( + demoLearningDuration.flatMap { + UsagePace.compute(window: $0, mode: .historical) + } == nil + && demoUnavailable.flatMap { + UsagePace.compute(window: $0, mode: .historical) + } == nil, + "demo learning-duration and unavailable rows suppress projections") + expect( + quota.agents.dropFirst(2).allSatisfy { agent in + agent.uniqueCardWindows.allSatisfy { + $0.paceStatus.state == .learningHistory + && $0.paceStatus.durationSource == .contract + && $0.paceStatus.completeCycles == 0 + && $0.historicalPace == nil + } + }, + "remaining demo quota cards stay on canonical learning-history fixtures") let modelReport = DemoData.modelReport let hourlyReport = DemoData.hourlyReport @@ -841,13 +1204,19 @@ enum SelfTest { expect( quota.agents.allSatisfy { agent in - !agent.windows.isEmpty && agent.windows.allSatisfy { - $0.windowMinutes ?? 0 > 0 - && $0.usedPercent >= 0 && $0.remainingPercent > 0 - && abs($0.usedPercent + $0.remainingPercent - 100) < 0.000_001 + !agent.windows.isEmpty && agent.windows.allSatisfy { window in + let durationShapeIsValid = switch window.paceStatus.state { + case .learningHistory, .available: + (window.windowMinutes ?? 0) > 0 + case .learningDuration, .unavailable, .legacyMissing: + window.windowMinutes == nil + } + return durationShapeIsValid + && window.usedPercent >= 0 && window.remainingPercent > 0 + && abs(window.usedPercent + window.remainingPercent - 100) < 0.000_001 } }, - "demo quota windows have valid labels and used-plus-remaining totals") + "demo quota windows have valid duration and percentage shapes") let rawDemoRate = DemoData.tokensPerMin let traceRate = trace.reduce(0.0) { $0 + $1.tokensPerMin } let selectedTraceRate = trace.first { $0.client == selectedClient }?.tokensPerMin ?? 0 diff --git a/Sources/TokenBar/Smoke.swift b/Sources/TokenBar/Smoke.swift index 3d573ad9..a589feb8 100644 --- a/Sources/TokenBar/Smoke.swift +++ b/Sources/TokenBar/Smoke.swift @@ -113,7 +113,7 @@ enum Smoke { if let error = snapshot.error { return "\(snapshot.clientId)=error(\(error))" } - return "\(snapshot.clientId)=\(snapshot.windows.count) windows" + return "\(snapshot.clientId)=\(snapshot.uniqueCardWindows.count) windows" } let subs = usage.opencodeSubscriptions ?? [] return cards.joined(separator: ", ") diff --git a/Sources/TokenBar/StatusItemController.swift b/Sources/TokenBar/StatusItemController.swift index da3c4973..590fda7c 100644 --- a/Sources/TokenBar/StatusItemController.swift +++ b/Sources/TokenBar/StatusItemController.swift @@ -179,8 +179,11 @@ final class StatusItemController: NSObject { /// couple of seconds. private func showQuotaMenu() { let menu = NSMenu() - let current = UserDefaults.standard.string(forKey: TrayAnimator.quotaSourceKey) + let payload = quotaPayloadProvider?() + let persistedSelection = UserDefaults.standard.string(forKey: TrayAnimator.quotaSourceKey) ?? QuotaResolver.auto + let current = QuotaResolver.canonicalSelection( + payload: payload, selection: persistedSelection) let header = NSMenuItem(title: "Menu bar tracks", action: nil, keyEquivalent: "") header.isEnabled = false @@ -196,20 +199,22 @@ final class StatusItemController: NSObject { } add("Auto (tightest window)", selection: QuotaResolver.auto) - if let payload = quotaPayloadProvider?() { - for agent in payload.agents where agent.error == nil && !agent.windows.isEmpty { + if let payload { + for agent in payload.agents where agent.error == nil { + let windows = agent.uniqueCardWindows + guard !windows.isEmpty else { continue } menu.addItem(.separator()) let name = NSMenuItem( title: ClientRegistry.style(agent.clientId).displayName, action: nil, keyEquivalent: "") name.isEnabled = false menu.addItem(name) - for window in agent.windows { + for window in windows { let left = "\(Int(min(100, max(0, window.remainingPercent)).rounded()))% left" add( "\(window.label) — \(left)", selection: QuotaResolver.selection( - clientId: agent.clientId, label: window.label)) + clientId: agent.clientId, cardId: window.cardId)) } } } else { diff --git a/Sources/TokenBar/TrayAnimator.swift b/Sources/TokenBar/TrayAnimator.swift index 20657242..43a7b596 100644 --- a/Sources/TokenBar/TrayAnimator.swift +++ b/Sources/TokenBar/TrayAnimator.swift @@ -158,7 +158,7 @@ final class TrayAnimator { /// Pure read: it updates the in-memory cache but does NOT write /// UserDefaults — persisting here (a side effect inside a getter that /// renderGaugeIcon / applyTitle call on every observer pass) re-posted - /// didChangeNotification and re-entered the observers. `persistRemaining()` + /// didChangeNotification and re-entered the observers. `persistQuotaState()` /// is called explicitly when fresh quota data arrives instead. var quotaRemaining: Double? { let persistedSelection = UserDefaults.standard.string(forKey: Self.quotaSourceKey) @@ -167,8 +167,7 @@ final class TrayAnimator { let selection = QuotaSelectionPolicy.effectiveSelection( payload: quota, persistedSelection: persistedSelection, - excluding: excluded, - fallbackUnknownExplicit: source.fallsBackUnknownQuotaSelectionToAuto) + excluding: excluded) if let value = QuotaResolver.resolve( payload: quota, selection: selection, excluding: excluded)? .window.remainingPercent @@ -190,16 +189,23 @@ final class TrayAnimator { return cachedQuotaRemaining } - /// Persist the last good remaining percent so a live-mode relaunch shows it - /// immediately. Demo mode is deliberately process-local and returns before - /// touching UserDefaults. Called at quota-arrival points, not from the - /// getter. Reads `quotaRemaining` (not `cachedQuotaRemaining`) so it + /// Persist a proven legacy-label migration and the last good remaining + /// percent when fresh live quota data arrives. Demo mode remains entirely + /// process-local. Reads `quotaRemaining` (not `cachedQuotaRemaining`) so it /// resolves the fresh value even for cat/parrot styles, where /// `renderGaugeIcon()` returns early without touching the cache. - private func persistRemaining() { + private func persistQuotaState() { guard source.allowsQuotaCachePersistence else { return } + let defaults = UserDefaults.standard + let persistedSelection = defaults.string(forKey: Self.quotaSourceKey) + ?? QuotaResolver.auto + if let migrated = QuotaSelectionPolicy.migrationToPersist( + payload: quota, persistedSelection: persistedSelection) + { + defaults.set(migrated, forKey: Self.quotaSourceKey) + } if let value = quotaRemaining { - UserDefaults.standard.set(value, forKey: Self.lastRemainingKey) + defaults.set(value, forKey: Self.lastRemainingKey) } } @@ -271,7 +277,7 @@ final class TrayAnimator { if let payload { self.quota = payload self.renderGaugeIcon() // refreshes cachedQuotaRemaining - self.persistRemaining() + self.persistQuotaState() self.onQuotaUpdated?() } try? await Task.sleep(for: .seconds(300)) diff --git a/Sources/TokenBar/UsageDataSource.swift b/Sources/TokenBar/UsageDataSource.swift index 9e0223ee..a06c4e50 100644 --- a/Sources/TokenBar/UsageDataSource.swift +++ b/Sources/TokenBar/UsageDataSource.swift @@ -7,8 +7,6 @@ import TokenBarCore protocol UsageDataSource: Sendable { /// Whether this source may read/write the persistent last-good quota cache. var allowsQuotaCachePersistence: Bool { get } - /// Demo-only policy for stale explicit quota labels; live keeps exact picks. - var fallsBackUnknownQuotaSelectionToAuto: Bool { get } func graph(year: String?, priority: TaskPriority) async throws -> UsagePayload func refreshGraph(year: String?, priority: TaskPriority) async throws -> UsagePayload @@ -27,7 +25,6 @@ protocol UsageDataSource: Sendable { /// The only normal-runtime owner of usage calls into `TBCore`. struct LiveUsageDataSource: UsageDataSource { let allowsQuotaCachePersistence = true - let fallsBackUnknownQuotaSelectionToAuto = false func graph(year: String?, priority: TaskPriority) async throws -> UsagePayload { try await Task.detached(priority: priority) { @@ -86,7 +83,6 @@ struct LiveUsageDataSource: UsageDataSource { /// deterministic fixtures in `DemoData`; it has no dependency on `TBCore`. struct DemoUsageDataSource: UsageDataSource { let allowsQuotaCachePersistence = false - let fallsBackUnknownQuotaSelectionToAuto = true func graph(year: String?, priority: TaskPriority) async throws -> UsagePayload { _ = priority diff --git a/Sources/TokenBar/Views/AgentLimitsCard.swift b/Sources/TokenBar/Views/AgentLimitsCard.swift index 2731c6a4..6354c208 100644 --- a/Sources/TokenBar/Views/AgentLimitsCard.swift +++ b/Sources/TokenBar/Views/AgentLimitsCard.swift @@ -44,6 +44,58 @@ struct AgentLimitsCard: View { private var paceMode: PaceMode { PaceMode(rawValue: paceModeRaw) ?? .historical } private var classic: Bool { LimitsLayout(rawValue: layoutRaw) ?? .full == .classic } + /// Pure state presentation shared by every AgentLimitsCard consumer. + enum PacePresentation { + static let learningHistoryText = "Learning history · Linear estimate" + static let learningDurationText = "Learning reset duration" + static let linearText = "Linear" + static let legacyText = "Pace unavailable · legacy data" + + static func statusText( + state: UsagePaceState, + reason: UsagePaceUnavailableReason?, + mode: PaceMode + ) -> String? { + guard mode != .off else { return nil } + switch state { + case .learningHistory: + return mode == .historical ? learningHistoryText : linearText + case .learningDuration: + return learningDurationText + case .available: + return mode == .linear ? linearText : nil + case .unavailable: + return unavailableText(reason) + case .legacyMissing: + return legacyText + } + } + + static func unavailableText(_ reason: UsagePaceUnavailableReason?) -> String { + guard let reason else { return "Pace unavailable · unavailable reason" } + switch reason { + case .windowIdentity: + return "Pace unavailable · unknown quota window" + case .missingReset: + return "Pace unavailable · missing reset" + case .invalidEvidence: + return "Pace unavailable · invalid quota data" + case .accountScope: + return "Pace unavailable · account identity unavailable" + case .storeCapacity: + return "Pace unavailable · history storage full" + case .history: + return "Pace unavailable · history unavailable" + case .nonRecurring: + return "Pace unavailable · non-recurring quota" + } + } + + static func isHistoricalDeficit(_ pace: UsagePace?) -> Bool { + pace?.isHistoricalDeficit == true + } + } + /// Placeholder window labels for agents we know carry quotas but have no /// snapshot yet (LIMIT_ROWS in the web card). private static let placeholderRows: [String: [String]] = [ @@ -232,6 +284,7 @@ struct AgentLimitsCard: View { @ViewBuilder private func agentSection(_ id: String, visible: [String]) -> some View { let style = ClientRegistry.style(id) let snapshot = snapshots[id] + let uniqueWindows = snapshot?.uniqueCardWindows ?? [] let isLive = liveClients.contains(id) let edge = dropEdge(id, in: visible) VStack(alignment: .leading, spacing: 6) { @@ -260,9 +313,10 @@ struct AgentLimitsCard: View { .help(snapshot?.error ?? detail) } VStack(spacing: 8) { - if let snapshot, !snapshot.windows.isEmpty { - ForEach(snapshot.windows, id: \.label) { window in + if !uniqueWindows.isEmpty { + ForEach(uniqueWindows, id: \.cardId) { window in windowRow(window, brand: style.color) + .id("\(id):\(window.cardId)") } } else { ForEach(Self.placeholderRows[id] ?? ["Limit"], id: \.self) { label in @@ -337,7 +391,7 @@ struct AgentLimitsCard: View { } else if snapshot?.error != nil { text = "Error" color = .red - } else if let snapshot, !snapshot.windows.isEmpty { + } else if let snapshot, !snapshot.uniqueCardWindows.isEmpty { text = snapshot.source.uppercased() } else if isLive { text = "Live" @@ -418,7 +472,7 @@ struct AgentLimitsCard: View { let left = asUsed ? $0.expectedUsedPercent : 100 - $0.expectedUsedPercent return min(100, max(0, left)) }, - paceIsDeficit: pace?.stage.isDeficit ?? false) + paceIsDeficit: Self.PacePresentation.isHistoricalDeficit(pace)) paceFooter(window: window, leftLabel: leftLabel, pace: pace) } } @@ -427,47 +481,52 @@ struct AgentLimitsCard: View { @ViewBuilder private func paceFooter( window: UsageWindow, leftLabel: String, pace: UsagePace? ) -> some View { - if let pace { - // Historical ETA/lasts and risk are composed together so a visible - // risk suppresses the generic "Lasts until reset" phrase when the - // backend reports both. - let projection = UsagePace.presentation( - window: window, mode: paceMode, pace: pace) - let projectionText = [projection.etaText, projection.riskText] - .compactMap(\.self).joined(separator: " · ") - - if projectionText.isEmpty { + let status = Self.PacePresentation.statusText( + state: window.paceStatus.state, + reason: window.paceStatus.reason, + mode: paceMode) + // Historical ETA/lasts and risk are composed together so a visible + // risk suppresses the generic "Lasts until reset" phrase when the + // backend reports both. + let projection = pace.map { + UsagePace.presentation(window: window, mode: paceMode, pace: $0) + } + let projectionText = [projection?.etaText, projection?.riskText] + .compactMap(\.self).joined(separator: " · ") + let paceText = [status, pace?.label] + .compactMap(\.self).joined(separator: " · ") + + if paceText.isEmpty { + paceLeftLabel(leftLabel) + } else if projectionText.isEmpty { + HStack(spacing: 8) { + paceLeftLabel(leftLabel) + Spacer(minLength: 8) + paceTextLabel(paceText, pace: pace) + } + } else { + ViewThatFits(in: .horizontal) { HStack(spacing: 8) { paceLeftLabel(leftLabel) + .fixedSize(horizontal: true, vertical: false) Spacer(minLength: 8) - paceText(pace.label, pace: pace) + paceTextLabel("\(paceText) · \(projectionText)", pace: pace) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } - } else { - ViewThatFits(in: .horizontal) { + + VStack(alignment: .trailing, spacing: 1) { HStack(spacing: 8) { paceLeftLabel(leftLabel) - .fixedSize(horizontal: true, vertical: false) Spacer(minLength: 8) - paceText("\(pace.label) · \(projectionText)", pace: pace) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - - VStack(alignment: .trailing, spacing: 1) { - HStack(spacing: 8) { - paceLeftLabel(leftLabel) - Spacer(minLength: 8) - paceText(pace.label, pace: pace) - } - paceText(projectionText, pace: pace) - .multilineTextAlignment(.trailing) - .frame(maxWidth: .infinity, alignment: .trailing) - .fixedSize(horizontal: false, vertical: true) + paceTextLabel(paceText, pace: pace) } + paceTextLabel(projectionText, pace: pace) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + .fixedSize(horizontal: false, vertical: true) } } - } else { - paceLeftLabel(leftLabel) } } @@ -477,11 +536,12 @@ struct AgentLimitsCard: View { .foregroundStyle(.secondary) } - private func paceText(_ text: String, pace: UsagePace) -> some View { + private func paceTextLabel(_ text: String, pace: UsagePace?) -> some View { Text(text) .font(.caption2) .foregroundStyle( - pace.stage.isDeficit ? AnyShapeStyle(.orange) : AnyShapeStyle(.tertiary)) + Self.PacePresentation.isHistoricalDeficit(pace) + ? AnyShapeStyle(.orange) : AnyShapeStyle(.tertiary)) } private func placeholderRow(_ label: String, brand: String) -> some View { diff --git a/Sources/TokenBar/Views/SettingsPanel.swift b/Sources/TokenBar/Views/SettingsPanel.swift index 1554ed16..547bcead 100644 --- a/Sources/TokenBar/Views/SettingsPanel.swift +++ b/Sources/TokenBar/Views/SettingsPanel.swift @@ -144,7 +144,17 @@ struct SettingsPanel: View { } section("Quota source") { - radioGroup(selection: $quotaSource, options: quotaSourceOptions) + radioGroup( + selection: Binding( + get: { + QuotaResolver.canonicalSelection( + payload: agentUsage, selection: quotaSource) + }, + set: { next in + quotaSource = QuotaResolver.canonicalSelection( + payload: agentUsage, selection: next) + }), + options: quotaSourceOptions) hint("Feeds the gauge icons and the \"Quota left\" title. Auto follows whichever window is closest to running out.") } @@ -163,7 +173,7 @@ struct SettingsPanel: View { radioGroup( selection: $paceModeRaw, options: PaceMode.allCases.map { ($0.rawValue, "Pace: \($0.rawValue.capitalized)") }) - hint("The deficit/reserve marker. Historical learns your weekly usage curve and shows run-out risk, falling back to linear until enough weeks accrue; Linear paces evenly by the clock; Off hides the marker.") + hint("The deficit/reserve marker. Historical learns each quota window's usage pattern; during learning, the Linear estimate is labeled; Linear uses the exact reset duration; Off hides the marker.") } if !limitOrdered.isEmpty { @@ -423,9 +433,9 @@ struct SettingsPanel: View { var options = [(QuotaResolver.auto, "Auto (tightest window)")] for agent in agentUsage?.agents ?? [] where agent.error == nil { let name = ClientRegistry.style(agent.clientId).displayName - for window in agent.windows { + for window in agent.uniqueCardWindows { options.append( - (QuotaResolver.selection(clientId: agent.clientId, label: window.label), + (QuotaResolver.selection(clientId: agent.clientId, cardId: window.cardId), "\(name) · \(window.label)")) } } diff --git a/Sources/TokenBar/Views/SettingsWindowView.swift b/Sources/TokenBar/Views/SettingsWindowView.swift index 6dd4e3ff..95bfc71d 100644 --- a/Sources/TokenBar/Views/SettingsWindowView.swift +++ b/Sources/TokenBar/Views/SettingsWindowView.swift @@ -185,8 +185,7 @@ private struct MenuBarMock: View { let selection = QuotaSelectionPolicy.effectiveSelection( payload: agentUsage, persistedSelection: quotaSource, - excluding: excluded, - fallbackUnknownExplicit: source.fallsBackUnknownQuotaSelectionToAuto) + excluding: excluded) if let value = QuotaResolver.resolve( payload: agentUsage, selection: selection, excluding: excluded)? .window.remainingPercent diff --git a/Sources/TokenBarCore/AgentUsage.swift b/Sources/TokenBarCore/AgentUsage.swift index 49a5b991..c9c73996 100644 --- a/Sources/TokenBarCore/AgentUsage.swift +++ b/Sources/TokenBarCore/AgentUsage.swift @@ -3,6 +3,13 @@ import Foundation // OAuth quota cards (`AgentUsagePayload` in the Tauri frontend's // src/lib/agentUsage.ts). +private let legacyPacePresentationID = "legacy.missing.v1" +private let maxPaceDurationSeconds: Int64 = 400 * 86_400 + +private func paceDataCorrupted(_ decoder: Decoder, _ message: String) -> DecodingError { + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) +} + public struct AgentIdentity: Decodable, Sendable { public let email: String? public let plan: String? @@ -25,39 +32,434 @@ public struct HistoricalPace: Decodable, Sendable { willLastToReset: Bool, runOutProbability: Double? = nil ) { + precondition( + Self.validationError( + expectedUsedPercent: expectedUsedPercent, + etaSeconds: etaSeconds, + willLastToReset: willLastToReset, + runOutProbability: runOutProbability + ) == nil, + "invalid HistoricalPace" + ) self.expectedUsedPercent = expectedUsedPercent self.etaSeconds = etaSeconds self.willLastToReset = willLastToReset self.runOutProbability = runOutProbability } + + private enum CodingKeys: String, CodingKey { + case expectedUsedPercent, etaSeconds, willLastToReset, runOutProbability + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let expected = try container.decode(Double.self, forKey: .expectedUsedPercent) + let eta = try container.decodeIfPresent(Double.self, forKey: .etaSeconds) + let willLast = try container.decode(Bool.self, forKey: .willLastToReset) + let probability = try container.decodeIfPresent(Double.self, forKey: .runOutProbability) + + if let message = Self.validationError( + expectedUsedPercent: expected, + etaSeconds: eta, + willLastToReset: willLast, + runOutProbability: probability + ) { + throw paceDataCorrupted(decoder, message) + } + + self.expectedUsedPercent = expected + self.etaSeconds = eta + self.willLastToReset = willLast + self.runOutProbability = probability + } + + private static func validationError( + expectedUsedPercent: Double, + etaSeconds: Double?, + willLastToReset: Bool, + runOutProbability: Double? + ) -> String? { + guard expectedUsedPercent.isFinite, (0...100).contains(expectedUsedPercent) else { + return "historical expectedUsedPercent is out of range" + } + if let etaSeconds, (!etaSeconds.isFinite || etaSeconds < 0) { + return "historical etaSeconds is invalid" + } + if let runOutProbability, (!runOutProbability.isFinite || !(0...1).contains(runOutProbability)) { + return "historical runOutProbability is invalid" + } + guard (etaSeconds == nil) == willLastToReset else { + return "historical etaSeconds and willLastToReset contradict" + } + return nil + } +} + +public enum UsagePaceState: String, Decodable, Sendable, Equatable { + case learningDuration + case learningHistory + case available + case unavailable + /// Internal marker used only when the complete `paceStatus` key is absent. + case legacyMissing + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + guard let value = Self(rawValue: raw), value != .legacyMissing else { + throw paceDataCorrupted(decoder, "unknown or internal pace state") + } + self = value + } +} + +public enum UsagePaceDurationSource: String, Decodable, Sendable, Equatable { + case provider + case contract + case observed +} + +public enum UsagePaceUnavailableReason: String, Decodable, Sendable, Equatable { + case windowIdentity + case missingReset + case invalidEvidence + case accountScope + case storeCapacity + case history + case nonRecurring +} + +/// The typed Rust v3 pace status nested inside one quota window. +public struct PaceStatus: Decodable, Sendable, Equatable { + public let state: UsagePaceState + public let windowKey: String? + public let durationSeconds: Int64? + public let durationSource: UsagePaceDurationSource? + public let completeCycles: Int + public let reason: UsagePaceUnavailableReason? + + public init( + state: UsagePaceState, + windowKey: String? = nil, + durationSeconds: Int64? = nil, + durationSource: UsagePaceDurationSource? = nil, + completeCycles: Int = 0, + reason: UsagePaceUnavailableReason? = nil + ) { + precondition( + Self.validationError( + state: state, + windowKey: windowKey, + durationSeconds: durationSeconds, + durationSource: durationSource, + completeCycles: completeCycles, + reason: reason + ) == nil, + "invalid PaceStatus" + ) + self.state = state + self.windowKey = windowKey + self.durationSeconds = durationSeconds + self.durationSource = durationSource + self.completeCycles = completeCycles + self.reason = reason + } + + public static let legacyMissing = PaceStatus( + state: .legacyMissing, + completeCycles: 0 + ) + + private enum CodingKeys: String, CodingKey { + case state, windowKey, durationSeconds, durationSource, completeCycles, reason + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let state = try container.decode(UsagePaceState.self, forKey: .state) + let windowKey = try container.decodeIfPresent(String.self, forKey: .windowKey) + let duration = try container.decodeIfPresent(Int64.self, forKey: .durationSeconds) + let source = try container.decodeIfPresent( + UsagePaceDurationSource.self, forKey: .durationSource) + let completeCycles = try container.decode(Int.self, forKey: .completeCycles) + let reason = try container.decodeIfPresent( + UsagePaceUnavailableReason.self, forKey: .reason) + + if let message = Self.validationError( + state: state, + windowKey: windowKey, + durationSeconds: duration, + durationSource: source, + completeCycles: completeCycles, + reason: reason + ) { + throw paceDataCorrupted(decoder, message) + } + + self.state = state + self.windowKey = windowKey + self.durationSeconds = duration + self.durationSource = source + self.completeCycles = completeCycles + self.reason = reason + } + + private static func validationError( + state: UsagePaceState, + windowKey: String?, + durationSeconds: Int64?, + durationSource: UsagePaceDurationSource?, + completeCycles: Int, + reason: UsagePaceUnavailableReason? + ) -> String? { + if state == .legacyMissing { + return (windowKey == nil && durationSeconds == nil && durationSource == nil + && completeCycles == 0 && reason == nil) ? nil : "legacy pace status has fields" + } + guard completeCycles >= 0 else { return "pace completeCycles must be non-negative" } + + let identityUnavailable = state == .unavailable && reason == .windowIdentity + if (windowKey == nil) != identityUnavailable { + return "pace windowKey identity invariant failed" + } + if let windowKey, windowKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "pace windowKey must be non-empty" + } + + if let durationSeconds { + guard durationSeconds > 0, durationSeconds <= maxPaceDurationSeconds else { + return "pace durationSeconds is out of range" + } + guard durationSource != nil else { + return "pace durationSource is required with durationSeconds" + } + } else if durationSource != nil + && !(state == .learningDuration && durationSource == .observed) { + return "pace durationSource requires a duration" + } + + switch state { + case .learningDuration: + guard durationSeconds == nil, reason == nil else { + return "learningDuration pace invariant failed" + } + case .learningHistory: + guard durationSeconds != nil, durationSource != nil, reason == nil else { + return "learningHistory pace invariant failed" + } + case .available: + guard durationSeconds != nil, durationSource != nil, reason == nil else { + return "available pace invariant failed" + } + case .unavailable: + guard reason != nil else { return "unavailable pace requires a reason" } + if durationSeconds == nil, durationSource != nil { + return "unavailable durationSource requires a duration" + } + case .legacyMissing: + return "legacy pace status is not a v3 wire state" + } + if state != .unavailable, reason != nil { + return "non-unavailable pace cannot have a reason" + } + return nil + } } public struct UsageWindow: Decodable, Sendable { + public let cardId: String public let label: String public let usedPercent: Double public let remainingPercent: Double public let resetsAt: String? public let resetText: String? - /// Total window length in minutes; enables pace (expected vs actual). + /// Legacy compatibility only. V3 pace calculations use `durationSeconds`. public let windowMinutes: Int64? + /// Exact v3 quota-window duration. Never inferred from legacy `windowMinutes`. + public let durationSeconds: Int64? + /// Typed v3 pace state, or the internal marker for an absent whole key. + public let paceStatus: PaceStatus /// Backend-owned historical projection, present only when enough complete - /// weeks exist. Missing or null means Swift uses its linear calculation. + /// cycles exist. Missing or null is state-dependent in the v3 contract. public let historicalPace: HistoricalPace? - // Memberwise init so --selftest can build fixture windows. + // Defaults preserve existing pure Swift linear fixtures. A v3 status is + // validated below; the legacy default deliberately does not derive a + // duration from windowMinutes. public init( label: String, usedPercent: Double, remainingPercent: Double, resetsAt: String? = nil, resetText: String? = nil, - windowMinutes: Int64? = nil, historicalPace: HistoricalPace? = nil + windowMinutes: Int64? = nil, historicalPace: HistoricalPace? = nil, + cardId: String? = nil, durationSeconds: Int64? = nil, + paceStatus: PaceStatus = .legacyMissing ) { + precondition( + Self.usagePercentageValidationError( + usedPercent: usedPercent, + remainingPercent: remainingPercent + ) == nil, + "invalid UsageWindow percentages" + ) + let resolvedCardId = cardId ?? legacyPacePresentationID + if paceStatus.state == .legacyMissing { + precondition(durationSeconds == nil, "legacy pace cannot carry durationSeconds") + } else { + precondition(cardId != nil && !resolvedCardId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "v3 pace requires a non-empty cardId") + let resolvedDuration = durationSeconds ?? paceStatus.durationSeconds + precondition(resolvedDuration == paceStatus.durationSeconds, + "top-level and nested durationSeconds differ") + precondition(Self.v3ValidationError( + paceStatus: paceStatus, + windowMinutes: windowMinutes, + durationSeconds: resolvedDuration, + historicalPace: historicalPace + ) == nil, "invalid UsageWindow pace invariants") + self.durationSeconds = resolvedDuration + self.cardId = resolvedCardId + self.label = label + self.usedPercent = usedPercent + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + self.resetText = resetText + self.windowMinutes = windowMinutes + self.paceStatus = paceStatus + self.historicalPace = historicalPace + return + } + + self.cardId = resolvedCardId + self.label = label + self.usedPercent = usedPercent + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + self.resetText = resetText + self.windowMinutes = windowMinutes + self.durationSeconds = nil + self.paceStatus = .legacyMissing + self.historicalPace = historicalPace + } + + private enum CodingKeys: String, CodingKey { + case cardId, label, usedPercent, remainingPercent, resetsAt, resetText + case windowMinutes, paceStatus, historicalPace + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let label = try container.decode(String.self, forKey: .label) + let usedPercent = try container.decode(Double.self, forKey: .usedPercent) + let remainingPercent = try container.decode(Double.self, forKey: .remainingPercent) + let resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt) + let resetText = try container.decodeIfPresent(String.self, forKey: .resetText) + let windowMinutes = try container.decodeIfPresent(Int64.self, forKey: .windowMinutes) + let historicalPace = try container.decodeIfPresent(HistoricalPace.self, forKey: .historicalPace) + + if let message = Self.usagePercentageValidationError( + usedPercent: usedPercent, + remainingPercent: remainingPercent + ) { + throw paceDataCorrupted(decoder, message) + } + + if container.contains(.paceStatus) { + let cardId = try container.decode(String.self, forKey: .cardId) + guard !cardId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw paceDataCorrupted(decoder, "v3 pace requires a non-empty cardId") + } + // `decode`, not `decodeIfPresent`, intentionally makes null fail. + let paceStatus = try container.decode(PaceStatus.self, forKey: .paceStatus) + if let message = Self.v3ValidationError( + paceStatus: paceStatus, + windowMinutes: windowMinutes, + durationSeconds: paceStatus.durationSeconds, + historicalPace: historicalPace + ) { + throw paceDataCorrupted(decoder, message) + } + self.cardId = cardId + self.label = label + self.usedPercent = usedPercent + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + self.resetText = resetText + self.windowMinutes = windowMinutes + self.durationSeconds = paceStatus.durationSeconds + self.paceStatus = paceStatus + self.historicalPace = historicalPace + return + } + + let cardId: String? + if container.contains(.cardId) { + cardId = try container.decode(String.self, forKey: .cardId) + if cardId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + throw paceDataCorrupted(decoder, "legacy pace cardId must be non-empty") + } + } else { + cardId = nil + } + self.cardId = cardId ?? legacyPacePresentationID self.label = label self.usedPercent = usedPercent self.remainingPercent = remainingPercent self.resetsAt = resetsAt self.resetText = resetText self.windowMinutes = windowMinutes + self.durationSeconds = nil + self.paceStatus = .legacyMissing self.historicalPace = historicalPace } + + private static func usagePercentageValidationError( + usedPercent: Double, + remainingPercent: Double + ) -> String? { + guard usedPercent.isFinite, remainingPercent.isFinite, + (0...100).contains(usedPercent), (0...100).contains(remainingPercent) else { + return "usage percentages are out of range" + } + guard abs(usedPercent + remainingPercent - 100) < 0.000_001 else { + return "usage percentages must sum to 100" + } + return nil + } + + private static func v3ValidationError( + paceStatus: PaceStatus, + windowMinutes: Int64?, + durationSeconds: Int64?, + historicalPace: HistoricalPace? + ) -> String? { + if let durationSeconds { + guard windowMinutes == durationSeconds / 60 else { + return "pace windowMinutes must derive from durationSeconds" + } + } else if windowMinutes != nil { + return "pace windowMinutes requires durationSeconds" + } + + switch paceStatus.state { + case .available: + guard let durationSeconds, durationSeconds > 0, historicalPace != nil else { + return "available pace requires duration and historicalPace" + } + case .learningHistory: + guard let durationSeconds, durationSeconds > 0, historicalPace == nil else { + return "learningHistory pace invariant failed" + } + case .learningDuration: + guard durationSeconds == nil, historicalPace == nil else { + return "learningDuration pace invariant failed" + } + case .unavailable: + guard historicalPace == nil else { + return "unavailable pace cannot carry historicalPace" + } + case .legacyMissing: + return "legacy pace status cannot appear in v3 wire" + } + return nil + } } public struct CreditsSnapshot: Decodable, Sendable { @@ -73,6 +475,14 @@ public struct AgentUsageSnapshot: Decodable, Sendable { public let windows: [UsageWindow] public let credits: CreditsSnapshot? public let error: String? + + /// Order-preserving card view shared by quota resolvers and consumers. + /// A duplicate card ID is fail-closed after the first occurrence; labels + /// never repair or disambiguate a card collision. + public var uniqueCardWindows: [UsageWindow] { + var seen = Set() + return windows.filter { seen.insert($0.cardId).inserted } + } } public struct AgentUsagePayload: Decodable, Sendable { diff --git a/Sources/TokenBarCore/QuotaResolver.swift b/Sources/TokenBarCore/QuotaResolver.swift index 3a30f8e8..2de3ebb2 100644 --- a/Sources/TokenBarCore/QuotaResolver.swift +++ b/Sources/TokenBarCore/QuotaResolver.swift @@ -1,18 +1,48 @@ import Foundation -/// Picks which quota window the menu bar displays. The selection string is -/// `"auto"` (the tightest window — lowest remaining percent — across every -/// agent) or `"|"` for an explicit pick. +/// Picks which quota window the menu bar displays. The canonical selection +/// string is `"auto"` or `"|"`. A legacy label in the +/// second component is migrated when the current payload makes it unique. public enum QuotaResolver { public static let auto = "auto" - public static func selection(clientId: String, label: String) -> String { - "\(clientId)|\(label)" + /// Builds the canonical persisted selection for one quota card. + public static func selection(clientId: String, cardId: String) -> String { + "\(clientId)|\(cardId)" + } + + /// Canonicalizes a persisted selection against the current payload. + /// + /// Empty and `auto` selections normalize to `auto`. Before a payload is + /// available, a well-formed explicit selection is preserved so a refresh + /// cannot erase an otherwise valid persisted choice. Once a payload exists, + /// exact card IDs win and a unique legacy label is migrated. A well-formed + /// unmatched selection remains explicit: the payload can be partial during a + /// provider failure, so silently changing it to Auto would show another + /// provider instead of letting callers retain the selected source's last-good + /// value. + public static func canonicalSelection( + payload: AgentUsagePayload?, selection: String + ) -> String { + guard let parsed = parseExplicitSelection(selection) else { return auto } + guard let payload else { return selection } + guard let agent = payload.agents.first(where: { $0.clientId == parsed.clientId }) else { + return selection + } + + let windows = agent.uniqueCardWindows + if let exact = windows.first(where: { $0.cardId == parsed.value }) { + return Self.selection(clientId: agent.clientId, cardId: exact.cardId) + } + + let labelMatches = windows.filter { $0.label == parsed.value } + guard labelMatches.count == 1, let migrated = labelMatches.first else { return selection } + return Self.selection(clientId: agent.clientId, cardId: migrated.cardId) } /// `excluding` is the set of client ids to skip in AUTO mode only (the /// user's tab-hidden ∪ limits-hidden clients) — so the menu-bar quota can't - /// surface a client the popover hides. An EXPLICIT `clientId|window` + /// surface a client the popover hides. An EXPLICIT `clientId|cardId` /// selection is always honored, even for an excluded client (the user /// deliberately picked it as the tray source). Empty set = pre-hide /// behavior, byte-identical. @@ -20,22 +50,14 @@ public enum QuotaResolver { payload: AgentUsagePayload?, selection: String, excluding: Set = [] ) -> (clientId: String, window: UsageWindow)? { guard let payload else { return nil } - if selection.isEmpty || selection == Self.auto { - var best: (clientId: String, window: UsageWindow)? - for agent in payload.agents - where agent.error == nil && !excluding.contains(agent.clientId) { - for window in agent.windows where window.remainingPercent.isFinite { - if best == nil || window.remainingPercent < best!.window.remainingPercent { - best = (agent.clientId, window) - } - } - } - return best + let canonical = canonicalSelection(payload: payload, selection: selection) + if canonical == Self.auto { + return autoCandidate(payload: payload, excluding: excluding) } - let parts = selection.split(separator: "|", maxSplits: 1).map(String.init) - guard parts.count == 2, - let agent = payload.agents.first(where: { $0.clientId == parts[0] }), - let window = agent.windows.first(where: { $0.label == parts[1] }) + + guard let parsed = parseExplicitSelection(canonical), + let agent = payload.agents.first(where: { $0.clientId == parsed.clientId }), + let window = agent.uniqueCardWindows.first(where: { $0.cardId == parsed.value }) else { return nil } return (agent.clientId, window) } @@ -51,8 +73,43 @@ public enum QuotaResolver { public static func excludedAllCandidates( payload: AgentUsagePayload?, selection: String, excluding: Set ) -> Bool { - guard selection.isEmpty || selection == Self.auto, !excluding.isEmpty else { return false } - return resolve(payload: payload, selection: selection, excluding: []) != nil - && resolve(payload: payload, selection: selection, excluding: excluding) == nil + guard !excluding.isEmpty else { return false } + guard let payload, + canonicalSelection(payload: payload, selection: selection) == Self.auto + else { return false } + guard autoCandidate(payload: payload, excluding: []) != nil else { return false } + return autoCandidate(payload: payload, excluding: excluding) == nil + } + + private static func parseExplicitSelection( + _ raw: String + ) -> (clientId: String, value: String)? { + guard !raw.isEmpty, raw != auto else { return nil } + let parts = raw.split(separator: "|", omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let clientId = String(parts[0]) + let value = String(parts[1]) + guard !clientId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return (clientId, value) + } + + private static func autoCandidate( + payload: AgentUsagePayload, excluding: Set + ) -> (clientId: String, window: UsageWindow)? { + var best: (clientId: String, window: UsageWindow)? + for agent in payload.agents + where agent.error == nil && !excluding.contains(agent.clientId) { + // Rust omits malformed percentage readings before serialization. + // Do not reject every `.invalidEvidence` pace status here: a reset or + // duration error can coexist with a valid remaining-percentage gauge. + for window in agent.uniqueCardWindows where window.remainingPercent.isFinite { + if best == nil || window.remainingPercent < best!.window.remainingPercent { + best = (agent.clientId, window) + } + } + } + return best } } diff --git a/Sources/TokenBarCore/UsagePace.swift b/Sources/TokenBarCore/UsagePace.swift index 860ceaba..e8cb3d59 100644 --- a/Sources/TokenBarCore/UsagePace.swift +++ b/Sources/TokenBarCore/UsagePace.swift @@ -26,8 +26,15 @@ public enum PaceStage: Sendable, Equatable { } } +/// The source of the expected-usage projection. +public enum UsagePaceBasis: Sendable, Equatable { + case linear + case historical +} + public struct UsagePace: Sendable { public let stage: PaceStage + public let basis: UsagePaceBasis /// actual − expected, in percentage points (>0 = ahead/deficit). public let deltaPercent: Double public let expectedUsedPercent: Double @@ -38,6 +45,11 @@ public struct UsagePace: Sendable { /// True if the current rate lasts past the reset (won't run out). public let willLastToReset: Bool + /// Yellow historical deficit is valid only for a backend historical result. + public var isHistoricalDeficit: Bool { + basis == .historical && stage.isDeficit + } + /// Short left-hand label: "On pace" / "12% in deficit" / "8% in reserve". public var label: String { if stage == .onTrack { return "On pace" } @@ -97,26 +109,41 @@ func parseRFC3339(_ s: String) -> Date? { } extension UsagePace { - /// Compute *linear* pace for a window, or nil if it can't be derived yet. + /// Compute *linear* pace for a duration-ready v3 window. public static func compute(window: UsageWindow, now: Date = Date()) -> UsagePace? { - computeCore(window: window, now: now) + guard isDurationReady(window.paceStatus.state) else { return nil } + return computeCore(window: window, now: now) } /// Compute pace under the user's chosen mode: - /// - `off` → nil (no pace marker). - /// - `historical` → use the backend's nested result if present, otherwise - /// transparently fall back to linear. - /// - `linear` → naive elapsed/duration pace. + /// - `off` → nil (no pace marker). + /// - `historical` → backend projection for `available`, exact-duration + /// Linear only while `learningHistory`. + /// - `linear` → exact-duration Linear for duration-ready states. public static func compute( window: UsageWindow, mode: PaceMode, now: Date = Date() ) -> UsagePace? { - if mode == .off { return nil } - if mode == .historical, let historical = window.historicalPace { - return computeHistorical(window: window, historical: historical, now: now) + switch mode { + case .off: + return nil + case .historical: + switch window.paceStatus.state { + case .available: + guard let historical = window.historicalPace else { return nil } + return computeHistorical(window: window, historical: historical, now: now) + case .learningHistory: + return computeCore(window: window, now: now) + case .learningDuration, .unavailable, .legacyMissing: + return nil + } + case .linear: + guard isDurationReady(window.paceStatus.state) else { return nil } + return computeCore(window: window, now: now) } - // A missing historical result is the learning-period fallback. Linear - // mode intentionally ignores a nested result too. - return computeCore(window: window, now: now) + } + + private static func isDurationReady(_ state: UsagePaceState) -> Bool { + state == .learningHistory || state == .available } /// Assemble display-only projection strings. Historical ETA and @@ -125,7 +152,9 @@ extension UsagePace { public static func presentation( window: UsageWindow, mode: PaceMode, pace: UsagePace ) -> UsagePacePresentation { - let risk = mode == .historical ? runOutRiskLabel(window: window) : nil + let risk = mode == .historical + ? runOutRiskLabel(window: window, pace: pace) + : nil let eta = pace.willLastToReset && risk != nil ? nil : pace.etaText return UsagePacePresentation(etaText: eta, riskText: risk) } @@ -142,7 +171,7 @@ extension UsagePace { let expected = clamp(historical.expectedUsedPercent, 0, 100) let delta = actual - expected return UsagePace( - stage: stageFor(delta), deltaPercent: delta, + stage: stageFor(delta), basis: .historical, deltaPercent: delta, expectedUsedPercent: expected, actualUsedPercent: actual, etaSeconds: historical.etaSeconds, willLastToReset: historical.willLastToReset) @@ -175,7 +204,7 @@ extension UsagePace { } return UsagePace( - stage: stageFor(delta), deltaPercent: delta, + stage: stageFor(delta), basis: .linear, deltaPercent: delta, expectedUsedPercent: expected, actualUsedPercent: actual, etaSeconds: etaSeconds, willLastToReset: willLastToReset) } @@ -188,11 +217,12 @@ extension UsagePace { private static func timing(for window: UsageWindow, now: Date) -> WindowTiming? { guard let resetsAtRaw = window.resetsAt, - let windowMinutes = window.windowMinutes, windowMinutes > 0, + let durationSeconds = window.paceStatus.durationSeconds, + durationSeconds > 0, let resetsAt = parseRFC3339(resetsAtRaw) else { return nil } - let duration = Double(windowMinutes) * 60 + let duration = Double(durationSeconds) let timeUntilReset = resetsAt.timeIntervalSince(now) if timeUntilReset <= 0 || timeUntilReset > duration { return nil } return WindowTiming( @@ -203,8 +233,12 @@ extension UsagePace { } /// codexbar-style historical run-out risk, e.g. "≈ 30% run-out risk", or nil. -public func runOutRiskLabel(window: UsageWindow) -> String? { - guard let probability = window.historicalPace?.runOutProbability else { return nil } +/// A supplied pace lets presentation suppress backend risk for a Linear result. +public func runOutRiskLabel(window: UsageWindow, pace: UsagePace? = nil) -> String? { + guard window.paceStatus.state == .available, + pace?.basis != .linear, + let probability = window.historicalPace?.runOutProbability + else { return nil } let pct = Int((clamp(probability, 0, 1) * 100).rounded()) if pct <= 0 { return nil } return "≈ \(pct)% run-out risk" diff --git a/crates/tb_core_ffi/Cargo.toml b/crates/tb_core_ffi/Cargo.toml index 5d164825..534a7bdd 100644 --- a/crates/tb_core_ffi/Cargo.toml +++ b/crates/tb_core_ffi/Cargo.toml @@ -15,11 +15,14 @@ crate-type = ["cdylib", "staticlib"] [dependencies] tokscale-core = { path = "../../vendor/tokscale-core" } serde = { version = "1", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["raw_value"] } # Version specs mirror the Tauri app (TokenBar-tokcat/src-tauri/Cargo.toml) so # the ported report/agent modules compile against the same dependency surface. reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } base64 = "0.22" +hmac = "0.12" +sha2 = "0.10" +fs2 = "0.4" tokio = { version = "1", features = ["full"] } parking_lot = "0.12" chrono = "0.4" @@ -27,3 +30,6 @@ chrono = "0.4" # same version line tokscale-core already pulls in. dirs = "5" rayon = "1.10" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3.7" diff --git a/crates/tb_core_ffi/src/agent_account_scope.rs b/crates/tb_core_ffi/src/agent_account_scope.rs new file mode 100644 index 00000000..388e3d9c --- /dev/null +++ b/crates/tb_core_ffi/src/agent_account_scope.rs @@ -0,0 +1,3252 @@ +//! Opaque account identity for provider quota history. +//! +//! The installation key lives in an owner-only binary file beside the authenticated +//! metadata. Raw provider identifiers and credential markers are reduced to +//! domain-separated HMACs before metadata is persisted. Provider adapters only get +//! an opaque scope or a typed failure; no raw identity crosses into history. + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use fs2::FileExt as _; +use hmac::{Hmac, Mac as _}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +type HmacSha256 = Hmac; + +const INSTALLATION_KEY_FILE: &str = "quota-account-scope-installation-key-v1.bin"; +const METADATA_FILE: &str = "quota-account-scope-v1.json"; +const METADATA_LOCK_FILE: &str = "quota-account-scope-v1.lock"; +const V3_HISTORY_FILE: &str = "quota-pace-history-v3.json"; +const METADATA_SCHEMA_VERSION: u32 = 1; +const INSTALLATION_KEY_BYTES: usize = 32; +const LINEAGE_ID_BYTES: usize = 16; +const DIGEST_BYTES: usize = 32; + +static ACCOUNT_SCOPE_PROCESS_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +static CODEX_REFRESH_LOCK: Mutex<()> = Mutex::new(()); +static CLAUDE_REFRESH_LOCK: Mutex<()> = Mutex::new(()); +static GROK_REFRESH_LOCK: Mutex<()> = Mutex::new(()); +static ANTIGRAVITY_REFRESH_LOCK: Mutex<()> = Mutex::new(()); +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct AccountScope(String); + +impl AccountScope { + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for AccountScope { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("AccountScope()") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AuthoritativeIdKind { + Email, + OpaqueId, +} + +impl AuthoritativeIdKind { + fn domain_value(self) -> &'static str { + match self { + Self::Email => "email", + Self::OpaqueId => "opaque-id", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountScopeError { + NoTrustedEvidence, + InvalidEvidence, + UnsupportedPlatform, + InstallationKeyRead, + InvalidInstallationKey, + InstallationKeyWrite, + OrphanedArtifacts, + RandomUnavailable, + StorageUnavailable, + MetadataLock, + MetadataRead, + MetadataCorrupt, + MetadataConflict, + MetadataWrite, + QuarantineFailed, +} + +impl std::fmt::Display for AccountScopeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::NoTrustedEvidence => "no trusted account evidence", + Self::InvalidEvidence => "invalid account evidence", + Self::UnsupportedPlatform => "secure account scope is unavailable on this platform", + Self::InstallationKeyRead => "installation key could not be read", + Self::InvalidInstallationKey => "installation key failed validation", + Self::InstallationKeyWrite => "installation key could not be saved", + Self::OrphanedArtifacts => "account-scope artifacts were orphaned after key loss", + Self::RandomUnavailable => "secure randomness is unavailable", + Self::StorageUnavailable => "account-scope storage is unavailable", + Self::MetadataLock => "account-scope metadata lock failed", + Self::MetadataRead => "account-scope metadata could not be read", + Self::MetadataCorrupt => "account-scope metadata failed authentication", + Self::MetadataConflict => "account-scope metadata contains conflicting bindings", + Self::MetadataWrite => "account-scope metadata could not be saved", + Self::QuarantineFailed => "account-scope metadata could not be quarantined", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AccountScopeError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FsOperation { + CreateDirectory, + ReadInstallationKey, + ValidateInstallationKey, + InspectArtifacts, + InspectOrphanedMetadata, + OpenMetadataLock, + AcquireMetadataLock, + ReadMetadata, + QuarantineMetadata, + CreateTemp, + WriteTemp, + SyncTemp, + ReplaceFile, + SyncDirectory, + OpenRefreshLock, + AcquireRefreshLock, +} + +trait Backend { + fn random_bytes(&self, length: usize) -> Result, AccountScopeError>; + fn storage_dir(&self) -> Result; + fn now_seconds(&self) -> i64; + fn before_fs(&self, _operation: FsOperation) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy)] +struct SystemBackend; + +impl Backend for SystemBackend { + #[cfg(target_os = "macos")] + fn random_bytes(&self, length: usize) -> Result, AccountScopeError> { + let mut bytes = vec![0_u8; length]; + security_framework::random::SecRandom::default() + .copy_bytes(&mut bytes) + .map_err(|_| AccountScopeError::RandomUnavailable)?; + Ok(bytes) + } + + #[cfg(not(target_os = "macos"))] + fn random_bytes(&self, _length: usize) -> Result, AccountScopeError> { + Err(AccountScopeError::UnsupportedPlatform) + } + + fn storage_dir(&self) -> Result { + dirs::data_dir() + .map(|path| path.join("com.nyanako.tokenbar")) + .ok_or(AccountScopeError::StorageUnavailable) + } + + fn now_seconds(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or(0) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MetadataEnvelope { + schema_version: u32, + payload_bytes_base64: String, + payload_mac: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MetadataPayload { + bindings: Vec, + current_fingerprint_by_slot: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Binding { + provider: String, + slot_digest: String, + credential_fingerprint: String, + random_lineage_id: String, +} + +pub(crate) fn resolve_authoritative( + provider: &str, + kind: AuthoritativeIdKind, + identifier: &str, +) -> Result { + resolve_authoritative_with( + &SystemBackend, + &ACCOUNT_SCOPE_PROCESS_LOCK, + provider, + kind, + identifier, + ) +} + +pub(crate) fn resolve_credential( + provider: &str, + semantic_source: &str, + canonical_location: &str, + raw_marker: &[u8], +) -> Result { + resolve_credential_with( + &SystemBackend, + &ACCOUNT_SCOPE_PROCESS_LOCK, + provider, + semantic_source, + canonical_location, + raw_marker, + ) +} + +pub(crate) fn canonical_file_location( + path: &Path, + record: Option<&str>, +) -> Result { + let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let path = canonical + .to_str() + .ok_or(AccountScopeError::InvalidEvidence)?; + let mut location = path.to_string(); + if let Some(record) = record.filter(|value| !value.is_empty()) { + location.push('\0'); + location.push_str(record); + } + Ok(location) +} + +fn resolve_authoritative_with( + backend: &B, + process_lock: &Mutex<()>, + provider: &str, + kind: AuthoritativeIdKind, + identifier: &str, +) -> Result { + let provider = validate_text(provider)?; + let normalized = match kind { + AuthoritativeIdKind::Email => identifier.trim().to_ascii_lowercase(), + AuthoritativeIdKind::OpaqueId => identifier.trim().to_string(), + }; + if normalized.is_empty() { + return Err(AccountScopeError::NoTrustedEvidence); + } + let key = ensure_installation_key(backend, process_lock)?; + let directory = ensure_storage_dir(backend)?; + with_metadata_lock(backend, process_lock, &directory, || { + load_metadata(backend, &directory, &key)?; + Ok(()) + })?; + scope_from_authoritative(&key, provider, kind, normalized.as_bytes()) +} + +fn resolve_credential_with( + backend: &B, + process_lock: &Mutex<()>, + provider: &str, + semantic_source: &str, + canonical_location: &str, + raw_marker: &[u8], +) -> Result { + validate_credential_evidence(provider, semantic_source, canonical_location, raw_marker)?; + let key = ensure_installation_key(backend, process_lock)?; + bind_current_credential( + backend, + process_lock, + &key, + provider, + semantic_source, + canonical_location, + raw_marker, + ) +} + +fn validate_credential_evidence<'a>( + provider: &'a str, + semantic_source: &str, + canonical_location: &str, + raw_marker: &[u8], +) -> Result<&'a str, AccountScopeError> { + let provider = validate_text(provider)?; + validate_text(semantic_source)?; + validate_text(canonical_location)?; + if raw_marker.is_empty() { + return Err(AccountScopeError::NoTrustedEvidence); + } + Ok(provider) +} + +fn validate_text(value: &str) -> Result<&str, AccountScopeError> { + if value.is_empty() || value.len() > u32::MAX as usize { + Err(AccountScopeError::InvalidEvidence) + } else { + Ok(value) + } +} + +fn ensure_installation_key( + backend: &B, + process_lock: &Mutex<()>, +) -> Result<[u8; INSTALLATION_KEY_BYTES], AccountScopeError> { + let directory = ensure_storage_dir(backend)?; + with_metadata_lock(backend, process_lock, &directory, || { + ensure_installation_key_locked(backend, &directory) + }) +} + +fn ensure_installation_key_locked( + backend: &B, + directory: &Path, +) -> Result<[u8; INSTALLATION_KEY_BYTES], AccountScopeError> { + let key_path = directory.join(INSTALLATION_KEY_FILE); + if let Some(key) = read_installation_key(backend, &key_path)? { + return Ok(key); + } + + backend + .before_fs(FsOperation::InspectArtifacts) + .map_err(|_| AccountScopeError::StorageUnavailable)?; + let metadata_path = directory.join(METADATA_FILE); + let history_path = directory.join(V3_HISTORY_FILE); + let metadata_exists = regular_artifact_exists(&metadata_path) + .map_err(|_| AccountScopeError::StorageUnavailable)?; + let history_exists = regular_artifact_exists(&history_path) + .map_err(|_| AccountScopeError::StorageUnavailable)?; + let orphaned_metadata_exists = orphaned_metadata_artifact_exists(backend, directory) + .map_err(|_| AccountScopeError::StorageUnavailable)?; + let had_artifacts = metadata_exists || history_exists || orphaned_metadata_exists; + + let generated = installation_key_from_bytes(&backend.random_bytes(INSTALLATION_KEY_BYTES)?)?; + if metadata_exists { + quarantine_metadata(backend, &metadata_path, "orphaned")?; + } + save_atomic(backend, directory, &key_path, &generated) + .map_err(|_| AccountScopeError::InstallationKeyWrite)?; + let winner = + read_installation_key(backend, &key_path)?.ok_or(AccountScopeError::InstallationKeyRead)?; + + if had_artifacts { + Err(AccountScopeError::OrphanedArtifacts) + } else { + Ok(winner) + } +} + +fn installation_key_from_bytes( + bytes: &[u8], +) -> Result<[u8; INSTALLATION_KEY_BYTES], AccountScopeError> { + bytes + .try_into() + .map_err(|_| AccountScopeError::InvalidInstallationKey) +} + +fn read_installation_key( + backend: &B, + path: &Path, +) -> Result, AccountScopeError> { + backend + .before_fs(FsOperation::ReadInstallationKey) + .map_err(|_| AccountScopeError::InstallationKeyRead)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => {} + Ok(_) => return Err(AccountScopeError::InvalidInstallationKey), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(AccountScopeError::InstallationKeyRead), + } + + let mut file = OpenOptions::new() + .read(true) + .open(path) + .map_err(|_| AccountScopeError::InstallationKeyRead)?; + backend + .before_fs(FsOperation::ValidateInstallationKey) + .map_err(|_| AccountScopeError::InstallationKeyRead)?; + verify_installation_key_file(path, &file)?; + + let mut key = [0_u8; INSTALLATION_KEY_BYTES]; + match file.read_exact(&mut key) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => { + return Err(AccountScopeError::InvalidInstallationKey) + } + Err(_) => return Err(AccountScopeError::InstallationKeyRead), + } + let mut trailing = [0_u8; 1]; + if file + .read(&mut trailing) + .map_err(|_| AccountScopeError::InstallationKeyRead)? + != 0 + { + return Err(AccountScopeError::InvalidInstallationKey); + } + verify_installation_key_file(path, &file)?; + Ok(Some(key)) +} + +fn verify_installation_key_file(path: &Path, file: &File) -> Result<(), AccountScopeError> { + verify_open_regular_file(path, file).map_err(|_| AccountScopeError::InvalidInstallationKey)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if file + .metadata() + .map_err(|_| AccountScopeError::InstallationKeyRead)? + .permissions() + .mode() + & 0o7777 + != 0o600 + { + return Err(AccountScopeError::InvalidInstallationKey); + } + } + Ok(()) +} + +fn bind_current_credential( + backend: &B, + process_lock: &Mutex<()>, + key: &[u8; INSTALLATION_KEY_BYTES], + provider: &str, + semantic_source: &str, + canonical_location: &str, + raw_marker: &[u8], +) -> Result { + let fingerprint = credential_fingerprint(key, provider, raw_marker)?; + let slot = slot_digest(key, provider, semantic_source, canonical_location)?; + let directory = ensure_storage_dir(backend)?; + with_metadata_lock(backend, process_lock, &directory, || { + let mut payload = load_metadata(backend, &directory, key)?; + let lineage = match lineage_for_fingerprint(&payload, provider, &fingerprint)? { + Some(lineage) => lineage, + None => encode_lineage_id(&backend.random_bytes(LINEAGE_ID_BYTES)?)?, + }; + add_binding(&mut payload, provider, &slot, &fingerprint, &lineage)?; + payload + .current_fingerprint_by_slot + .insert(slot, fingerprint); + validate_payload(&payload)?; + save_metadata(backend, &directory, key, &payload)?; + scope_from_lineage(key, provider, &lineage) + }) +} + +fn transfer_credential_with( + backend: &B, + process_lock: &Mutex<()>, + key: &[u8; INSTALLATION_KEY_BYTES], + provider: &str, + semantic_source: &str, + canonical_location: &str, + old_marker: &[u8], + new_marker: &[u8], +) -> Result { + validate_credential_evidence(provider, semantic_source, canonical_location, old_marker)?; + if new_marker.is_empty() { + return Err(AccountScopeError::NoTrustedEvidence); + } + let old_fingerprint = credential_fingerprint(key, provider, old_marker)?; + let new_fingerprint = credential_fingerprint(key, provider, new_marker)?; + let slot = slot_digest(key, provider, semantic_source, canonical_location)?; + let directory = ensure_storage_dir(backend)?; + with_metadata_lock(backend, process_lock, &directory, || { + let mut payload = load_metadata(backend, &directory, key)?; + let old_lineage = lineage_for_fingerprint(&payload, provider, &old_fingerprint)?; + let new_lineage = lineage_for_fingerprint(&payload, provider, &new_fingerprint)?; + let lineage = match (old_lineage, new_lineage) { + (Some(old), Some(new)) if old != new => { + return Err(AccountScopeError::MetadataConflict) + } + (Some(lineage), _) | (_, Some(lineage)) => lineage, + (None, None) => encode_lineage_id(&backend.random_bytes(LINEAGE_ID_BYTES)?)?, + }; + add_binding(&mut payload, provider, &slot, &old_fingerprint, &lineage)?; + add_binding(&mut payload, provider, &slot, &new_fingerprint, &lineage)?; + payload + .current_fingerprint_by_slot + .insert(slot, new_fingerprint); + validate_payload(&payload)?; + save_metadata(backend, &directory, key, &payload)?; + scope_from_lineage(key, provider, &lineage) + }) +} + +fn add_binding( + payload: &mut MetadataPayload, + provider: &str, + slot_digest: &str, + credential_fingerprint: &str, + lineage: &str, +) -> Result<(), AccountScopeError> { + for binding in &payload.bindings { + if binding.provider == provider + && binding.credential_fingerprint == credential_fingerprint + && binding.random_lineage_id != lineage + { + return Err(AccountScopeError::MetadataConflict); + } + if binding.provider == provider + && binding.slot_digest == slot_digest + && binding.credential_fingerprint == credential_fingerprint + { + return if binding.random_lineage_id == lineage { + Ok(()) + } else { + Err(AccountScopeError::MetadataConflict) + }; + } + } + payload.bindings.push(Binding { + provider: provider.to_string(), + slot_digest: slot_digest.to_string(), + credential_fingerprint: credential_fingerprint.to_string(), + random_lineage_id: lineage.to_string(), + }); + Ok(()) +} + +fn lineage_for_fingerprint( + payload: &MetadataPayload, + provider: &str, + fingerprint: &str, +) -> Result, AccountScopeError> { + let mut lineage: Option<&str> = None; + for binding in payload.bindings.iter().filter(|binding| { + binding.provider == provider && binding.credential_fingerprint == fingerprint + }) { + match lineage { + None => lineage = Some(&binding.random_lineage_id), + Some(existing) if existing == binding.random_lineage_id => {} + Some(_) => return Err(AccountScopeError::MetadataConflict), + } + } + Ok(lineage.map(str::to_string)) +} + +fn load_metadata( + backend: &B, + directory: &Path, + key: &[u8; INSTALLATION_KEY_BYTES], +) -> Result { + backend + .before_fs(FsOperation::ReadMetadata) + .map_err(|_| AccountScopeError::MetadataRead)?; + let path = directory.join(METADATA_FILE); + let Some(bytes) = read_owner_only(&path).map_err(|_| AccountScopeError::MetadataRead)? else { + return Ok(MetadataPayload::default()); + }; + match decode_metadata(key, &bytes) { + Ok(payload) => Ok(payload), + Err(AccountScopeError::MetadataConflict) => Err(AccountScopeError::MetadataConflict), + Err(_) => { + quarantine_metadata(backend, &path, "corrupt")?; + Err(AccountScopeError::MetadataCorrupt) + } + } +} + +fn decode_metadata( + key: &[u8; INSTALLATION_KEY_BYTES], + bytes: &[u8], +) -> Result { + let envelope: MetadataEnvelope = + serde_json::from_slice(bytes).map_err(|_| AccountScopeError::MetadataCorrupt)?; + if envelope.schema_version != METADATA_SCHEMA_VERSION { + return Err(AccountScopeError::MetadataCorrupt); + } + let payload_bytes = STANDARD + .decode(envelope.payload_bytes_base64.as_bytes()) + .map_err(|_| AccountScopeError::MetadataCorrupt)?; + let stored_mac = URL_SAFE_NO_PAD + .decode(envelope.payload_mac.as_bytes()) + .map_err(|_| AccountScopeError::MetadataCorrupt)?; + if stored_mac.len() != DIGEST_BYTES { + return Err(AccountScopeError::MetadataCorrupt); + } + let metadata_key = metadata_mac_key(key)?; + let encoded = encode_fields(&[payload_bytes.as_slice()])?; + let mut mac = HmacSha256::new_from_slice(&metadata_key) + .map_err(|_| AccountScopeError::MetadataCorrupt)?; + mac.update(&encoded); + mac.verify_slice(&stored_mac) + .map_err(|_| AccountScopeError::MetadataCorrupt)?; + let payload: MetadataPayload = + serde_json::from_slice(&payload_bytes).map_err(|_| AccountScopeError::MetadataCorrupt)?; + validate_payload(&payload)?; + Ok(payload) +} + +fn save_metadata( + backend: &B, + directory: &Path, + key: &[u8; INSTALLATION_KEY_BYTES], + payload: &MetadataPayload, +) -> Result<(), AccountScopeError> { + validate_payload(payload)?; + let mut payload = payload.clone(); + payload.bindings.sort_by(|left, right| { + left.provider + .cmp(&right.provider) + .then(left.slot_digest.cmp(&right.slot_digest)) + .then( + left.credential_fingerprint + .cmp(&right.credential_fingerprint), + ) + .then(left.random_lineage_id.cmp(&right.random_lineage_id)) + }); + let payload_bytes = + serde_json::to_vec(&payload).map_err(|_| AccountScopeError::MetadataWrite)?; + let metadata_key = metadata_mac_key(key)?; + let payload_mac = hmac_digest(&metadata_key, &[payload_bytes.as_slice()])?; + let envelope = MetadataEnvelope { + schema_version: METADATA_SCHEMA_VERSION, + payload_bytes_base64: STANDARD.encode(&payload_bytes), + payload_mac: encode_digest(&payload_mac), + }; + let bytes = + serde_json::to_vec_pretty(&envelope).map_err(|_| AccountScopeError::MetadataWrite)?; + save_atomic(backend, directory, &directory.join(METADATA_FILE), &bytes) + .map_err(|_| AccountScopeError::MetadataWrite) +} + +fn validate_payload(payload: &MetadataPayload) -> Result<(), AccountScopeError> { + let mut exact = BTreeSet::new(); + let mut fingerprint_lineages: BTreeMap<(&str, &str), &str> = BTreeMap::new(); + let mut slot_providers: BTreeMap<&str, &str> = BTreeMap::new(); + for binding in &payload.bindings { + validate_text(&binding.provider).map_err(|_| AccountScopeError::MetadataConflict)?; + validate_digest_text(&binding.slot_digest)?; + validate_digest_text(&binding.credential_fingerprint)?; + validate_lineage_text(&binding.random_lineage_id)?; + if !exact.insert(( + binding.provider.as_str(), + binding.slot_digest.as_str(), + binding.credential_fingerprint.as_str(), + )) { + return Err(AccountScopeError::MetadataConflict); + } + match fingerprint_lineages.insert( + ( + binding.provider.as_str(), + binding.credential_fingerprint.as_str(), + ), + binding.random_lineage_id.as_str(), + ) { + Some(existing) if existing != binding.random_lineage_id => { + return Err(AccountScopeError::MetadataConflict) + } + _ => {} + } + match slot_providers.insert(binding.slot_digest.as_str(), binding.provider.as_str()) { + Some(existing) if existing != binding.provider => { + return Err(AccountScopeError::MetadataConflict) + } + _ => {} + } + } + + for (slot, fingerprint) in &payload.current_fingerprint_by_slot { + validate_digest_text(slot)?; + validate_digest_text(fingerprint)?; + let matches = payload + .bindings + .iter() + .filter(|binding| { + binding.slot_digest == *slot && binding.credential_fingerprint == *fingerprint + }) + .count(); + if matches != 1 { + return Err(AccountScopeError::MetadataConflict); + } + } + Ok(()) +} + +fn validate_digest_text(value: &str) -> Result<(), AccountScopeError> { + let decoded = URL_SAFE_NO_PAD + .decode(value.as_bytes()) + .map_err(|_| AccountScopeError::MetadataConflict)?; + if decoded.len() != DIGEST_BYTES || URL_SAFE_NO_PAD.encode(decoded) != value { + return Err(AccountScopeError::MetadataConflict); + } + Ok(()) +} + +fn validate_lineage_text(value: &str) -> Result<(), AccountScopeError> { + let decoded = URL_SAFE_NO_PAD + .decode(value.as_bytes()) + .map_err(|_| AccountScopeError::MetadataConflict)?; + if decoded.len() != LINEAGE_ID_BYTES || URL_SAFE_NO_PAD.encode(decoded) != value { + return Err(AccountScopeError::MetadataConflict); + } + Ok(()) +} + +fn quarantine_metadata( + backend: &B, + path: &Path, + reason: &str, +) -> Result { + quarantine_metadata_with( + backend, + path, + reason, + |source, candidate| fs::hard_link(source, candidate), + |source| fs::remove_file(source), + ) +} + +fn quarantine_metadata_with( + backend: &B, + path: &Path, + reason: &str, + mut link: L, + unlink: U, +) -> Result +where + B: Backend, + L: FnMut(&Path, &Path) -> io::Result<()>, + U: Fn(&Path) -> io::Result<()>, +{ + backend + .before_fs(FsOperation::QuarantineMetadata) + .map_err(|_| AccountScopeError::QuarantineFailed)?; + let source = open_existing_owner_only(path) + .map_err(|_| AccountScopeError::QuarantineFailed)? + .ok_or(AccountScopeError::QuarantineFailed)?; + let directory = path.parent().ok_or(AccountScopeError::QuarantineFailed)?; + let now = backend.now_seconds(); + for suffix in 0..=u32::MAX { + let name = if suffix == 0 { + format!("quota-account-scope-v1.{reason}-{now}.json") + } else { + format!("quota-account-scope-v1.{reason}-{now}.{suffix}.json") + }; + let candidate = directory.join(name); + if verify_open_regular_file(path, &source).is_err() { + return Err(AccountScopeError::QuarantineFailed); + } + match link(path, &candidate) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(AccountScopeError::QuarantineFailed), + } + if verify_open_regular_file(path, &source).is_err() + || verify_open_regular_file(&candidate, &source).is_err() + { + rollback_quarantine_link(&candidate, &source); + return Err(AccountScopeError::QuarantineFailed); + } + if unlink(path).is_err() { + rollback_quarantine_link(&candidate, &source); + return Err(AccountScopeError::QuarantineFailed); + } + sync_directory(backend, directory).map_err(|_| AccountScopeError::QuarantineFailed)?; + return Ok(candidate); + } + Err(AccountScopeError::QuarantineFailed) +} + +fn save_atomic( + backend: &B, + directory: &Path, + path: &Path, + bytes: &[u8], +) -> io::Result<()> { + let target_name = path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing target filename"))? + .to_string_lossy(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp = directory.join(format!( + ".{target_name}.tmp-{}-{counter}", + std::process::id() + )); + let staged = (|| -> io::Result<()> { + backend.before_fs(FsOperation::CreateTemp)?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = secure_open_regular_file(&temp, options.open(&temp)?)?; + backend.before_fs(FsOperation::WriteTemp)?; + file.write_all(bytes)?; + file.flush()?; + backend.before_fs(FsOperation::SyncTemp)?; + file.sync_all()?; + drop(file); + backend.before_fs(FsOperation::ReplaceFile)?; + tokscale_core::fs_atomic::replace_file(&temp, path)?; + sync_directory(backend, directory) + })(); + if staged.is_err() { + let _ = fs::remove_file(&temp); + } + staged +} + +fn ensure_storage_dir(backend: &B) -> Result { + let directory = backend.storage_dir()?; + backend + .before_fs(FsOperation::CreateDirectory) + .map_err(|_| AccountScopeError::StorageUnavailable)?; + ensure_real_directory(&directory).map_err(|_| AccountScopeError::StorageUnavailable)?; + Ok(directory) +} + +fn with_metadata_lock( + backend: &B, + process_lock: &Mutex<()>, + directory: &Path, + body: impl FnOnce() -> Result, +) -> Result { + let _process_guard = process_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let lock_path = directory.join(METADATA_LOCK_FILE); + backend + .before_fs(FsOperation::OpenMetadataLock) + .map_err(|_| AccountScopeError::MetadataLock)?; + let lock_file = open_owner_only(&lock_path).map_err(|_| AccountScopeError::MetadataLock)?; + backend + .before_fs(FsOperation::AcquireMetadataLock) + .map_err(|_| AccountScopeError::MetadataLock)?; + lock_file + .lock_exclusive() + .map_err(|_| AccountScopeError::MetadataLock)?; + let result = body(); + let unlock = fs2::FileExt::unlock(&lock_file).map_err(|_| AccountScopeError::MetadataLock); + match (result, unlock) { + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(value), Ok(())) => Ok(value), + } +} + +fn ensure_real_directory(directory: &Path) -> io::Result<()> { + match fs::symlink_metadata(directory) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope storage is not a real directory", + )) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(directory)?; + } + Err(error) => return Err(error), + } + + let path_metadata = fs::symlink_metadata(directory)?; + if !path_metadata.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope storage is not a real directory", + )); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let file = File::open(directory)?; + verify_open_directory(directory, &file)?; + file.set_permissions(fs::Permissions::from_mode(0o700))?; + verify_open_directory(directory, &file)?; + } + Ok(()) +} + +fn open_owner_only(path: &Path) -> io::Result { + let mut create = OpenOptions::new(); + create.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + create.mode(0o600); + } + match create.open(path) { + Ok(file) => secure_open_regular_file(path, file), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + require_regular_file_path(path)?; + let file = OpenOptions::new().read(true).write(true).open(path)?; + secure_open_regular_file(path, file) + } + Err(error) => Err(error), + } +} + +fn open_existing_owner_only(path: &Path) -> io::Result> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope artifact is not a regular file", + )) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + } + let file = OpenOptions::new().read(true).open(path)?; + secure_open_regular_file(path, file).map(Some) +} + +fn read_owner_only(path: &Path) -> io::Result>> { + let Some(mut file) = open_existing_owner_only(path)? else { + return Ok(None); + }; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + verify_open_regular_file(path, &file)?; + Ok(Some(bytes)) +} + +fn require_regular_file_path(path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_file() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope artifact is not a regular file", + )) + } +} + +fn regular_artifact_exists(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => Ok(true), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope artifact is not a regular file", + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +fn orphaned_metadata_artifact_exists( + backend: &B, + directory: &Path, +) -> io::Result { + backend.before_fs(FsOperation::InspectOrphanedMetadata)?; + let mut found = false; + for entry in fs::read_dir(directory)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !is_orphaned_metadata_name(name) { + continue; + } + require_regular_file_path(&entry.path())?; + found = true; + } + Ok(found) +} + +fn is_orphaned_metadata_name(name: &str) -> bool { + let Some(stem) = name + .strip_prefix("quota-account-scope-v1.orphaned-") + .and_then(|name| name.strip_suffix(".json")) + else { + return false; + }; + let mut parts = stem.split('.'); + let Some(timestamp) = parts + .next() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value >= 0) + else { + return false; + }; + let Some(suffix) = parts.next() else { + return timestamp.to_string() == stem; + }; + let Some(suffix) = suffix.parse::().ok().filter(|value| *value > 0) else { + return false; + }; + parts.next().is_none() && format!("{timestamp}.{suffix}") == stem +} + +fn secure_open_regular_file(path: &Path, file: File) -> io::Result { + verify_open_regular_file(path, &file)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + verify_open_regular_file(path, &file)?; + Ok(file) +} + +fn verify_open_regular_file(path: &Path, file: &File) -> io::Result<()> { + let file_metadata = file.metadata()?; + let path_metadata = fs::symlink_metadata(path)?; + if !file_metadata.file_type().is_file() || !path_metadata.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope artifact is not a regular file", + )); + } + #[cfg(unix)] + if !same_file(&file_metadata, &path_metadata) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope artifact changed while opening", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_open_directory(path: &Path, file: &File) -> io::Result<()> { + let file_metadata = file.metadata()?; + let path_metadata = fs::symlink_metadata(path)?; + if !file_metadata.file_type().is_dir() + || !path_metadata.file_type().is_dir() + || !same_file(&file_metadata, &path_metadata) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "account-scope storage changed while opening", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt as _; + left.dev() == right.dev() && left.ino() == right.ino() +} + +fn rollback_quarantine_link(path: &Path, source: &File) { + if verify_open_regular_file(path, source).is_ok() { + let _ = fs::remove_file(path); + } +} + +fn open_refresh_lock_file( + backend: &B, + directory: &Path, + provider: &str, +) -> Result { + validate_text(provider)?; + backend + .before_fs(FsOperation::OpenRefreshLock) + .map_err(|_| AccountScopeError::MetadataLock)?; + let file = open_owner_only(&directory.join(format!("quota-auth-refresh-{provider}.lock"))) + .map_err(|_| AccountScopeError::MetadataLock)?; + backend + .before_fs(FsOperation::AcquireRefreshLock) + .map_err(|_| AccountScopeError::MetadataLock)?; + file.lock_exclusive() + .map_err(|_| AccountScopeError::MetadataLock)?; + Ok(file) +} + +fn sync_directory(backend: &B, directory: &Path) -> io::Result<()> { + backend.before_fs(FsOperation::SyncDirectory)?; + File::open(directory)?.sync_all() +} + +fn scope_from_authoritative( + key: &[u8; INSTALLATION_KEY_BYTES], + provider: &str, + kind: AuthoritativeIdKind, + normalized_identifier: &[u8], +) -> Result { + let digest = hmac_digest( + key, + &[ + b"scope-id-v1", + provider.as_bytes(), + kind.domain_value().as_bytes(), + normalized_identifier, + ], + )?; + Ok(AccountScope(encode_digest(&digest))) +} + +fn credential_fingerprint( + key: &[u8; INSTALLATION_KEY_BYTES], + provider: &str, + marker: &[u8], +) -> Result { + hmac_digest(key, &[b"credential-v1", provider.as_bytes(), marker]) + .map(|digest| encode_digest(&digest)) +} + +fn slot_digest( + key: &[u8; INSTALLATION_KEY_BYTES], + provider: &str, + semantic_source: &str, + canonical_location: &str, +) -> Result { + hmac_digest( + key, + &[ + b"slot-v1", + provider.as_bytes(), + semantic_source.as_bytes(), + canonical_location.as_bytes(), + ], + ) + .map(|digest| encode_digest(&digest)) +} + +fn scope_from_lineage( + key: &[u8; INSTALLATION_KEY_BYTES], + provider: &str, + encoded_lineage: &str, +) -> Result { + let lineage = URL_SAFE_NO_PAD + .decode(encoded_lineage.as_bytes()) + .map_err(|_| AccountScopeError::MetadataConflict)?; + if lineage.len() != LINEAGE_ID_BYTES { + return Err(AccountScopeError::MetadataConflict); + } + let digest = hmac_digest( + key, + &[b"scope-lineage-v1", provider.as_bytes(), lineage.as_slice()], + )?; + Ok(AccountScope(encode_digest(&digest))) +} + +fn metadata_mac_key( + key: &[u8; INSTALLATION_KEY_BYTES], +) -> Result<[u8; DIGEST_BYTES], AccountScopeError> { + hmac_digest(key, &[b"metadata-key-v1"]) +} + +fn encode_lineage_id(bytes: &[u8]) -> Result { + if bytes.len() != LINEAGE_ID_BYTES { + return Err(AccountScopeError::RandomUnavailable); + } + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn encode_digest(bytes: &[u8; DIGEST_BYTES]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +fn hmac_digest(key: &[u8], fields: &[&[u8]]) -> Result<[u8; DIGEST_BYTES], AccountScopeError> { + let encoded = encode_fields(fields)?; + let mut mac = + HmacSha256::new_from_slice(key).map_err(|_| AccountScopeError::InvalidEvidence)?; + mac.update(&encoded); + Ok(mac.finalize().into_bytes().into()) +} + +fn encode_fields(fields: &[&[u8]]) -> Result, AccountScopeError> { + let capacity = fields.iter().try_fold(0_usize, |total, field| { + let _ = u32::try_from(field.len()).map_err(|_| AccountScopeError::InvalidEvidence)?; + total + .checked_add(4) + .and_then(|value| value.checked_add(field.len())) + .ok_or(AccountScopeError::InvalidEvidence) + })?; + let mut encoded = Vec::with_capacity(capacity); + for field in fields { + let length = u32::try_from(field.len()).map_err(|_| AccountScopeError::InvalidEvidence)?; + encoded.extend_from_slice(&length.to_be_bytes()); + encoded.extend_from_slice(field); + } + Ok(encoded) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RefreshCheckpoint { + Reloaded, + NetworkReturned, + MetadataHandled, + CredentialsPersisted, +} + +pub(crate) trait RefreshScopeTransaction { + fn resolve_current( + &self, + semantic_source: &str, + canonical_location: &str, + marker: &[u8], + ) -> Result; + + fn transfer( + &self, + semantic_source: &str, + canonical_location: &str, + old_marker: &[u8], + new_marker: &[u8], + ) -> Result; +} + +pub(crate) struct RefreshTransaction { + provider: &'static str, + key: Result<[u8; INSTALLATION_KEY_BYTES], AccountScopeError>, + _process_guard: MutexGuard<'static, ()>, + lock_file: File, +} + +pub(crate) fn begin_refresh( + provider: &'static str, +) -> Result { + let backend = SystemBackend; + // Installation-key read or key-loss recovery completes before the provider + // refresh lock is acquired. The refresh transaction keeps the existing + // refresh-lock -> metadata-lock ordering below this point. + let key = ensure_installation_key(&backend, &ACCOUNT_SCOPE_PROCESS_LOCK); + let directory = ensure_storage_dir(&backend)?; + let process_guard = refresh_process_lock(provider)? + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let lock_file = open_refresh_lock_file(&backend, &directory, provider)?; + Ok(RefreshTransaction { + provider, + key, + _process_guard: process_guard, + lock_file, + }) +} + +impl RefreshTransaction { + pub(crate) fn resolve_current( + &self, + semantic_source: &str, + canonical_location: &str, + marker: &[u8], + ) -> Result { + validate_credential_evidence(self.provider, semantic_source, canonical_location, marker)?; + let key = self.key.as_ref().map_err(|error| *error)?; + bind_current_credential( + &SystemBackend, + &ACCOUNT_SCOPE_PROCESS_LOCK, + key, + self.provider, + semantic_source, + canonical_location, + marker, + ) + } + + pub(crate) fn transfer( + &self, + semantic_source: &str, + canonical_location: &str, + old_marker: &[u8], + new_marker: &[u8], + ) -> Result { + let key = self.key.as_ref().map_err(|error| *error)?; + transfer_credential_with( + &SystemBackend, + &ACCOUNT_SCOPE_PROCESS_LOCK, + key, + self.provider, + semantic_source, + canonical_location, + old_marker, + new_marker, + ) + } +} + +impl RefreshScopeTransaction for RefreshTransaction { + fn resolve_current( + &self, + semantic_source: &str, + canonical_location: &str, + marker: &[u8], + ) -> Result { + RefreshTransaction::resolve_current(self, semantic_source, canonical_location, marker) + } + + fn transfer( + &self, + semantic_source: &str, + canonical_location: &str, + old_marker: &[u8], + new_marker: &[u8], + ) -> Result { + RefreshTransaction::transfer( + self, + semantic_source, + canonical_location, + old_marker, + new_marker, + ) + } +} + +impl Drop for RefreshTransaction { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.lock_file); + } +} + +fn refresh_process_lock(provider: &str) -> Result<&'static Mutex<()>, AccountScopeError> { + match provider { + "codex" => Ok(&CODEX_REFRESH_LOCK), + "claude" => Ok(&CLAUDE_REFRESH_LOCK), + "grok" => Ok(&GROK_REFRESH_LOCK), + "antigravity" => Ok(&ANTIGRAVITY_REFRESH_LOCK), + _ => Err(AccountScopeError::InvalidEvidence), + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use std::collections::VecDeque; + use std::sync::Arc; + + #[derive(Clone)] + pub(super) struct TestBackend { + pub(super) directory: PathBuf, + pub(super) state: Arc>, + } + + pub(super) struct TestState { + pub(super) random: VecDeque>, + pub(super) fail_fs_once: Option, + pub(super) replace_installation_key_on_validate: Option>, + pub(super) events: Vec<&'static str>, + pub(super) now: i64, + } + + impl TestBackend { + pub(super) fn new(tag: &str) -> Self { + let directory = std::env::temp_dir().join(format!( + "tb-account-scope-{tag}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&directory); + Self { + directory, + state: Arc::new(Mutex::new(TestState { + random: VecDeque::from([ + vec![0x11; INSTALLATION_KEY_BYTES], + vec![0x21; LINEAGE_ID_BYTES], + vec![0x22; LINEAGE_ID_BYTES], + vec![0x23; LINEAGE_ID_BYTES], + vec![0x24; LINEAGE_ID_BYTES], + ]), + fail_fs_once: None, + replace_installation_key_on_validate: None, + events: Vec::new(), + now: 1_752_710_400, + })), + } + } + + pub(super) fn with_installation_key(self, key: Vec) -> Self { + self.write_installation_key(&key); + self + } + + pub(super) fn write_installation_key(&self, key: &[u8]) { + ensure_real_directory(&self.directory).unwrap(); + let path = self.directory.join(INSTALLATION_KEY_FILE); + fs::write(&path, key).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + } + + pub(super) fn fail_fs(&self, operation: FsOperation) { + self.state.lock().unwrap().fail_fs_once = Some(operation); + } + + pub(super) fn replace_installation_key_on_validate(&self, key: Vec) { + self.state + .lock() + .unwrap() + .replace_installation_key_on_validate = Some(key); + } + + pub(super) fn cleanup(&self) { + let _ = fs::remove_dir_all(&self.directory); + } + } + + impl Backend for TestBackend { + fn random_bytes(&self, length: usize) -> Result, AccountScopeError> { + let mut state = self.state.lock().unwrap(); + let index = state + .random + .iter() + .position(|bytes| bytes.len() == length) + .ok_or(AccountScopeError::RandomUnavailable)?; + state + .random + .remove(index) + .ok_or(AccountScopeError::RandomUnavailable) + } + + fn storage_dir(&self) -> Result { + Ok(self.directory.clone()) + } + + fn now_seconds(&self) -> i64 { + self.state.lock().unwrap().now + } + + fn before_fs(&self, operation: FsOperation) -> io::Result<()> { + let replacement = { + let mut state = self.state.lock().unwrap(); + state.events.push(match operation { + FsOperation::CreateDirectory => "create-directory", + FsOperation::ReadInstallationKey => "read-installation-key", + FsOperation::ValidateInstallationKey => "validate-installation-key", + FsOperation::InspectArtifacts => "inspect-artifacts", + FsOperation::InspectOrphanedMetadata => "inspect-orphaned-metadata", + FsOperation::OpenMetadataLock => "open-metadata-lock", + FsOperation::AcquireMetadataLock => "acquire-metadata-lock", + FsOperation::ReadMetadata => "read-metadata", + FsOperation::QuarantineMetadata => "quarantine-metadata", + FsOperation::CreateTemp => "create-temp", + FsOperation::WriteTemp => "write-temp", + FsOperation::SyncTemp => "sync-temp", + FsOperation::ReplaceFile => "replace-file", + FsOperation::SyncDirectory => "sync-directory", + FsOperation::OpenRefreshLock => "open-refresh-lock", + FsOperation::AcquireRefreshLock => "acquire-refresh-lock", + }); + if state.fail_fs_once == Some(operation) { + state.fail_fs_once = None; + return Err(io::Error::other("injected failure")); + } + if operation == FsOperation::ValidateInstallationKey { + state.replace_installation_key_on_validate.take() + } else { + None + } + }; + if let Some(bytes) = replacement { + let replacement_path = self.directory.join(".installation-key-replacement"); + fs::write(&replacement_path, bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&replacement_path, fs::Permissions::from_mode(0o600))?; + } + fs::rename(replacement_path, self.directory.join(INSTALLATION_KEY_FILE))?; + } + Ok(()) + } + } + + pub(crate) struct TestRefreshScope { + backend: TestBackend, + process_lock: Mutex<()>, + provider: &'static str, + } + + impl TestRefreshScope { + pub(crate) fn new(provider: &'static str, tag: &str) -> Self { + Self { + backend: TestBackend::new(tag) + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]), + process_lock: Mutex::new(()), + provider, + } + } + + pub(crate) fn root(&self) -> &Path { + &self.backend.directory + } + + pub(crate) fn metadata_bytes(&self) -> Vec { + fs::read(self.backend.directory.join(METADATA_FILE)).unwrap() + } + + pub(crate) fn fail_metadata_save(&self) { + self.backend.fail_fs(FsOperation::ReplaceFile); + } + + pub(crate) fn cleanup(&self) { + self.backend.cleanup(); + } + } + + impl RefreshScopeTransaction for TestRefreshScope { + fn resolve_current( + &self, + semantic_source: &str, + canonical_location: &str, + marker: &[u8], + ) -> Result { + resolve_credential_with( + &self.backend, + &self.process_lock, + self.provider, + semantic_source, + canonical_location, + marker, + ) + } + + fn transfer( + &self, + semantic_source: &str, + canonical_location: &str, + old_marker: &[u8], + new_marker: &[u8], + ) -> Result { + let key = ensure_installation_key(&self.backend, &self.process_lock)?; + transfer_credential_with( + &self.backend, + &self.process_lock, + &key, + self.provider, + semantic_source, + canonical_location, + old_marker, + new_marker, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::*; + use super::*; + use sha2::{Digest as _, Sha256}; + use std::collections::VecDeque; + use std::sync::{Arc, Barrier}; + use std::thread; + + fn resolve_test( + backend: &TestBackend, + process_lock: &Mutex<()>, + marker: &[u8], + ) -> Result { + resolve_credential_with( + backend, + process_lock, + "claude", + "fixture-source", + "fixture-location", + marker, + ) + } + + fn metadata_bytes(backend: &TestBackend) -> Vec { + fs::read(backend.directory.join(METADATA_FILE)).unwrap() + } + + fn installation_key(backend: &TestBackend) -> [u8; INSTALLATION_KEY_BYTES] { + read_installation_key(backend, &backend.directory.join(INSTALLATION_KEY_FILE)) + .unwrap() + .unwrap() + } + + fn installation_key_path(backend: &TestBackend) -> PathBuf { + backend.directory.join(INSTALLATION_KEY_FILE) + } + + #[cfg(unix)] + fn unix_mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + #[test] + fn length_prefix_and_hmac_known_vectors_are_stable_and_domain_separated() { + let key: [u8; INSTALLATION_KEY_BYTES] = std::array::from_fn(|index| index as u8); + assert_eq!( + encode_fields(&[b"ab", b"c"]).unwrap(), + vec![0, 0, 0, 2, b'a', b'b', 0, 0, 0, 1, b'c'] + ); + assert_ne!( + encode_fields(&[b"ab", b"c"]).unwrap(), + encode_fields(&[b"a", b"bc"]).unwrap() + ); + assert_eq!( + scope_from_authoritative( + &key, + "antigravity", + AuthoritativeIdKind::Email, + b"user@example.com" + ) + .unwrap() + .as_str(), + "sK_jjcbkOzChAgJHtE1pPpjKU4AEg_MiNut8GaL1woM" + ); + assert_eq!( + credential_fingerprint(&key, "claude", b"fixture-token").unwrap(), + "JCR4YryCMKNOeEjYQEHYrXfanXoq24YteoyJyoiSPtc" + ); + assert_eq!( + slot_digest(&key, "claude", "environment", "CLAUDE_CODE_OAUTH_TOKEN").unwrap(), + "1nTOH8E7TUly1xvVG2sbUI_C0AzksMJ3iOj9vt2PNj8" + ); + let lineage = URL_SAFE_NO_PAD.encode([0xA5; LINEAGE_ID_BYTES]); + assert_eq!( + scope_from_lineage(&key, "claude", &lineage) + .unwrap() + .as_str(), + "QsM_upNybGz6Hljs9K4Qj5uIuBI1HtHpfmPahxb1SEw" + ); + assert_ne!( + credential_fingerprint(&key, "claude", b"fixture-token").unwrap(), + encode_digest(&hmac_digest(&key, &[b"slot-v1", b"claude", b"fixture-token"]).unwrap()) + ); + } + + #[test] + fn different_installation_keys_cannot_link_the_same_identifier() { + let one = scope_from_authoritative( + &[1; INSTALLATION_KEY_BYTES], + "codex", + AuthoritativeIdKind::OpaqueId, + b"acct-123", + ) + .unwrap(); + let two = scope_from_authoritative( + &[2; INSTALLATION_KEY_BYTES], + "codex", + AuthoritativeIdKind::OpaqueId, + b"acct-123", + ) + .unwrap(); + assert_ne!(one, two); + } + + #[test] + fn authoritative_normalization_is_frozen() { + let backend = TestBackend::new("authoritative-normalization"); + let lock = Mutex::new(()); + let mixed = resolve_authoritative_with( + &backend, + &lock, + "antigravity", + AuthoritativeIdKind::Email, + " User@Example.COM ", + ) + .unwrap(); + let normalized = resolve_authoritative_with( + &backend, + &lock, + "antigravity", + AuthoritativeIdKind::Email, + "user@example.com", + ) + .unwrap(); + assert_eq!(mixed, normalized); + let id_upper = resolve_authoritative_with( + &backend, + &lock, + "codex", + AuthoritativeIdKind::OpaqueId, + " Account-A ", + ) + .unwrap(); + let id_lower = resolve_authoritative_with( + &backend, + &lock, + "codex", + AuthoritativeIdKind::OpaqueId, + "account-a", + ) + .unwrap(); + assert_ne!(id_upper, id_lower); + backend.cleanup(); + } + + #[test] + fn same_marker_reuses_lineage_across_sources_but_external_replacement_fragments() { + let backend = TestBackend::new("lineage-rules"); + let lock = Mutex::new(()); + let first = + resolve_credential_with(&backend, &lock, "claude", "file", "/fixture/a", b"token-a") + .unwrap(); + let cross_source = resolve_credential_with( + &backend, + &lock, + "claude", + "keychain", + "service-a", + b"token-a", + ) + .unwrap(); + let replacement = + resolve_credential_with(&backend, &lock, "claude", "file", "/fixture/a", b"token-b") + .unwrap(); + assert_eq!(first, cross_source); + assert_ne!(first, replacement); + backend.cleanup(); + } + + #[test] + fn refresh_crash_points_keep_old_and_new_recoverable_without_partial_metadata() { + let backend = TestBackend::new("refresh-crashes"); + let lock = Mutex::new(()); + let old = resolve_test(&backend, &lock, b"old-refresh").unwrap(); + let key = installation_key(&backend); + + // Crash before metadata save: credentials are still old and metadata is unchanged. + let before = metadata_bytes(&backend); + assert_eq!(resolve_test(&backend, &lock, b"old-refresh").unwrap(), old); + assert_eq!(metadata_bytes(&backend), before); + + // Crash after metadata save but before credential save: either credential resolves. + let transferred = transfer_credential_with( + &backend, + &lock, + &key, + "claude", + "fixture-source", + "fixture-location", + b"old-refresh", + b"new-refresh", + ) + .unwrap(); + assert_eq!(transferred, old); + assert_eq!(resolve_test(&backend, &lock, b"old-refresh").unwrap(), old); + assert_eq!(resolve_test(&backend, &lock, b"new-refresh").unwrap(), old); + + // Crash after credential save: the new marker still resolves the same lineage. + assert_eq!(resolve_test(&backend, &lock, b"new-refresh").unwrap(), old); + backend.cleanup(); + } + + #[test] + fn refresh_reuses_an_existing_new_fingerprint_lineage_when_old_is_unseen() { + let backend = TestBackend::new("refresh-known-new"); + let lock = Mutex::new(()); + let known_new = resolve_credential_with( + &backend, + &lock, + "claude", + "keychain", + "known-slot", + b"new-refresh", + ) + .unwrap(); + let key = installation_key(&backend); + + let transferred = transfer_credential_with( + &backend, + &lock, + &key, + "claude", + "file", + "refreshing-slot", + b"previously-unseen-old-refresh", + b"new-refresh", + ) + .unwrap(); + + assert_eq!(transferred, known_new); + assert_eq!( + resolve_credential_with( + &backend, + &lock, + "claude", + "file", + "refreshing-slot", + b"previously-unseen-old-refresh", + ) + .unwrap(), + known_new + ); + backend.cleanup(); + } + + #[test] + fn metadata_save_failure_is_unavailable_and_preserves_last_valid_bytes() { + let backend = TestBackend::new("save-failure"); + let lock = Mutex::new(()); + let old = resolve_test(&backend, &lock, b"old").unwrap(); + let before = metadata_bytes(&backend); + let key = installation_key(&backend); + backend.fail_fs(FsOperation::ReplaceFile); + assert_eq!( + transfer_credential_with( + &backend, + &lock, + &key, + "claude", + "fixture-source", + "fixture-location", + b"old", + b"new" + ), + Err(AccountScopeError::MetadataWrite) + ); + assert_eq!(metadata_bytes(&backend), before); + assert_eq!(resolve_test(&backend, &lock, b"old").unwrap(), old); + assert_ne!(resolve_test(&backend, &lock, b"new").unwrap(), old); + backend.cleanup(); + } + + #[test] + fn atomic_metadata_failure_points_never_leave_partial_json() { + for operation in [ + FsOperation::CreateTemp, + FsOperation::WriteTemp, + FsOperation::SyncTemp, + FsOperation::ReplaceFile, + ] { + let backend = TestBackend::new("atomic-failure-point"); + let lock = Mutex::new(()); + let old = resolve_test(&backend, &lock, b"old").unwrap(); + let before = metadata_bytes(&backend); + backend.fail_fs(operation); + assert_eq!( + resolve_test(&backend, &lock, b"new"), + Err(AccountScopeError::MetadataWrite) + ); + assert_eq!(metadata_bytes(&backend), before); + assert_eq!(resolve_test(&backend, &lock, b"old").unwrap(), old); + decode_metadata(&installation_key(&backend), &metadata_bytes(&backend)).unwrap(); + backend.cleanup(); + } + } + + #[test] + fn directory_sync_failure_returns_unavailable_but_keeps_valid_metadata() { + let backend = TestBackend::new("directory-sync-failure"); + let lock = Mutex::new(()); + let old = resolve_test(&backend, &lock, b"old").unwrap(); + backend.fail_fs(FsOperation::SyncDirectory); + assert_eq!( + resolve_test(&backend, &lock, b"new"), + Err(AccountScopeError::MetadataWrite) + ); + let new_scope = resolve_test(&backend, &lock, b"new").unwrap(); + assert_ne!(new_scope, old); + backend.cleanup(); + } + + #[test] + fn key_loss_reloads_replacement_key_before_metadata_recovery() { + let backend = TestBackend::new("key-loss-reload"); + backend.state.lock().unwrap().random = VecDeque::from([ + vec![0x31; INSTALLATION_KEY_BYTES], + vec![0x41; LINEAGE_ID_BYTES], + vec![0x32; INSTALLATION_KEY_BYTES], + vec![0x42; LINEAGE_ID_BYTES], + ]); + let lock = Mutex::new(()); + let old_scope = resolve_test(&backend, &lock, b"same-marker").unwrap(); + let old_metadata = metadata_bytes(&backend); + + fs::remove_file(installation_key_path(&backend)).unwrap(); + assert_eq!( + resolve_test(&backend, &lock, b"same-marker"), + Err(AccountScopeError::OrphanedArtifacts) + ); + assert_eq!( + fs::read( + backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json") + ) + .unwrap(), + old_metadata + ); + + let replacement_scope = resolve_test(&backend, &lock, b"same-marker").unwrap(); + assert_ne!(replacement_scope, old_scope); + let replacement_metadata = metadata_bytes(&backend); + assert_eq!( + resolve_test(&backend, &lock, b"same-marker").unwrap(), + replacement_scope + ); + assert_eq!(metadata_bytes(&backend), replacement_metadata); + backend.cleanup(); + } + + #[test] + fn concurrent_first_creation_persists_one_winner_under_the_file_lock() { + let backend = TestBackend::new("concurrent-key"); + backend.state.lock().unwrap().random = VecDeque::from([ + vec![0x31; INSTALLATION_KEY_BYTES], + vec![0x32; INSTALLATION_KEY_BYTES], + ]); + let start = Arc::new(Barrier::new(3)); + let one_backend = backend.clone(); + let one_start = start.clone(); + let one = thread::spawn(move || { + one_start.wait(); + ensure_installation_key(&one_backend, &Mutex::new(())) + }); + let two_backend = backend.clone(); + let two_start = start.clone(); + let two = thread::spawn(move || { + two_start.wait(); + ensure_installation_key(&two_backend, &Mutex::new(())) + }); + start.wait(); + + let one = one.join().unwrap().unwrap(); + let two = two.join().unwrap().unwrap(); + assert_eq!(one, two); + assert_eq!(fs::read(installation_key_path(&backend)).unwrap(), one); + assert_eq!( + backend + .state + .lock() + .unwrap() + .events + .iter() + .filter(|event| **event == "replace-file") + .count(), + 1 + ); + backend.cleanup(); + } + + #[test] + fn existing_installation_key_is_reloaded_on_every_call_without_a_process_cache() { + let backend = TestBackend::new("key-reload"); + let first = ensure_installation_key(&backend, &Mutex::new(())).unwrap(); + assert_eq!(first, [0x11; INSTALLATION_KEY_BYTES]); + + backend.write_installation_key(&[0x52; INSTALLATION_KEY_BYTES]); + let second = ensure_installation_key(&backend, &Mutex::new(())).unwrap(); + assert_eq!(second, [0x52; INSTALLATION_KEY_BYTES]); + assert_ne!(first, second); + backend.cleanup(); + } + + #[test] + fn conflicting_two_process_transfers_fail_closed() { + let backend = TestBackend::new("transfer-conflict"); + let setup_lock = Mutex::new(()); + let scope_a = + resolve_credential_with(&backend, &setup_lock, "claude", "file", "slot-a", b"old-a") + .unwrap(); + let scope_b = + resolve_credential_with(&backend, &setup_lock, "claude", "file", "slot-b", b"old-b") + .unwrap(); + assert_ne!(scope_a, scope_b); + let key = installation_key(&backend); + let one_backend = backend.clone(); + let two_backend = backend.clone(); + let one = thread::spawn(move || { + transfer_credential_with( + &one_backend, + &Mutex::new(()), + &key, + "claude", + "file", + "slot-a", + b"old-a", + b"shared-new", + ) + }); + let two = thread::spawn(move || { + transfer_credential_with( + &two_backend, + &Mutex::new(()), + &key, + "claude", + "file", + "slot-b", + b"old-b", + b"shared-new", + ) + }); + let results = [one.join().unwrap(), two.join().unwrap()]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| **result == Err(AccountScopeError::MetadataConflict)) + .count(), + 1 + ); + backend.cleanup(); + } + + #[test] + fn installation_key_round_trip_is_exact_and_owner_only() { + let backend = TestBackend::new("installation-key-round-trip"); + let first = ensure_installation_key(&backend, &Mutex::new(())).unwrap(); + let second = ensure_installation_key(&backend, &Mutex::new(())).unwrap(); + assert_eq!(first, [0x11; INSTALLATION_KEY_BYTES]); + assert_eq!(second, first); + assert_eq!(fs::read(installation_key_path(&backend)).unwrap(), first); + #[cfg(unix)] + { + assert_eq!(unix_mode(&backend.directory), 0o700); + assert_eq!(unix_mode(&installation_key_path(&backend)), 0o600); + } + backend.cleanup(); + } + + #[test] + fn installation_key_read_failure_and_invalid_lengths_preserve_artifacts() { + let read_failure = TestBackend::new("installation-key-read-failure") + .with_installation_key(vec![0x61; INSTALLATION_KEY_BYTES]); + let metadata = b"metadata-read-failure"; + let history = b"history-read-failure"; + fs::write(read_failure.directory.join(METADATA_FILE), metadata).unwrap(); + fs::write(read_failure.directory.join(V3_HISTORY_FILE), history).unwrap(); + read_failure.fail_fs(FsOperation::ReadInstallationKey); + assert_eq!( + ensure_installation_key(&read_failure, &Mutex::new(())), + Err(AccountScopeError::InstallationKeyRead) + ); + assert_eq!(metadata_bytes(&read_failure), metadata); + assert_eq!( + fs::read(read_failure.directory.join(V3_HISTORY_FILE)).unwrap(), + history + ); + read_failure.cleanup(); + + for (tag, length) in [ + ("installation-key-short", 31), + ("installation-key-long", 33), + ] { + let backend = TestBackend::new(tag).with_installation_key(vec![0x62; length]); + let metadata = b"metadata-invalid-key"; + let history = b"history-invalid-key"; + fs::write(backend.directory.join(METADATA_FILE), metadata).unwrap(); + fs::write(backend.directory.join(V3_HISTORY_FILE), history).unwrap(); + let original_key = fs::read(installation_key_path(&backend)).unwrap(); + + assert_eq!( + ensure_installation_key(&backend, &Mutex::new(())), + Err(AccountScopeError::InvalidInstallationKey), + "{tag}" + ); + assert_eq!( + fs::read(installation_key_path(&backend)).unwrap(), + original_key + ); + assert_eq!(metadata_bytes(&backend), metadata); + assert_eq!( + fs::read(backend.directory.join(V3_HISTORY_FILE)).unwrap(), + history + ); + backend.cleanup(); + } + } + + #[cfg(unix)] + #[test] + fn installation_key_path_attacks_fail_closed_without_mutating_artifacts() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + for case in ["symlink", "dangling", "non-regular", "mode"] { + let backend = TestBackend::new(case); + ensure_real_directory(&backend.directory).unwrap(); + let key_path = installation_key_path(&backend); + let external = backend.directory.with_extension(format!("{case}-external")); + match case { + "symlink" => { + fs::write(&external, [0x71; INSTALLATION_KEY_BYTES]).unwrap(); + fs::set_permissions(&external, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&external, &key_path).unwrap(); + } + "dangling" => symlink(&external, &key_path).unwrap(), + "non-regular" => fs::create_dir(&key_path).unwrap(), + "mode" => { + fs::write(&key_path, [0x72; INSTALLATION_KEY_BYTES]).unwrap(); + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o640)).unwrap(); + } + _ => unreachable!(), + } + let metadata = format!("metadata-{case}").into_bytes(); + let history = format!("history-{case}").into_bytes(); + fs::write(backend.directory.join(METADATA_FILE), &metadata).unwrap(); + fs::write(backend.directory.join(V3_HISTORY_FILE), &history).unwrap(); + + assert_eq!( + ensure_installation_key(&backend, &Mutex::new(())), + Err(AccountScopeError::InvalidInstallationKey), + "{case}" + ); + assert_eq!(metadata_bytes(&backend), metadata, "{case}"); + assert_eq!( + fs::read(backend.directory.join(V3_HISTORY_FILE)).unwrap(), + history, + "{case}" + ); + match case { + "symlink" => { + assert_eq!(fs::read(&external).unwrap(), [0x71; INSTALLATION_KEY_BYTES]); + assert!(fs::symlink_metadata(&key_path) + .unwrap() + .file_type() + .is_symlink()); + } + "dangling" => { + assert!(fs::symlink_metadata(&key_path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!external.exists()); + } + "non-regular" => assert!(key_path.is_dir()), + "mode" => { + assert_eq!(fs::read(&key_path).unwrap(), [0x72; INSTALLATION_KEY_BYTES]); + assert_eq!(unix_mode(&key_path), 0o640); + } + _ => unreachable!(), + } + + backend.cleanup(); + if external.exists() { + fs::remove_file(external).unwrap(); + } + } + } + + #[cfg(unix)] + #[test] + fn installation_key_inode_replacement_fails_closed() { + let backend = TestBackend::new("installation-key-inode-swap") + .with_installation_key(vec![0x73; INSTALLATION_KEY_BYTES]); + let metadata = b"metadata-inode-swap"; + let history = b"history-inode-swap"; + fs::write(backend.directory.join(METADATA_FILE), metadata).unwrap(); + fs::write(backend.directory.join(V3_HISTORY_FILE), history).unwrap(); + backend.replace_installation_key_on_validate(vec![0x74; INSTALLATION_KEY_BYTES]); + + assert_eq!( + ensure_installation_key(&backend, &Mutex::new(())), + Err(AccountScopeError::InvalidInstallationKey) + ); + assert_eq!(metadata_bytes(&backend), metadata); + assert_eq!( + fs::read(backend.directory.join(V3_HISTORY_FILE)).unwrap(), + history + ); + assert_eq!( + fs::read(installation_key_path(&backend)).unwrap(), + [0x74; INSTALLATION_KEY_BYTES] + ); + backend.cleanup(); + } + + #[test] + fn atomic_installation_key_failures_leave_no_partial_file_or_temp() { + for operation in [ + FsOperation::CreateTemp, + FsOperation::WriteTemp, + FsOperation::SyncTemp, + FsOperation::ReplaceFile, + FsOperation::SyncDirectory, + ] { + let backend = TestBackend::new("installation-key-atomic-failure"); + backend.fail_fs(operation); + assert_eq!( + ensure_installation_key(&backend, &Mutex::new(())), + Err(AccountScopeError::InstallationKeyWrite), + "{operation:?}" + ); + let key_path = installation_key_path(&backend); + if key_path.exists() { + assert_eq!(fs::read(&key_path).unwrap(), [0x11; INSTALLATION_KEY_BYTES]); + #[cfg(unix)] + assert_eq!(unix_mode(&key_path), 0o600); + assert_eq!( + ensure_installation_key(&backend, &Mutex::new(())).unwrap(), + [0x11; INSTALLATION_KEY_BYTES] + ); + } + let temp_prefix = format!(".{INSTALLATION_KEY_FILE}.tmp-"); + assert!(!fs::read_dir(&backend.directory).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(&temp_prefix) + })); + backend.cleanup(); + } + } + + #[test] + fn metadata_only_orphan_recovery_key_write_failures_preserve_evidence_and_defer_scope() { + for operation in [ + FsOperation::CreateTemp, + FsOperation::WriteTemp, + FsOperation::SyncTemp, + FsOperation::ReplaceFile, + ] { + let backend = TestBackend::new("metadata-only-orphan-key-write-failure"); + backend.state.lock().unwrap().random = VecDeque::from([ + vec![0x11; INSTALLATION_KEY_BYTES], + vec![0x12; INSTALLATION_KEY_BYTES], + vec![0x21; LINEAGE_ID_BYTES], + ]); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata = b"orphan-metadata-before-key-write"; + let orphaned = backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json"); + fs::write(backend.directory.join(METADATA_FILE), metadata).unwrap(); + backend.fail_fs(operation); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::InstallationKeyWrite), + "{operation:?}" + ); + assert!(!installation_key_path(&backend).exists()); + assert!(!backend.directory.join(METADATA_FILE).exists()); + assert_eq!(fs::read(&orphaned).unwrap(), metadata); + assert!(!backend.directory.join(V3_HISTORY_FILE).exists()); + let temp_prefix = format!(".{INSTALLATION_KEY_FILE}.tmp-"); + assert!(!fs::read_dir(&backend.directory).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(&temp_prefix) + })); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::OrphanedArtifacts) + ); + assert_eq!(fs::read(&orphaned).unwrap(), metadata); + assert!(!backend.directory.join(METADATA_FILE).exists()); + let winner = installation_key(&backend); + let scope = resolve_test(&backend, &Mutex::new(()), b"marker").unwrap(); + assert_eq!(installation_key(&backend), winner); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker").unwrap(), + scope + ); + assert_eq!(installation_key(&backend), winner); + backend.cleanup(); + } + } + + #[test] + fn orphaned_metadata_name_accepts_only_production_canonical_numbers() { + for (name, expected) in [ + ("quota-account-scope-v1.orphaned-0.json", true), + ("quota-account-scope-v1.orphaned-0.1.json", true), + ( + "quota-account-scope-v1.orphaned-9223372036854775807.4294967295.json", + true, + ), + ("quota-account-scope-v1.orphaned--1.json", false), + ("quota-account-scope-v1.orphaned-+1.json", false), + ("quota-account-scope-v1.orphaned-01.json", false), + ( + "quota-account-scope-v1.orphaned-9223372036854775808.json", + false, + ), + ("quota-account-scope-v1.orphaned-1.1.2.json", false), + ("quota-account-scope-v1.orphaned-1.0.json", false), + ("quota-account-scope-v1.orphaned-1.+1.json", false), + ("quota-account-scope-v1.orphaned-1.01.json", false), + ("quota-account-scope-v1.orphaned-1.4294967296.json", false), + ("quota-account-scope-v1.orphaned-1.-1.json", false), + ] { + assert_eq!(is_orphaned_metadata_name(name), expected, "{name}"); + } + } + + #[test] + fn forged_negative_timestamp_orphan_does_not_defer_first_scope() { + let backend = TestBackend::new("forged-negative-orphan"); + ensure_real_directory(&backend.directory).unwrap(); + let forged = backend + .directory + .join("quota-account-scope-v1.orphaned--1.json"); + let evidence = b"forged-negative-orphan-evidence"; + fs::write(&forged, evidence).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&forged, fs::Permissions::from_mode(0o000)).unwrap(); + } + assert!(!installation_key_path(&backend).exists()); + assert!(!backend.directory.join(METADATA_FILE).exists()); + assert!(!backend.directory.join(V3_HISTORY_FILE).exists()); + + let scope = resolve_test(&backend, &Mutex::new(()), b"marker").unwrap(); + + assert!(installation_key_path(&backend).exists()); + assert!(backend.directory.join(METADATA_FILE).exists()); + assert!(!backend.directory.join(V3_HISTORY_FILE).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!(unix_mode(&forged), 0o000); + fs::set_permissions(&forged, fs::Permissions::from_mode(0o600)).unwrap(); + } + assert_eq!(fs::read(&forged).unwrap(), evidence); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker").unwrap(), + scope + ); + assert_eq!(fs::read(&forged).unwrap(), evidence); + backend.cleanup(); + } + + #[test] + fn orphaned_metadata_inspection_failure_fails_closed_without_creating_key() { + let backend = TestBackend::new("orphan-inspection-failure"); + ensure_real_directory(&backend.directory).unwrap(); + let orphaned = backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json"); + let evidence = b"preserved-orphan-evidence"; + fs::write(&orphaned, evidence).unwrap(); + backend.fail_fs(FsOperation::InspectOrphanedMetadata); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::StorageUnavailable) + ); + assert_eq!(fs::read(&orphaned).unwrap(), evidence); + assert!(!installation_key_path(&backend).exists()); + assert!(!backend.directory.join(METADATA_FILE).exists()); + assert!(!backend + .state + .lock() + .unwrap() + .events + .contains(&"create-temp")); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::OrphanedArtifacts) + ); + assert_eq!(fs::read(&orphaned).unwrap(), evidence); + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn orphaned_metadata_non_regular_entries_fail_closed_without_touching_targets() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + for case in ["symlink", "directory"] { + let backend = TestBackend::new(&format!("orphan-{case}")); + ensure_real_directory(&backend.directory).unwrap(); + let orphaned = backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json"); + let external = backend.directory.with_extension(format!("{case}-target")); + let target_bytes = b"external-orphan-target"; + match case { + "symlink" => { + fs::write(&external, target_bytes).unwrap(); + fs::set_permissions(&external, fs::Permissions::from_mode(0o640)).unwrap(); + symlink(&external, &orphaned).unwrap(); + } + "directory" => fs::create_dir(&orphaned).unwrap(), + _ => unreachable!(), + } + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::StorageUnavailable), + "{case}" + ); + assert!(!installation_key_path(&backend).exists(), "{case}"); + assert!(!backend.directory.join(METADATA_FILE).exists(), "{case}"); + assert!(!backend + .state + .lock() + .unwrap() + .events + .contains(&"create-temp")); + match case { + "symlink" => { + assert_eq!(fs::read(&external).unwrap(), target_bytes); + assert_eq!(unix_mode(&external), 0o640); + assert!(fs::symlink_metadata(&orphaned) + .unwrap() + .file_type() + .is_symlink()); + } + "directory" => assert!(orphaned.is_dir()), + _ => unreachable!(), + } + + backend.cleanup(); + if external.exists() { + fs::remove_file(external).unwrap(); + } + } + } + + #[test] + fn secure_random_failure_creates_no_key_or_metadata() { + let backend = TestBackend::new("random-failure"); + backend.state.lock().unwrap().random.clear(); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::RandomUnavailable) + ); + assert!(!installation_key_path(&backend).exists()); + assert!(!backend.directory.join(METADATA_FILE).exists()); + backend.cleanup(); + } + + #[test] + fn missing_key_quarantines_metadata_preserves_v3_and_defers_one_poll() { + let backend = TestBackend::new("orphan-recovery"); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata = b"legacy-metadata-bytes"; + let history = b"legacy-v3-bytes"; + fs::write(backend.directory.join(METADATA_FILE), metadata).unwrap(); + fs::write(backend.directory.join(V3_HISTORY_FILE), history).unwrap(); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::OrphanedArtifacts) + ); + assert_eq!( + fs::read(backend.directory.join(V3_HISTORY_FILE)).unwrap(), + history + ); + assert_eq!( + fs::read( + backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json") + ) + .unwrap(), + metadata + ); + let winner = installation_key(&backend); + assert!(resolve_test(&backend, &Mutex::new(()), b"marker").is_ok()); + assert_eq!(installation_key(&backend), winner); + backend.cleanup(); + } + + #[test] + fn missing_key_with_only_v3_preserves_history_and_defers_one_poll() { + let backend = TestBackend::new("v3-only-orphan"); + fs::create_dir_all(&backend.directory).unwrap(); + let history = b"orphaned-v3-scopes"; + fs::write(backend.directory.join(V3_HISTORY_FILE), history).unwrap(); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::OrphanedArtifacts) + ); + assert_eq!( + fs::read(backend.directory.join(V3_HISTORY_FILE)).unwrap(), + history + ); + let winner = installation_key(&backend); + assert!(resolve_test(&backend, &Mutex::new(()), b"marker").is_ok()); + assert_eq!(installation_key(&backend), winner); + backend.cleanup(); + } + + #[test] + fn orphan_quarantine_collision_uses_unique_suffix_without_overwrite() { + let backend = TestBackend::new("orphan-collision"); + fs::create_dir_all(&backend.directory).unwrap(); + fs::write(backend.directory.join(METADATA_FILE), b"source").unwrap(); + fs::write( + backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json"), + b"existing-zero", + ) + .unwrap(); + fs::write( + backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.1.json"), + b"existing-one", + ) + .unwrap(); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::OrphanedArtifacts) + ); + assert_eq!( + fs::read( + backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.2.json") + ) + .unwrap(), + b"source" + ); + assert_eq!( + fs::read( + backend + .directory + .join("quota-account-scope-v1.orphaned-1752710400.json") + ) + .unwrap(), + b"existing-zero" + ); + backend.cleanup(); + } + + #[test] + fn quarantine_failure_does_not_create_or_replace_the_key() { + let backend = TestBackend::new("orphan-quarantine-failure"); + fs::create_dir_all(&backend.directory).unwrap(); + let original = b"metadata-before-key-loss"; + fs::write(backend.directory.join(METADATA_FILE), original).unwrap(); + backend.fail_fs(FsOperation::QuarantineMetadata); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::QuarantineFailed) + ); + assert_eq!(metadata_bytes(&backend), original); + assert!(!installation_key_path(&backend).exists()); + backend.cleanup(); + } + + #[test] + fn corrupt_quarantine_failure_preserves_authenticated_recovery_evidence() { + let backend = TestBackend::new("corrupt-quarantine-failure") + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let corrupt = b"corrupt-but-preserved"; + fs::write(backend.directory.join(METADATA_FILE), corrupt).unwrap(); + backend.fail_fs(FsOperation::QuarantineMetadata); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::QuarantineFailed) + ); + assert_eq!(metadata_bytes(&backend), corrupt); + backend.cleanup(); + } + + #[test] + fn authoritative_scope_quarantines_corrupt_metadata_before_hmac() { + for (tag, provider, kind, identifier, bytes) in [ + ( + "authoritative-invalid-json", + "codex", + AuthoritativeIdKind::OpaqueId, + "acct-123", + b"not-json".as_slice(), + ), + ( + "authoritative-bad-mac", + "antigravity", + AuthoritativeIdKind::Email, + "user@example.com", + br#"{"schemaVersion":1,"payloadBytesBase64":"e30=","payloadMac":"bad"}"#.as_slice(), + ), + ( + "authoritative-bad-schema", + "codex", + AuthoritativeIdKind::OpaqueId, + "acct-456", + br#"{"schemaVersion":2,"payloadBytesBase64":"e30=","payloadMac":"bad"}"#.as_slice(), + ), + ] { + let backend = + TestBackend::new(tag).with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata_path = backend.directory.join(METADATA_FILE); + fs::write(&metadata_path, bytes).unwrap(); + + assert_eq!( + resolve_authoritative_with(&backend, &Mutex::new(()), provider, kind, identifier,), + Err(AccountScopeError::MetadataCorrupt), + "{tag}" + ); + let quarantine = backend.directory.join(format!( + "quota-account-scope-v1.corrupt-{}.json", + backend.now_seconds() + )); + assert_eq!(fs::read(quarantine).unwrap(), bytes, "{tag}"); + assert!(!metadata_path.exists(), "{tag}"); + assert!(resolve_authoritative_with( + &backend, + &Mutex::new(()), + provider, + kind, + identifier, + ) + .is_ok()); + assert!(!metadata_path.exists(), "{tag}"); + backend.cleanup(); + } + + let backend = TestBackend::new("authoritative-corrupt-quarantine-failure") + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata_path = backend.directory.join(METADATA_FILE); + let original = b"corrupt-authoritative-metadata"; + fs::write(&metadata_path, original).unwrap(); + backend.fail_fs(FsOperation::QuarantineMetadata); + assert_eq!( + resolve_authoritative_with( + &backend, + &Mutex::new(()), + "codex", + AuthoritativeIdKind::OpaqueId, + "acct-789", + ), + Err(AccountScopeError::QuarantineFailed) + ); + assert_eq!(fs::read(&metadata_path).unwrap(), original); + backend.cleanup(); + } + + #[test] + fn mac_or_schema_corruption_is_quarantined_then_next_poll_recovers() { + for (tag, bytes) in [ + ( + "bad-mac", + br#"{"schemaVersion":1,"payloadBytesBase64":"e30=","payloadMac":"bad"}"#.as_slice(), + ), + ( + "bad-schema", + br#"{"schemaVersion":2,"payloadBytesBase64":"e30=","payloadMac":"bad"}"#.as_slice(), + ), + ] { + let backend = + TestBackend::new(tag).with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + fs::write(backend.directory.join(METADATA_FILE), bytes).unwrap(); + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::MetadataCorrupt) + ); + let quarantine = backend.directory.join(format!( + "quota-account-scope-v1.corrupt-{}.json", + backend.now_seconds() + )); + assert_eq!(fs::read(quarantine).unwrap(), bytes); + assert!(resolve_test(&backend, &Mutex::new(()), b"marker").is_ok()); + backend.cleanup(); + } + } + + #[test] + fn authenticated_payload_with_missing_schema_fields_is_quarantined() { + let backend = TestBackend::new("missing-payload-field") + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let key = installation_key(&backend); + let payload_bytes = br#"{"bindings":[]}"#; + let metadata_key = metadata_mac_key(&key).unwrap(); + let mac = hmac_digest(&metadata_key, &[payload_bytes.as_slice()]).unwrap(); + let envelope = MetadataEnvelope { + schema_version: METADATA_SCHEMA_VERSION, + payload_bytes_base64: STANDARD.encode(payload_bytes), + payload_mac: encode_digest(&mac), + }; + let original = serde_json::to_vec_pretty(&envelope).unwrap(); + fs::write(backend.directory.join(METADATA_FILE), &original).unwrap(); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::MetadataCorrupt) + ); + assert_eq!( + fs::read( + backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.json") + ) + .unwrap(), + original + ); + backend.cleanup(); + } + + #[test] + fn lock_failure_preserves_last_valid_metadata() { + let backend = TestBackend::new("lock-failure"); + let lock = Mutex::new(()); + resolve_test(&backend, &lock, b"old").unwrap(); + let before = metadata_bytes(&backend); + backend.fail_fs(FsOperation::AcquireMetadataLock); + assert_eq!( + resolve_test(&backend, &lock, b"new"), + Err(AccountScopeError::MetadataLock) + ); + assert_eq!(metadata_bytes(&backend), before); + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn metadata_lock_symlink_fails_closed_before_lock_acquisition() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let backend = TestBackend::new("metadata-lock-symlink") + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let target = backend.directory.with_extension("external-metadata-lock"); + let lock_path = backend.directory.join(METADATA_LOCK_FILE); + let original = b"external-metadata-lock-target"; + fs::write(&target, original).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &lock_path).unwrap(); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::MetadataLock) + ); + assert_eq!(fs::read(&target).unwrap(), original); + assert_eq!(unix_mode(&target), original_mode); + assert!(fs::symlink_metadata(&lock_path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!backend.directory.join(METADATA_FILE).exists()); + let events = backend.state.lock().unwrap().events.clone(); + assert!(events.contains(&"open-metadata-lock")); + assert!(!events.contains(&"acquire-metadata-lock")); + assert!(!events.contains(&"read-metadata")); + assert!(!events.contains(&"create-temp")); + + backend.cleanup(); + fs::remove_file(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn active_metadata_symlink_fails_closed_without_touching_target() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let backend = TestBackend::new("metadata-symlink") + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let target = backend.directory.with_extension("external-metadata"); + let metadata_path = backend.directory.join(METADATA_FILE); + let original = b"external-metadata-target"; + fs::write(&target, original).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &metadata_path).unwrap(); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::MetadataRead) + ); + assert_eq!(fs::read(&target).unwrap(), original); + assert_eq!(unix_mode(&target), original_mode); + assert!(fs::symlink_metadata(&metadata_path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.json") + .exists()); + let events = backend.state.lock().unwrap().events.clone(); + assert!(events.contains(&"acquire-metadata-lock")); + assert!(events.contains(&"read-metadata")); + assert!(!events.contains(&"quarantine-metadata")); + assert!(!events.contains(&"create-temp")); + + backend.cleanup(); + fs::remove_file(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn metadata_symlink_with_missing_key_never_creates_or_replaces_key() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let backend = TestBackend::new("metadata-symlink-missing-key"); + fs::create_dir_all(&backend.directory).unwrap(); + let target = backend.directory.with_extension("external-orphan-metadata"); + let metadata_path = backend.directory.join(METADATA_FILE); + let original = b"external-orphan-metadata-target"; + fs::write(&target, original).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &metadata_path).unwrap(); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::StorageUnavailable) + ); + assert_eq!(fs::read(&target).unwrap(), original); + assert_eq!(unix_mode(&target), original_mode); + assert!(fs::symlink_metadata(&metadata_path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!installation_key_path(&backend).exists()); + let events = backend.state.lock().unwrap().events.clone(); + assert!(events.contains(&"open-metadata-lock")); + assert!(events.contains(&"acquire-metadata-lock")); + assert!(events.contains(&"inspect-artifacts")); + assert!(!events.contains(&"quarantine-metadata")); + + backend.cleanup(); + fs::remove_file(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn account_final_directory_symlink_fails_before_chmod_or_artifact_creation() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let backend = TestBackend::new("directory-symlink"); + let target = backend.directory.with_extension("external-directory"); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &backend.directory).unwrap(); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::StorageUnavailable) + ); + assert_eq!(unix_mode(&target), original_mode); + assert_eq!(fs::read_dir(&target).unwrap().count(), 0); + assert!(fs::symlink_metadata(&backend.directory) + .unwrap() + .file_type() + .is_symlink()); + let events = backend.state.lock().unwrap().events.clone(); + assert!(events.contains(&"create-directory")); + assert!(!events.contains(&"open-metadata-lock")); + assert!(!events.contains(&"create-temp")); + + fs::remove_file(&backend.directory).unwrap(); + fs::remove_dir(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn atomic_metadata_quarantine_hard_link_closes_collision_race() { + use std::cell::Cell; + use std::os::unix::fs::{symlink, MetadataExt as _, PermissionsExt as _}; + + let backend = TestBackend::new("metadata-quarantine-reservation-race"); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata_path = backend.directory.join(METADATA_FILE); + let original = b"metadata-race-source"; + fs::write(&metadata_path, original).unwrap(); + fs::set_permissions(&metadata_path, fs::Permissions::from_mode(0o644)).unwrap(); + let source_inode = fs::metadata(&metadata_path).unwrap().ino(); + let collision = backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.json"); + let missing_target = backend.directory.with_extension("race-dangling-target"); + let raced = Cell::new(false); + + let quarantined = quarantine_metadata_with( + &backend, + &metadata_path, + "corrupt", + |source, candidate| { + if !raced.replace(true) { + symlink(&missing_target, candidate)?; + } + fs::hard_link(source, candidate) + }, + |source| fs::remove_file(source), + ) + .unwrap(); + + assert_eq!( + quarantined, + backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.1.json") + ); + assert!(fs::symlink_metadata(&collision) + .unwrap() + .file_type() + .is_symlink()); + assert!(!missing_target.exists()); + assert_eq!(fs::read(&quarantined).unwrap(), original); + assert_eq!(unix_mode(&quarantined), 0o600); + assert_eq!(fs::metadata(&quarantined).unwrap().ino(), source_inode); + assert!(!metadata_path.exists()); + + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn metadata_quarantine_unlink_failure_rolls_back_link() { + use std::os::unix::fs::PermissionsExt as _; + + let backend = TestBackend::new("metadata-quarantine-unlink-failure"); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata_path = backend.directory.join(METADATA_FILE); + let original = b"metadata-before-rename-failure"; + fs::write(&metadata_path, original).unwrap(); + fs::set_permissions(&metadata_path, fs::Permissions::from_mode(0o644)).unwrap(); + let candidate = backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.json"); + + assert_eq!( + quarantine_metadata_with( + &backend, + &metadata_path, + "corrupt", + |source, candidate| fs::hard_link(source, candidate), + |_source| Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected")), + ), + Err(AccountScopeError::QuarantineFailed) + ); + assert_eq!(fs::read(&metadata_path).unwrap(), original); + assert_eq!(unix_mode(&metadata_path), 0o600); + assert!(matches!( + fs::symlink_metadata(&candidate), + Err(error) if error.kind() == io::ErrorKind::NotFound + )); + assert!(!backend + .state + .lock() + .unwrap() + .events + .contains(&"sync-directory")); + + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn dangling_metadata_quarantine_collision_is_not_overwritten() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let backend = TestBackend::new("dangling-quarantine") + .with_installation_key(vec![0x11; INSTALLATION_KEY_BYTES]); + fs::create_dir_all(&backend.directory).unwrap(); + let metadata_path = backend.directory.join(METADATA_FILE); + let corrupt = b"corrupt-metadata-with-dangling-collision"; + fs::write(&metadata_path, corrupt).unwrap(); + fs::set_permissions(&metadata_path, fs::Permissions::from_mode(0o644)).unwrap(); + let collision = backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.json"); + let missing_target = backend + .directory + .with_extension("missing-quarantine-target"); + symlink(&missing_target, &collision).unwrap(); + + assert_eq!( + resolve_test(&backend, &Mutex::new(()), b"marker"), + Err(AccountScopeError::MetadataCorrupt) + ); + assert!(fs::symlink_metadata(&collision) + .unwrap() + .file_type() + .is_symlink()); + assert!(!missing_target.exists()); + let quarantined = backend + .directory + .join("quota-account-scope-v1.corrupt-1752710400.1.json"); + assert_eq!(fs::read(&quarantined).unwrap(), corrupt); + assert_eq!(unix_mode(&quarantined), 0o600); + assert!(!metadata_path.exists()); + assert!(!backend + .state + .lock() + .unwrap() + .events + .contains(&"create-temp")); + + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn restored_metadata_is_tightened_before_read_without_changing_bytes() { + use std::os::unix::fs::PermissionsExt as _; + + let backend = TestBackend::new("restored-mode"); + let lock = Mutex::new(()); + resolve_test(&backend, &lock, b"marker").unwrap(); + let metadata_path = backend.directory.join(METADATA_FILE); + let bytes = fs::read(&metadata_path).unwrap(); + fs::set_permissions(&metadata_path, fs::Permissions::from_mode(0o644)).unwrap(); + let key = installation_key(&backend); + + let payload = load_metadata(&backend, &backend.directory, &key).unwrap(); + assert_eq!(payload.bindings.len(), 1); + assert_eq!(fs::read(&metadata_path).unwrap(), bytes); + assert_eq!(unix_mode(&metadata_path), 0o600); + + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn symlinked_ancestor_is_allowed_when_final_account_directory_is_real() { + use std::os::unix::fs::symlink; + + let mut backend = TestBackend::new("ancestor-symlink"); + let seed = backend.directory.clone(); + let real_parent = seed.with_extension("real-parent"); + let linked_parent = seed.with_extension("linked-parent"); + backend.directory = linked_parent.join("com.nyanako.tokenbar"); + fs::create_dir(&real_parent).unwrap(); + symlink(&real_parent, &linked_parent).unwrap(); + + assert!(resolve_test(&backend, &Mutex::new(()), b"marker").is_ok()); + assert!(fs::symlink_metadata(&backend.directory) + .unwrap() + .file_type() + .is_dir()); + + fs::remove_file(linked_parent).unwrap(); + fs::remove_dir_all(real_parent).unwrap(); + } + + #[test] + fn refresh_lock_is_owner_only_and_failure_is_typed() { + let backend = TestBackend::new("refresh-lock"); + let directory = ensure_storage_dir(&backend).unwrap(); + let file = open_refresh_lock_file(&backend, &directory, "claude").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(directory.join("quota-auth-refresh-claude.lock")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + fs2::FileExt::unlock(&file).unwrap(); + backend.fail_fs(FsOperation::AcquireRefreshLock); + assert!(matches!( + open_refresh_lock_file(&backend, &directory, "claude"), + Err(AccountScopeError::MetadataLock) + )); + backend.cleanup(); + } + + #[cfg(unix)] + #[test] + fn refresh_lock_symlink_fails_closed_before_lock_acquisition() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let backend = TestBackend::new("refresh-lock-symlink"); + let directory = ensure_storage_dir(&backend).unwrap(); + let target = backend.directory.with_extension("external-refresh-lock"); + let lock_path = directory.join("quota-auth-refresh-claude.lock"); + let original = b"external-refresh-lock-target"; + fs::write(&target, original).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &lock_path).unwrap(); + + assert!(matches!( + open_refresh_lock_file(&backend, &directory, "claude"), + Err(AccountScopeError::MetadataLock) + )); + assert_eq!(fs::read(&target).unwrap(), original); + assert_eq!(unix_mode(&target), original_mode); + assert!(fs::symlink_metadata(&lock_path) + .unwrap() + .file_type() + .is_symlink()); + let events = backend.state.lock().unwrap().events.clone(); + assert!(events.contains(&"open-refresh-lock")); + assert!(!events.contains(&"acquire-refresh-lock")); + + backend.cleanup(); + fs::remove_file(target).unwrap(); + } + + #[test] + fn authenticated_binding_conflict_is_preserved_not_quarantined() { + let backend = TestBackend::new("binding-conflict"); + let lock = Mutex::new(()); + resolve_test(&backend, &lock, b"old").unwrap(); + let key = installation_key(&backend); + let directory = backend.directory.clone(); + let mut payload = decode_metadata(&key, &metadata_bytes(&backend)).unwrap(); + let mut duplicate = payload.bindings[0].clone(); + duplicate.random_lineage_id = URL_SAFE_NO_PAD.encode([0xEF; LINEAGE_ID_BYTES]); + payload.bindings.push(duplicate); + // Encode a valid-MAC envelope without running semantic validation. + let payload_bytes = serde_json::to_vec(&payload).unwrap(); + let metadata_key = metadata_mac_key(&key).unwrap(); + let mac = hmac_digest(&metadata_key, &[payload_bytes.as_slice()]).unwrap(); + let envelope = MetadataEnvelope { + schema_version: METADATA_SCHEMA_VERSION, + payload_bytes_base64: STANDARD.encode(&payload_bytes), + payload_mac: encode_digest(&mac), + }; + fs::write( + directory.join(METADATA_FILE), + serde_json::to_vec_pretty(&envelope).unwrap(), + ) + .unwrap(); + let before = metadata_bytes(&backend); + assert_eq!( + resolve_test(&backend, &lock, b"old"), + Err(AccountScopeError::MetadataConflict) + ); + assert_eq!(metadata_bytes(&backend), before); + assert!(!directory + .join(format!( + "quota-account-scope-v1.corrupt-{}.json", + backend.now_seconds() + )) + .exists()); + backend.cleanup(); + } + + #[test] + fn persisted_files_are_owner_only_and_contain_no_raw_or_plain_sha_values() { + let backend = TestBackend::new("raw-scan"); + let lock = Mutex::new(()); + let raw_values = [ + "fixture-secret-refresh-token", + "User.LowEntropy@example.com", + "/Users/fixture/private/auth.json", + "Fixture Display Label", + "Provider-Account-ID-ByteCase", + ]; + let credential_scope = resolve_credential_with( + &backend, + &lock, + "grok", + "auth-json", + raw_values[2], + raw_values[0].as_bytes(), + ) + .unwrap(); + let email_scope = resolve_authoritative_with( + &backend, + &lock, + "antigravity", + AuthoritativeIdKind::Email, + raw_values[1], + ) + .unwrap(); + let id_scope = resolve_authoritative_with( + &backend, + &lock, + "codex", + AuthoritativeIdKind::OpaqueId, + raw_values[4], + ) + .unwrap(); + let history = format!( + r#"{{"accountScopes":["{}","{}","{}"]}}"#, + credential_scope.as_str(), + email_scope.as_str(), + id_scope.as_str() + ); + fs::write(backend.directory.join(V3_HISTORY_FILE), history).unwrap(); + let metadata = metadata_bytes(&backend); + let key = installation_key(&backend); + decode_metadata(&key, &metadata).unwrap(); + let envelope: MetadataEnvelope = serde_json::from_slice(&metadata).unwrap(); + let decoded_payload = STANDARD + .decode(envelope.payload_bytes_base64.as_bytes()) + .unwrap(); + let files = [ + fs::read(installation_key_path(&backend)).unwrap(), + metadata, + decoded_payload, + fs::read(backend.directory.join(V3_HISTORY_FILE)).unwrap(), + ]; + for bytes in files { + for raw in raw_values { + let digest = Sha256::digest(raw.as_bytes()); + for forbidden in [ + raw.as_bytes().to_vec(), + format!("{digest:x}").into_bytes(), + STANDARD.encode(digest).into_bytes(), + URL_SAFE_NO_PAD.encode(digest).into_bytes(), + ] { + assert!(!bytes + .windows(forbidden.len()) + .any(|window| window == forbidden)); + } + } + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(&backend.directory) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + for path in [ + backend.directory.join(INSTALLATION_KEY_FILE), + backend.directory.join(METADATA_FILE), + backend.directory.join(METADATA_LOCK_FILE), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + backend.cleanup(); + } + + #[test] + fn reinstall_with_consistent_key_and_metadata_restores_the_same_scope() { + let backend = TestBackend::new("restore"); + let first = resolve_test(&backend, &Mutex::new(()), b"marker").unwrap(); + let restarted = backend.clone(); + let second = resolve_test(&restarted, &Mutex::new(()), b"marker").unwrap(); + assert_eq!(first, second); + backend.cleanup(); + } + + #[test] + fn account_scope_source_has_no_legacy_installation_key_query() { + let source = include_str!("agent_account_scope.rs"); + for forbidden in [ + ["com.nyanako.tokenbar.account-scope.", "v1"].concat(), + ["SecItem", "CopyMatching"].concat(), + ["SecItem", "Add"].concat(), + ] { + assert!(!source.contains(&forbidden), "{forbidden}"); + } + let manifest = include_str!("../Cargo.toml"); + for forbidden in [ + ["core-", "foundation"].concat(), + ["security-framework", "-sys"].concat(), + ] { + assert!(!manifest.contains(&forbidden), "{forbidden}"); + } + } +} diff --git a/crates/tb_core_ffi/src/agent_antigravity.rs b/crates/tb_core_ffi/src/agent_antigravity.rs index bc49799c..69366fb6 100644 --- a/crates/tb_core_ffi/src/agent_antigravity.rs +++ b/crates/tb_core_ffi/src/agent_antigravity.rs @@ -15,11 +15,15 @@ //! //! Both yield per-model "remaining fraction + reset" which map to `UsageWindow`s. +use crate::agent_account_scope::{ + self, AccountScope, AccountScopeError, AuthoritativeIdKind, RefreshCheckpoint, + RefreshScopeTransaction, +}; use crate::agent_usage::{clean_plan, parse_datetime, percent_encode, AgentIdentity, UsageWindow}; use chrono::{DateTime, Utc}; use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::BTreeSet; +use serde_json::{json, value::RawValue, Value}; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; @@ -32,6 +36,7 @@ const REFRESH_SAFETY_SECS: i64 = 60; pub(crate) struct Fetched { pub source: String, pub identity: Option, + pub account_scope: Result, pub windows: Vec, } @@ -80,8 +85,10 @@ async fn fetch_local_ide(now: DateTime) -> Result { // language-server ports use the language-server CSRF; the extension server // (if advertised) carries its own token. - let mut candidates: Vec<(u16, String)> = - ports.iter().map(|p| (*p, proc.csrf_token.clone())).collect(); + let mut candidates: Vec<(u16, String)> = ports + .iter() + .map(|p| (*p, proc.csrf_token.clone())) + .collect(); if let Some(port) = proc.extension_port { if let Some(csrf) = proc.extension_csrf.as_ref() { candidates.push((port, csrf.clone())); @@ -115,8 +122,9 @@ async fn fetch_local_ide(now: DateTime) -> Result { continue; }; match parse_user_status(&text, now) { - Ok(fetched) if !fetched.windows.is_empty() || fetched.identity.is_some() => { - return Ok(fetched) + Ok(mut fetched) if !fetched.windows.is_empty() || fetched.identity.is_some() => { + fetched.account_scope = resolve_local_account_scope(fetched.identity.as_ref()); + return Ok(fetched); } Ok(_) => last_err = "local API returned no model quotas".to_string(), Err(e) => last_err = e, @@ -152,7 +160,8 @@ fn detect_process() -> Result { return Ok(ProcInfo { pid, csrf_token: csrf, - extension_port: extract_flag(cmd, "--extension_server_port").and_then(|s| s.parse().ok()), + extension_port: extract_flag(cmd, "--extension_server_port") + .and_then(|s| s.parse().ok()), extension_csrf: extract_flag(cmd, "--extension_server_csrf_token"), }); } @@ -250,29 +259,44 @@ struct NamedTier { #[derive(Debug, Deserialize)] struct ModelConfigData { #[serde(rename = "clientModelConfigs")] - client_model_configs: Option>, + client_model_configs: Option>>, } #[derive(Debug, Deserialize)] -struct ModelConfig { - label: Option, - #[serde(rename = "modelOrAlias")] - model_or_alias: Option, - #[serde(rename = "quotaInfo")] - quota_info: Option, +struct AvailableModelsResponse { + #[serde(default)] + models: BTreeMap>, } #[derive(Debug, Deserialize)] -struct ModelAlias { - model: Option, +struct QuotaBucketsResponse { + #[serde(default)] + buckets: Vec>, } -#[derive(Debug, Deserialize)] -struct QuotaInfo { - #[serde(rename = "remainingFraction")] - remaining_fraction: Option, - #[serde(rename = "resetTime")] - reset_time: Option, +#[derive(Debug)] +struct ModelCandidate { + model_id: Option, + fraction: f64, + reset: Option>, + source_index: usize, + label: String, +} + +fn valid_remaining_fraction(fraction: f64) -> bool { + fraction.is_finite() && (0.0..=1.0).contains(&fraction) +} + +fn quota_window( + label: String, + fraction: f64, + reset: Option>, + now: DateTime, + card_id: String, + window_key: Option, +) -> Option { + UsageWindow::try_from_provider_fraction(label, fraction, reset, now) + .map(|window| window.with_identity(card_id, window_key, None, None)) } fn parse_user_status(body: &str, now: DateTime) -> Result { @@ -286,18 +310,86 @@ fn parse_user_status(body: &str, now: DateTime) -> Result .cascade_model_config_data .and_then(|d| d.client_model_configs) .unwrap_or_default(); - let windows: Vec = configs + let mut selected: BTreeMap = BTreeMap::new(); + let mut missing_model = Vec::new(); + for (index, config) in configs.into_iter().enumerate() { + let Ok(config) = serde_json::from_str::(config.get()) else { + continue; + }; + let Some(quota) = config.get("quotaInfo") else { + continue; + }; + let Some(fraction) = quota.get("remainingFraction").and_then(Value::as_f64) else { + continue; + }; + let reset = quota + .get("resetTime") + .and_then(Value::as_str) + .and_then(parse_datetime); + let model_id = config + .pointer("/modelOrAlias/model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_string); + let label = config + .get("label") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .or_else(|| model_id.clone()) + .unwrap_or_else(|| "Model".to_string()); + let candidate = ModelCandidate { + model_id: model_id.clone(), + fraction, + reset, + source_index: index, + label, + }; + let Some(model_id) = model_id else { + missing_model.push(candidate); + continue; + }; + match selected.get(&model_id) { + Some(current) + if !binding_candidate_is_better( + candidate.fraction, + candidate.reset, + candidate.source_index, + current.fraction, + current.reset, + current.source_index, + now, + ) => {} + _ => { + selected.insert(model_id, candidate); + } + } + } + let mut candidates: Vec = selected.into_values().collect(); + candidates.extend(missing_model); + candidates.sort_by_key(|candidate| candidate.source_index); + let windows: Vec = candidates .into_iter() - .filter_map(|config| { - let quota = config.quota_info?; - let fraction = quota.remaining_fraction?; - let reset = quota.reset_time.as_deref().and_then(parse_datetime); - let label = config - .label - .filter(|s| !s.trim().is_empty()) - .or_else(|| config.model_or_alias.and_then(|m| m.model)) - .unwrap_or_else(|| "Model".to_string()); - Some(UsageWindow::from_fraction(label, fraction, reset, now)) + .filter_map(|candidate| { + let (card_id, window_key) = match candidate.model_id { + Some(model_id) => { + let key = format!("model.{model_id}.v1"); + (key.clone(), Some(key)) + } + None => ( + format!("row.cli.config.{}.v1", candidate.source_index), + None, + ), + }; + quota_window( + candidate.label, + candidate.fraction, + candidate.reset, + now, + card_id, + window_key, + ) }) .collect(); @@ -305,18 +397,33 @@ fn parse_user_status(body: &str, now: DateTime) -> Result .user_tier .and_then(|t| t.name) .filter(|s| !s.trim().is_empty()) - .or_else(|| status.plan_status.and_then(|p| p.plan_info).and_then(local_plan_name)); + .or_else(|| { + status + .plan_status + .and_then(|p| p.plan_info) + .and_then(local_plan_name) + }); + let email = status.email.filter(|value| !value.trim().is_empty()); Ok(Fetched { source: "cli".to_string(), - identity: Some(AgentIdentity { - email: status.email.filter(|s| !s.trim().is_empty()), - plan, - }), + identity: Some(AgentIdentity { email, plan }), + // Parsing remains pure and hermetic. fetch_local_ide resolves this only + // after the authenticated loopback response has been accepted. + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows, }) } +fn resolve_local_account_scope( + identity: Option<&AgentIdentity>, +) -> Result { + let email = identity + .and_then(|identity| identity.email.as_deref()) + .ok_or(AccountScopeError::NoTrustedEvidence)?; + agent_account_scope::resolve_authoritative("antigravity", AuthoritativeIdKind::Email, email) +} + fn local_plan_name(info: LocalPlanInfo) -> Option { [ info.plan_display_name, @@ -334,30 +441,17 @@ fn local_plan_name(info: LocalPlanInfo) -> Option { async fn fetch_oauth_remote(now: DateTime) -> Result { let creds_path = gemini_home() - .map(|h| h.join("oauth_creds.json")) + .map(|home| home.join("oauth_creds.json")) .ok_or_else(|| "Could not resolve ~/.gemini".to_string())?; - let raw = std::fs::read_to_string(&creds_path) - .map_err(|_| "Antigravity not logged in (no ~/.gemini/oauth_creds.json)".to_string())?; - let mut creds: Value = - serde_json::from_str(&raw).map_err(|e| format!("decode oauth_creds.json: {e}"))?; - - let mut access_token = creds - .get("access_token") - .and_then(Value::as_str) - .map(str::to_string) - .filter(|s| !s.is_empty()) - .ok_or_else(|| "Antigravity creds have no access token".to_string())?; - - let expiry_ms = creds.get("expiry_date").and_then(Value::as_f64); - let now_ms = now.timestamp_millis() as f64; - if expiry_ms.is_none_or(|exp| exp <= now_ms + (REFRESH_SAFETY_SECS * 1000) as f64) { - let refresh_token = creds - .get("refresh_token") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .ok_or_else(|| "Antigravity access token expired and no refresh token".to_string())? - .to_string(); - access_token = refresh_access_token(&refresh_token, &mut creds, now, &creds_path).await?; + let mut creds = load_remote_credentials(&creds_path)?; + let mut access_token = remote_access_token(&creds)?; + let mut refreshed_scope = None; + + if remote_credentials_need_refresh(&creds, now) { + let refreshed = refresh_access_token(&creds_path, now).await?; + creds = refreshed.0; + access_token = refreshed.1; + refreshed_scope = Some(refreshed.2); } let client = reqwest::Client::builder() @@ -365,7 +459,7 @@ async fn fetch_oauth_remote(now: DateTime) -> Result { .build() .map_err(|e| format!("build Antigravity client: {e}"))?; - let code_assist = code_assist_post( + let code_assist_body = code_assist_post( &client, "loadCodeAssist", &json!({ @@ -374,25 +468,95 @@ async fn fetch_oauth_remote(now: DateTime) -> Result { &access_token, ) .await?; + let code_assist: Value = serde_json::from_str(&code_assist_body) + .map_err(|e| format!("decode Antigravity loadCodeAssist: {e}"))?; let project = project_id(&code_assist); let plan = resolve_remote_plan(&code_assist); - let windows = fetch_model_quotas(&client, &access_token, project.as_deref(), now).await?; - let email = gemini_active_email(); + let account_scope = + refreshed_scope.unwrap_or_else(|| resolve_remote_account_scope(&creds_path, &creds)); Ok(Fetched { source: "oauth".to_string(), - identity: Some(AgentIdentity { email, plan }), + // google_accounts.active is unrelated local state, not authenticated by + // the credential that fetched these quotas. It is neither presentation + // identity nor account-scope evidence for the remote route. + identity: Some(remote_identity(plan)), + account_scope, windows, }) } +fn remote_identity(plan: Option) -> AgentIdentity { + AgentIdentity { email: None, plan } +} + +fn load_remote_credentials(path: &Path) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|_| "Antigravity not logged in (no ~/.gemini/oauth_creds.json)".to_string())?; + serde_json::from_str(&raw).map_err(|e| format!("decode oauth_creds.json: {e}")) +} + +fn remote_access_token(creds: &Value) -> Result { + creds + .get("access_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .ok_or_else(|| "Antigravity creds have no access token".to_string()) +} + +fn remote_refresh_marker(creds: &Value) -> Option<&[u8]> { + creds + .get("refresh_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::as_bytes) +} + +fn remote_credentials_need_refresh(creds: &Value, now: DateTime) -> bool { + let expiry_ms = creds.get("expiry_date").and_then(Value::as_f64); + let now_ms = now.timestamp_millis() as f64; + expiry_ms.is_none_or(|expiry| expiry <= now_ms + (REFRESH_SAFETY_SECS * 1000) as f64) +} + +fn remote_scope_location(path: &Path) -> Result { + agent_account_scope::canonical_file_location(path, Some("refresh_token")) +} + +fn resolve_remote_account_scope( + path: &Path, + creds: &Value, +) -> Result { + let marker = remote_refresh_marker(creds).ok_or(AccountScopeError::NoTrustedEvidence)?; + agent_account_scope::resolve_credential( + "antigravity", + "google-oauth-creds", + &remote_scope_location(path)?, + marker, + ) +} + async fn refresh_access_token( - refresh_token: &str, - creds: &mut Value, - now: DateTime, creds_path: &Path, -) -> Result { + now: DateTime, +) -> Result<(Value, String, Result), String> { + let refresh = agent_account_scope::begin_refresh("antigravity") + .map_err(|_| "Antigravity credential refresh lock is unavailable.".to_string())?; + refresh_access_token_with( + creds_path, + now, + &refresh, + request_access_token, + |creds| write_creds_atomic(creds_path, creds), + |_| Ok(()), + ) + .await +} + +async fn request_access_token(refresh_token: String) -> Result { let client = resolve_oauth_client() .ok_or_else(|| "Antigravity OAuth client not found. Install Antigravity.app or set ANTIGRAVITY_OAUTH_CLIENT_ID/SECRET.".to_string())?; let http = reqwest::Client::builder() @@ -403,7 +567,7 @@ async fn refresh_access_token( "client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token", percent_encode(&client.0), percent_encode(&client.1), - percent_encode(refresh_token), + percent_encode(&refresh_token), ); let response = http .post(GOOGLE_TOKEN_URL) @@ -418,19 +582,54 @@ async fn refresh_access_token( if !response.status().is_success() { return Err("Antigravity token refresh rejected. Re-login in Antigravity.".to_string()); } - let json: Value = response + response .json() .await - .map_err(|e| format!("decode refresh response: {e}"))?; + .map_err(|e| format!("decode refresh response: {e}")) +} + +async fn refresh_access_token_with( + creds_path: &Path, + now: DateTime, + refresh: &R, + request: Request, + save: Save, + mut checkpoint: Checkpoint, +) -> Result<(Value, String, Result), String> +where + R: RefreshScopeTransaction + ?Sized, + Request: FnOnce(String) -> RequestFuture, + RequestFuture: std::future::Future>, + Save: FnOnce(&Value) -> std::io::Result<()>, + Checkpoint: FnMut(RefreshCheckpoint) -> Result<(), String>, +{ + let mut creds = load_remote_credentials(creds_path)?; + checkpoint(RefreshCheckpoint::Reloaded)?; + let location = remote_scope_location(creds_path) + .map_err(|_| "Antigravity auth location cannot be scoped safely.".to_string())?; + if !remote_credentials_need_refresh(&creds, Utc::now()) { + let access_token = remote_access_token(&creds)?; + let scope = match remote_refresh_marker(&creds) { + Some(marker) => refresh.resolve_current("google-oauth-creds", &location, marker), + None => Err(AccountScopeError::NoTrustedEvidence), + }; + return Ok((creds, access_token, scope)); + } + + let old_marker = remote_refresh_marker(&creds) + .ok_or_else(|| "Antigravity access token expired and no refresh token".to_string())? + .to_vec(); + let refresh_token = std::str::from_utf8(&old_marker) + .map_err(|_| "Antigravity refresh credential is not valid text.".to_string())? + .to_string(); + let json = request(refresh_token).await?; + checkpoint(RefreshCheckpoint::NetworkReturned)?; let access_token = json .get("access_token") .and_then(Value::as_str) .ok_or_else(|| "refresh response missing access_token".to_string())? .to_string(); - // Persist back to ~/.gemini/oauth_creds.json so we share a single source of - // truth with Antigravity. Preserve every original field; only touch the ones - // the refresh changed. A write failure is non-fatal (use the token in-memory). if let Some(obj) = creds.as_object_mut() { obj.insert("access_token".into(), Value::String(access_token.clone())); if let Some(expires_in) = json.get("expires_in").and_then(Value::as_f64) { @@ -440,21 +639,77 @@ async fn refresh_access_token( if let Some(id_token) = json.get("id_token").and_then(Value::as_str) { obj.insert("id_token".into(), Value::String(id_token.to_string())); } + if let Some(replacement) = json + .get("refresh_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + { + obj.insert( + "refresh_token".into(), + Value::String(replacement.to_string()), + ); + } + } + let new_marker = remote_refresh_marker(&creds); + let marker_rotated = new_marker.is_some_and(|marker| marker != old_marker.as_slice()); + let scope = match new_marker { + Some(new_marker) => { + refresh.transfer("google-oauth-creds", &location, &old_marker, new_marker) + } + None => Err(AccountScopeError::NoTrustedEvidence), + }; + checkpoint(RefreshCheckpoint::MetadataHandled)?; + // A rotated marker may reach disk only after its lineage transfer is durable. + // The refreshed access token remains usable in memory for this poll. + if marker_rotated && scope.is_err() { + return Ok((creds, access_token, scope)); } - let _ = write_creds_atomic(creds_path, creds); - Ok(access_token) + if let Err(error) = save(&creds) { + eprintln!("tb_core_ffi: failed to persist refreshed Antigravity credentials: {error}"); + } + checkpoint(RefreshCheckpoint::CredentialsPersisted)?; + Ok((creds, access_token, scope)) } fn write_creds_atomic(path: &Path, creds: &Value) -> std::io::Result<()> { - let data = serde_json::to_vec_pretty(creds).unwrap_or_default(); - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, &data)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); + use std::io::Write as _; + use std::sync::atomic::{AtomicU64, Ordering}; + + let data = serde_json::to_vec_pretty(creds).map_err(std::io::Error::other)?; + let directory = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "credential path has no parent", + ) + })?; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let tmp = directory.join(format!( + ".oauth_creds.json.tokenbar.{}.{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let staged = (|| { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&tmp)?; + file.write_all(&data)?; + file.sync_all() + })(); + if let Err(error) = staged { + let _ = std::fs::remove_file(&tmp); + return Err(error); } - std::fs::rename(&tmp, path) + if let Err(error) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(error); + } + std::fs::File::open(directory)?.sync_all() } async fn code_assist_post( @@ -462,7 +717,7 @@ async fn code_assist_post( method: &str, body: &Value, access_token: &str, -) -> Result { +) -> Result { let resp = client .post(format!("{CODE_ASSIST_BASE}:{method}")) .bearer_auth(access_token) @@ -482,9 +737,9 @@ async fn code_assist_post( if !status.is_success() { return Err(format!("Antigravity {method} returned {}", status.as_u16())); } - resp.json() + resp.text() .await - .map_err(|e| format!("decode Antigravity {method}: {e}")) + .map_err(|e| format!("read Antigravity {method}: {e}")) } async fn fetch_model_quotas( @@ -499,19 +754,14 @@ async fn fetch_model_quotas( }; // Primary: fetchAvailableModels (per-model quotaInfo). Fall back to // retrieveUserQuota buckets if the catalog endpoint is denied. - match code_assist_post(client, "fetchAvailableModels", &body, access_token).await { - Ok(value) => { - let windows = models_from_available(&value, now); - if windows.is_empty() { - let quota = code_assist_post(client, "retrieveUserQuota", &body, access_token).await?; - Ok(buckets_from_quota("a, now)) - } else { - Ok(windows) - } - } - Err(_) => { + match code_assist_post(client, "fetchAvailableModels", &body, access_token) + .await + .and_then(|body| models_from_available(&body, now)) + { + Ok(windows) if !windows.is_empty() => Ok(windows), + _ => { let quota = code_assist_post(client, "retrieveUserQuota", &body, access_token).await?; - Ok(buckets_from_quota("a, now)) + buckets_from_quota("a, now) } } } @@ -555,50 +805,107 @@ fn resolve_remote_plan(code_assist: &Value) -> Option { } } -fn models_from_available(value: &Value, now: DateTime) -> Vec { - let Some(models) = value.get("models").and_then(Value::as_object) else { - return Vec::new(); - }; - models - .iter() - .filter_map(|(id, model)| { - let quota = model.get("quotaInfo")?; - let fraction = quota.get("remainingFraction").and_then(Value::as_f64)?; - let reset = quota - .get("resetTime") - .and_then(Value::as_str) - .and_then(parse_datetime); - let label = model - .get("displayName") - .and_then(Value::as_str) - .filter(|s| !s.trim().is_empty()) - .or_else(|| { - model - .get("label") - .and_then(Value::as_str) - .filter(|s| !s.trim().is_empty()) - }) - .unwrap_or(id.as_str()) - .to_string(); - Some(UsageWindow::from_fraction(label, fraction, reset, now)) +fn models_from_available(body: &str, now: DateTime) -> Result, String> { + let response: AvailableModelsResponse = serde_json::from_str(body) + .map_err(|e| format!("decode Antigravity fetchAvailableModels: {e}"))?; + let mut selected: BTreeMap = BTreeMap::new(); + let mut missing_model = Vec::new(); + for (source_index, (raw_id, model)) in response.models.into_iter().enumerate() { + let Ok(model) = serde_json::from_str::(model.get()) else { + continue; + }; + let Some(quota) = model.get("quotaInfo") else { + continue; + }; + let Some(fraction) = quota.get("remainingFraction").and_then(Value::as_f64) else { + continue; + }; + let reset = quota + .get("resetTime") + .and_then(Value::as_str) + .and_then(parse_datetime); + let model_id = raw_id.trim().to_string(); + let model_id = (!model_id.is_empty()).then_some(model_id); + let label = model + .get("displayName") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + model + .get("label") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + }) + .unwrap_or(raw_id.as_str()) + .to_string(); + let candidate = ModelCandidate { + model_id: model_id.clone(), + fraction, + reset, + source_index, + label, + }; + let Some(model_id) = model_id else { + missing_model.push(candidate); + continue; + }; + match selected.get(&model_id) { + Some(current) + if !binding_candidate_is_better( + candidate.fraction, + candidate.reset, + candidate.source_index, + current.fraction, + current.reset, + current.source_index, + now, + ) => {} + _ => { + selected.insert(model_id, candidate); + } + } + } + + let mut candidates: Vec = selected.into_values().collect(); + candidates.extend(missing_model); + candidates.sort_by_key(|candidate| candidate.source_index); + Ok(candidates + .into_iter() + .filter_map(|candidate| { + let (card_id, window_key) = match candidate.model_id { + Some(model_id) => { + let key = format!("model.{model_id}.v1"); + (key.clone(), Some(key)) + } + None => (format!("row.models.{}.v1", candidate.source_index), None), + }; + quota_window( + candidate.label, + candidate.fraction, + candidate.reset, + now, + card_id, + window_key, + ) }) - .collect() + .collect()) } -fn buckets_from_quota(value: &Value, now: DateTime) -> Vec { - let Some(buckets) = value.get("buckets").and_then(Value::as_array) else { - return Vec::new(); - }; - // Keep the lowest remaining fraction per model (the binding limit). - let mut by_model: std::collections::BTreeMap>)> = - std::collections::BTreeMap::new(); - for bucket in buckets { - let Some(model) = bucket - .get("modelId") - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - else { +#[derive(Debug)] +struct QuotaBucketCandidate { + model_id: Option, + fraction: f64, + reset: Option>, + source_index: usize, +} + +fn buckets_from_quota(body: &str, now: DateTime) -> Result, String> { + let response: QuotaBucketsResponse = serde_json::from_str(body) + .map_err(|e| format!("decode Antigravity retrieveUserQuota: {e}"))?; + let mut selected: BTreeMap = BTreeMap::new(); + let mut missing = Vec::new(); + for (source_index, bucket) in response.buckets.into_iter().enumerate() { + let Ok(bucket) = serde_json::from_str::(bucket.get()) else { continue; }; let Some(fraction) = bucket.get("remainingFraction").and_then(Value::as_f64) else { @@ -608,19 +915,109 @@ fn buckets_from_quota(value: &Value, now: DateTime) -> Vec { .get("resetTime") .and_then(Value::as_str) .and_then(parse_datetime); - by_model - .entry(model.to_string()) - .and_modify(|cur| { - if fraction < cur.0 { - *cur = (fraction, reset); - } - }) - .or_insert((fraction, reset)); + let model_id = bucket + .get("modelId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_string); + let candidate = QuotaBucketCandidate { + model_id: model_id.clone(), + fraction, + reset, + source_index, + }; + let Some(model_id) = model_id else { + missing.push(candidate); + continue; + }; + match selected.get(&model_id) { + Some(current) if !bucket_candidate_is_better(&candidate, current, now) => {} + _ => { + selected.insert(model_id, candidate); + } + } } - by_model + + let mut chosen: Vec = selected.into_values().collect(); + chosen.extend(missing); + chosen.sort_by_key(|candidate| candidate.source_index); + Ok(chosen .into_iter() - .map(|(model, (fraction, reset))| UsageWindow::from_fraction(model, fraction, reset, now)) - .collect() + .filter_map(|candidate| { + let label = candidate + .model_id + .clone() + .unwrap_or_else(|| "Model".to_string()); + let (card_id, window_key) = match candidate.model_id { + Some(model_id) => { + let key = format!("model.{model_id}.v1"); + (key.clone(), Some(key)) + } + None => ( + format!("row.quota.bucket.{}.v1", candidate.source_index), + None, + ), + }; + quota_window( + label, + candidate.fraction, + candidate.reset, + now, + card_id, + window_key, + ) + }) + .collect()) +} + +fn bucket_candidate_is_better( + candidate: &QuotaBucketCandidate, + current: &QuotaBucketCandidate, + now: DateTime, +) -> bool { + binding_candidate_is_better( + candidate.fraction, + candidate.reset, + candidate.source_index, + current.fraction, + current.reset, + current.source_index, + now, + ) +} + +fn binding_candidate_is_better( + candidate_fraction: f64, + candidate_reset: Option>, + candidate_index: usize, + current_fraction: f64, + current_reset: Option>, + current_index: usize, + now: DateTime, +) -> bool { + match ( + valid_remaining_fraction(candidate_fraction), + valid_remaining_fraction(current_fraction), + ) { + (true, false) => return true, + (false, true) => return false, + _ => {} + } + match candidate_fraction.total_cmp(¤t_fraction) { + std::cmp::Ordering::Less => return true, + std::cmp::Ordering::Greater => return false, + std::cmp::Ordering::Equal => {} + } + let candidate_reset = candidate_reset.filter(|reset| *reset > now); + let current_reset = current_reset.filter(|reset| *reset > now); + match (candidate_reset, current_reset) { + (Some(candidate), Some(current)) if candidate != current => return candidate < current, + (Some(_), None) => return true, + (None, Some(_)) => return false, + _ => {} + } + candidate_index < current_index } // ── OAuth client discovery (scan installed Antigravity.app) ─────────────────── @@ -754,26 +1151,19 @@ fn gemini_home() -> Option { std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".gemini")) } -fn gemini_active_email() -> Option { - let path = gemini_home()?.join("google_accounts.json"); - let raw = std::fs::read_to_string(path).ok()?; - let json: Value = serde_json::from_str(&raw).ok()?; - json.get("active") - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; + use crate::agent_account_scope::test_support::TestRefreshScope; #[test] fn extracts_flags_both_forms() { let cmd = "/x/language_server --app_data_dir /Users/me/.gemini/antigravity --csrf_token=ABC123 --extension_server_port 4567"; assert_eq!(extract_flag(cmd, "--csrf_token").as_deref(), Some("ABC123")); - assert_eq!(extract_flag(cmd, "--extension_server_port").as_deref(), Some("4567")); + assert_eq!( + extract_flag(cmd, "--extension_server_port").as_deref(), + Some("4567") + ); assert!(is_language_server(&cmd.to_lowercase())); assert!(is_antigravity(&cmd.to_lowercase())); } @@ -790,7 +1180,10 @@ mod tests { let blob = b"junk\x00123-abcDEF_g.apps.googleusercontent.com\x00\x00GOCSPX-abcdefghijklmnopqrstuvwxyz12\x00tail"; let ids = scan_client_ids(blob); let secrets = scan_client_secrets(blob); - assert_eq!(ids, vec!["123-abcDEF_g.apps.googleusercontent.com".to_string()]); + assert_eq!( + ids, + vec!["123-abcDEF_g.apps.googleusercontent.com".to_string()] + ); assert_eq!(secrets.len(), 1); let client = preferred_client(&ids, &secrets).unwrap(); assert_eq!(client.0, "123-abcDEF_g.apps.googleusercontent.com"); @@ -799,9 +1192,15 @@ mod tests { #[test] fn prefers_last_id_when_single_secret() { - let ids = vec!["1-a.apps.googleusercontent.com".into(), "2-b.apps.googleusercontent.com".into()]; + let ids = vec![ + "1-a.apps.googleusercontent.com".into(), + "2-b.apps.googleusercontent.com".into(), + ]; let secrets = vec!["GOCSPX-only".into()]; - assert_eq!(preferred_client(&ids, &secrets).unwrap().0, "2-b.apps.googleusercontent.com"); + assert_eq!( + preferred_client(&ids, &secrets).unwrap().0, + "2-b.apps.googleusercontent.com" + ); } #[test] @@ -822,8 +1221,14 @@ mod tests { }"#; let fetched = parse_user_status(body, now).unwrap(); assert_eq!(fetched.source, "cli"); - assert_eq!(fetched.identity.as_ref().unwrap().email.as_deref(), Some("me@gmail.com")); - assert_eq!(fetched.identity.as_ref().unwrap().plan.as_deref(), Some("Pro")); + assert_eq!( + fetched.identity.as_ref().unwrap().email.as_deref(), + Some("me@gmail.com") + ); + assert_eq!( + fetched.identity.as_ref().unwrap().plan.as_deref(), + Some("Pro") + ); assert_eq!(fetched.windows.len(), 1); assert_eq!(fetched.windows[0].label_for_test(), "Gemini 3 Pro"); assert!((fetched.windows[0].remaining_for_test() - 42.0).abs() < 0.01); @@ -837,7 +1242,7 @@ mod tests { "gemini-3-pro": { "displayName": "Gemini 3 Pro", "quotaInfo": { "remainingFraction": 0.5 } } } }); - let w = models_from_available(&models, now); + let w = models_from_available(&models.to_string(), now).unwrap(); assert_eq!(w.len(), 1); let quota = json!({ @@ -846,14 +1251,573 @@ mod tests { { "modelId": "claude", "remainingFraction": 0.3 } ] }); - let b = buckets_from_quota("a, now); + let b = buckets_from_quota("a.to_string(), now).unwrap(); assert_eq!(b.len(), 1); assert!((b[0].remaining_for_test() - 30.0).abs() < 0.01); // lowest kept } + #[test] + fn stage4_antigravity_identity_and_duplicate_rules_are_deterministic() { + let now = DateTime::parse_from_rfc3339("2026-07-10T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let models = json!({ + "models": { + "model-A": { + "displayName": "Trimmed loser", + "quotaInfo": { "remainingFraction": 0.5, "resetTime": "2026-07-12T00:00:00Z" } + }, + " model-A ": { + "displayName": "Trimmed winner", + "quotaInfo": { "remainingFraction": 0.2, "resetTime": "2026-07-13T00:00:00Z" } + }, + "model-B": { + "displayName": "Shared label", + "quotaInfo": { "remainingFraction": 0.4, "resetTime": "2026-07-12T00:00:00Z" } + }, + "Model-Byte-Case": { + "displayName": "Shared label", + "quotaInfo": { "remainingFraction": 0.3, "resetTime": "2026-07-13T00:00:00Z" } + } + } + }); + let windows = models_from_available(&models.to_string(), now).unwrap(); + assert_eq!(windows.len(), 3); + let model_a = windows + .iter() + .find(|window| window.pace_window_key_for_test() == Some("model.model-A.v1")) + .unwrap(); + assert_eq!(model_a.label_for_test(), "Trimmed winner"); + assert!((model_a.remaining_for_test() - 20.0).abs() < 0.01); + assert_eq!( + windows + .iter() + .filter(|window| window.label_for_test() == "Shared label") + .count(), + 2, + "display labels never merge distinct model IDs" + ); + assert!(windows.iter().any(|window| { + window.pace_window_key_for_test() == Some("model.Model-Byte-Case.v1") + })); + + let cli = r#"{ + "userStatus": { + "cascadeModelConfigData": { + "clientModelConfigs": [ + { + "label": "CLI loser", + "modelOrAlias": { "model": " Model-X " }, + "quotaInfo": { "remainingFraction": 0.6, "resetTime": "2026-07-12T00:00:00Z" } + }, + { + "label": "CLI winner", + "modelOrAlias": { "model": "Model-X" }, + "quotaInfo": { "remainingFraction": 0.2, "resetTime": "2026-07-13T00:00:00Z" } + }, + { "label": "Config only", "quotaInfo": { "remainingFraction": 0.7 } } + ] + } + } + }"#; + let fetched = parse_user_status(cli, now).unwrap(); + assert_eq!(fetched.windows.len(), 2); + assert_eq!( + fetched.windows[0].pace_window_key_for_test(), + Some("model.Model-X.v1") + ); + assert_eq!(fetched.windows[0].label_for_test(), "CLI winner"); + let missing_wire = serde_json::to_value(&fetched.windows[1]).unwrap(); + assert_eq!(missing_wire["cardId"], "row.cli.config.2.v1"); + assert_eq!(missing_wire["paceStatus"]["reason"], "windowIdentity"); + + let missing_remote = models_from_available( + &json!({ + "models": { + " ": { + "displayName": "Remote model", + "quotaInfo": { "remainingFraction": 0.7 } + } + } + }) + .to_string(), + now, + ) + .unwrap(); + let wire = serde_json::to_value(&missing_remote[0]).unwrap(); + assert_eq!(wire["cardId"], "row.models.0.v1"); + assert_eq!(wire["paceStatus"]["reason"], "windowIdentity"); + + let missing_bucket = buckets_from_quota( + &json!({ + "buckets": [ + { "modelId": " ", "remainingFraction": 0.7 } + ] + }) + .to_string(), + now, + ) + .unwrap(); + let wire = serde_json::to_value(&missing_bucket[0]).unwrap(); + assert_eq!(wire["cardId"], "row.quota.bucket.0.v1"); + assert_eq!(wire["paceStatus"]["reason"], "windowIdentity"); + + let duplicate = json!({ + "buckets": [ + { + "modelId": "same-model", + "remainingFraction": 0.25, + "resetTime": "2026-07-09T00:00:00Z" + }, + { + "modelId": "same-model", + "remainingFraction": 0.25, + "resetTime": "2026-07-12T00:00:00Z" + }, + { + "modelId": "same-model", + "remainingFraction": 0.25, + "resetTime": "2026-07-11T00:00:00Z" + } + ] + }); + let selected = buckets_from_quota(&duplicate.to_string(), now).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!( + selected[0].resets_at_for_test(), + Some("2026-07-11T00:00:00.000Z"), + "future reset beats past reset, then earliest future reset wins" + ); + let same_reset = parse_datetime("2026-07-11T00:00:00Z"); + assert!(!binding_candidate_is_better( + 0.25, same_reset, 1, 0.25, same_reset, 0, now + )); + } + + #[test] + fn stage4_antigravity_rejects_invalid_fractions_at_every_source() { + assert!(!valid_remaining_fraction(f64::NAN)); + assert!(!valid_remaining_fraction(f64::INFINITY)); + assert!(!valid_remaining_fraction(-0.01)); + assert!(!valid_remaining_fraction(1.01)); + assert!(valid_remaining_fraction(0.0)); + assert!(valid_remaining_fraction(1.0)); + + let now = DateTime::parse_from_rfc3339("2026-07-10T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let assert_rows = |windows: Vec| { + assert_eq!(windows.len(), 1); + assert_eq!( + windows[0].pace_window_key_for_test(), + Some("model.valid.v1") + ); + for key in ["model.negative.v1", "model.over.v1"] { + assert!( + windows + .iter() + .all(|window| window.pace_window_key_for_test() != Some(key)), + "invalid quota row must not be published: {key}" + ); + } + }; + + let cli = r#"{ + "userStatus": { + "cascadeModelConfigData": { + "clientModelConfigs": [ + { + "modelOrAlias": { "model": "valid" }, + "quotaInfo": { "remainingFraction": 0.5 } + }, + { + "modelOrAlias": { "model": "negative" }, + "quotaInfo": { "remainingFraction": -0.1 } + }, + { + "modelOrAlias": { "model": "over" }, + "quotaInfo": { "remainingFraction": 1.1 } + }, + { + "modelOrAlias": { "model": "missing" }, + "quotaInfo": {} + } + ] + } + } + }"#; + assert_rows(parse_user_status(cli, now).unwrap().windows); + + let assert_overflowing_duplicate = |windows: Vec| { + assert_eq!(windows.len(), 1); + assert_eq!( + windows[0].pace_window_key_for_test(), + Some("model.same-model.v1") + ); + assert!((windows[0].remaining_for_test() - 50.0).abs() < 0.01); + }; + let overflowing_cli = r#"{ + "userStatus": { + "cascadeModelConfigData": { + "clientModelConfigs": [ + { + "modelOrAlias": { "model": "same-model" }, + "quotaInfo": { "remainingFraction": 1e400 } + }, + { + "modelOrAlias": { "model": "same-model" }, + "quotaInfo": { "remainingFraction": 0.5 } + } + ] + } + } + }"#; + assert_overflowing_duplicate(parse_user_status(overflowing_cli, now).unwrap().windows); + + assert_rows( + models_from_available( + &json!({ + "models": { + "valid": { "quotaInfo": { "remainingFraction": 0.5 } }, + "negative": { "quotaInfo": { "remainingFraction": -0.1 } }, + "over": { "quotaInfo": { "remainingFraction": 1.1 } }, + "missing": { "quotaInfo": {} } + } + }) + .to_string(), + now, + ) + .unwrap(), + ); + + let overflowing_models = r#"{ + "models": { + "same-model": { + "quotaInfo": { "remainingFraction": 1e400 } + }, + " same-model ": { + "quotaInfo": { "remainingFraction": 0.5 } + } + } + }"#; + assert_overflowing_duplicate(models_from_available(overflowing_models, now).unwrap()); + + assert_rows( + buckets_from_quota( + &json!({ + "buckets": [ + { "modelId": "valid", "remainingFraction": 0.5 }, + { "modelId": "negative", "remainingFraction": -0.1 }, + { "modelId": "over", "remainingFraction": 1.1 }, + { "modelId": "missing" } + ] + }) + .to_string(), + now, + ) + .unwrap(), + ); + + let overflowing_buckets = r#"{ + "buckets": [ + { "modelId": "same-model", "remainingFraction": 1e400 }, + { "modelId": "same-model", "remainingFraction": 0.5 } + ] + }"#; + assert_overflowing_duplicate(buckets_from_quota(overflowing_buckets, now).unwrap()); + + let malformed_cli_row = r#"{ + "userStatus": { + "cascadeModelConfigData": { + "clientModelConfigs": [ + { + "label": 1e400, + "modelOrAlias": { "model": "same-model" }, + "quotaInfo": { "remainingFraction": 0.4 } + }, + { + "modelOrAlias": { "model": "same-model" }, + "quotaInfo": { "remainingFraction": 0.5 } + } + ] + } + } + }"#; + assert_overflowing_duplicate(parse_user_status(malformed_cli_row, now).unwrap().windows); + + let malformed_model_row = r#"{ + "models": { + "same-model": { + "displayName": 1e400, + "quotaInfo": { "remainingFraction": 0.4 } + }, + " same-model ": { + "quotaInfo": { "remainingFraction": 0.5 } + } + } + }"#; + assert_overflowing_duplicate(models_from_available(malformed_model_row, now).unwrap()); + + let malformed_bucket_fields = r#"{ + "buckets": [ + { "modelId": 42, "remainingFraction": 0.4 }, + { "modelId": "valid", "remainingFraction": 0.5 } + ] + }"#; + let malformed_bucket_windows = buckets_from_quota(malformed_bucket_fields, now).unwrap(); + assert_eq!(malformed_bucket_windows.len(), 2); + assert!(malformed_bucket_windows + .iter() + .any(|window| window.pace_window_key_for_test() == Some("model.valid.v1"))); + let malformed_bucket_wire = serde_json::to_value(&malformed_bucket_windows[0]).unwrap(); + assert_eq!( + malformed_bucket_wire["paceStatus"]["reason"], + "windowIdentity" + ); + + assert!(quota_window( + "Non-finite".to_string(), + f64::NAN, + None, + now, + "model.non-finite.v1".to_string(), + Some("model.non-finite.v1".to_string()), + ) + .is_none()); + assert!(!binding_candidate_is_better( + -0.1, None, 1, 0.5, None, 0, now + )); + } + + #[test] + fn remote_scope_and_presentation_ignore_unbound_active_email() { + let stale_active_email = "stale-other-account@example.com"; + let credentials = json!({ + "access_token": "short-lived-access", + "refresh_token": "bound-google-refresh" + }); + assert_eq!( + remote_refresh_marker(&credentials), + Some(b"bound-google-refresh".as_slice()) + ); + assert_ne!( + remote_refresh_marker(&credentials), + Some(stale_active_email.as_bytes()) + ); + let identity = remote_identity(Some("Paid".to_string())); + assert_eq!(identity.email, None); + assert_eq!(identity.plan.as_deref(), Some("Paid")); + + let access_only = json!({ "access_token": "access-is-not-the-frozen-marker" }); + assert_eq!(remote_refresh_marker(&access_only), None); + } + + #[test] + fn local_route_fails_closed_without_authenticated_email() { + let fetched = parse_user_status( + r#"{"userStatus":{"cascadeModelConfigData":{"clientModelConfigs":[]}}}"#, + Utc::now(), + ) + .unwrap(); + assert_eq!( + fetched.account_scope, + Err(AccountScopeError::NoTrustedEvidence) + ); + } + #[test] fn resolves_remote_plan_from_tier() { - assert_eq!(resolve_remote_plan(&json!({"currentTier":{"id":"free-tier"}})).as_deref(), Some("Free")); - assert_eq!(resolve_remote_plan(&json!({"planInfo":{"planType":"standard"}})).as_deref(), Some("Standard")); + assert_eq!( + resolve_remote_plan(&json!({"currentTier":{"id":"free-tier"}})).as_deref(), + Some("Free") + ); + assert_eq!( + resolve_remote_plan(&json!({"planInfo":{"planType":"standard"}})).as_deref(), + Some("Standard") + ); + } + + fn checkpoint_at( + target: Option, + ) -> impl FnMut(RefreshCheckpoint) -> Result<(), String> { + move |checkpoint| { + if Some(checkpoint) == target { + Err("injected crash".to_string()) + } else { + Ok(()) + } + } + } + + async fn test_refresh_response(refresh_token: String) -> Result { + assert_eq!(refresh_token, "antigravity-old-refresh"); + Ok(json!({ + "access_token": "antigravity-new-access", + "refresh_token": "antigravity-new-refresh", + "expires_in": 3600 + })) + } + + fn setup_refresh(tag: &str) -> (TestRefreshScope, PathBuf, AccountScope, Vec, String) { + let scope = TestRefreshScope::new("antigravity", tag); + let path = scope.root().join("antigravity/oauth_creds.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "access_token": "antigravity-old-access", + "refresh_token": "antigravity-old-refresh", + "expiry_date": 0 + })) + .unwrap(), + ) + .unwrap(); + let location = remote_scope_location(&path).unwrap(); + let old_scope = scope + .resolve_current("google-oauth-creds", &location, b"antigravity-old-refresh") + .unwrap(); + let metadata = scope.metadata_bytes(); + (scope, path, old_scope, metadata, location) + } + + async fn run_refresh( + scope: &TestRefreshScope, + path: &Path, + crash: Option, + ) -> Result<(Value, String, Result), String> { + let now = DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + refresh_access_token_with( + path, + now, + scope, + test_refresh_response, + |creds| write_creds_atomic(path, creds), + checkpoint_at(crash), + ) + .await + } + + fn stored_refresh_token(path: &Path) -> String { + let credentials = load_remote_credentials(path).unwrap(); + std::str::from_utf8(remote_refresh_marker(&credentials).unwrap()) + .unwrap() + .to_string() + } + + #[tokio::test] + async fn refresh_crash_boundaries_and_scope_gate_use_production_sequence() { + for boundary in [ + RefreshCheckpoint::Reloaded, + RefreshCheckpoint::NetworkReturned, + RefreshCheckpoint::MetadataHandled, + RefreshCheckpoint::CredentialsPersisted, + ] { + let (scope, path, old_scope, before, location) = setup_refresh("antigravity-crash"); + assert_eq!( + run_refresh(&scope, &path, Some(boundary)) + .await + .unwrap_err(), + "injected crash" + ); + assert_eq!( + stored_refresh_token(&path), + if boundary == RefreshCheckpoint::CredentialsPersisted { + "antigravity-new-refresh" + } else { + "antigravity-old-refresh" + } + ); + if matches!( + boundary, + RefreshCheckpoint::Reloaded | RefreshCheckpoint::NetworkReturned + ) { + assert_eq!(scope.metadata_bytes(), before); + } else { + assert_ne!(scope.metadata_bytes(), before); + assert_eq!( + scope + .resolve_current( + "google-oauth-creds", + &location, + b"antigravity-old-refresh", + ) + .unwrap(), + old_scope + ); + assert_eq!( + scope + .resolve_current( + "google-oauth-creds", + &location, + b"antigravity-new-refresh", + ) + .unwrap(), + old_scope + ); + } + scope.cleanup(); + } + + let (scope, path, old_scope, before, location) = setup_refresh("antigravity-metadata-fail"); + scope.fail_metadata_save(); + let (refreshed, access_token, scope_outcome) = + run_refresh(&scope, &path, None).await.unwrap(); + assert_eq!(access_token, "antigravity-new-access"); + assert_eq!(remote_access_token(&refreshed).unwrap(), access_token); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + assert_eq!(stored_refresh_token(&path), "antigravity-old-refresh"); + assert_eq!( + scope + .resolve_current("google-oauth-creds", &location, b"antigravity-old-refresh",) + .unwrap(), + old_scope + ); + scope.cleanup(); + + let (scope, path, _old_scope, before, _) = + setup_refresh("antigravity-metadata-fail-unchanged"); + scope.fail_metadata_save(); + let now = DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let save_path = path.clone(); + let (refreshed, access_token, scope_outcome) = refresh_access_token_with( + &path, + now, + &scope, + |refresh_token| async move { + assert_eq!(refresh_token, "antigravity-old-refresh"); + Ok(json!({ + "access_token": "antigravity-new-access", + "expires_in": 3600 + })) + }, + move |credentials| write_creds_atomic(&save_path, credentials), + checkpoint_at(None), + ) + .await + .unwrap(); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + assert_eq!(access_token, "antigravity-new-access"); + assert_eq!(remote_access_token(&refreshed).unwrap(), access_token); + let persisted = load_remote_credentials(&path).unwrap(); + assert_eq!(remote_access_token(&persisted).unwrap(), access_token); + assert_eq!(stored_refresh_token(&path), "antigravity-old-refresh"); + scope.cleanup(); + + let (scope, path, old_scope, _, location) = setup_refresh("antigravity-success"); + let (_, _, scope_outcome) = run_refresh(&scope, &path, None).await.unwrap(); + assert_eq!(scope_outcome.unwrap(), old_scope); + assert_eq!( + scope + .resolve_current("google-oauth-creds", &location, b"antigravity-new-refresh",) + .unwrap(), + old_scope + ); + scope.cleanup(); } } diff --git a/crates/tb_core_ffi/src/agent_copilot.rs b/crates/tb_core_ffi/src/agent_copilot.rs index 9a4ea2aa..2d114b29 100644 --- a/crates/tb_core_ffi/src/agent_copilot.rs +++ b/crates/tb_core_ffi/src/agent_copilot.rs @@ -6,7 +6,10 @@ //! stored for its Copilot login (`~/.local/share/opencode/auth.json`), so the //! card appears whenever Copilot is signed in there. Maps to `UsageWindow`s. +use crate::agent_account_scope::{self, AccountScope, AccountScopeError}; +use crate::agent_quota_duration::{copilot_calendar_duration, DurationEvidence}; use crate::agent_usage::{clean_plan, AgentIdentity, UsageWindow}; +use crate::opencode_integrations::GitHubCopilotCredential; use chrono::{DateTime, NaiveDate, TimeZone, Utc}; use serde::Deserialize; @@ -14,6 +17,7 @@ const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user"; pub(crate) struct CopilotData { pub identity: Option, + pub account_scope: Result, pub windows: Vec, } @@ -29,9 +33,15 @@ struct CopilotUser { #[derive(Debug, Deserialize)] struct QuotaSnapshots { - #[serde(default)] + #[serde( + default, + deserialize_with = "crate::agent_usage::deserialize_optional_raw" + )] premium_interactions: Option, - #[serde(default)] + #[serde( + default, + deserialize_with = "crate::agent_usage::deserialize_optional_raw" + )] chat: Option, } @@ -45,17 +55,20 @@ struct QuotaSnapshot { percent_remaining: Option, } -pub(crate) async fn fetch(now: DateTime) -> Result { - let token = crate::opencode_integrations::github_copilot_token() - .ok_or_else(|| "GitHub Copilot not signed in (no opencode github-copilot auth).".to_string())?; - +pub(crate) async fn fetch( + now: DateTime, + credential: GitHubCopilotCredential, +) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| format!("build Copilot client: {e}"))?; let response = client .get(COPILOT_USAGE_URL) - .header(reqwest::header::AUTHORIZATION, format!("token {token}")) + .header( + reqwest::header::AUTHORIZATION, + format!("token {}", credential.request_token), + ) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, "GitHubCopilotChat/0.26.7") .header("Editor-Version", "vscode/1.96.2") @@ -79,50 +92,101 @@ pub(crate) async fn fetch(now: DateTime) -> Result { let usage: CopilotUser = serde_json::from_str(&body).map_err(|e| format!("decode Copilot usage: {e}"))?; - let resets_at = usage - .quota_reset_date - .as_deref() - .and_then(parse_reset_date); - let snapshots = usage.quota_snapshots; + let (plan, windows) = map_user(usage, now); + + let account_scope = agent_account_scope::resolve_credential( + "copilot", + credential.semantic_source, + &credential.canonical_location, + &credential.marker, + ); + Ok(CopilotData { + identity: Some(AgentIdentity { email: None, plan }), + account_scope, + windows, + }) +} + +fn map_user(usage: CopilotUser, now: DateTime) -> (Option, Vec) { + let resets_at = usage.quota_reset_date.as_deref().and_then(parse_reset_date); let mut windows = Vec::new(); - if let Some(snapshots) = snapshots { - if let Some(window) = snapshot_window("Premium", snapshots.premium_interactions, resets_at, now) { + if let Some(snapshots) = usage.quota_snapshots { + if let Some(window) = snapshot_window_with_identity( + "Premium", + "premium_interactions.v1", + snapshots.premium_interactions, + resets_at, + now, + ) { windows.push(window); } - if let Some(window) = snapshot_window("Chat", snapshots.chat, resets_at, now) { + if let Some(window) = + snapshot_window_with_identity("Chat", "chat.v1", snapshots.chat, resets_at, now) + { windows.push(window); } } - - Ok(CopilotData { - identity: Some(AgentIdentity { - email: None, - plan: usage.copilot_plan.filter(|s| !s.trim().is_empty()).map(clean_plan), - }), - windows, - }) + let plan = usage + .copilot_plan + .filter(|plan| !plan.trim().is_empty()) + .map(clean_plan); + (plan, windows) } -fn snapshot_window( +fn snapshot_window_with_identity( label: &str, + window_key: &str, snapshot: Option, resets_at: Option>, now: DateTime, ) -> Option { let snapshot = snapshot?; // Skip explicit zero-entitlement placeholders (no usable quota signal). - if snapshot.entitlement == 0.0 && snapshot.remaining == 0.0 && snapshot.percent_remaining.is_none() { + if snapshot.entitlement == 0.0 + && snapshot.remaining == 0.0 + && snapshot.percent_remaining.is_none() + { return None; } - let percent_remaining = snapshot.percent_remaining.or_else(|| { - (snapshot.entitlement > 0.0).then(|| (snapshot.remaining / snapshot.entitlement) * 100.0) - })?; - Some(UsageWindow::from_fraction( - label.to_string(), - percent_remaining / 100.0, - resets_at, - now, - )) + let percent_remaining = snapshot + .percent_remaining + .or_else(|| { + (snapshot.entitlement > 0.0) + .then(|| (snapshot.remaining / snapshot.entitlement) * 100.0) + }) + .filter(|percent| percent.is_finite() && (0.0..=100.0).contains(percent))?; + let contract_duration = resets_at + .and_then(|reset| copilot_calendar_duration(reset.timestamp())) + .map(DurationEvidence::contract); + Some( + UsageWindow::from_provider_used_percent( + label.to_string(), + 100.0 - percent_remaining, + resets_at, + now, + ) + .with_identity( + window_key, + Some(window_key.to_string()), + None, + contract_duration, + ), + ) +} + +#[cfg(test)] +fn snapshot_window( + label: &str, + snapshot: Option, + resets_at: Option>, + now: DateTime, +) -> Option { + let window_key = match label { + "Premium" => "premium_interactions.v1", + "Chat" => "chat.v1", + _ => "row.copilot.unknown.v1", + }; + snapshot_window_with_identity(label, window_key, snapshot, resets_at, now) } /// Copilot reports `quota_reset_date` as a bare `YYYY-MM-DD`; treat it as UTC midnight. @@ -154,6 +218,132 @@ mod tests { assert!(snapshot_window("Chat", snaps.chat, None, now).is_none()); } + #[test] + fn stage4_copilot_maps_shared_reset_to_both_quota_cards() { + let now = Utc.timestamp_opt(1_751_328_000, 0).single().unwrap(); + let usage: CopilotUser = serde_json::from_str( + r#"{ + "copilot_plan": "individual", + "quota_reset_date": "2026-08-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 90, + "percent_remaining": 30 + }, + "chat": { + "entitlement": 100, + "remaining": 75 + } + } + }"#, + ) + .unwrap(); + let (plan, windows) = map_user(usage, now); + assert_eq!(plan.as_deref(), Some("Individual")); + assert_eq!(windows.len(), 2); + let premium = &windows[0]; + let chat = &windows[1]; + + assert_eq!(premium.label_for_test(), "Premium"); + assert_eq!(chat.label_for_test(), "Chat"); + assert_eq!( + premium.resets_at_for_test(), + Some("2026-08-01T00:00:00.000Z") + ); + assert_eq!(chat.resets_at_for_test(), premium.resets_at_for_test()); + assert_eq!( + premium.window_minutes_for_test(), + Some(44_640), + "first-of-month reset uses the exact preceding calendar month" + ); + assert_eq!(chat.window_minutes_for_test(), Some(44_640)); + assert_eq!( + premium.pace_window_key_for_test(), + Some("premium_interactions.v1") + ); + assert_eq!(chat.pace_window_key_for_test(), Some("chat.v1")); + for window in &windows { + let wire = serde_json::to_value(window).unwrap(); + assert_eq!(wire["paceStatus"]["durationSource"], "contract"); + assert_eq!(wire["paceStatus"]["durationSeconds"], 2_678_400); + } + + let non_calendar_reset = parse_reset_date("2026-08-15").unwrap(); + let observed = snapshot_window( + "Premium", + Some(QuotaSnapshot { + entitlement: 300.0, + remaining: 90.0, + percent_remaining: Some(30.0), + }), + Some(non_calendar_reset), + now, + ) + .unwrap(); + assert_eq!( + observed.window_minutes_for_test(), + None, + "copilot.premium.observed-fallback" + ); + } + + #[test] + fn rejects_invalid_remaining_percentages_before_wire() { + let now = Utc::now(); + assert!(snapshot_window( + "Premium", + Some(QuotaSnapshot { + entitlement: 300.0, + remaining: 90.0, + percent_remaining: Some(101.0), + }), + None, + now, + ) + .is_none()); + assert!(snapshot_window( + "Chat", + Some(QuotaSnapshot { + entitlement: 100.0, + remaining: f64::NAN, + percent_remaining: None, + }), + None, + now, + ) + .is_none()); + } + + #[test] + fn malformed_snapshot_percentage_does_not_poison_valid_sibling() { + let now = Utc.timestamp_opt(1_751_328_000, 0).single().unwrap(); + for invalid in ["1e400", r#""NaN""#] { + let usage: CopilotUser = serde_json::from_str(&format!( + r#"{{ + "quota_reset_date": "2026-08-01", + "quota_snapshots": {{ + "premium_interactions": {{ + "entitlement": 300, + "remaining": 90, + "percent_remaining": {invalid} + }}, + "chat": {{ + "entitlement": 100, + "remaining": 75, + "percent_remaining": 75 + }} + }} + }}"# + )) + .unwrap(); + let (_, windows) = map_user(usage, now); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].label_for_test(), "Chat"); + assert!((windows[0].remaining_for_test() - 75.0).abs() < 0.01); + } + } + #[test] fn parses_reset_date() { assert!(parse_reset_date("2026-07-01").is_some()); diff --git a/crates/tb_core_ffi/src/agent_grok.rs b/crates/tb_core_ffi/src/agent_grok.rs index d152cef0..ee6f2c4b 100644 --- a/crates/tb_core_ffi/src/agent_grok.rs +++ b/crates/tb_core_ffi/src/agent_grok.rs @@ -11,10 +11,14 @@ //! `creditUsagePercent`. Omit the card entirely when no Grok auth is on disk //! (same stance as Copilot). +use crate::agent_account_scope::{ + self, AccountScope, AccountScopeError, RefreshCheckpoint, RefreshScopeTransaction, +}; +use crate::agent_quota_duration::DurationEvidence; use crate::agent_usage::{AgentIdentity, UsageWindow}; use chrono::{DateTime, SecondsFormat, Utc}; use serde::Deserialize; -use serde_json::Value; +use serde_json::{value::RawValue, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -25,6 +29,7 @@ const ACCESS_SKEW_SECS: i64 = 120; pub(crate) struct GrokData { pub identity: Option, + pub account_scope: Result, pub windows: Vec, } @@ -41,6 +46,28 @@ struct GrokCredentials { raw_json: Value, } +impl GrokCredentials { + fn scope_marker(&self) -> Option<&[u8]> { + (!self.refresh_token.is_empty()).then_some(self.refresh_token.as_bytes()) + } + + fn scope_location(&self) -> Result { + agent_account_scope::canonical_file_location(&self.auth_path, Some(&self.entry_key)) + } + + fn resolve_account_scope(&self) -> Result { + let marker = self + .scope_marker() + .ok_or(AccountScopeError::NoTrustedEvidence)?; + agent_account_scope::resolve_credential( + "grok", + "grok-auth-json", + &self.scope_location()?, + marker, + ) + } +} + #[derive(Debug, Deserialize)] struct BillingResponse { #[serde(default)] @@ -56,7 +83,7 @@ struct BillingConfig { #[serde(default)] current_period: Option, #[serde(default)] - credit_usage_percent: Option, + credit_usage_percent: Option>, #[serde(default)] product_usage: Option>, #[serde(default)] @@ -81,7 +108,7 @@ struct ProductUsage { #[serde(default)] product: Option, #[serde(default)] - usage_percent: Option, + usage_percent: Option>, } #[derive(Debug, Deserialize)] @@ -109,8 +136,12 @@ async fn fetch_with_credentials( mut credentials: GrokCredentials, now: DateTime, ) -> Result { + let mut refreshed_scope = None; if credentials_needs_refresh(&credentials, now) { - credentials = refresh_credentials(credentials).await?; + let refreshed = + refresh_credentials(&credentials.auth_path, &credentials.entry_key, false).await?; + credentials = refreshed.0; + refreshed_scope = merge_refreshed_scope(refreshed_scope, refreshed.1); } let client = reqwest::Client::builder() @@ -137,7 +168,10 @@ async fn fetch_with_credentials( // One retry after a forced refresh in case the access token was revoked // mid-window while the refresh token still works. if !credentials.refresh_token.is_empty() { - credentials = refresh_credentials(credentials).await?; + let refreshed = + refresh_credentials(&credentials.auth_path, &credentials.entry_key, true).await?; + credentials = refreshed.0; + refreshed_scope = merge_refreshed_scope(refreshed_scope, refreshed.1); let retry = client .get(GROK_BILLING_URL) .bearer_auth(&credentials.access_token) @@ -157,7 +191,9 @@ async fn fetch_with_credentials( retry_status.as_u16() )); } - return map_billing(&retry_body, &credentials, now); + let account_scope = + refreshed_scope.unwrap_or_else(|| credentials.resolve_account_scope()); + return map_billing(&retry_body, &credentials, now, account_scope); } return Err("Grok OAuth token expired or invalid. Run `grok` to log in again.".to_string()); } @@ -165,13 +201,30 @@ async fn fetch_with_credentials( return Err(format!("Grok billing API returned {}.", status.as_u16())); } - map_billing(&body, &credentials, now) + let account_scope = refreshed_scope.unwrap_or_else(|| credentials.resolve_account_scope()); + map_billing(&body, &credentials, now, account_scope) +} + +fn merge_refreshed_scope( + current: Option>, + next: Result, +) -> Option> { + Some(match current { + None => next, + Some(Err(first_error)) => Err(first_error), + Some(Ok(current_scope)) => match next { + Err(error) => Err(error), + Ok(next_scope) if next_scope == current_scope => Ok(current_scope), + Ok(_) => Err(AccountScopeError::MetadataConflict), + }, + }) } fn map_billing( body: &str, credentials: &GrokCredentials, now: DateTime, + account_scope: Result, ) -> Result { let payload: BillingResponse = serde_json::from_str(body).map_err(|e| format!("decode Grok billing response: {e}"))?; @@ -183,9 +236,31 @@ fn map_billing( "Grok billing response has no creditUsagePercent or GrokBuild usage.".to_string() })?; - let (label, resets_at, window_minutes) = period_meta(&config); - let window = - UsageWindow::from_used_percent(label, used_percent, resets_at, now, window_minutes); + let period = period_details(&config); + let mut window = match period.kind { + Some((label, window_key)) => UsageWindow::from_provider_used_percent( + label.to_string(), + used_percent, + period.end, + now, + ) + .with_identity( + window_key, + Some(window_key.to_string()), + period.duration, + None, + ), + None => UsageWindow::from_provider_used_percent( + "Unknown".to_string(), + used_percent, + period.end, + now, + ) + .with_identity("row.billing.unknown.v1", None, None, None), + }; + if period.invalid_evidence && period.kind.is_some() { + window.unavailable("invalidEvidence"); + } Ok(GrokData { identity: Some(AgentIdentity { @@ -195,6 +270,7 @@ fn map_billing( .filter(|s| !s.trim().is_empty()) .map(|s| s.trim().to_string()), }), + account_scope, windows: vec![window], }) } @@ -204,51 +280,73 @@ fn used_percent_from_config(config: &BillingConfig) -> Option { for product in products { let name = product.product.as_deref().unwrap_or(""); if name.eq_ignore_ascii_case("GrokBuild") { - if let Some(pct) = product.usage_percent { - return Some(pct); + if let Some(usage_percent) = product.usage_percent.as_deref() { + return valid_percentage(usage_percent); } } } } - config.credit_usage_percent + config + .credit_usage_percent + .as_deref() + .and_then(valid_percentage) +} + +fn valid_percentage(raw: &RawValue) -> Option { + serde_json::from_str::(raw.get()) + .ok() + .filter(|pct| pct.is_finite() && (0.0..=100.0).contains(pct)) +} + +struct PeriodMeta { + kind: Option<(&'static str, &'static str)>, + end: Option>, + duration: Option, + invalid_evidence: bool, } -fn period_meta(config: &BillingConfig) -> (String, Option>, Option) { - let period_type = config - .current_period - .as_ref() - .and_then(|p| p.period_type.as_deref()) +fn period_details(config: &BillingConfig) -> PeriodMeta { + let period = config.current_period.as_ref(); + let period_type = period + .and_then(|period| period.period_type.as_deref()) .unwrap_or(""); - let label = if period_type.contains("WEEKLY") { - "Weekly".to_string() - } else if period_type.contains("MONTHLY") { - "Monthly".to_string() + let kind = if period_type.to_ascii_uppercase().contains("WEEKLY") { + Some(("Weekly", "billing.weekly.v1")) + } else if period_type.to_ascii_uppercase().contains("MONTHLY") { + Some(("Monthly", "billing.monthly.v1")) } else { - "Weekly".to_string() + None }; - let start = config - .current_period - .as_ref() - .and_then(|p| p.start.as_deref()) - .or(config.billing_period_start.as_deref()) - .and_then(parse_timestamp); - let end = config - .current_period - .as_ref() - .and_then(|p| p.end.as_deref()) - .or(config.billing_period_end.as_deref()) - .and_then(parse_timestamp); - - let window_minutes = match (start, end) { - (Some(s), Some(e)) => { - let mins = (e - s).num_minutes(); - (mins > 0).then_some(mins) - } - _ => None, + let start_raw = period + .and_then(|period| period.start.as_deref()) + .or(config.billing_period_start.as_deref()); + let end_raw = period + .and_then(|period| period.end.as_deref()) + .or(config.billing_period_end.as_deref()); + let start = start_raw.and_then(parse_timestamp); + let end = end_raw.and_then(parse_timestamp); + let (duration, invalid_evidence) = match (start_raw, end_raw, start, end) { + (Some(_), Some(_), Some(start), Some(end)) if end > start => ( + Some(DurationEvidence::provider( + end.timestamp(), + (end - start).num_seconds(), + )), + false, + ), + (Some(_), Some(_), Some(_), Some(_)) => (None, true), + (Some(_), Some(_), _, _) => (None, true), + (Some(_), None, _, _) => (None, false), + (None, Some(_), _, Some(_)) => (None, false), + (None, Some(_), _, None) => (None, true), + _ => (None, false), }; - - (label, end, window_minutes) + PeriodMeta { + kind, + end, + duration, + invalid_evidence, + } } fn parse_timestamp(value: &str) -> Option> { @@ -273,25 +371,37 @@ fn credentials_needs_refresh(credentials: &GrokCredentials, now: DateTime) } } -async fn refresh_credentials(mut credentials: GrokCredentials) -> Result { - if credentials.refresh_token.trim().is_empty() { - return Err( - "Grok OAuth token needs refresh but auth.json has no refresh token.".to_string(), - ); - } - if credentials.client_id.trim().is_empty() { - return Err("Grok auth.json is missing oidc_client_id.".to_string()); - } +async fn refresh_credentials( + auth_path: &Path, + entry_key: &str, + force: bool, +) -> Result<(GrokCredentials, Result), String> { + let refresh = agent_account_scope::begin_refresh("grok") + .map_err(|_| "Grok credential refresh lock is unavailable.".to_string())?; + refresh_credentials_with( + auth_path, + entry_key, + force, + &refresh, + request_refresh, + save_credentials, + |_| Ok(()), + ) + .await +} +async fn request_refresh( + refresh_token: String, + client_id: String, +) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| format!("build Grok token client: {e}"))?; - let form = [ ("grant_type", "refresh_token"), - ("refresh_token", credentials.refresh_token.as_str()), - ("client_id", credentials.client_id.as_str()), + ("refresh_token", refresh_token.as_str()), + ("client_id", client_id.as_str()), ] .iter() .map(|(k, v)| { @@ -329,26 +439,86 @@ async fn refresh_credentials(mut credentials: GrokCredentials) -> Result( + auth_path: &Path, + entry_key: &str, + force: bool, + refresh: &R, + request: Request, + save: Save, + mut checkpoint: Checkpoint, +) -> Result<(GrokCredentials, Result), String> +where + R: RefreshScopeTransaction + ?Sized, + Request: FnOnce(String, String) -> RequestFuture, + RequestFuture: std::future::Future>, + Save: FnOnce(&GrokCredentials) -> Result<(), String>, + Checkpoint: FnMut(RefreshCheckpoint) -> Result<(), String>, +{ + let mut credentials = load_credentials_entry_from(auth_path, Some(entry_key))? + .ok_or_else(|| "Grok auth entry disappeared during refresh.".to_string())?; + checkpoint(RefreshCheckpoint::Reloaded)?; + if !force && !credentials_needs_refresh(&credentials, Utc::now()) { + let scope = refresh.resolve_current( + "grok-auth-json", + &credentials + .scope_location() + .map_err(|_| "Grok auth location cannot be scoped safely.".to_string())?, + credentials.refresh_token.as_bytes(), + ); + return Ok((credentials, scope)); + } + if credentials.refresh_token.trim().is_empty() { + return Err( + "Grok OAuth token needs refresh but auth.json has no refresh token.".to_string(), + ); + } + if credentials.client_id.trim().is_empty() { + return Err("Grok auth.json is missing oidc_client_id.".to_string()); + } + + let old_marker = credentials.refresh_token.as_bytes().to_vec(); + let tokens = request( + credentials.refresh_token.clone(), + credentials.client_id.clone(), + ) + .await?; + checkpoint(RefreshCheckpoint::NetworkReturned)?; credentials.access_token = tokens.access_token; - if let Some(refresh) = tokens.refresh_token.filter(|s| !s.trim().is_empty()) { - credentials.refresh_token = refresh; + if let Some(refresh_token) = tokens.refresh_token.filter(|s| !s.trim().is_empty()) { + credentials.refresh_token = refresh_token; } if let Some(expires_in) = tokens.expires_in { credentials.expires_at = Some(Utc::now() + chrono::Duration::seconds(expires_in.max(0))); } + let refresh_token_rotated = credentials.refresh_token.as_bytes() != old_marker.as_slice(); + let location = credentials + .scope_location() + .map_err(|_| "Grok auth location cannot be scoped safely.".to_string())?; + let scope = refresh.transfer( + "grok-auth-json", + &location, + &old_marker, + credentials.refresh_token.as_bytes(), + ); + checkpoint(RefreshCheckpoint::MetadataHandled)?; + + // A rotated marker may reach disk only after its lineage transfer is durable. + // The refreshed access token remains usable in memory for this poll. + if refresh_token_rotated && scope.is_err() { + return Ok((credentials, scope)); + } - // Grok rotates refresh tokens on refresh: the token we just spent is now - // dead. Persist the new pair back, or the next refresh — by TokenBar *or* the - // grok CLI — fails with a stale token, forcing a manual `grok` re-login. - // Best-effort: a write failure shouldn't sink this usage fetch, but it's - // worth surfacing in logs (mirrors the Claude write-back in agent_usage.rs). - if let Err(error) = save_credentials(&credentials) { + // If write-back fails, the still-stored old marker resolves the same scope. + if let Err(error) = save(&credentials) { eprintln!("tb_core_ffi: failed to persist refreshed Grok credentials: {error}"); } + checkpoint(RefreshCheckpoint::CredentialsPersisted)?; - Ok(credentials) + Ok((credentials, scope)) } fn load_credentials() -> Result, String> { @@ -356,6 +526,13 @@ fn load_credentials() -> Result, String> { } fn load_credentials_from(auth_path: &Path) -> Result, String> { + load_credentials_entry_from(auth_path, None) +} + +fn load_credentials_entry_from( + auth_path: &Path, + expected_entry_key: Option<&str>, +) -> Result, String> { if !auth_path.is_file() { return Ok(None); } @@ -372,9 +549,18 @@ fn load_credentials_from(auth_path: &Path) -> Result, St // billing endpoint and, on 401, POSTed to auth.x.ai/oauth2/token. Absent that // entry, treat it as no Grok auth on disk and omit the card silently — the // same stance as a missing auth.json. - let (entry_key, entry) = match map.iter().find(|(k, _)| is_grok_auth_entry_key(k)) { - Some((k, v)) => (k.clone(), v.clone()), - None => return Ok(None), + let selected = match expected_entry_key { + Some(expected) if is_grok_auth_entry_key(expected) => map + .get(expected) + .map(|entry| (expected.to_string(), entry.clone())), + Some(_) => None, + None => map + .iter() + .find(|(key, _)| is_grok_auth_entry_key(key)) + .map(|(key, entry)| (key.clone(), entry.clone())), + }; + let Some((entry_key, entry)) = selected else { + return Ok(None); }; let obj = entry @@ -527,6 +713,7 @@ fn grok_home() -> PathBuf { #[cfg(test)] mod tests { use super::*; + use crate::agent_account_scope::test_support::TestRefreshScope; #[test] fn prefers_grok_build_product_percent() { @@ -558,6 +745,44 @@ mod tests { assert!((used_percent_from_config(&config).unwrap() - 12.5).abs() < 0.01); } + #[test] + fn rejects_invalid_usage_percentages_before_wire() { + let invalid_product: BillingConfig = serde_json::from_str( + r#"{ + "creditUsagePercent": 12.5, + "productUsage": [ + { "product": "GrokBuild", "usagePercent": 150.0 } + ] + }"#, + ) + .unwrap(); + assert!(used_percent_from_config(&invalid_product).is_none()); + + for invalid in ["1e400", r#""NaN""#] { + let malformed_product: BillingConfig = serde_json::from_str(&format!( + r#"{{ + "creditUsagePercent": 12.5, + "productUsage": [ + {{ "product": "GrokBuild", "usagePercent": {invalid} }} + ] + }}"# + )) + .unwrap(); + assert!(used_percent_from_config(&malformed_product).is_none()); + } + + let invalid: BillingConfig = serde_json::from_str( + r#"{ + "creditUsagePercent": -1.0, + "productUsage": [ + { "product": "GrokBuild", "usagePercent": 150.0 } + ] + }"#, + ) + .unwrap(); + assert!(used_percent_from_config(&invalid).is_none()); + } + #[test] fn maps_weekly_window_from_period() { let body = r#"{ @@ -585,12 +810,24 @@ mod tests { email: Some("user@example.com".into()), raw_json: Value::Object(Default::default()), }; + assert_eq!(credentials.scope_marker(), Some(b"r".as_slice())); let now = DateTime::parse_from_rfc3339("2026-07-11T12:00:00Z") .unwrap() .with_timezone(&Utc); - let data = map_billing(body, &credentials, now).unwrap(); + let data = map_billing( + body, + &credentials, + now, + Err(AccountScopeError::NoTrustedEvidence), + ) + .unwrap(); assert_eq!(data.windows.len(), 1); assert_eq!(data.windows[0].label_for_test(), "Weekly"); + assert_eq!(data.windows[0].window_minutes_for_test(), Some(10_080)); + assert_eq!( + data.windows[0].pace_window_key_for_test(), + Some("billing.weekly.v1") + ); assert!((data.windows[0].remaining_for_test() - 96.0).abs() < 0.01); assert_eq!( data.identity.as_ref().and_then(|i| i.email.as_deref()), @@ -602,6 +839,130 @@ mod tests { ); } + #[test] + fn stage4_grok_period_routes_are_exact_and_fail_closed() { + let credentials = GrokCredentials { + auth_path: PathBuf::from("/tmp/unused"), + entry_key: "k".into(), + access_token: "t".into(), + refresh_token: "r".into(), + client_id: "c".into(), + expires_at: None, + email: None, + raw_json: Value::Object(Default::default()), + }; + let map = |period: Value, now: DateTime| { + let body = serde_json::json!({ + "config": { + "currentPeriod": period, + "creditUsagePercent": 12.0 + } + }) + .to_string(); + map_billing( + &body, + &credentials, + now, + Err(AccountScopeError::NoTrustedEvidence), + ) + .unwrap() + .windows + .into_iter() + .next() + .unwrap() + }; + + for (label, start, end, days) in [ + ("28-day", "2023-02-01T00:00:00Z", "2023-03-01T00:00:00Z", 28), + ("29-day", "2024-02-01T00:00:00Z", "2024-03-01T00:00:00Z", 29), + ("30-day", "2024-04-01T00:00:00Z", "2024-05-01T00:00:00Z", 30), + ("31-day", "2024-05-01T00:00:00Z", "2024-06-01T00:00:00Z", 31), + ] { + let now = parse_timestamp(start).unwrap() + chrono::Duration::days(1); + let window = map( + serde_json::json!({ + "type": "USAGE_PERIOD_TYPE_MONTHLY", + "start": start, + "end": end + }), + now, + ); + let wire = serde_json::to_value(&window).unwrap(); + assert_eq!(wire["cardId"], "billing.monthly.v1", "{label}"); + assert_eq!( + wire["paceStatus"]["windowKey"], "billing.monthly.v1", + "{label}" + ); + assert_eq!( + wire["paceStatus"]["durationSeconds"], + days * 86_400, + "{label}" + ); + assert_eq!(wire["paceStatus"]["durationSource"], "provider", "{label}"); + assert_eq!(wire["paceStatus"]["state"], "learningHistory", "{label}"); + } + + let end_only = map( + serde_json::json!({ + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "end": "2026-07-24T00:00:00Z" + }), + parse_timestamp("2026-07-17T00:00:00Z").unwrap(), + ); + let wire = serde_json::to_value(&end_only).unwrap(); + assert_eq!(wire["cardId"], "billing.weekly.v1"); + assert_eq!(wire["paceStatus"]["windowKey"], "billing.weekly.v1"); + assert_eq!(wire["paceStatus"]["state"], "learningDuration"); + assert!(wire["resetsAt"].as_str().is_some()); + assert!(wire["paceStatus"].get("durationSeconds").is_none()); + assert!(wire["paceStatus"].get("durationSource").is_none()); + + let unknown = map( + serde_json::json!({ + "type": "USAGE_PERIOD_TYPE_DAILY", + "start": "2026-07-17T00:00:00Z", + "end": "2026-07-18T00:00:00Z" + }), + parse_timestamp("2026-07-17T12:00:00Z").unwrap(), + ); + let wire = serde_json::to_value(&unknown).unwrap(); + assert_eq!(wire["cardId"], "row.billing.unknown.v1"); + assert_eq!(wire["paceStatus"]["state"], "unavailable"); + assert_eq!(wire["paceStatus"]["reason"], "windowIdentity"); + assert!(wire["paceStatus"].get("windowKey").is_none()); + assert!(wire["paceStatus"].get("durationSeconds").is_none()); + + for (label, start, end) in [ + ( + "contradictory", + "2026-07-18T00:00:00Z", + "2026-07-17T00:00:00Z", + ), + ("malformed-start", "not-a-date", "2026-07-24T00:00:00Z"), + ("malformed-end", "2026-07-17T00:00:00Z", "not-a-date"), + ] { + let window = map( + serde_json::json!({ + "type": "USAGE_PERIOD_TYPE_WEEKLY", + "start": start, + "end": end + }), + parse_timestamp("2026-07-17T12:00:00Z").unwrap(), + ); + let wire = serde_json::to_value(&window).unwrap(); + assert_eq!( + wire["paceStatus"]["windowKey"], "billing.weekly.v1", + "{label}" + ); + assert_eq!(wire["paceStatus"]["state"], "unavailable", "{label}"); + assert_eq!(wire["paceStatus"]["reason"], "invalidEvidence", "{label}"); + assert!( + wire["paceStatus"].get("durationSeconds").is_none(), + "{label}" + ); + } + } + #[test] fn client_id_from_key() { assert_eq!( @@ -669,7 +1030,9 @@ mod tests { } }"#, ); - let creds = load_credentials_from(&path).unwrap().expect("auth.x.ai entry loads"); + let creds = load_credentials_from(&path) + .unwrap() + .expect("auth.x.ai entry loads"); assert!(creds.entry_key.contains("auth.x.ai")); assert_eq!(creds.access_token, "FAKE-XAI-ACCESS"); assert_eq!(creds.refresh_token, "FAKE-XAI-REFRESH"); @@ -744,7 +1107,10 @@ mod tests { let creds = load_credentials_from(&path) .unwrap() .expect("genuine auth.x.ai entry loads"); - assert_eq!(creds.entry_key, "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"); + assert_eq!( + creds.entry_key, + "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828" + ); assert_eq!(creds.access_token, "FAKE-XAI-ACCESS"); assert_eq!(creds.refresh_token, "FAKE-XAI-REFRESH"); assert_ne!(creds.access_token, "FAKE-LOOKALIKE-ACCESS"); @@ -802,4 +1168,214 @@ mod tests { ); let _ = fs::remove_dir_all(&dir); } + + const TEST_ENTRY: &str = "https://auth.x.ai::fixture-client"; + + fn checkpoint_at( + target: Option, + ) -> impl FnMut(RefreshCheckpoint) -> Result<(), String> { + move |checkpoint| { + if Some(checkpoint) == target { + Err("injected crash".to_string()) + } else { + Ok(()) + } + } + } + + async fn grok_test_response( + refresh_token: String, + client_id: String, + ) -> Result { + assert_eq!(refresh_token, "grok-old-refresh"); + assert_eq!(client_id, "fixture-client"); + Ok(TokenResponse { + access_token: "grok-new-access".to_string(), + refresh_token: Some("grok-new-refresh".to_string()), + expires_in: Some(3_600), + }) + } + + fn setup_refresh(tag: &str) -> (TestRefreshScope, PathBuf, AccountScope, Vec, String) { + let scope = TestRefreshScope::new("grok", tag); + let path = scope.root().join("grok/auth.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + serde_json::to_vec_pretty(&serde_json::json!({ + (TEST_ENTRY): { + "key": "grok-old-access", + "refresh_token": "grok-old-refresh", + "oidc_client_id": "fixture-client", + "expires_at": "1970-01-01T00:00:00Z" + } + })) + .unwrap(), + ) + .unwrap(); + let credentials = load_credentials_entry_from(&path, Some(TEST_ENTRY)) + .unwrap() + .unwrap(); + let location = credentials.scope_location().unwrap(); + let old_scope = scope + .resolve_current( + "grok-auth-json", + &location, + credentials.refresh_token.as_bytes(), + ) + .unwrap(); + let metadata = scope.metadata_bytes(); + (scope, path, old_scope, metadata, location) + } + + async fn run_refresh( + scope: &TestRefreshScope, + path: &Path, + crash: Option, + ) -> Result<(GrokCredentials, Result), String> { + refresh_credentials_with( + path, + TEST_ENTRY, + true, + scope, + grok_test_response, + save_credentials, + checkpoint_at(crash), + ) + .await + } + + fn stored_refresh_token(path: &Path) -> String { + load_credentials_entry_from(path, Some(TEST_ENTRY)) + .unwrap() + .unwrap() + .refresh_token + } + + #[test] + fn refresh_scope_merge_is_sticky_and_reaches_billing_map() { + let (scope, path, scope_a, _, location) = setup_refresh("grok-scope-merge"); + let scope_b = scope + .resolve_current("grok-auth-json", &location, b"different-refresh") + .unwrap(); + assert_ne!(scope_a, scope_b); + let credentials = load_credentials_entry_from(&path, Some(TEST_ENTRY)) + .unwrap() + .unwrap(); + let now = DateTime::parse_from_rfc3339("2026-07-11T12:00:00Z") + .unwrap() + .with_timezone(&Utc); + let body = r#"{ + "config": { + "creditUsagePercent": 4.0 + } + }"#; + + let cases = vec![ + ( + "error then success keeps first failure", + vec![Err(AccountScopeError::MetadataWrite), Ok(scope_a.clone())], + Err(AccountScopeError::MetadataWrite), + ), + ( + "success then error stays failed", + vec![Ok(scope_a.clone()), Err(AccountScopeError::MetadataRead)], + Err(AccountScopeError::MetadataRead), + ), + ( + "matching successes keep scope", + vec![Ok(scope_a.clone()), Ok(scope_a.clone())], + Ok(scope_a.clone()), + ), + ( + "different successes fail closed", + vec![Ok(scope_a.clone()), Ok(scope_b)], + Err(AccountScopeError::MetadataConflict), + ), + ]; + + for (label, outcomes, expected) in cases { + let merged = outcomes + .into_iter() + .fold(None, merge_refreshed_scope) + .unwrap(); + let mapped = map_billing(body, &credentials, now, merged).unwrap(); + assert_eq!(mapped.account_scope, expected, "{label}"); + } + scope.cleanup(); + } + + #[tokio::test] + async fn refresh_crash_boundaries_and_scope_gate_use_production_sequence() { + for boundary in [ + RefreshCheckpoint::Reloaded, + RefreshCheckpoint::NetworkReturned, + RefreshCheckpoint::MetadataHandled, + RefreshCheckpoint::CredentialsPersisted, + ] { + let (scope, path, old_scope, before, location) = setup_refresh("grok-crash"); + assert_eq!( + run_refresh(&scope, &path, Some(boundary)) + .await + .unwrap_err(), + "injected crash" + ); + assert_eq!( + stored_refresh_token(&path), + if boundary == RefreshCheckpoint::CredentialsPersisted { + "grok-new-refresh" + } else { + "grok-old-refresh" + } + ); + if matches!( + boundary, + RefreshCheckpoint::Reloaded | RefreshCheckpoint::NetworkReturned + ) { + assert_eq!(scope.metadata_bytes(), before); + } else { + assert_ne!(scope.metadata_bytes(), before); + assert_eq!( + scope + .resolve_current("grok-auth-json", &location, b"grok-old-refresh") + .unwrap(), + old_scope + ); + assert_eq!( + scope + .resolve_current("grok-auth-json", &location, b"grok-new-refresh") + .unwrap(), + old_scope + ); + } + scope.cleanup(); + } + + let (scope, path, old_scope, before, location) = setup_refresh("grok-metadata-fail"); + scope.fail_metadata_save(); + let (refreshed, scope_outcome) = run_refresh(&scope, &path, None).await.unwrap(); + assert_eq!(refreshed.access_token, "grok-new-access"); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + let persisted_marker = stored_refresh_token(&path); + assert_eq!(persisted_marker, "grok-old-refresh"); + assert_eq!( + scope + .resolve_current("grok-auth-json", &location, persisted_marker.as_bytes()) + .unwrap(), + old_scope + ); + scope.cleanup(); + + let (scope, path, old_scope, _, location) = setup_refresh("grok-success"); + let (_, scope_outcome) = run_refresh(&scope, &path, None).await.unwrap(); + assert_eq!(scope_outcome.unwrap(), old_scope); + assert_eq!( + scope + .resolve_current("grok-auth-json", &location, b"grok-new-refresh") + .unwrap(), + old_scope + ); + scope.cleanup(); + } } diff --git a/crates/tb_core_ffi/src/agent_history.rs b/crates/tb_core_ffi/src/agent_history.rs index 12ef0d53..684d1bbd 100644 --- a/crates/tb_core_ffi/src/agent_history.rs +++ b/crates/tb_core_ffi/src/agent_history.rs @@ -745,7 +745,7 @@ fn interpolate(curve: &[f64], u: f64) -> f64 { } } -fn weighted_median(values: &[f64], weights: &[f64]) -> f64 { +pub(crate) fn weighted_median(values: &[f64], weights: &[f64]) -> f64 { if values.len() != weights.len() || values.is_empty() { return 0.0; } diff --git a/crates/tb_core_ffi/src/agent_quota_duration.rs b/crates/tb_core_ffi/src/agent_quota_duration.rs new file mode 100644 index 00000000..31678ff4 --- /dev/null +++ b/crates/tb_core_ffi/src/agent_quota_duration.rs @@ -0,0 +1,828 @@ +//! Provider-neutral quota duration evidence and observed rollover lifecycle. +//! +//! Stage 2 deliberately keeps this module independent from provider adapters and +//! wire models. Adapters can supply provider/contract evidence later; observed +//! state is persisted by `agent_quota_history` in the same v3 transaction. + +#![allow(dead_code)] + +use chrono::{Datelike, TimeZone, Timelike, Utc}; +use serde::{Deserialize, Serialize}; + +pub(crate) const MAX_DURATION_SECONDS: i64 = 400 * 86_400; +pub(crate) const ROLLOVER_GRACE_SECONDS: i64 = 15 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DurationSource { + Provider, + Contract, + Observed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DurationUnavailableReason { + MissingReset, + InvalidEvidence, +} + +impl std::fmt::Display for DurationUnavailableReason { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::MissingReset => "missing reset", + Self::InvalidEvidence => "invalid duration evidence", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DurationEvidence { + pub(crate) reset_at: Option, + pub(crate) duration_seconds: i64, +} + +impl DurationEvidence { + pub(crate) fn provider(reset_at: i64, duration_seconds: i64) -> Self { + Self { + reset_at: Some(reset_at), + duration_seconds, + } + } + + pub(crate) fn contract(duration_seconds: i64) -> Self { + Self { + reset_at: None, + duration_seconds, + } + } + + pub(crate) fn observed(reset_at: i64, duration_seconds: i64) -> Self { + Self { + reset_at: Some(reset_at), + duration_seconds, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DurationResolution { + Ready { + duration_seconds: i64, + source: DurationSource, + }, + LearningDuration, + Unavailable(DurationUnavailableReason), +} + +/// Resolve the first valid duration in the frozen provider -> contract -> +/// observed order. A missing observed candidate means the caller is still +/// learning, while malformed supplied evidence is typed as unavailable. +pub(crate) fn resolve_duration( + now: i64, + reset_at: Option, + provider: Option, + contract: Option, + observed: Option, +) -> DurationResolution { + let Some(reset_at) = reset_at else { + return DurationResolution::Unavailable(DurationUnavailableReason::MissingReset); + }; + if !valid_reset(reset_at, now) { + return DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence); + } + + if let Some(evidence) = provider { + return if valid_evidence(evidence, reset_at, now, true) { + DurationResolution::Ready { + duration_seconds: evidence.duration_seconds, + source: DurationSource::Provider, + } + } else { + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + }; + } + if let Some(evidence) = contract { + return if valid_evidence(evidence, reset_at, now, false) { + DurationResolution::Ready { + duration_seconds: evidence.duration_seconds, + source: DurationSource::Contract, + } + } else { + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + }; + } + if let Some(evidence) = observed { + return if valid_evidence(evidence, reset_at, now, true) { + DurationResolution::Ready { + duration_seconds: evidence.duration_seconds, + source: DurationSource::Observed, + } + } else { + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + }; + } + DurationResolution::LearningDuration +} + +pub(crate) fn valid_reset(reset_at: i64, now: i64) -> bool { + reset_at > now +} + +pub(crate) fn valid_duration(duration_seconds: i64) -> bool { + (1..=MAX_DURATION_SECONDS).contains(&duration_seconds) +} + +pub(crate) fn valid_evidence( + evidence: DurationEvidence, + current_reset_at: i64, + now: i64, + require_reset_match: bool, +) -> bool { + let Some(cycle_started_at) = current_reset_at.checked_sub(evidence.duration_seconds) else { + return false; + }; + valid_duration(evidence.duration_seconds) + && cycle_started_at <= now + && now < current_reset_at + && (!require_reset_match || evidence.reset_at == Some(current_reset_at)) + && evidence + .reset_at + .is_none_or(|evidence_reset| evidence_reset == current_reset_at) +} + +/// Copilot's immediate calendar contract. It intentionally accepts only an +/// exact UTC first-of-month midnight reset; all other resets must use observed +/// rollover and cannot silently become a 30-day duration. +pub(crate) fn copilot_calendar_duration(reset_at: i64) -> Option { + let reset = Utc.timestamp_opt(reset_at, 0).single()?; + if reset.day() != 1 + || reset.hour() != 0 + || reset.minute() != 0 + || reset.second() != 0 + || reset.timestamp_subsec_nanos() != 0 + { + return None; + } + + let (year, month) = if reset.month() == 1 { + (reset.year() - 1, 12) + } else { + (reset.year(), reset.month() - 1) + }; + let previous = Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).single()?; + let duration = reset.timestamp().checked_sub(previous.timestamp())?; + valid_duration(duration).then_some(duration) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "camelCase", deny_unknown_fields)] +pub(crate) enum ObservedState { + Watching { + reset_at: i64, + first_seen_at: i64, + last_seen_at: i64, + consecutive_count: u8, + }, + Candidate { + old_reset_at: i64, + old_seen_at: i64, + new_reset_at: i64, + first_new_seen_at: i64, + }, + Ready { + cycle_started_at: i64, + reset_at: i64, + duration_seconds: i64, + confirmed_at: i64, + last_seen_at: i64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObservedTransition { + pub(crate) state: ObservedState, + pub(crate) duration_seconds: Option, + pub(crate) became_ready: bool, + pub(crate) duplicate: bool, +} + +impl ObservedTransition { + fn learning(state: ObservedState, duplicate: bool) -> Self { + Self { + state, + duration_seconds: None, + became_ready: false, + duplicate, + } + } + + fn ready(state: ObservedState, became_ready: bool, duplicate: bool) -> Self { + let duration_seconds = match &state { + ObservedState::Ready { + duration_seconds, .. + } => Some(*duration_seconds), + _ => None, + }; + Self { + state, + duration_seconds, + became_ready, + duplicate, + } + } +} + +pub(crate) fn ready_state(reset_at: i64, now: i64, duration_seconds: i64) -> Option { + if !valid_duration(duration_seconds) { + return None; + } + let cycle_started_at = reset_at.checked_sub(duration_seconds)?; + let confirmation_deadline = cycle_started_at.checked_add(ROLLOVER_GRACE_SECONDS)?; + (cycle_started_at <= now && now <= confirmation_deadline && now < reset_at).then_some( + ObservedState::Ready { + cycle_started_at, + reset_at, + duration_seconds, + confirmed_at: now, + last_seen_at: now, + }, + ) +} + +pub(crate) fn observed_duration(state: &ObservedState) -> Option { + match state { + ObservedState::Ready { + duration_seconds, .. + } => Some(*duration_seconds), + _ => None, + } +} + +pub(crate) fn validate_observed_state(state: &ObservedState) -> bool { + match state { + ObservedState::Watching { + reset_at, + first_seen_at, + last_seen_at, + consecutive_count, + } => { + *consecutive_count > 0 + && *consecutive_count <= 2 + && first_seen_at <= last_seen_at + && *last_seen_at < *reset_at + } + ObservedState::Candidate { + old_reset_at, + old_seen_at, + new_reset_at, + first_new_seen_at, + } => { + let Some(old_boundary_start) = old_reset_at.checked_sub(ROLLOVER_GRACE_SECONDS) else { + return false; + }; + let Some(old_boundary_end) = old_reset_at.checked_add(ROLLOVER_GRACE_SECONDS) else { + return false; + }; + old_reset_at < new_reset_at + && new_reset_at + .checked_sub(*old_reset_at) + .is_some_and(valid_duration) + && *old_seen_at >= old_boundary_start + && *old_seen_at < *old_reset_at + && *first_new_seen_at >= *old_reset_at + && *first_new_seen_at <= old_boundary_end + && *old_seen_at <= *first_new_seen_at + && *first_new_seen_at < *new_reset_at + } + ObservedState::Ready { + cycle_started_at, + reset_at, + duration_seconds, + confirmed_at, + last_seen_at, + } => { + let Some(confirmation_deadline) = cycle_started_at.checked_add(ROLLOVER_GRACE_SECONDS) + else { + return false; + }; + valid_duration(*duration_seconds) + && cycle_started_at.checked_add(*duration_seconds) == Some(*reset_at) + && *cycle_started_at <= *confirmed_at + && *confirmed_at <= confirmation_deadline + && *confirmed_at <= *last_seen_at + && *last_seen_at < *reset_at + } + } +} + +fn watching(reset_at: i64, now: i64) -> ObservedState { + ObservedState::Watching { + reset_at, + first_seen_at: now, + last_seen_at: now, + consecutive_count: 1, + } +} + +fn update_seen(previous: i64, now: i64) -> i64 { + previous.max(now) +} + +fn within_after(reset_at: i64, now: i64) -> bool { + now >= reset_at + && reset_at + .checked_add(ROLLOVER_GRACE_SECONDS) + .is_some_and(|deadline| now <= deadline) +} + +fn seen_near_boundary(seen_at: i64, reset_at: i64) -> bool { + reset_at + .checked_sub(ROLLOVER_GRACE_SECONDS) + .is_some_and(|start| seen_at >= start && seen_at <= reset_at) +} + +fn candidate( + old_reset_at: i64, + old_seen_at: i64, + new_reset_at: i64, + now: i64, +) -> Option { + let duration_seconds = new_reset_at.checked_sub(old_reset_at)?; + valid_duration(duration_seconds).then_some(ObservedState::Candidate { + old_reset_at, + old_seen_at, + new_reset_at, + first_new_seen_at: now, + }) +} + +/// Advance the durable observed rollover state machine by one provider reading. +/// The previous state is never mutated in place, so a failed history save can +/// discard the returned transition and leave the last committed transaction +/// intact. +pub(crate) fn observe_reset( + previous: Option<&ObservedState>, + reset_at: i64, + now: i64, +) -> Result { + if !valid_reset(reset_at, now) { + return Err(DurationUnavailableReason::InvalidEvidence); + } + + let Some(previous) = previous else { + return Ok(ObservedTransition::learning(watching(reset_at, now), false)); + }; + if !validate_observed_state(previous) { + return Err(DurationUnavailableReason::InvalidEvidence); + } + + match previous { + ObservedState::Watching { + reset_at: old_reset_at, + first_seen_at, + last_seen_at, + consecutive_count, + } => { + if reset_at == *old_reset_at { + let state = ObservedState::Watching { + reset_at: *old_reset_at, + first_seen_at: *first_seen_at, + last_seen_at: update_seen(*last_seen_at, now), + consecutive_count: consecutive_count.saturating_add(1).min(2), + }; + return Ok(ObservedTransition::learning(state, true)); + } + + if reset_at > *old_reset_at + && *consecutive_count >= 2 + && within_after(*old_reset_at, now) + && seen_near_boundary(*last_seen_at, *old_reset_at) + { + if let Some(state) = candidate(*old_reset_at, *last_seen_at, reset_at, now) { + return Ok(ObservedTransition::learning(state, false)); + } + } + + // A forward slide before expiry, a backward reset, an implausible + // gap, or an unstable old baseline all restart learning at the + // newest reset without manufacturing a cycle count. + Ok(ObservedTransition::learning(watching(reset_at, now), false)) + } + ObservedState::Candidate { + old_reset_at, + new_reset_at, + first_new_seen_at, + .. + } => { + let within_confirmation_window = old_reset_at + .checked_add(ROLLOVER_GRACE_SECONDS) + .is_some_and(|deadline| now <= deadline); + if reset_at == *new_reset_at && now >= *first_new_seen_at && within_confirmation_window + { + let duration_seconds = new_reset_at + .checked_sub(*old_reset_at) + .filter(|duration| valid_duration(*duration)) + .ok_or(DurationUnavailableReason::InvalidEvidence)?; + let state = ObservedState::Ready { + cycle_started_at: *old_reset_at, + reset_at: *new_reset_at, + duration_seconds, + confirmed_at: now, + last_seen_at: now, + }; + return Ok(ObservedTransition::ready(state, true, false)); + } + + // Any changed, reversed, early, or late candidate is not a + // confirmed boundary; restart from the newest reset. + Ok(ObservedTransition::learning(watching(reset_at, now), false)) + } + ObservedState::Ready { + cycle_started_at, + reset_at: old_reset_at, + duration_seconds, + confirmed_at, + last_seen_at, + } => { + if reset_at == *old_reset_at { + let state = ObservedState::Ready { + cycle_started_at: *cycle_started_at, + reset_at: *old_reset_at, + duration_seconds: *duration_seconds, + confirmed_at: *confirmed_at, + last_seen_at: update_seen(*last_seen_at, now), + }; + return Ok(ObservedTransition::ready(state, false, true)); + } + + if reset_at > *old_reset_at + && within_after(*old_reset_at, now) + && seen_near_boundary(*last_seen_at, *old_reset_at) + { + if let Some(state) = candidate(*old_reset_at, *last_seen_at, reset_at, now) { + return Ok(ObservedTransition::learning(state, false)); + } + } + + // Sliding, backward, or missed boundaries restart from the current + // reset. In particular, never divide a large reset delta by a + // guessed cycle count. + Ok(ObservedTransition::learning(watching(reset_at, now), false)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOUR: i64 = 3_600; + const DAY: i64 = 86_400; + + #[test] + fn precedence_prefers_provider_then_contract_then_observed() { + let now = 10_000; + let provider_reset = now + 5 * HOUR; + let provider = DurationEvidence::provider(provider_reset, 5 * HOUR); + let contract_reset = now + 7 * DAY; + let contract = DurationEvidence::contract(7 * DAY); + let observed = DurationEvidence::observed(contract_reset, 7 * DAY); + + assert_eq!( + resolve_duration( + now, + Some(provider_reset), + Some(provider), + Some(contract), + Some(observed) + ), + DurationResolution::Ready { + duration_seconds: 5 * HOUR, + source: DurationSource::Provider, + } + ); + assert_eq!( + resolve_duration( + now, + Some(contract_reset), + None, + Some(contract), + Some(observed) + ), + DurationResolution::Ready { + duration_seconds: 7 * DAY, + source: DurationSource::Contract, + } + ); + assert_eq!( + resolve_duration(now, Some(contract_reset), None, None, Some(observed)), + DurationResolution::Ready { + duration_seconds: 7 * DAY, + source: DurationSource::Observed, + } + ); + } + + #[test] + fn malformed_present_evidence_is_invalid_without_fallback() { + let now = 10_000; + let reset = now + 7 * DAY; + assert_eq!( + resolve_duration( + now, + Some(reset), + Some(DurationEvidence::provider(reset + 1, 5 * HOUR)), + Some(DurationEvidence::contract(7 * DAY)), + None, + ), + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + assert_eq!( + resolve_duration( + now, + Some(reset), + None, + Some(DurationEvidence::contract(5 * HOUR)), + Some(DurationEvidence::observed(reset, 7 * DAY)), + ), + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + } + + #[test] + fn missing_and_invalid_evidence_are_typed() { + assert_eq!( + resolve_duration(10, None, None, None, None), + DurationResolution::Unavailable(DurationUnavailableReason::MissingReset) + ); + assert_eq!( + resolve_duration( + 10, + None, + Some(DurationEvidence::provider(10 + DAY, DAY)), + Some(DurationEvidence::contract(DAY)), + Some(DurationEvidence::observed(10 + DAY, DAY)), + ), + DurationResolution::Unavailable(DurationUnavailableReason::MissingReset) + ); + assert_eq!( + resolve_duration( + 10, + Some(10), + Some(DurationEvidence::provider(10, 0)), + None, + Some(DurationEvidence::observed(10, 0)), + ), + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + } + + #[test] + fn five_hour_and_seven_day_durations_are_exact() { + let now = 10_000; + for duration in [5 * HOUR, 7 * DAY] { + let reset = now + duration; + assert_eq!( + resolve_duration( + now, + Some(reset), + None, + Some(DurationEvidence::contract(duration)), + None, + ), + DurationResolution::Ready { + duration_seconds: duration, + source: DurationSource::Contract, + } + ); + } + let reset = now + MAX_DURATION_SECONDS; + assert!(matches!( + resolve_duration( + now, + Some(reset), + None, + Some(DurationEvidence::contract(MAX_DURATION_SECONDS)), + None, + ), + DurationResolution::Ready { + duration_seconds: MAX_DURATION_SECONDS, + source: DurationSource::Contract, + } + )); + assert_eq!( + resolve_duration( + now, + Some(reset), + None, + Some(DurationEvidence::contract(MAX_DURATION_SECONDS + 1)), + None, + ), + DurationResolution::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + } + + #[test] + fn copilot_calendar_duration_preserves_28_to_31_day_months() { + let cases = [ + ("2023-03-01T00:00:00Z", 28 * DAY), + ("2024-03-01T00:00:00Z", 29 * DAY), + ("2023-05-01T00:00:00Z", 30 * DAY), + ("2023-08-01T00:00:00Z", 31 * DAY), + ]; + for (text, expected) in cases { + let reset = text.parse::>().unwrap().timestamp(); + assert_eq!(copilot_calendar_duration(reset), Some(expected)); + } + let not_month_start = "2023-08-02T00:00:00Z" + .parse::>() + .unwrap() + .timestamp(); + let not_midnight = "2023-08-01T00:00:01Z" + .parse::>() + .unwrap() + .timestamp(); + assert_eq!(copilot_calendar_duration(not_month_start), None); + assert_eq!(copilot_calendar_duration(not_midnight), None); + } + + #[test] + fn observed_requires_stable_old_reset_boundary_and_next_poll_confirmation() { + let now = 1_000_000; + let old_reset = now + DAY; + let new_reset = old_reset + 7 * DAY; + let first = observe_reset(None, old_reset, now).unwrap(); + assert!(matches!( + first.state, + ObservedState::Watching { + consecutive_count: 1, + .. + } + )); + let stable = observe_reset(Some(&first.state), old_reset, old_reset - 5 * 60).unwrap(); + assert!(matches!( + stable.state, + ObservedState::Watching { + consecutive_count: 2, + .. + } + )); + let candidate = observe_reset(Some(&stable.state), new_reset, old_reset + 5 * 60).unwrap(); + assert!(matches!(candidate.state, ObservedState::Candidate { .. })); + assert_eq!(candidate.duration_seconds, None); + let ready = observe_reset(Some(&candidate.state), new_reset, old_reset + 10 * 60).unwrap(); + assert!(ready.became_ready); + assert_eq!(ready.duration_seconds, Some(7 * DAY)); + assert!(matches!(ready.state, ObservedState::Ready { .. })); + } + + #[test] + fn candidate_confirms_at_exact_fifteen_minute_boundary() { + let old_reset = 1_000_000; + let new_reset = old_reset + 7 * DAY; + let stable = ObservedState::Watching { + reset_at: old_reset, + first_seen_at: old_reset - ROLLOVER_GRACE_SECONDS, + last_seen_at: old_reset - 1, + consecutive_count: 2, + }; + let candidate = observe_reset(Some(&stable), new_reset, old_reset).unwrap(); + let confirmed = observe_reset( + Some(&candidate.state), + new_reset, + old_reset + ROLLOVER_GRACE_SECONDS, + ) + .unwrap(); + assert!(confirmed.became_ready); + assert_eq!(confirmed.duration_seconds, Some(7 * DAY)); + } + + #[test] + fn candidate_confirmation_after_timeout_restarts_watching() { + let old_reset = 1_000_000; + let new_reset = old_reset + 7 * DAY; + let candidate = ObservedState::Candidate { + old_reset_at: old_reset, + old_seen_at: old_reset - 60, + new_reset_at: new_reset, + first_new_seen_at: old_reset, + }; + let expired = observe_reset( + Some(&candidate), + new_reset, + old_reset + ROLLOVER_GRACE_SECONDS + 1, + ) + .unwrap(); + assert_eq!(expired.duration_seconds, None); + assert!(matches!( + expired.state, + ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + } if reset_at == new_reset + )); + } + + #[test] + fn persisted_observed_state_validation_rejects_out_of_bounds_timestamps() { + let invalid_watching = ObservedState::Watching { + reset_at: 100, + first_seen_at: 50, + last_seen_at: 100, + consecutive_count: 2, + }; + assert!(!validate_observed_state(&invalid_watching)); + + let invalid_candidate = ObservedState::Candidate { + old_reset_at: 1_000, + old_seen_at: 900, + new_reset_at: 1_000 + DAY, + first_new_seen_at: 1_000 + ROLLOVER_GRACE_SECONDS + 1, + }; + assert!(!validate_observed_state(&invalid_candidate)); + + let late_ready = ObservedState::Ready { + cycle_started_at: 1_000, + reset_at: 1_000 + DAY, + duration_seconds: DAY, + confirmed_at: 1_000 + ROLLOVER_GRACE_SECONDS + 1, + last_seen_at: 1_000 + ROLLOVER_GRACE_SECONDS + 1, + }; + assert!(!validate_observed_state(&late_ready)); + assert!(ready_state(1_000 + DAY, 1_000 + ROLLOVER_GRACE_SECONDS + 1, DAY,).is_none()); + } + + #[test] + fn duplicate_reset_updates_stability_without_manufacturing_boundary() { + let now = 1_000_000; + let reset = now + DAY; + let first = observe_reset(None, reset, now).unwrap(); + let duplicate = observe_reset(Some(&first.state), reset, now + 1).unwrap(); + assert!(duplicate.duplicate); + assert!(!duplicate.became_ready); + assert_eq!(duplicate.duration_seconds, None); + assert!(matches!( + duplicate.state, + ObservedState::Watching { + consecutive_count: 2, + .. + } + )); + let ready = ready_state(reset, now, DAY).unwrap(); + let duplicate_ready = observe_reset(Some(&ready), reset, now + 1).unwrap(); + assert!(duplicate_ready.duplicate); + assert!(!duplicate_ready.became_ready); + assert_eq!(duplicate_ready.duration_seconds, Some(DAY)); + } + + #[test] + fn sliding_backward_and_missed_boundaries_restart_learning() { + let now = 1_000_000; + let old = now + DAY; + let stable = ObservedState::Watching { + reset_at: old, + first_seen_at: old - 10 * 60, + last_seen_at: old - 5 * 60, + consecutive_count: 2, + }; + let sliding = observe_reset(Some(&stable), old + DAY, old - DAY).unwrap(); + assert!( + matches!(sliding.state, ObservedState::Watching { reset_at, consecutive_count: 1, .. } if reset_at == old + DAY) + ); + let backward = observe_reset(Some(&stable), old - HOUR, old - 2 * HOUR).unwrap(); + assert!( + matches!(backward.state, ObservedState::Watching { reset_at, consecutive_count: 1, .. } if reset_at == old - HOUR) + ); + + let missed = + observe_reset(Some(&stable), old + DAY, old + ROLLOVER_GRACE_SECONDS + 1).unwrap(); + assert!( + matches!(missed.state, ObservedState::Watching { reset_at, consecutive_count: 1, .. } if reset_at == old + DAY) + ); + } + + #[test] + fn candidate_change_does_not_divide_or_guess_cycle_count() { + let old = 1_000_000; + let candidate = ObservedState::Candidate { + old_reset_at: old, + old_seen_at: old - 60, + new_reset_at: old + 7 * DAY, + first_new_seen_at: old + 60, + }; + let changed = observe_reset(Some(&candidate), old + 14 * DAY, old + 120).unwrap(); + assert!( + matches!(changed.state, ObservedState::Watching { reset_at, .. } if reset_at == old + 14 * DAY) + ); + assert_eq!(changed.duration_seconds, None); + } + + #[test] + fn ready_state_validation_keeps_exact_duration() { + let reset = 10_000_000; + let state = ready_state(reset, reset - 5 * HOUR + 10 * 60, 5 * HOUR).unwrap(); + assert!(validate_observed_state(&state)); + assert_eq!(observed_duration(&state), Some(5 * HOUR)); + } +} diff --git a/crates/tb_core_ffi/src/agent_quota_history.rs b/crates/tb_core_ffi/src/agent_quota_history.rs new file mode 100644 index 00000000..83e5d9f2 --- /dev/null +++ b/crates/tb_core_ffi/src/agent_quota_history.rs @@ -0,0 +1,5097 @@ +//! Schema-3 provider-neutral quota pace history transaction. +//! +//! The locked v3 transaction owns sampling, cycle-aware retention, migration, +//! and the coherent historical evaluator. Provider adapters resolve identity +//! and duration, then call the APIs here; no provider fetch lives in this module. + +#![allow(dead_code)] + +use crate::agent_quota_duration::{ + self, observe_reset, valid_duration, DurationEvidence, DurationResolution, DurationSource, + DurationUnavailableReason, ObservedState, +}; +use fs2::FileExt as _; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex}; + +pub(crate) const HISTORY_SCHEMA_VERSION: u32 = 3; +pub(crate) const HISTORY_FILE_NAME: &str = "quota-pace-history-v3.json"; +pub(crate) const HISTORY_LOCK_FILE_NAME: &str = "quota-pace-v3.lock"; +pub(crate) const LEGACY_V2_FILE_NAME: &str = "codex-weekly-history-v2.json"; +pub(crate) const PHASE_BUCKET_COUNT: usize = 48; +pub(crate) const GRID_POINT_COUNT: usize = 169; +pub(crate) const MAX_SERIES: usize = 512; +pub(crate) const MAX_SAMPLES: usize = 65_536; +pub(crate) const MAX_SAMPLES_PER_CYCLE: usize = PHASE_BUCKET_COUNT; +pub(crate) const MIN_COMPLETE_BUCKETS: usize = 6; +pub(crate) const MAX_PHASE_GAP: f64 = 0.30; +pub(crate) const RETENTION_MIN_SECONDS: i64 = 56 * 86_400; +pub(crate) const RETENTION_MAX_SECONDS: i64 = 400 * 86_400; +pub(crate) const RETENTION_MIN_CYCLES: usize = 8; +pub(crate) const RETENTION_MAX_CYCLES: usize = 128; +pub(crate) const RUNOUT_THRESHOLD_PERCENT: f64 = 100.0 - 1e-9; +pub(crate) const EPSILON: f64 = 1e-9; + +static HISTORY_PROCESS_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HistoryError { + StorageUnavailable, + LockOpen, + LockAcquire, + LockRelease, + Read, + CorruptQuarantine, + InvalidSeriesKey, + StoreCapacity, + Serialize, + AtomicSave, +} + +impl std::fmt::Display for HistoryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::StorageUnavailable => "quota pace storage is unavailable", + Self::LockOpen => "quota pace lock could not be opened", + Self::LockAcquire => "quota pace lock could not be acquired", + Self::LockRelease => "quota pace lock could not be released", + Self::Read => "quota pace history could not be read", + Self::CorruptQuarantine => "quota pace history could not be quarantined", + Self::InvalidSeriesKey => "quota pace series key is invalid", + Self::StoreCapacity => "quota pace history store capacity is exhausted", + Self::Serialize => "quota pace history could not be serialized", + Self::AtomicSave => "quota pace history could not be saved atomically", + }) + } +} + +impl std::error::Error for HistoryError {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SeriesKey { + pub(crate) provider_id: String, + pub(crate) account_scope: String, + pub(crate) window_key: String, +} + +impl SeriesKey { + pub(crate) fn new( + provider_id: impl Into, + account_scope: impl Into, + window_key: impl Into, + ) -> Self { + Self { + provider_id: provider_id.into(), + account_scope: account_scope.into(), + window_key: window_key.into(), + } + } + + fn is_valid(&self) -> bool { + !self.provider_id.trim().is_empty() + && !self.account_scope.trim().is_empty() + && !self.window_key.trim().is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum SampleOrigin { + LiveV3, + ImportedV2, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct QuotaSample { + pub(crate) reset_at: i64, + pub(crate) duration_seconds: i64, + pub(crate) duration_source: DurationSource, + pub(crate) used_percent: f64, + pub(crate) sampled_at: i64, + pub(crate) origin: SampleOrigin, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SeriesState { + pub(crate) provider_id: String, + pub(crate) account_scope: String, + pub(crate) window_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) active_reset_at: Option, + pub(crate) last_activity_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) rollover: Option, + pub(crate) samples: Vec, +} + +impl SeriesState { + fn new(key: &SeriesKey, now: i64) -> Self { + Self { + provider_id: key.provider_id.clone(), + account_scope: key.account_scope.clone(), + window_key: key.window_key.clone(), + active_reset_at: None, + last_activity_at: now, + rollover: None, + samples: Vec::new(), + } + } + + fn key(&self) -> SeriesKey { + SeriesKey::new( + self.provider_id.clone(), + self.account_scope.clone(), + self.window_key.clone(), + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Store { + schema_version: u32, + series: Vec, +} + +impl Default for Store { + fn default() -> Self { + Self { + schema_version: HISTORY_SCHEMA_VERSION, + series: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HistoryOutcome { + Ready { + duration_seconds: i64, + source: DurationSource, + sampled: bool, + }, + LearningDuration, + Unavailable(DurationUnavailableReason), +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct HistoricalPace { + pub(crate) expected_percent: f64, + pub(crate) eta_seconds: Option, + pub(crate) will_last_to_reset: bool, + pub(crate) run_out_probability: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MigrationOutcome { + pub(crate) imported_samples: usize, + pub(crate) skipped_samples: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct QuotaObservation { + pub(crate) key: SeriesKey, + pub(crate) reset_at: Option, + pub(crate) used_percent: f64, + pub(crate) provider: Option, + pub(crate) contract: Option, +} + +pub(crate) type BatchObservationResult = + Result<(HistoryOutcome, Option, usize), HistoryError>; + +#[derive(Debug, Clone)] +enum PreparedObservation { + Early(BatchObservationResult), + Candidate(DurationResolution), +} + +#[derive(Debug, Clone, Copy)] +struct ObservationAdmission { + index: usize, + resolution: DurationResolution, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LegacyV2Sample { + account_key: String, + resets_at: i64, + window_minutes: i64, + used_percent: f64, + sampled_at: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LegacyV2Store { + schema_version: u32, + samples: Vec, +} + +#[derive(Debug)] +struct LoadedStore { + store: Store, +} + +/// Record a provider-neutral quota observation in the production v3 store. +/// Account scope and stable window identity must already have been resolved by +/// the caller; this function never invents either value. +pub(crate) fn record_observation( + key: SeriesKey, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, +) -> Result { + record_observation_and_evaluate(key, reset_at, used_percent, now, provider, contract) + .map(|(outcome, _)| outcome) +} + +/// Record and evaluate in one locked v3 transaction. Stage 4 provider adapters +/// use this entry point so the returned projection describes the same committed +/// store snapshot that accepted the observation. +pub(crate) fn record_observation_and_evaluate( + key: SeriesKey, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, +) -> Result<(HistoryOutcome, Option), HistoryError> { + let path = production_history_path().ok_or(HistoryError::StorageUnavailable)?; + record_observation_at_path_and_evaluate( + key, + reset_at, + used_percent, + now, + provider, + contract, + &path, + ) +} + +pub(crate) fn production_history_path() -> Option { + dirs::data_dir().map(|directory| { + directory + .join("com.nyanako.tokenbar") + .join(HISTORY_FILE_NAME) + }) +} + +/// Import only the legacy Codex records bound to the account ID used by the +/// successful request. The caller supplies the already-resolved opaque scope; +/// this API never turns a legacy raw key into a v3 scope. +pub(crate) fn migrate_codex_v2( + request_account_id: &str, + account_scope: &str, + now: i64, +) -> Result { + let Some(directory) = dirs::data_dir().map(|directory| directory.join("com.nyanako.tokenbar")) + else { + return Err(HistoryError::StorageUnavailable); + }; + migrate_codex_v2_at_paths( + request_account_id, + account_scope, + now, + &directory.join(LEGACY_V2_FILE_NAME), + &directory.join(HISTORY_FILE_NAME), + ) +} + +pub(crate) fn migrate_codex_v2_at_paths( + request_account_id: &str, + account_scope: &str, + now: i64, + v2_path: &Path, + v3_path: &Path, +) -> Result { + migrate_codex_v2_at_paths_with_clock( + request_account_id, + account_scope, + now, + v2_path, + v3_path, + unix_now, + ) +} + +fn migrate_codex_v2_at_paths_with_clock( + request_account_id: &str, + account_scope: &str, + now: i64, + v2_path: &Path, + v3_path: &Path, + transaction_clock: impl FnOnce() -> i64, +) -> Result { + let accepted_account = request_account_id.trim(); + if accepted_account.is_empty() || account_scope.trim().is_empty() { + return Ok(MigrationOutcome { + imported_samples: 0, + skipped_samples: 0, + }); + } + let bytes = match fs::read(v2_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(MigrationOutcome { + imported_samples: 0, + skipped_samples: 0, + }) + } + Err(_) => { + return Ok(MigrationOutcome { + imported_samples: 0, + skipped_samples: 0, + }) + } + }; + let legacy = match serde_json::from_slice::(&bytes) { + Ok(store) if store.schema_version == 2 => store, + _ => { + // Corrupt or unsupported v2 is deliberately left byte-for-byte at + // its original path. It is evidence, not a migration marker. + return Ok(MigrationOutcome { + imported_samples: 0, + skipped_samples: 0, + }); + } + }; + + let mut candidates = Vec::new(); + let mut skipped = 0; + for sample in legacy.samples { + let account_matches = sample.account_key.trim() == accepted_account; + let valid = sample.window_minutes == 10_080 + && sample.sampled_at <= now + && sample.used_percent.is_finite() + && (0.0 < sample.used_percent && sample.used_percent <= 100.0); + let normalized_reset = normalize_legacy_reset(sample.resets_at); + let in_bounds = normalized_reset + .checked_sub(10_080 * 60) + .is_some_and(|start| { + start <= sample.sampled_at && sample.sampled_at <= normalized_reset + }); + if !account_matches || !valid || !in_bounds { + skipped += 1; + continue; + } + candidates.push(QuotaSample { + reset_at: normalized_reset, + duration_seconds: 10_080 * 60, + duration_source: DurationSource::Provider, + used_percent: sample.used_percent, + sampled_at: sample.sampled_at, + origin: SampleOrigin::ImportedV2, + }); + } + if candidates.is_empty() { + return Ok(MigrationOutcome { + imported_samples: 0, + skipped_samples: skipped, + }); + } + + let key = SeriesKey::new("codex", account_scope.trim(), "main.weekly.v1"); + if !key.is_valid() { + return Err(HistoryError::InvalidSeriesKey); + } + let imported = with_locked_transaction(v3_path, now, transaction_clock, |store| { + let merge = match store + .series + .binary_search_by(|series| series.key().cmp(&key)) + { + Ok(index) => { + let series = &mut store.series[index]; + let merge = merge_imported_samples(series, &candidates); + if let Some(activity) = merge.accepted_last_activity_at { + series.last_activity_at = series.last_activity_at.max(activity); + } + merge + } + Err(index) => { + let mut series = SeriesState::new(&key, now); + let merge = merge_imported_samples(&mut series, &candidates); + if merge.imported_samples == 0 { + return Ok(0); + } + series.last_activity_at = merge.accepted_last_activity_at.unwrap_or(now); + store.series.insert(index, series); + merge + } + }; + if merge.imported_samples == 0 { + return Ok(0); + } + let active_keys = BTreeSet::from([key.clone()]); + retain_store(store, now, &active_keys)?; + Ok(merge.imported_samples) + })?; + Ok(MigrationOutcome { + imported_samples: imported, + skipped_samples: skipped, + }) +} + +#[derive(Debug, Clone, Copy)] +struct MergeOutcome { + imported_samples: usize, + accepted_last_activity_at: Option, +} + +fn merge_imported_samples(series: &mut SeriesState, imported: &[QuotaSample]) -> MergeOutcome { + let mut merged = BTreeMap::new(); + for sample in series + .samples + .iter() + .cloned() + .chain(imported.iter().cloned()) + { + let key = sample_key(&sample); + let selected = match merged.remove(&key) { + Some(existing) => choose_sample(existing, sample), + None => sample, + }; + merged.insert(key, selected); + } + let before = series.samples.clone(); + series.samples = merged.into_values().collect(); + series.samples.sort_by(sample_order); + let accepted = series + .samples + .iter() + .filter(|sample| sample.origin == SampleOrigin::ImportedV2) + .filter(|sample| !before.iter().any(|old| old == *sample)) + .collect::>(); + MergeOutcome { + imported_samples: accepted.len(), + accepted_last_activity_at: accepted.iter().map(|sample| sample.sampled_at).max(), + } +} + +fn choose_sample(existing: QuotaSample, candidate: QuotaSample) -> QuotaSample { + if origin_order(candidate.origin) != origin_order(existing.origin) { + return if origin_order(candidate.origin) > origin_order(existing.origin) { + candidate + } else { + existing + }; + } + if candidate.sampled_at != existing.sampled_at { + return if candidate.sampled_at > existing.sampled_at { + candidate + } else { + existing + }; + } + if candidate.used_percent.total_cmp(&existing.used_percent) != std::cmp::Ordering::Equal { + return if candidate.used_percent > existing.used_percent { + candidate + } else { + existing + }; + } + let candidate_bytes = serde_json::to_vec(&candidate).unwrap_or_default(); + let existing_bytes = serde_json::to_vec(&existing).unwrap_or_default(); + if candidate_bytes < existing_bytes { + candidate + } else { + existing + } +} + +/// Record a complete provider snapshot in one locked transaction. The active +/// key set may include emitted cards that have no observation in this poll. +pub(crate) fn record_observations_and_evaluate( + emitted_active_keys: &[SeriesKey], + observations: &[QuotaObservation], + now: i64, +) -> Result, HistoryError> { + let path = production_history_path().ok_or(HistoryError::StorageUnavailable)?; + record_observations_at_path_and_evaluate(emitted_active_keys, observations, now, &path) +} + +/// Testable path-injected batch variant. Load, admission, mutation, retention, +/// atomic save, and all evaluations share one v3 transaction. +pub(crate) fn record_observations_at_path_and_evaluate( + emitted_active_keys: &[SeriesKey], + observations: &[QuotaObservation], + now: i64, + path: &Path, +) -> Result, HistoryError> { + record_observations_at_path_and_evaluate_with_clock( + emitted_active_keys, + observations, + now, + path, + unix_now, + ) +} + +/// Testable path-injected single-observation compatibility wrapper. +pub(crate) fn record_observation_at_path( + key: SeriesKey, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, + path: &Path, +) -> Result { + record_observation_at_path_and_evaluate( + key, + reset_at, + used_percent, + now, + provider, + contract, + path, + ) + .map(|(outcome, _)| outcome) +} + +pub(crate) fn record_observation_at_path_and_evaluate( + key: SeriesKey, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, + path: &Path, +) -> Result<(HistoryOutcome, Option), HistoryError> { + record_observation_at_path_and_evaluate_with_clock( + key, + reset_at, + used_percent, + now, + provider, + contract, + path, + unix_now, + ) +} + +#[allow(clippy::too_many_arguments)] +fn record_observation_at_path_with_clock( + key: SeriesKey, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, + path: &Path, + transaction_clock: impl FnOnce() -> i64, +) -> Result { + record_observation_at_path_and_evaluate_with_clock( + key, + reset_at, + used_percent, + now, + provider, + contract, + path, + transaction_clock, + ) + .map(|(outcome, _)| outcome) +} + +#[allow(clippy::too_many_arguments)] +fn record_observation_at_path_and_evaluate_with_clock( + key: SeriesKey, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, + path: &Path, + transaction_clock: impl FnOnce() -> i64, +) -> Result<(HistoryOutcome, Option), HistoryError> { + let observation = QuotaObservation { + key, + reset_at, + used_percent, + provider, + contract, + }; + if let Some(result) = preflight_single_observation(&observation, now)? { + return result.map(|(outcome, historical, _)| (outcome, historical)); + } + let active_keys = [observation.key.clone()]; + let observations = [observation]; + let mut results = record_observations_at_path_and_evaluate_with_clock( + &active_keys, + &observations, + now, + path, + transaction_clock, + )?; + let (outcome, historical, _) = results.pop().ok_or(HistoryError::Serialize)??; + Ok((outcome, historical)) +} + +fn preflight_single_observation( + observation: &QuotaObservation, + now: i64, +) -> Result, HistoryError> { + if !observation.key.is_valid() { + return Err(HistoryError::InvalidSeriesKey); + } + Ok(match prepare_observation(observation, now) { + PreparedObservation::Early(result) => Some(result), + PreparedObservation::Candidate(_) => None, + }) +} + +fn prepare_observation(observation: &QuotaObservation, now: i64) -> PreparedObservation { + let resolution = agent_quota_duration::resolve_duration( + now, + observation.reset_at, + observation.provider, + observation.contract, + None, + ); + if let DurationResolution::Unavailable(reason) = resolution { + return PreparedObservation::Early(Ok((HistoryOutcome::Unavailable(reason), None, 0))); + } + if !observation.used_percent.is_finite() || !(0.0..=100.0).contains(&observation.used_percent) { + return PreparedObservation::Early(Ok(( + HistoryOutcome::Unavailable(DurationUnavailableReason::InvalidEvidence), + None, + 0, + ))); + } + PreparedObservation::Candidate(resolution) +} + +#[allow(clippy::too_many_arguments)] +fn record_observations_at_path_and_evaluate_with_clock( + emitted_active_keys: &[SeriesKey], + observations: &[QuotaObservation], + now: i64, + path: &Path, + transaction_clock: impl FnOnce() -> i64, +) -> Result, HistoryError> { + record_observations_at_path_and_evaluate_with_clock_and_save( + emitted_active_keys, + observations, + now, + path, + transaction_clock, + save_store_atomic, + ) +} + +#[allow(clippy::too_many_arguments)] +fn record_observations_at_path_and_evaluate_with_clock_and_save( + emitted_active_keys: &[SeriesKey], + observations: &[QuotaObservation], + now: i64, + path: &Path, + transaction_clock: impl FnOnce() -> i64, + save: impl Fn(&Path, &Store) -> io::Result<()>, +) -> Result, HistoryError> { + let active_keys = validate_batch_keys(emitted_active_keys, observations)?; + let mut results = vec![None; observations.len()]; + let mut admissions = Vec::new(); + let mut candidate_keys = BTreeSet::new(); + for (index, observation) in observations.iter().enumerate() { + match prepare_observation(observation, now) { + PreparedObservation::Early(result) => results[index] = Some(result), + PreparedObservation::Candidate(resolution) => { + admissions.push(ObservationAdmission { index, resolution }); + candidate_keys.insert(observation.key.clone()); + } + } + } + + with_locked_transaction_with_save(path, now, transaction_clock, save, |store| { + retain_store(store, now, &active_keys)?; + let admitted = admit_observation_keys(store, &active_keys, &candidate_keys, now)?; + for admission in &admissions { + let observation = &observations[admission.index]; + if !admitted.contains(&observation.key) { + results[admission.index] = Some(Err(HistoryError::StoreCapacity)); + continue; + } + let key_index = store + .series + .binary_search_by(|series| series.key().cmp(&observation.key)) + .map_err(|_| HistoryError::Serialize)?; + let stale = is_stale_observation( + &store.series[key_index], + observation.reset_at.ok_or(HistoryError::Serialize)?, + now, + ); + let outcome = if stale { + stale_outcome(admission.resolution) + } else { + let series = &mut store.series[key_index]; + let outcome = match admission.resolution { + DurationResolution::Ready { + duration_seconds, + source, + } => apply_known_duration( + series, + observation.reset_at.ok_or(HistoryError::Serialize)?, + duration_seconds, + source, + observation.used_percent, + now, + ), + DurationResolution::LearningDuration => apply_observed_duration( + series, + observation.reset_at.ok_or(HistoryError::Serialize)?, + observation.used_percent, + now, + )?, + DurationResolution::Unavailable(_) => return Err(HistoryError::Serialize), + }; + if !matches!(outcome, HistoryOutcome::Unavailable(_)) { + series.last_activity_at = series.last_activity_at.max(now); + } + outcome + }; + results[admission.index] = Some(Ok((outcome, None, 0))); + } + retain_store(store, now, &active_keys)?; + for admission in &admissions { + let Some(Ok((outcome, _, _))) = results[admission.index].as_ref() else { + continue; + }; + let observation = &observations[admission.index]; + let Some(reset_at) = observation.reset_at else { + continue; + }; + let (pace, complete_cycles) = duration_for_outcome(*outcome) + .map(|duration| { + let complete_cycles = + complete_cycle_count(store, &observation.key, reset_at, duration, now); + let pace = evaluate_current( + store, + &observation.key, + reset_at, + duration, + observation.used_percent, + now, + ); + (pace, complete_cycles) + }) + .unwrap_or((None, 0)); + results[admission.index] = Some(Ok((*outcome, pace, complete_cycles))); + } + Ok(()) + })?; + + results + .into_iter() + .map(|result| result.ok_or(HistoryError::Serialize)) + .collect() +} + +fn validate_batch_keys( + emitted_active_keys: &[SeriesKey], + observations: &[QuotaObservation], +) -> Result, HistoryError> { + let mut active_keys = BTreeSet::new(); + for key in emitted_active_keys { + if !key.is_valid() || !active_keys.insert(key.clone()) { + return Err(HistoryError::InvalidSeriesKey); + } + } + let mut observation_keys = BTreeSet::new(); + for observation in observations { + if !observation.key.is_valid() || !observation_keys.insert(observation.key.clone()) { + return Err(HistoryError::InvalidSeriesKey); + } + active_keys.insert(observation.key.clone()); + } + Ok(active_keys) +} + +fn admit_observation_keys( + store: &mut Store, + active_keys: &BTreeSet, + candidate_keys: &BTreeSet, + now: i64, +) -> Result, HistoryError> { + if store.series.len() > MAX_SERIES { + return Err(HistoryError::StoreCapacity); + } + let existing_candidates = candidate_keys + .iter() + .filter(|key| { + store + .series + .binary_search_by(|series| series.key().cmp(key)) + .is_ok() + }) + .cloned() + .collect::>(); + let new_candidates = candidate_keys + .difference(&existing_candidates) + .cloned() + .collect::>(); + let available = MAX_SERIES.saturating_sub(store.series.len()); + let needed_evictions = new_candidates.len().saturating_sub(available); + if needed_evictions > 0 { + let mut inactive = store + .series + .iter() + .filter(|series| { + let key = series.key(); + !series_is_active(series, &key, active_keys, now) + && !existing_candidates.contains(&key) + }) + .map(|series| (series.last_activity_at, series.key())) + .collect::>(); + inactive.sort(); + for (_, key) in inactive.into_iter().take(needed_evictions) { + if let Ok(index) = store + .series + .binary_search_by(|series| series.key().cmp(&key)) + { + store.series.remove(index); + } + } + } + let mut admitted = existing_candidates; + let available = MAX_SERIES.saturating_sub(store.series.len()); + for key in new_candidates.into_iter().take(available) { + let index = store + .series + .binary_search_by(|series| series.key().cmp(&key)) + .unwrap_or_else(|index| index); + store.series.insert(index, SeriesState::new(&key, now)); + admitted.insert(key); + } + Ok(admitted) +} + +fn unix_now() -> i64 { + chrono::Utc::now().timestamp() +} + +fn stale_outcome(resolution: DurationResolution) -> HistoryOutcome { + match resolution { + DurationResolution::Ready { + duration_seconds, + source, + } => HistoryOutcome::Ready { + duration_seconds, + source, + sampled: false, + }, + DurationResolution::LearningDuration => HistoryOutcome::LearningDuration, + DurationResolution::Unavailable(reason) => HistoryOutcome::Unavailable(reason), + } +} + +fn rollover_reset_at(state: &ObservedState) -> i64 { + match state { + ObservedState::Watching { reset_at, .. } | ObservedState::Ready { reset_at, .. } => { + *reset_at + } + ObservedState::Candidate { new_reset_at, .. } => *new_reset_at, + } +} + +fn tracked_reset_at(series: &SeriesState) -> Option { + series + .active_reset_at + .or_else(|| series.rollover.as_ref().map(rollover_reset_at)) + .or_else(|| series.samples.iter().map(|sample| sample.reset_at).max()) +} + +fn is_stale_observation(series: &SeriesState, reset_at: i64, now: i64) -> bool { + now < series.last_activity_at + || (now == series.last_activity_at + && tracked_reset_at(series).is_some_and(|tracked| reset_at < tracked)) +} + +fn is_backward_reset(series: &SeriesState, reset_at: i64, duration_seconds: i64) -> bool { + tracked_reset_at(series).is_some_and(|tracked| { + normalize_reset(reset_at, duration_seconds) < normalize_reset(tracked, duration_seconds) + }) +} + +fn requires_observed_relearning(series: &SeriesState) -> bool { + if series.active_reset_at.is_some() || series.samples.is_empty() { + return false; + } + let Some(rollover) = series.rollover.as_ref() else { + return false; + }; + let newest_sample = series.samples.iter().map(|sample| sample.reset_at).max(); + match rollover { + ObservedState::Watching { reset_at, .. } => { + newest_sample.is_some_and(|sample_reset| sample_reset > *reset_at) + } + ObservedState::Candidate { old_reset_at, .. } => { + newest_sample.is_some_and(|sample_reset| sample_reset > *old_reset_at) + } + ObservedState::Ready { .. } => false, + } +} + +fn apply_known_duration( + series: &mut SeriesState, + reset_at: i64, + duration_seconds: i64, + source: DurationSource, + used_percent: f64, + now: i64, +) -> HistoryOutcome { + let backward = is_backward_reset(series, reset_at, duration_seconds); + let relearning = requires_observed_relearning(series); + let transition = match observe_reset(series.rollover.as_ref(), reset_at, now) { + Ok(transition) => transition, + Err(reason) => return HistoryOutcome::Unavailable(reason), + }; + series.rollover = Some(transition.state); + if backward || relearning { + series.active_reset_at = None; + return HistoryOutcome::LearningDuration; + } + series.active_reset_at = Some(reset_at); + let sampled = add_sample_if_new( + series, + reset_at, + duration_seconds, + source, + used_percent, + now, + ); + HistoryOutcome::Ready { + duration_seconds, + source, + sampled, + } +} + +fn apply_observed_duration( + series: &mut SeriesState, + reset_at: i64, + used_percent: f64, + now: i64, +) -> Result { + let transition = match observe_reset(series.rollover.as_ref(), reset_at, now) { + Ok(transition) => transition, + Err(reason) => return Ok(HistoryOutcome::Unavailable(reason)), + }; + let duration_seconds = transition.duration_seconds; + series.rollover = Some(transition.state); + if let Some(duration_seconds) = duration_seconds { + series.active_reset_at = Some(reset_at); + // Once an observed rollover is confirmed, subsequent polls use the + // same phase-bucket admission rules as provider/contract samples. The + // duplicate flag only describes the rollover state transition; it must + // not suppress useful later samples in the ready cycle. + let sampled = add_sample_if_new( + series, + reset_at, + duration_seconds, + DurationSource::Observed, + used_percent, + now, + ); + Ok(HistoryOutcome::Ready { + duration_seconds, + source: DurationSource::Observed, + sampled, + }) + } else { + if series.active_reset_at != Some(reset_at) { + series.active_reset_at = None; + } + Ok(HistoryOutcome::LearningDuration) + } +} + +fn add_sample_if_new( + series: &mut SeriesState, + reset_at: i64, + duration_seconds: i64, + source: DurationSource, + used_percent: f64, + sampled_at: i64, +) -> bool { + if !(valid_duration(duration_seconds) + && used_percent.is_finite() + && 0.0 < used_percent + && used_percent <= 100.0) + { + return false; + } + let normalized_reset = normalize_sample_reset(reset_at, duration_seconds, sampled_at); + let candidate = QuotaSample { + reset_at: normalized_reset, + duration_seconds, + duration_source: source, + used_percent, + sampled_at, + origin: SampleOrigin::LiveV3, + }; + if !validate_sample(&candidate) { + return false; + } + let candidate_key = sample_key(&candidate); + if let Some(index) = series + .samples + .iter() + .position(|sample| sample_key(sample) == candidate_key) + { + let existing = &series.samples[index]; + if (used_percent - existing.used_percent).abs() < 1.0 + || sampled_at < existing.sampled_at + || (sampled_at == existing.sampled_at && used_percent <= existing.used_percent) + { + return false; + } + series.samples[index] = candidate; + return true; + } + + let cycle_count = series + .samples + .iter() + .filter(|sample| { + normalize_reset(sample.reset_at, sample.duration_seconds) == normalized_reset + }) + .count(); + if cycle_count >= MAX_SAMPLES_PER_CYCLE { + return false; + } + series.samples.push(candidate); + true +} + +fn sample_key(sample: &QuotaSample) -> (i64, usize) { + let normalized_reset = normalize_reset(sample.reset_at, sample.duration_seconds); + ( + normalized_reset, + phase_bucket(normalized_reset, sample.duration_seconds, sample.sampled_at), + ) +} + +fn sample_order(left: &QuotaSample, right: &QuotaSample) -> std::cmp::Ordering { + left.reset_at + .cmp(&right.reset_at) + .then(left.duration_seconds.cmp(&right.duration_seconds)) + .then(left.sampled_at.cmp(&right.sampled_at)) + .then(left.used_percent.total_cmp(&right.used_percent)) + .then_with(|| origin_order(left.origin).cmp(&origin_order(right.origin))) + .then_with(|| source_order(left.duration_source).cmp(&source_order(right.duration_source))) +} + +fn origin_order(origin: SampleOrigin) -> u8 { + match origin { + SampleOrigin::ImportedV2 => 0, + SampleOrigin::LiveV3 => 1, + } +} + +fn source_order(source: DurationSource) -> u8 { + match source { + DurationSource::Provider => 0, + DurationSource::Contract => 1, + DurationSource::Observed => 2, + } +} + +fn series_order(left: &SeriesState, right: &SeriesState) -> std::cmp::Ordering { + left.key().cmp(&right.key()) +} + +fn normalize_reset(reset_at: i64, duration_seconds: i64) -> i64 { + let quantum = duration_seconds + .checked_div(100) + .unwrap_or(0) + .clamp(60, 300); + let quantum = quantum.max(1); + let quotient = reset_at.div_euclid(quantum); + let remainder = reset_at.rem_euclid(quantum); + let rounded = if remainder.saturating_mul(2) >= quantum { + quotient.saturating_add(1) + } else { + quotient + }; + rounded.saturating_mul(quantum) +} + +fn normalize_sample_reset(reset_at: i64, duration_seconds: i64, sampled_at: i64) -> i64 { + normalize_reset(reset_at, duration_seconds).clamp( + sampled_at, + sampled_at.saturating_add(duration_seconds.max(1)), + ) +} + +fn normalize_legacy_reset(reset_at: i64) -> i64 { + let quantum = 300_i64; + let quotient = reset_at.div_euclid(quantum); + let remainder = reset_at.rem_euclid(quantum); + let rounded = if remainder >= quantum / 2 { + quotient.saturating_add(1) + } else { + quotient + }; + rounded.saturating_mul(quantum) +} + +fn phase(sample: &QuotaSample) -> f64 { + let normalized_reset = normalize_reset(sample.reset_at, sample.duration_seconds); + let Some(remaining) = normalized_reset.checked_sub(sample.sampled_at) else { + return 0.0; + }; + (1.0 - remaining as f64 / sample.duration_seconds as f64).clamp(0.0, 1.0) +} + +fn phase_bucket(reset_at: i64, duration_seconds: i64, sampled_at: i64) -> usize { + let remaining = reset_at.saturating_sub(sampled_at); + let u = (1.0 - remaining as f64 / duration_seconds.max(1) as f64).clamp(0.0, 1.0); + ((u * PHASE_BUCKET_COUNT as f64).floor() as usize).min(PHASE_BUCKET_COUNT - 1) +} + +fn validate_sample(sample: &QuotaSample) -> bool { + let Some(cycle_started_at) = sample.reset_at.checked_sub(sample.duration_seconds) else { + return false; + }; + valid_duration(sample.duration_seconds) + && cycle_started_at <= sample.sampled_at + && sample.sampled_at <= sample.reset_at + && sample.used_percent.is_finite() + && (0.0 < sample.used_percent && sample.used_percent <= 100.0) +} + +fn validate_series(series: &SeriesState) -> bool { + let key = series.key(); + let mut sample_keys = BTreeSet::new(); + let mut cycle_counts: BTreeMap = BTreeMap::new(); + let samples_valid = series.samples.iter().all(|sample| { + if !validate_sample(sample) || !sample_keys.insert(sample_key(sample)) { + return false; + } + let reset = normalize_reset(sample.reset_at, sample.duration_seconds); + let count = cycle_counts.entry(reset).or_default(); + *count += 1; + *count <= MAX_SAMPLES_PER_CYCLE + }); + let rollover_valid = series.rollover.as_ref().is_none_or(|rollover| { + if !agent_quota_duration::validate_observed_state(rollover) { + return false; + } + match rollover { + ObservedState::Watching { reset_at, .. } + | ObservedState::Candidate { + new_reset_at: reset_at, + .. + } => series + .active_reset_at + .is_none_or(|active_reset| active_reset == *reset_at), + ObservedState::Ready { reset_at, .. } => series.active_reset_at == Some(*reset_at), + } + }); + let activity_valid = series + .samples + .iter() + .all(|sample| series.last_activity_at >= sample.sampled_at) + && series + .rollover + .as_ref() + .is_none_or(|rollover| match rollover { + ObservedState::Watching { last_seen_at, .. } => { + series.last_activity_at >= *last_seen_at + } + ObservedState::Candidate { + first_new_seen_at, .. + } => series.last_activity_at >= *first_new_seen_at, + ObservedState::Ready { last_seen_at, .. } => { + series.last_activity_at >= *last_seen_at + } + }); + key.is_valid() && rollover_valid && samples_valid && activity_valid +} + +fn validate_store(store: &Store) -> bool { + store.schema_version == HISTORY_SCHEMA_VERSION + && store + .series + .windows(2) + .all(|pair| series_order(&pair[0], &pair[1]).is_lt()) + && store.series.iter().all(validate_series) +} + +fn validate_store_at(store: &Store, now: i64) -> bool { + validate_store(store) + && store + .series + .iter() + .all(|series| series.last_activity_at <= now) +} + +fn duration_for_resolution(resolution: DurationResolution) -> Option { + match resolution { + DurationResolution::Ready { + duration_seconds, .. + } => Some(duration_seconds), + DurationResolution::LearningDuration | DurationResolution::Unavailable(_) => None, + } +} + +fn duration_for_outcome(outcome: HistoryOutcome) -> Option { + match outcome { + HistoryOutcome::Ready { + duration_seconds, .. + } => Some(duration_seconds), + HistoryOutcome::LearningDuration | HistoryOutcome::Unavailable(_) => None, + } +} + +fn complete_cycle_count( + store: &Store, + key: &SeriesKey, + current_reset_at: i64, + duration_seconds: i64, + now: i64, +) -> usize { + let current_reset_at = normalize_reset(current_reset_at, duration_seconds); + store + .series + .iter() + .find(|series| series.key() == *key) + .map(|series| historical_cycles(series, current_reset_at, now).len()) + .unwrap_or_default() +} + +#[derive(Debug, Clone)] +struct CycleProfile { + reset_at: i64, + duration_seconds: i64, + cycle_started_at: i64, + curve: Vec, +} + +fn median_i64(values: impl IntoIterator) -> Option { + let mut values = values.into_iter().collect::>(); + if values.is_empty() { + return None; + } + values.sort_unstable(); + let middle = values.len() / 2; + if values.len() % 2 == 1 { + Some(values[middle]) + } else { + Some(values[middle - 1].saturating_add(values[middle]) / 2) + } +} + +fn cycle_duration(samples: &[QuotaSample]) -> Option { + median_i64(samples.iter().map(|sample| sample.duration_seconds)) + .filter(|duration| valid_duration(*duration)) +} + +fn grouped_samples(samples: &[QuotaSample]) -> BTreeMap> { + let mut groups = BTreeMap::new(); + for sample in samples.iter().filter(|sample| validate_sample(sample)) { + groups + .entry(normalize_reset(sample.reset_at, sample.duration_seconds)) + .or_insert_with(Vec::new) + .push(sample.clone()); + } + groups +} + +fn cycle_profile(reset_at: i64, samples: &[QuotaSample], now: i64) -> Option { + if reset_at > now || samples.len() < MIN_COMPLETE_BUCKETS { + return None; + } + let duration_seconds = cycle_duration(samples)?; + let mut buckets = BTreeSet::new(); + let mut phases = Vec::with_capacity(samples.len()); + for sample in samples { + if !validate_sample(sample) + || normalize_reset(sample.reset_at, sample.duration_seconds) != reset_at + { + return None; + } + buckets.insert(sample_key(sample).1); + phases.push(phase(sample)); + } + if buckets.len() < MIN_COMPLETE_BUCKETS { + return None; + } + phases.sort_by(f64::total_cmp); + let boundary = (0.10_f64).min(86_400.0 / duration_seconds as f64); + let has_start = phases + .first() + .is_some_and(|phase| *phase <= boundary + EPSILON); + let has_end = phases + .last() + .is_some_and(|phase| *phase + EPSILON >= 1.0 - boundary); + if !has_start || !has_end { + return None; + } + let max_gap = phases + .iter() + .copied() + .fold((0.0_f64, 0.0_f64), |(largest, previous), current| { + (largest.max(current - previous), current) + }) + .0 + .max(1.0 - phases.last().copied().unwrap_or(0.0)); + if max_gap > MAX_PHASE_GAP + EPSILON { + return None; + } + let cycle_started_at = reset_at.checked_sub(duration_seconds)?; + Some(CycleProfile { + reset_at, + duration_seconds, + cycle_started_at, + curve: reconstruct_cycle_curve(samples), + }) +} + +fn reconstruct_cycle_curve(samples: &[QuotaSample]) -> Vec { + let mut points = samples + .iter() + .filter(|sample| validate_sample(sample)) + .map(|sample| (phase(sample), sample.used_percent.clamp(0.0, 100.0))) + .collect::>(); + points.sort_by(|left, right| left.0.total_cmp(&right.0).then(left.1.total_cmp(&right.1))); + + let mut monotone = Vec::with_capacity(points.len() + 2); + let mut running_max = 0.0_f64; + for (phase, value) in points { + running_max = running_max.max(value); + monotone.push((phase, running_max)); + } + let end = monotone.last().map(|(_, value)| *value).unwrap_or(0.0); + monotone.push((0.0, 0.0)); + monotone.push((1.0, end)); + monotone.sort_by(|left, right| left.0.total_cmp(&right.0).then(left.1.total_cmp(&right.1))); + running_max = 0.0; + for (_, value) in &mut monotone { + running_max = running_max.max(*value); + *value = running_max.clamp(0.0, 100.0); + } + + let mut curve = vec![0.0; GRID_POINT_COUNT]; + let mut upper = 1usize; + for (index, value) in curve.iter_mut().enumerate() { + let phase = index as f64 / (GRID_POINT_COUNT - 1) as f64; + while upper < monotone.len() && monotone[upper].0 < phase { + upper += 1; + } + if phase <= monotone[0].0 { + *value = monotone[0].1; + } else if phase >= monotone[monotone.len() - 1].0 { + *value = monotone[monotone.len() - 1].1; + } else { + let high = monotone[upper.min(monotone.len() - 1)]; + let low = monotone[upper.saturating_sub(1)]; + let ratio = if high.0 <= low.0 { + 0.0 + } else { + ((phase - low.0) / (high.0 - low.0)).clamp(0.0, 1.0) + }; + *value = low.1 + (high.1 - low.1) * ratio; + } + } + let mut maximum = 0.0_f64; + for value in &mut curve { + maximum = maximum.max(*value); + *value = maximum.clamp(0.0, 100.0); + } + curve +} + +fn historical_cycles(series: &SeriesState, current_reset_at: i64, now: i64) -> Vec { + grouped_samples(&series.samples) + .into_iter() + .filter(|(reset_at, _)| *reset_at < current_reset_at) + .filter_map(|(reset_at, samples)| cycle_profile(reset_at, &samples, now)) + .collect() +} + +fn current_group_reset(series: &SeriesState) -> Option { + let active_reset = series.active_reset_at?; + series + .samples + .iter() + .map(|sample| normalize_reset(active_reset, sample.duration_seconds)) + .find(|reset| { + series + .samples + .iter() + .any(|sample| normalize_reset(sample.reset_at, sample.duration_seconds) == *reset) + }) +} + +fn series_nominal_duration(series: &SeriesState, now: i64) -> i64 { + let completed = historical_cycles(series, i64::MAX, now); + median_i64(completed.iter().map(|cycle| cycle.duration_seconds)) + .or_else(|| { + series + .rollover + .as_ref() + .and_then(agent_quota_duration::observed_duration) + }) + .or_else(|| median_i64(series.samples.iter().map(|sample| sample.duration_seconds))) + .unwrap_or(1) + .clamp(1, agent_quota_duration::MAX_DURATION_SECONDS) +} + +fn retention_limits(nominal_duration: i64) -> (usize, i64) { + let nominal = nominal_duration.max(1) as f64; + let cycles = (28.0 * 86_400.0 / nominal).ceil() as usize; + let retained = cycles.clamp(RETENTION_MIN_CYCLES, RETENTION_MAX_CYCLES); + let horizon = (retained as i64) + .saturating_mul(nominal_duration.max(1)) + .max(RETENTION_MIN_SECONDS) + .clamp(RETENTION_MIN_SECONDS, RETENTION_MAX_SECONDS); + (retained, horizon) +} + +fn clear_stale_rollover(series: &mut SeriesState, now: i64) { + let stale = series + .rollover + .as_ref() + .is_some_and(|rollover| match rollover { + ObservedState::Watching { reset_at, .. } => reset_at + .checked_add(agent_quota_duration::ROLLOVER_GRACE_SECONDS) + .is_some_and(|deadline| now > deadline), + ObservedState::Candidate { old_reset_at, .. } => old_reset_at + .checked_add(agent_quota_duration::ROLLOVER_GRACE_SECONDS) + .is_some_and(|deadline| now > deadline), + ObservedState::Ready { reset_at, .. } => reset_at + .checked_add(agent_quota_duration::ROLLOVER_GRACE_SECONDS) + .is_some_and(|deadline| now > deadline), + }); + if stale { + series.rollover = None; + series.active_reset_at = None; + } +} + +fn retain_series(series: &mut SeriesState, now: i64) { + clear_stale_rollover(series, now); + let nominal = series_nominal_duration(series, now); + let (retained_cycles, horizon) = retention_limits(nominal); + let cutoff = now.saturating_sub(horizon); + let groups = grouped_samples(&series.samples); + let complete = groups + .iter() + .filter_map(|(reset_at, samples)| cycle_profile(*reset_at, samples, now)) + .collect::>(); + let mut keep_completed = complete + .iter() + .filter(|cycle| cycle.reset_at >= cutoff) + .map(|cycle| cycle.reset_at) + .collect::>(); + keep_completed.sort_unstable_by(|left, right| right.cmp(left)); + keep_completed.truncate(retained_cycles); + let keep_completed = keep_completed.into_iter().collect::>(); + let current_group = current_group_reset(series); + series.samples.retain(|sample| { + let reset = normalize_reset(sample.reset_at, sample.duration_seconds); + if keep_completed.contains(&reset) { + return true; + } + current_group == Some(reset) + }); + series.samples.sort_by(sample_order); + + if series.samples.is_empty() + && series.rollover.is_some() + && series.last_activity_at < now.saturating_sub(RETENTION_MIN_SECONDS) + { + series.rollover = None; + series.active_reset_at = None; + } + if series.samples.is_empty() && series.rollover.is_none() { + series.active_reset_at = None; + } +} + +fn series_is_active( + series: &SeriesState, + key: &SeriesKey, + active_keys: &BTreeSet, + now: i64, +) -> bool { + if active_keys.contains(key) { + return true; + } + let reset_active = series.active_reset_at.is_some_and(|reset| { + reset >= now.saturating_sub(agent_quota_duration::ROLLOVER_GRACE_SECONDS) + }); + let rollover_active = series.rollover.as_ref().is_some_and(|rollover| { + rollover_reset_at(rollover) + >= now.saturating_sub(agent_quota_duration::ROLLOVER_GRACE_SECONDS) + }); + reset_active || rollover_active +} + +fn evict_inactive_series( + store: &mut Store, + active_keys: &BTreeSet, + now: i64, +) -> Result<(), HistoryError> { + if store.series.len() <= MAX_SERIES { + return Ok(()); + } + let mut inactive = store + .series + .iter() + .filter(|series| !series_is_active(series, &series.key(), active_keys, now)) + .map(|series| (series.last_activity_at, series.key())) + .collect::>(); + inactive.sort(); + for (_, key) in inactive { + if store.series.len() <= MAX_SERIES { + break; + } + if let Ok(index) = store + .series + .binary_search_by(|series| series.key().cmp(&key)) + { + store.series.remove(index); + } + } + if store.series.len() > MAX_SERIES { + return Err(HistoryError::StoreCapacity); + } + Ok(()) +} + +fn evict_old_completed_samples(store: &mut Store, now: i64) -> Result<(), HistoryError> { + let mut candidates = Vec::new(); + for series in &store.series { + let current_group = current_group_reset(series); + for (reset_at, samples) in grouped_samples(&series.samples) { + if current_group == Some(reset_at) { + continue; + } + if cycle_profile(reset_at, &samples, now).is_some() { + candidates.push(( + reset_at, + series.provider_id.clone(), + series.account_scope.clone(), + series.window_key.clone(), + )); + } + } + } + candidates.sort(); + while store + .series + .iter() + .map(|series| series.samples.len()) + .sum::() + > MAX_SAMPLES + { + let Some((reset_at, provider_id, account_scope, window_key)) = candidates.first().cloned() + else { + return Err(HistoryError::StoreCapacity); + }; + candidates.remove(0); + if let Some(series) = store.series.iter_mut().find(|series| { + series.provider_id == provider_id + && series.account_scope == account_scope + && series.window_key == window_key + }) { + series.samples.retain(|sample| { + normalize_reset(sample.reset_at, sample.duration_seconds) != reset_at + }); + } + } + Ok(()) +} + +fn retain_store( + store: &mut Store, + now: i64, + active_keys: &BTreeSet, +) -> Result<(), HistoryError> { + for series in &mut store.series { + retain_series(series, now); + } + store.series.retain(|series| { + !series.samples.is_empty() + || series.rollover.is_some() + || active_keys.contains(&series.key()) + }); + store.series.sort_by(series_order); + evict_inactive_series(store, active_keys, now)?; + evict_old_completed_samples(store, now)?; + store.series.retain(|series| { + !series.samples.is_empty() + || series.rollover.is_some() + || active_keys.contains(&series.key()) + }); + store.series.sort_by(series_order); + Ok(()) +} + +fn interpolate_curve(curve: &[f64], phase: f64) -> f64 { + if curve.is_empty() { + return 0.0; + } + if curve.len() == 1 { + return curve[0]; + } + let scaled = phase.clamp(0.0, 1.0) * (curve.len() - 1) as f64; + let lower = scaled.floor() as usize; + let upper = (lower + 1).min(curve.len() - 1); + if lower == upper { + curve[lower] + } else { + curve[lower] + (curve[upper] - curve[lower]) * (scaled - lower as f64) + } +} + +fn first_crossing(phase_now: f64, curve: &[f64], shift: f64, actual_at_now: f64) -> Option { + if curve.len() < 2 { + return None; + } + let denominator = (curve.len() - 1) as f64; + let mut previous_phase = phase_now; + let mut previous_value = actual_at_now; + let start = ((phase_now * denominator).floor() as usize + 1).clamp(1, curve.len() - 1); + for (index, value) in curve.iter().enumerate().skip(start) { + let phase = index as f64 / denominator; + if phase <= phase_now + EPSILON { + continue; + } + let shifted = *value + shift; + if previous_value < RUNOUT_THRESHOLD_PERCENT && shifted >= RUNOUT_THRESHOLD_PERCENT { + let delta = shifted - previous_value; + if delta.abs() <= EPSILON { + return Some(phase); + } + let ratio = ((100.0 - previous_value) / delta).clamp(0.0, 1.0); + return Some((previous_phase + ratio * (phase - previous_phase)).clamp(phase_now, 1.0)); + } + previous_phase = phase; + previous_value = shifted; + } + None +} + +fn evaluate_current( + store: &Store, + key: &SeriesKey, + reset_at: i64, + duration_seconds: i64, + actual: f64, + now: i64, +) -> Option { + if !valid_duration(duration_seconds) + || !actual.is_finite() + || !(0.0..=100.0).contains(&actual) + || reset_at <= now + { + return None; + } + let series = store.series.iter().find(|series| series.key() == *key)?; + let normalized_current_reset = normalize_reset(reset_at, duration_seconds); + let cycles = historical_cycles(series, normalized_current_reset, now); + if cycles.len() < 3 { + return None; + } + let nominal_duration = median_i64(cycles.iter().map(|cycle| cycle.duration_seconds))? + .clamp(1, agent_quota_duration::MAX_DURATION_SECONDS); + let span = cycles + .iter() + .map(|cycle| cycle.reset_at) + .max()? + .saturating_sub(cycles.iter().map(|cycle| cycle.cycle_started_at).min()?); + let expected_span = (2 * nominal_duration).max(86_400); + if span < expected_span { + return None; + } + + let tau_cycles = (7.0 * 86_400.0 / nominal_duration as f64).clamp(3.0, 64.0); + let weighted = cycles + .iter() + .map(|cycle| { + let age_cycles = ((normalized_current_reset - cycle.reset_at).max(0) as f64) + / nominal_duration as f64; + let weight = (-age_cycles / tau_cycles).exp(); + (cycle, weight) + }) + .collect::>(); + let total_weight = weighted.iter().map(|(_, weight)| *weight).sum::(); + let squared_weight = weighted + .iter() + .map(|(_, weight)| weight * weight) + .sum::(); + if !total_weight.is_finite() || total_weight <= EPSILON || squared_weight <= EPSILON { + return None; + } + let n_eff = total_weight * total_weight / squared_weight; + if !n_eff.is_finite() || n_eff < 2.5 { + return None; + } + let lambda = ((n_eff - 2.0) / 6.0).clamp(0.0, 1.0); + let denominator = (GRID_POINT_COUNT - 1) as f64; + let weights = weighted + .iter() + .map(|(_, weight)| *weight) + .collect::>(); + let mut expected_curve = vec![0.0; GRID_POINT_COUNT]; + for (index, value) in expected_curve.iter_mut().enumerate() { + let historical = weighted + .iter() + .map(|(cycle, _)| cycle.curve[index]) + .collect::>(); + let median = crate::agent_history::weighted_median(&historical, &weights); + let linear = 100.0 * index as f64 / denominator; + *value = (lambda * median + (1.0 - lambda) * linear).clamp(0.0, 100.0); + } + let mut expected_max = 0.0_f64; + for value in &mut expected_curve { + expected_max = expected_max.max(*value); + *value = expected_max; + } + + let elapsed = duration_seconds.saturating_sub(reset_at.saturating_sub(now)); + let phase_now = (elapsed as f64 / duration_seconds as f64).clamp(0.0, 1.0); + let expected_now = interpolate_curve(&expected_curve, phase_now).clamp(0.0, 100.0); + let mut weighted_run_out_mass = 0.0; + let mut crossing_candidates = Vec::new(); + for (cycle, weight) in &weighted { + let mut extended = cycle.curve.clone(); + if let Some(cap_index) = extended + .iter() + .position(|value| *value >= RUNOUT_THRESHOLD_PERCENT) + .filter(|index| *index > 0 && *index < extended.len() - 1) + { + let cap_phase = cap_index as f64 / denominator; + let slope = extended[cap_index] / cap_phase; + if slope.is_finite() { + for (index, value) in extended.iter_mut().enumerate().skip(cap_index) { + *value = slope * index as f64 / denominator; + } + } + } + let historical_now = interpolate_curve(&extended, phase_now); + let shift = actual - historical_now; + let shifted_end = extended.last().copied().unwrap_or(0.0) + shift; + if shifted_end >= RUNOUT_THRESHOLD_PERCENT { + weighted_run_out_mass += *weight; + if let Some(crossing) = first_crossing(phase_now, &extended, shift, actual) { + crossing_candidates.push(( + (crossing - phase_now).max(0.0) * duration_seconds as f64, + *weight, + )); + } + } + } + let smoothed = ((weighted_run_out_mass + 0.5) / (total_weight + 1.0)).clamp(0.0, 1.0); + let risk_span = (4 * nominal_duration).max(7 * 86_400); + let observation_span = span; + let risk_gate = cycles.len() >= 5 && n_eff >= 4.0 && observation_span >= risk_span; + let mut run_out_probability = risk_gate.then_some(smoothed); + let mut will_last = smoothed < 0.5; + let mut eta_seconds = None; + if actual >= 100.0 { + run_out_probability = Some(1.0); + will_last = false; + eta_seconds = Some(0.0); + } else if !will_last { + if crossing_candidates.is_empty() { + will_last = true; + } else { + let values = crossing_candidates + .iter() + .map(|(eta, _)| *eta) + .collect::>(); + let weights = crossing_candidates + .iter() + .map(|(_, weight)| *weight) + .collect::>(); + eta_seconds = Some(crate::agent_history::weighted_median(&values, &weights).max(0.0)); + } + } + Some(HistoricalPace { + expected_percent: expected_now, + eta_seconds, + will_last_to_reset: will_last, + run_out_probability, + }) +} + +fn with_locked_transaction( + path: &Path, + observation_now: i64, + transaction_clock: impl FnOnce() -> i64, + body: impl FnOnce(&mut Store) -> Result, +) -> Result { + with_locked_transaction_with_save( + path, + observation_now, + transaction_clock, + save_store_atomic, + body, + ) +} + +fn with_locked_transaction_with_save( + path: &Path, + observation_now: i64, + transaction_clock: impl FnOnce() -> i64, + save: impl Fn(&Path, &Store) -> io::Result<()>, + body: impl FnOnce(&mut Store) -> Result, +) -> Result { + let directory = path.parent().ok_or(HistoryError::StorageUnavailable)?; + ensure_real_directory(directory).map_err(|_| HistoryError::StorageUnavailable)?; + + let _process_guard = HISTORY_PROCESS_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let lock_file = open_owner_only(&directory.join(HISTORY_LOCK_FILE_NAME)) + .map_err(|_| HistoryError::LockOpen)?; + lock_file + .lock_exclusive() + .map_err(|_| HistoryError::LockAcquire)?; + let lock_time = transaction_clock(); + let upper_bound = observation_now.max(lock_time); + + let loaded = load_store_at(path, upper_bound, observation_now); + let result = match loaded { + Ok(mut loaded) => { + let before = loaded.store.clone(); + let result = body(&mut loaded.store); + match result { + Ok(value) => { + if !validate_store_at(&loaded.store, upper_bound) { + Err(HistoryError::Serialize) + } else if loaded.store == before { + Ok(value) + } else if save(path, &loaded.store).is_err() { + Err(HistoryError::AtomicSave) + } else { + Ok(value) + } + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + }; + let unlock = fs2::FileExt::unlock(&lock_file).map_err(|_| HistoryError::LockRelease); + match (result, unlock) { + (Err(error), _) => Err(error), + (Ok(_value), Err(error)) => Err(error), + (Ok(value), Ok(())) => Ok(value), + } +} + +fn read_owner_only(path: &Path) -> io::Result>> { + let Some(mut file) = open_existing_owner_only(path)? else { + return Ok(None); + }; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + verify_open_regular_file(path, &file)?; + Ok(Some(bytes)) +} + +fn load_store(path: &Path, now: i64) -> Result { + load_store_at(path, now, now) +} + +fn load_store_at( + path: &Path, + validation_now: i64, + quarantine_now: i64, +) -> Result { + let Some(bytes) = read_owner_only(path).map_err(|_| HistoryError::Read)? else { + return Ok(LoadedStore { + store: Store::default(), + }); + }; + + let parsed = serde_json::from_slice::(&bytes) + .ok() + .filter(|store| validate_store_at(store, validation_now)); + if let Some(store) = parsed { + return Ok(LoadedStore { store }); + } + + quarantine_corrupt(path, quarantine_now).map_err(|_| HistoryError::CorruptQuarantine)?; + Ok(LoadedStore { + store: Store::default(), + }) +} + +fn quarantine_corrupt(path: &Path, now: i64) -> io::Result { + quarantine_corrupt_with(path, now, |source| fs::remove_file(source)) +} + +fn quarantine_corrupt_with(path: &Path, now: i64, unlink: U) -> io::Result +where + U: Fn(&Path) -> io::Result<()>, +{ + quarantine_corrupt_with_ops( + path, + now, + |source, candidate| fs::hard_link(source, candidate), + unlink, + ) +} + +fn quarantine_corrupt_with_ops( + path: &Path, + now: i64, + mut link: L, + unlink: U, +) -> io::Result +where + L: FnMut(&Path, &Path) -> io::Result<()>, + U: Fn(&Path) -> io::Result<()>, +{ + let source = open_existing_owner_only(path)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "quota pace history disappeared before quarantine", + ) + })?; + let directory = path.parent().unwrap_or_else(|| Path::new(".")); + for suffix in 0..=u32::MAX { + let name = if suffix == 0 { + format!("quota-pace-history-v3.corrupt-{now}.json") + } else { + format!("quota-pace-history-v3.corrupt-{now}.{suffix}.json") + }; + let candidate = directory.join(name); + verify_open_regular_file(path, &source)?; + match link(path, &candidate) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + if let Err(error) = verify_open_regular_file(path, &source) { + rollback_quarantine_link(&candidate, &source); + return Err(error); + } + if let Err(error) = verify_open_regular_file(&candidate, &source) { + rollback_quarantine_link(&candidate, &source); + return Err(error); + } + if let Err(error) = unlink(path) { + rollback_quarantine_link(&candidate, &source); + return Err(error); + } + return Ok(candidate); + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to choose a quota pace quarantine name", + )) +} + +fn save_store_atomic(path: &Path, store: &Store) -> io::Result<()> { + save_store_atomic_with(path, store, |temp, destination| { + tokscale_core::fs_atomic::replace_file(temp, destination) + }) +} + +fn save_store_atomic_with(path: &Path, store: &Store, replace: F) -> io::Result<()> +where + F: Fn(&Path, &Path) -> io::Result<()>, +{ + save_store_atomic_with_sync(path, store, replace, sync_directory) +} + +fn save_store_atomic_with_sync( + path: &Path, + store: &Store, + replace: F, + sync: S, +) -> io::Result<()> +where + F: Fn(&Path, &Path) -> io::Result<()>, + S: Fn(&Path) -> io::Result<()>, +{ + let directory = path.parent().unwrap_or_else(|| Path::new(".")); + ensure_real_directory(directory)?; + let mut canonical = store.clone(); + canonical.series.sort_by(series_order); + for series in &mut canonical.series { + series.samples.sort_by(sample_order); + } + let payload = serde_json::to_vec_pretty(&canonical).map_err(io::Error::other)?; + let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(HISTORY_FILE_NAME); + let temp_path = directory.join(format!(".{file_name}.tmp-{}-{counter}", std::process::id())); + + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&temp_path)?; + file.write_all(&payload)?; + file.flush()?; + file.sync_all()?; + drop(file); + replace(&temp_path, path)?; + Ok::<(), io::Error>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + return result; + } + + // replace is the commit point; directory sync is best-effort because it + // cannot restore the previous file after the rename has succeeded. + let _ = sync(directory); + Ok(()) +} + +fn sync_directory(directory: &Path) -> io::Result<()> { + File::open(directory)?.sync_all() +} + +fn ensure_real_directory(directory: &Path) -> io::Result<()> { + match fs::symlink_metadata(directory) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace storage is not a real directory", + )) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(directory)?; + } + Err(error) => return Err(error), + } + + let path_metadata = fs::symlink_metadata(directory)?; + if !path_metadata.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace storage is not a real directory", + )); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let file = File::open(directory)?; + verify_open_directory(directory, &file)?; + file.set_permissions(fs::Permissions::from_mode(0o700))?; + verify_open_directory(directory, &file)?; + } + Ok(()) +} + +fn open_owner_only(path: &Path) -> io::Result { + let mut create = OpenOptions::new(); + create.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + create.mode(0o600); + } + match create.open(path) { + Ok(file) => secure_open_regular_file(path, file), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + require_regular_file_path(path)?; + let file = OpenOptions::new().read(true).write(true).open(path)?; + secure_open_regular_file(path, file) + } + Err(error) => Err(error), + } +} + +fn open_existing_owner_only(path: &Path) -> io::Result> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace artifact is not a regular file", + )) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + } + let file = OpenOptions::new().read(true).open(path)?; + secure_open_regular_file(path, file).map(Some) +} + +fn require_regular_file_path(path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_file() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace artifact is not a regular file", + )) + } +} + +fn secure_open_regular_file(path: &Path, file: File) -> io::Result { + verify_open_regular_file(path, &file)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + verify_open_regular_file(path, &file)?; + Ok(file) +} + +fn verify_open_regular_file(path: &Path, file: &File) -> io::Result<()> { + let file_metadata = file.metadata()?; + let path_metadata = fs::symlink_metadata(path)?; + if !file_metadata.file_type().is_file() || !path_metadata.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace artifact is not a regular file", + )); + } + #[cfg(unix)] + if !same_file(&file_metadata, &path_metadata) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace artifact changed while opening", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_open_directory(path: &Path, file: &File) -> io::Result<()> { + let file_metadata = file.metadata()?; + let path_metadata = fs::symlink_metadata(path)?; + if !file_metadata.file_type().is_dir() + || !path_metadata.file_type().is_dir() + || !same_file(&file_metadata, &path_metadata) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quota pace storage changed while opening", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt as _; + left.dev() == right.dev() && left.ino() == right.ino() +} + +fn rollback_quarantine_link(path: &Path, source: &File) { + if verify_open_regular_file(path, source).is_ok() { + let _ = fs::remove_file(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use std::time::{SystemTime, UNIX_EPOCH}; + + const HOUR: i64 = 3_600; + const DAY: i64 = 86_400; + + fn key(account: &str) -> SeriesKey { + SeriesKey::new("copilot", account, "premium_interactions.v1") + } + + fn temp_path(label: &str) -> (PathBuf, PathBuf) { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "tokenbar-quota-v3-{}-{}-{label}", + std::process::id(), + nonce + )); + fs::create_dir_all(&directory).unwrap(); + (directory.clone(), directory.join(HISTORY_FILE_NAME)) + } + + #[cfg(unix)] + fn unix_mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + fn provider(reset_at: i64, duration_seconds: i64) -> Option { + Some(DurationEvidence::provider(reset_at, duration_seconds)) + } + + fn observation( + key: SeriesKey, + reset_at: i64, + used_percent: f64, + duration_seconds: i64, + ) -> QuotaObservation { + QuotaObservation { + key, + reset_at: Some(reset_at), + used_percent, + provider: provider(reset_at, duration_seconds), + contract: None, + } + } + + fn batch_series(key: SeriesKey, now: i64, active_reset_at: Option) -> SeriesState { + let rollover = active_reset_at.map(|reset_at| ObservedState::Watching { + reset_at, + first_seen_at: now, + last_seen_at: now, + consecutive_count: 1, + }); + SeriesState { + provider_id: key.provider_id, + account_scope: key.account_scope, + window_key: key.window_key, + active_reset_at, + last_activity_at: now, + rollover, + samples: complete_cycle(now - DAY, DAY, 60.0), + } + } + + fn rollover_only_series( + key: SeriesKey, + last_activity_at: i64, + reset_at: i64, + candidate: bool, + ) -> SeriesState { + let rollover = if candidate { + ObservedState::Candidate { + old_reset_at: last_activity_at, + old_seen_at: last_activity_at - 60, + new_reset_at: reset_at, + first_new_seen_at: last_activity_at, + } + } else { + ObservedState::Watching { + reset_at, + first_seen_at: last_activity_at, + last_seen_at: last_activity_at, + consecutive_count: 1, + } + }; + SeriesState { + provider_id: key.provider_id, + account_scope: key.account_scope, + window_key: key.window_key, + active_reset_at: Some(reset_at), + last_activity_at, + rollover: Some(rollover), + samples: Vec::new(), + } + } + + fn record( + path: &Path, + account: &str, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, + ) -> HistoryOutcome { + record_observation_at_path( + key(account), + reset_at, + used_percent, + now, + provider, + contract, + path, + ) + .unwrap() + } + + #[allow(clippy::too_many_arguments)] + fn record_at_lock_time( + path: &Path, + account: &str, + reset_at: Option, + used_percent: f64, + now: i64, + provider: Option, + contract: Option, + lock_time: i64, + ) -> HistoryOutcome { + record_observation_at_path_with_clock( + key(account), + reset_at, + used_percent, + now, + provider, + contract, + path, + || lock_time, + ) + .unwrap() + } + + fn read_store(path: &Path) -> Store { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() + } + + fn quota_sample( + reset_at: i64, + duration_seconds: i64, + phase: f64, + used_percent: f64, + origin: SampleOrigin, + ) -> QuotaSample { + let sampled_at = + reset_at - duration_seconds + (phase.clamp(0.0, 1.0) * duration_seconds as f64) as i64; + QuotaSample { + reset_at: normalize_sample_reset(reset_at, duration_seconds, sampled_at), + duration_seconds, + duration_source: DurationSource::Provider, + used_percent, + sampled_at, + origin, + } + } + + fn complete_cycle(reset_at: i64, duration_seconds: i64, end: f64) -> Vec { + [0.01, 0.10, 0.25, 0.40, 0.60, 0.75, 0.90, 0.99] + .into_iter() + .enumerate() + .map(|(index, phase)| { + quota_sample( + reset_at, + duration_seconds, + phase, + (end * phase).max(0.1) + index as f64 * 0.01, + SampleOrigin::LiveV3, + ) + }) + .collect() + } + + fn seeded_series( + provider_id: &str, + account_scope: &str, + window_key: &str, + current_reset: i64, + duration_seconds: i64, + cycles: usize, + ) -> SeriesState { + let key = SeriesKey::new(provider_id, account_scope, window_key); + let mut samples = Vec::new(); + for offset in 1..=cycles { + samples.extend(complete_cycle( + current_reset - offset as i64 * duration_seconds, + duration_seconds, + 80.0, + )); + } + SeriesState { + provider_id: key.provider_id, + account_scope: key.account_scope, + window_key: key.window_key, + active_reset_at: Some(current_reset), + last_activity_at: current_reset - duration_seconds / 2, + rollover: Some(ObservedState::Watching { + reset_at: current_reset, + first_seen_at: current_reset - duration_seconds / 2, + last_seen_at: current_reset - duration_seconds / 2, + consecutive_count: 1, + }), + samples, + } + } + + #[test] + fn writes_schema_three_sorted_series_and_exact_sample_fields() { + let (directory, path) = temp_path("schema"); + let now = 1_000_000; + let reset = now + 7 * DAY; + assert!(matches!( + record(&path, "b", Some(reset), 10.0, now, provider(reset, 7 * DAY), None), + HistoryOutcome::Ready { + duration_seconds, + source: DurationSource::Provider, + sampled: true + } if duration_seconds == 7 * DAY + )); + assert!(matches!( + record( + &path, + "a", + Some(reset), + 20.0, + now, + provider(reset, 7 * DAY), + None + ), + HistoryOutcome::Ready { sampled: true, .. } + )); + let store = read_store(&path); + assert_eq!(store.schema_version, HISTORY_SCHEMA_VERSION); + assert_eq!( + store + .series + .iter() + .map(|series| series.account_scope.as_str()) + .collect::>(), + vec!["a", "b"] + ); + assert_eq!(store.series[0].samples[0].duration_seconds, 7 * DAY); + assert_eq!(store.series[0].samples[0].origin, SampleOrigin::LiveV3); + assert_eq!(store.series[0].samples[0].sampled_at, now); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn restart_continues_watching_and_candidate_across_transactions() { + let (directory, path) = temp_path("restart"); + let old_reset = 2_000_000; + let new_reset = old_reset + 7 * DAY; + assert_eq!( + record( + &path, + "acct", + Some(old_reset), + 0.0, + old_reset - 20 * 60, + None, + None + ), + HistoryOutcome::LearningDuration + ); + assert_eq!( + record( + &path, + "acct", + Some(old_reset), + 0.0, + old_reset - 5 * 60, + None, + None + ), + HistoryOutcome::LearningDuration + ); + assert_eq!( + record( + &path, + "acct", + Some(new_reset), + 10.0, + old_reset + 5 * 60, + None, + None, + ), + HistoryOutcome::LearningDuration + ); + assert!(matches!( + record( + &path, + "acct", + Some(new_reset), + 12.0, + old_reset + 10 * 60, + None, + None, + ), + HistoryOutcome::Ready { + duration_seconds, + source: DurationSource::Observed, + sampled: true + } if duration_seconds == 7 * DAY + )); + let store = read_store(&path); + assert_eq!(store.series[0].samples.len(), 1); + assert_eq!(store.series[0].samples[0].sampled_at, old_reset + 10 * 60); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn corrupt_history_is_quarantined_byte_for_byte_then_rebuilt() { + let (directory, path) = temp_path("corrupt"); + let corrupt = b"{not-json\nquota-v3"; + fs::write(&path, corrupt).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + } + let now = 3_000_000; + let reset = now + 7 * DAY; + assert!(matches!( + record( + &path, + "acct", + Some(reset), + 10.0, + now, + provider(reset, 7 * DAY), + None + ), + HistoryOutcome::Ready { sampled: true, .. } + )); + let quarantined = directory.join(format!("quota-pace-history-v3.corrupt-{now}.json")); + assert_eq!(fs::read(&quarantined).unwrap(), corrupt); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(&quarantined).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let recovered = read_store(&path); + assert_eq!(recovered.schema_version, HISTORY_SCHEMA_VERSION); + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn history_lock_symlink_fails_closed_without_touching_target() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let (directory, path) = temp_path("lock-symlink"); + let target = directory.with_extension("external-lock"); + let lock_path = directory.join(HISTORY_LOCK_FILE_NAME); + let original = b"external-lock-target"; + fs::write(&target, original).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &lock_path).unwrap(); + + let now = 3_100_000; + let reset = now + DAY; + assert_eq!( + record_observation_at_path( + key("acct"), + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + &path, + ), + Err(HistoryError::LockOpen) + ); + assert_eq!(fs::read(&target).unwrap(), original); + assert_eq!(unix_mode(&target), original_mode); + assert!(fs::symlink_metadata(&lock_path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!path.exists()); + + fs::remove_dir_all(directory).unwrap(); + fs::remove_file(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn active_history_symlink_fails_closed_without_touching_target() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let (directory, path) = temp_path("active-symlink"); + let target = directory.with_extension("external-history"); + let original = b"external-history-target"; + fs::write(&target, original).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &path).unwrap(); + + let now = 3_200_000; + let reset = now + DAY; + assert_eq!( + record_observation_at_path( + key("acct"), + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + &path, + ), + Err(HistoryError::Read) + ); + assert_eq!(fs::read(&target).unwrap(), original); + assert_eq!(unix_mode(&target), original_mode); + assert!(fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + assert!(!directory + .join(format!("quota-pace-history-v3.corrupt-{now}.json")) + .exists()); + + fs::remove_dir_all(directory).unwrap(); + fs::remove_file(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn history_final_directory_symlink_fails_before_chmod_or_lock_creation() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let (directory, path) = temp_path("directory-symlink"); + fs::remove_dir(&directory).unwrap(); + let target = directory.with_extension("external-directory"); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + let original_mode = unix_mode(&target); + symlink(&target, &directory).unwrap(); + + let now = 3_300_000; + let reset = now + DAY; + assert_eq!( + record_observation_at_path( + key("acct"), + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + &path, + ), + Err(HistoryError::StorageUnavailable) + ); + assert_eq!(unix_mode(&target), original_mode); + assert_eq!(fs::read_dir(&target).unwrap().count(), 0); + assert!(fs::symlink_metadata(&directory) + .unwrap() + .file_type() + .is_symlink()); + + fs::remove_file(directory).unwrap(); + fs::remove_dir(target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn atomic_history_quarantine_hard_link_closes_collision_race() { + use std::cell::Cell; + use std::os::unix::fs::{symlink, MetadataExt as _, PermissionsExt as _}; + + let (directory, path) = temp_path("quarantine-reservation-race"); + let corrupt = b"history-race-source"; + fs::write(&path, corrupt).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + let source_inode = fs::metadata(&path).unwrap().ino(); + let now = 3_350_000; + let collision = directory.join(format!("quota-pace-history-v3.corrupt-{now}.json")); + let missing_target = directory.with_extension("race-dangling-target"); + let raced = Cell::new(false); + + let quarantined = quarantine_corrupt_with_ops( + &path, + now, + |source, candidate| { + if !raced.replace(true) { + symlink(&missing_target, candidate)?; + } + fs::hard_link(source, candidate) + }, + |source| fs::remove_file(source), + ) + .unwrap(); + + assert_eq!( + quarantined, + directory.join(format!("quota-pace-history-v3.corrupt-{now}.1.json")) + ); + assert!(fs::symlink_metadata(&collision) + .unwrap() + .file_type() + .is_symlink()); + assert!(!missing_target.exists()); + assert_eq!(fs::read(&quarantined).unwrap(), corrupt); + assert_eq!(unix_mode(&quarantined), 0o600); + assert_eq!(fs::metadata(&quarantined).unwrap().ino(), source_inode); + assert!(!path.exists()); + + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_history_quarantine_collision_is_not_overwritten() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let (directory, path) = temp_path("dangling-quarantine"); + let corrupt = b"corrupt-history-with-dangling-collision"; + fs::write(&path, corrupt).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + let now = 3_400_000; + let collision = directory.join(format!("quota-pace-history-v3.corrupt-{now}.json")); + let missing_target = directory.with_extension("missing-quarantine-target"); + symlink(&missing_target, &collision).unwrap(); + + let reset = now + DAY; + assert!(matches!( + record_observation_at_path( + key("acct"), + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + &path, + ), + Ok(HistoryOutcome::Ready { sampled: true, .. }) + )); + assert!(fs::symlink_metadata(&collision) + .unwrap() + .file_type() + .is_symlink()); + assert!(!missing_target.exists()); + let quarantined = directory.join(format!("quota-pace-history-v3.corrupt-{now}.1.json")); + assert_eq!(fs::read(&quarantined).unwrap(), corrupt); + assert_eq!(unix_mode(&quarantined), 0o600); + assert_eq!(unix_mode(&path), 0o600); + + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn restored_history_is_tightened_before_read_without_changing_bytes() { + use std::os::unix::fs::PermissionsExt as _; + + let (directory, path) = temp_path("restored-mode"); + let bytes = serde_json::to_vec_pretty(&Store::default()).unwrap(); + fs::write(&path, &bytes).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let loaded = load_store(&path, 3_500_000).unwrap(); + assert_eq!(loaded.store, Store::default()); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert_eq!(unix_mode(&path), 0o600); + + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn symlinked_ancestor_is_allowed_when_final_history_directory_is_real() { + use std::os::unix::fs::symlink; + + let (seed, _) = temp_path("ancestor-symlink"); + fs::remove_dir(&seed).unwrap(); + let real_parent = seed.with_extension("real-parent"); + let linked_parent = seed.with_extension("linked-parent"); + let final_directory = linked_parent.join("com.nyanako.tokenbar"); + let path = final_directory.join(HISTORY_FILE_NAME); + fs::create_dir(&real_parent).unwrap(); + symlink(&real_parent, &linked_parent).unwrap(); + + let now = 3_600_000; + let reset = now + DAY; + assert!(matches!( + record_observation_at_path( + key("acct"), + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + &path, + ), + Ok(HistoryOutcome::Ready { sampled: true, .. }) + )); + assert!(fs::symlink_metadata(&final_directory) + .unwrap() + .file_type() + .is_dir()); + + fs::remove_file(linked_parent).unwrap(); + fs::remove_dir_all(real_parent).unwrap(); + } + + #[test] + fn quarantine_unlink_failure_rolls_back_link_and_preserves_source() { + let (directory, path) = temp_path("quarantine-failure"); + let corrupt = b"preserve-me"; + fs::write(&path, corrupt).unwrap(); + let result = quarantine_corrupt_with(&path, 123, |_source| { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected")) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), corrupt); + #[cfg(unix)] + assert_eq!(unix_mode(&path), 0o600); + let candidate = directory.join("quota-pace-history-v3.corrupt-123.json"); + assert!(matches!( + fs::symlink_metadata(candidate), + Err(error) if error.kind() == io::ErrorKind::NotFound + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn atomic_failure_keeps_last_valid_bytes_and_removes_temp() { + let (directory, path) = temp_path("atomic-failure"); + let now = 4_000_000; + let reset = now + 7 * DAY; + let store = Store::default(); + let original = b"last-valid-v3-bytes"; + fs::write(&path, original).unwrap(); + let result = save_store_atomic_with(&path, &store, |_temp, _destination| { + Err(io::Error::other("injected replace failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(!directory + .join(format!(".{HISTORY_FILE_NAME}.tmp-{}-0", std::process::id())) + .exists()); + let _ = reset; + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn post_replace_directory_sync_failure_keeps_committed_bytes() { + let (directory, path) = temp_path("post-replace-sync"); + let original = b"old-v3-bytes"; + fs::write(&path, original).unwrap(); + let store = Store::default(); + let result = save_store_atomic_with_sync( + &path, + &store, + tokscale_core::fs_atomic::replace_file, + |_directory| Err(io::Error::other("injected directory sync failure")), + ); + assert!(result.is_ok()); + assert_ne!(fs::read(&path).unwrap(), original); + assert_eq!( + serde_json::from_slice::(&fs::read(&path).unwrap()).unwrap(), + store + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn account_isolation_and_concurrent_transactions_do_not_lose_updates() { + let (directory, path) = temp_path("concurrency"); + let now = 5_000_000; + let reset = now + 7 * DAY; + let left = path.clone(); + let right = path.clone(); + let one = std::thread::spawn(move || { + record_observation_at_path( + key("account-a"), + Some(reset), + 10.0, + now, + provider(reset, 7 * DAY), + None, + &left, + ) + .unwrap() + }); + let two = std::thread::spawn(move || { + record_observation_at_path( + key("account-b"), + Some(reset), + 20.0, + now, + provider(reset, 7 * DAY), + None, + &right, + ) + .unwrap() + }); + assert!(matches!(one.join().unwrap(), HistoryOutcome::Ready { .. })); + assert!(matches!(two.join().unwrap(), HistoryOutcome::Ready { .. })); + let store = read_store(&path); + assert_eq!(store.series.len(), 2); + assert_eq!(store.series[0].account_scope, "account-a"); + assert_eq!(store.series[1].account_scope, "account-b"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn missing_reset_and_invalid_reading_are_typed_without_touching_store() { + let (directory, path) = temp_path("invalid"); + let now = 6_000_000; + assert_eq!( + record( + &path, + "acct", + None, + 10.0, + now, + provider(now + DAY, DAY), + Some(DurationEvidence::contract(DAY)), + ), + HistoryOutcome::Unavailable(DurationUnavailableReason::MissingReset) + ); + assert!(!path.exists()); + assert_eq!( + record(&path, "acct", Some(now + DAY), f64::NAN, now, None, None), + HistoryOutcome::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn malformed_duration_evidence_does_not_create_series_or_store() { + let (directory, path) = temp_path("invalid-duration"); + let now = 6_500_000; + let reset = now + 7 * DAY; + assert_eq!( + record( + &path, + "acct", + Some(reset), + 10.0, + now, + Some(DurationEvidence::provider(reset + 1, 5 * HOUR)), + Some(DurationEvidence::contract(7 * DAY)), + ), + HistoryOutcome::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + assert!(!path.exists()); + assert!(!directory.join(HISTORY_LOCK_FILE_NAME).exists()); + + assert_eq!( + record( + &path, + "acct", + Some(reset), + 10.0, + now, + None, + Some(DurationEvidence::contract(5 * HOUR)), + ), + HistoryOutcome::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn provider_precedes_contract_and_invalid_provider_does_not_fall_through() { + let (directory, path) = temp_path("precedence"); + let now = 7_000_000; + let provider_reset = now + 5 * HOUR; + let result = record( + &path, + "acct", + Some(provider_reset), + 10.0, + now, + provider(provider_reset, 5 * HOUR), + Some(DurationEvidence::contract(7 * DAY)), + ); + assert_eq!( + result, + HistoryOutcome::Ready { + duration_seconds: 5 * HOUR, + source: DurationSource::Provider, + sampled: true, + } + ); + + let contract_reset = now + 7 * DAY; + let result = record( + &path, + "other", + Some(contract_reset), + 10.0, + now, + Some(DurationEvidence::provider(contract_reset + 1, 5 * HOUR)), + Some(DurationEvidence::contract(7 * DAY)), + ); + assert_eq!( + result, + HistoryOutcome::Unavailable(DurationUnavailableReason::InvalidEvidence) + ); + assert_eq!(read_store(&path).series.len(), 1); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn observed_confirmation_accepts_exact_boundary_and_rejects_timeout() { + let (directory, path) = temp_path("boundary"); + let old = 8_000_000; + let new = old + 7 * DAY; + record( + &path, + "exact", + Some(old), + 0.0, + old - agent_quota_duration::ROLLOVER_GRACE_SECONDS, + None, + None, + ); + record(&path, "exact", Some(old), 0.0, old - 60, None, None); + record(&path, "exact", Some(new), 10.0, old, None, None); + assert!(matches!( + record(&path, "exact", Some(new), 20.0, old + agent_quota_duration::ROLLOVER_GRACE_SECONDS, None, None), + HistoryOutcome::Ready { + duration_seconds, + source: DurationSource::Observed, + sampled: true, + } if duration_seconds == 7 * DAY + )); + + record( + &path, + "timeout", + Some(old), + 0.0, + old - agent_quota_duration::ROLLOVER_GRACE_SECONDS, + None, + None, + ); + record(&path, "timeout", Some(old), 0.0, old - 60, None, None); + record(&path, "timeout", Some(new), 10.0, old, None, None); + assert_eq!( + record( + &path, + "timeout", + Some(new), + 20.0, + old + agent_quota_duration::ROLLOVER_GRACE_SECONDS + 1, + None, + None, + ), + HistoryOutcome::LearningDuration + ); + let store = read_store(&path); + let timeout_series = store + .series + .iter() + .find(|series| series.account_scope == "timeout") + .unwrap(); + assert!(timeout_series.samples.is_empty()); + assert!(matches!( + timeout_series.rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == new + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn observed_duplicate_sliding_backward_and_missed_boundaries_do_not_sample() { + let (directory, path) = temp_path("edges"); + let old = 8_000_000; + let new = old + 7 * DAY; + assert_eq!( + record(&path, "acct", Some(old), 10.0, old - 20 * 60, None, None), + HistoryOutcome::LearningDuration + ); + assert_eq!( + record(&path, "acct", Some(old), 10.0, old - 5 * 60, None, None), + HistoryOutcome::LearningDuration + ); + assert_eq!( + record(&path, "acct", Some(new), 10.0, old + 5 * 60, None, None), + HistoryOutcome::LearningDuration + ); + let duplicate = record(&path, "acct", Some(new), 10.0, old + 6 * 60, None, None); + assert!(matches!( + duplicate, + HistoryOutcome::Ready { sampled: true, .. } + )); + let before = read_store(&path).series[0].samples.len(); + let duplicate_again = record(&path, "acct", Some(new), 20.0, old + 7 * 60, None, None); + assert!(matches!( + duplicate_again, + HistoryOutcome::Ready { sampled: true, .. } + )); + assert_eq!(read_store(&path).series[0].samples.len(), before); + + let sliding = record( + &path, + "acct", + Some(new + DAY), + 20.0, + new - 2 * DAY, + None, + None, + ); + assert_eq!(sliding, HistoryOutcome::LearningDuration); + let backward = record( + &path, + "acct", + Some(new - DAY), + 20.0, + new - 2 * DAY + HOUR, + None, + None, + ); + assert_eq!(backward, HistoryOutcome::LearningDuration); + let missed = record( + &path, + "acct", + Some(new + 7 * DAY), + 20.0, + new + agent_quota_duration::ROLLOVER_GRACE_SECONDS + 1, + None, + None, + ); + assert_eq!(missed, HistoryOutcome::LearningDuration); + assert_eq!(read_store(&path).series[0].samples.len(), 0); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn unready_readings_never_become_retroactive_samples() { + let (directory, path) = temp_path("no-retroactive"); + let old = 9_000_000; + let new = old + 7 * DAY; + record(&path, "acct", Some(old), 80.0, old - 20 * 60, None, None); + record(&path, "acct", Some(old), 90.0, old - 5 * 60, None, None); + record(&path, "acct", Some(new), 95.0, old + 5 * 60, None, None); + assert!(read_store(&path).series[0].samples.is_empty()); + record(&path, "acct", Some(new), 100.0, old + 10 * 60, None, None); + let store = read_store(&path); + assert_eq!(store.series[0].samples.len(), 1); + assert_eq!(store.series[0].samples[0].reset_at, new); + assert_eq!(store.series[0].samples[0].sampled_at, old + 10 * 60); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn copilot_calendar_contract_accepts_exact_month_boundaries() { + let (directory, path) = temp_path("copilot-calendar"); + let reset = "2024-03-01T00:00:00Z" + .parse::>() + .unwrap() + .timestamp(); + let now = reset - HOUR; + assert_eq!( + record( + &path, + "acct", + Some(reset), + 10.0, + now, + None, + Some(DurationEvidence::contract( + crate::agent_quota_duration::copilot_calendar_duration(reset).unwrap(), + )), + ), + HistoryOutcome::Ready { + duration_seconds: 29 * DAY, + source: DurationSource::Contract, + sampled: true, + } + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn known_route_can_coexist_with_real_observed_confirmation() { + let (directory, path) = temp_path("known-and-observed"); + let old = 12_000_000; + let new = old + 7 * DAY; + let known_duration = 7 * DAY; + assert!(matches!( + record( + &path, + "acct", + Some(old), + 10.0, + old - 20 * 60, + provider(old, known_duration), + None, + ), + HistoryOutcome::Ready { + source: DurationSource::Provider, + .. + } + )); + assert_eq!( + record(&path, "acct", Some(old), 0.0, old - 5 * 60, None, None), + HistoryOutcome::LearningDuration + ); + assert_eq!( + record(&path, "acct", Some(new), 0.0, old + 5 * 60, None, None), + HistoryOutcome::LearningDuration + ); + assert!(matches!( + record(&path, "acct", Some(new), 20.0, old + 10 * 60, None, None), + HistoryOutcome::Ready { + source: DurationSource::Observed, + duration_seconds, + .. + } if duration_seconds == 7 * DAY + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn history_directory_file_and_lock_are_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let (directory, path) = temp_path("permissions"); + let now = 10_000_000; + let reset = now + DAY; + let _ = record( + &path, + "acct", + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + ); + assert_eq!( + fs::metadata(&directory).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(directory.join(HISTORY_LOCK_FILE_NAME)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn validate_series_rejects_cross_field_mismatches_and_stale_activity() { + let key = key("acct"); + let mut series = SeriesState::new(&key, 2_000_000); + let watching_reset = 2_100_000; + let watching_last_seen = watching_reset - 60; + series.rollover = Some(ObservedState::Watching { + reset_at: watching_reset, + first_seen_at: watching_reset - 120, + last_seen_at: watching_last_seen, + consecutive_count: 2, + }); + series.active_reset_at = Some(watching_reset + 1); + series.last_activity_at = watching_last_seen; + assert!(!validate_series(&series)); + + let old_reset = 3_000_000; + let new_reset = old_reset + DAY; + let first_new_seen_at = old_reset + 60; + series.rollover = Some(ObservedState::Candidate { + old_reset_at: old_reset, + old_seen_at: old_reset - 60, + new_reset_at: new_reset, + first_new_seen_at, + }); + series.active_reset_at = Some(old_reset); + series.last_activity_at = first_new_seen_at; + assert!(!validate_series(&series)); + series.active_reset_at = Some(new_reset); + series.last_activity_at = first_new_seen_at - 1; + assert!(!validate_series(&series)); + + series.rollover = None; + series.active_reset_at = Some(new_reset); + series.last_activity_at = first_new_seen_at; + series.samples.clear(); + assert!(validate_series(&series)); + + let sample_reset = 4_000_000; + series.active_reset_at = None; + series.last_activity_at = sample_reset - 1; + series.samples = vec![QuotaSample { + reset_at: sample_reset, + duration_seconds: DAY, + duration_source: DurationSource::Provider, + used_percent: 10.0, + sampled_at: sample_reset, + origin: SampleOrigin::LiveV3, + }]; + assert!(!validate_series(&series)); + series.last_activity_at = sample_reset + 60; + let retained_activity = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![series.clone()], + }; + assert!(validate_store_at(&retained_activity, sample_reset + 60)); + assert!(!validate_store_at(&retained_activity, sample_reset + 59)); + } + + #[test] + fn late_ready_history_is_quarantined_before_observed_fallback() { + let (directory, path) = temp_path("late-ready"); + let now = 14_000_000; + let reset = now + DAY; + let late_confirmed = now + agent_quota_duration::ROLLOVER_GRACE_SECONDS + 1; + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![SeriesState { + provider_id: "copilot".into(), + account_scope: "acct".into(), + window_key: "premium_interactions.v1".into(), + active_reset_at: Some(reset), + last_activity_at: late_confirmed, + rollover: Some(ObservedState::Ready { + cycle_started_at: now, + reset_at: reset, + duration_seconds: DAY, + confirmed_at: late_confirmed, + last_seen_at: late_confirmed, + }), + samples: Vec::new(), + }], + }; + let bytes = serde_json::to_vec_pretty(&store).unwrap(); + fs::write(&path, &bytes).unwrap(); + + assert_eq!( + record(&path, "acct", Some(reset), 10.0, now, None, None), + HistoryOutcome::LearningDuration + ); + assert_eq!( + fs::read(directory.join(format!("quota-pace-history-v3.corrupt-{now}.json"))).unwrap(), + bytes + ); + let recovered = read_store(&path); + assert_eq!(recovered.series.len(), 1); + assert_eq!(recovered.series[0].active_reset_at, None); + assert!(recovered.series[0].samples.is_empty()); + assert!(matches!( + recovered.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == reset + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn future_last_activity_is_quarantined_before_observed_fallback() { + let (directory, path) = temp_path("future-activity"); + let now = 16_000_000; + let lock_time = now + 1; + let reset = now + DAY; + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![SeriesState { + provider_id: "copilot".into(), + account_scope: "acct".into(), + window_key: "premium_interactions.v1".into(), + active_reset_at: Some(reset), + last_activity_at: lock_time + 1, + rollover: None, + samples: vec![QuotaSample { + reset_at: reset, + duration_seconds: DAY, + duration_source: DurationSource::Provider, + used_percent: 10.0, + sampled_at: now, + origin: SampleOrigin::LiveV3, + }], + }], + }; + let bytes = serde_json::to_vec_pretty(&store).unwrap(); + fs::write(&path, &bytes).unwrap(); + + assert_eq!( + record_at_lock_time(&path, "acct", Some(reset), 10.0, now, None, None, lock_time,), + HistoryOutcome::LearningDuration + ); + assert_eq!( + fs::read(directory.join(format!("quota-pace-history-v3.corrupt-{now}.json"))).unwrap(), + bytes + ); + let recovered = read_store(&path); + assert_eq!(recovered.series[0].active_reset_at, None); + assert!(recovered.series[0].samples.is_empty()); + assert!(matches!( + recovered.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == reset + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn stale_caller_after_newer_commit_preserves_history_without_quarantine_or_lost_update() { + let (directory, path) = temp_path("stale-caller-order"); + let now = 17_000_000; + let newer_now = now + 10; + let stale_lock_time = newer_now + 10; + let reset = now + DAY; + + assert!(matches!( + record_at_lock_time( + &path, + "acct", + Some(reset), + 10.0, + newer_now, + provider(reset, DAY), + None, + newer_now, + ), + HistoryOutcome::Ready { sampled: true, .. } + )); + assert!(matches!( + record_at_lock_time( + &path, + "acct", + Some(reset), + 20.0, + now, + provider(reset, DAY), + None, + stale_lock_time, + ), + HistoryOutcome::Ready { sampled: false, .. } + )); + + assert!(!directory + .join(format!("quota-pace-history-v3.corrupt-{now}.json")) + .exists()); + let store = read_store(&path); + assert_eq!(store.series.len(), 1); + assert_eq!(store.series[0].active_reset_at, Some(reset)); + assert_eq!(store.series[0].last_activity_at, newer_now); + assert_eq!(store.series[0].samples.len(), 1); + assert_eq!(store.series[0].samples[0].sampled_at, newer_now); + assert!(matches!( + store.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == reset + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn stale_older_reset_after_newer_commit_preserves_state_and_rejects_observed_duration() { + let (directory, path) = temp_path("stale-older-reset"); + let now = 18_000_000; + let old_reset = now + DAY; + let new_reset = old_reset + 7 * DAY; + + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(new_reset), + 10.0, + now, + provider(new_reset, 8 * DAY), + None, + now, + ), + HistoryOutcome::Ready { + duration_seconds: 8 * DAY, + source: DurationSource::Provider, + sampled: true, + } + ); + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(old_reset), + 20.0, + now, + provider(old_reset, DAY), + None, + now + 10, + ), + HistoryOutcome::Ready { + duration_seconds: DAY, + source: DurationSource::Provider, + sampled: false, + } + ); + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(old_reset), + 20.0, + now, + None, + None, + now + 10, + ), + HistoryOutcome::LearningDuration + ); + + assert!(!directory + .join(format!("quota-pace-history-v3.corrupt-{now}.json")) + .exists()); + let store = read_store(&path); + assert_eq!(store.series.len(), 1); + assert_eq!(store.series[0].active_reset_at, Some(new_reset)); + assert_eq!(store.series[0].last_activity_at, now); + assert_eq!(store.series[0].samples.len(), 1); + assert_eq!(store.series[0].samples[0].reset_at, new_reset); + assert_eq!(store.series[0].samples[0].duration_seconds, 8 * DAY); + assert_eq!(store.series[0].samples[0].sampled_at, now); + assert!(matches!( + store.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == new_reset + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn later_known_backward_reset_invalidates_duration_without_sampling() { + let now = 20_000_000; + let old_reset = now + 2 * DAY; + let new_reset = old_reset + 7 * DAY; + let later = now + HOUR; + let cases = [ + ( + "provider", + Some(DurationEvidence::provider(new_reset, 9 * DAY)), + None, + Some(DurationEvidence::provider(old_reset, 2 * DAY)), + None, + DurationSource::Provider, + ), + ( + "contract", + None, + Some(DurationEvidence::contract(9 * DAY)), + None, + Some(DurationEvidence::contract(2 * DAY)), + DurationSource::Contract, + ), + ]; + + for (label, new_provider, new_contract, old_provider, old_contract, old_source) in cases { + let (directory, path) = temp_path(label); + assert!(matches!( + record_at_lock_time( + &path, + "acct", + Some(new_reset), + 10.0, + now, + new_provider, + new_contract, + now, + ), + HistoryOutcome::Ready { sampled: true, .. } + )); + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(old_reset), + 20.0, + later, + old_provider, + old_contract, + later + 10, + ), + HistoryOutcome::LearningDuration + ); + + let after_backward = read_store(&path); + assert_eq!(after_backward.series[0].active_reset_at, None); + assert_eq!(after_backward.series[0].last_activity_at, later); + assert!(after_backward.series[0].samples.is_empty()); + assert!(matches!( + after_backward.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == old_reset + )); + + let repeat = later + HOUR; + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(old_reset), + 30.0, + repeat, + old_provider, + old_contract, + repeat + 10, + ), + HistoryOutcome::Ready { + duration_seconds: 2 * DAY, + source: old_source, + sampled: true, + } + ); + let after_known_repeat = read_store(&path); + assert_eq!( + after_known_repeat.series[0].active_reset_at, + Some(old_reset) + ); + assert_eq!(after_known_repeat.series[0].last_activity_at, repeat); + assert_eq!(after_known_repeat.series[0].samples.len(), 1); + assert_eq!( + after_known_repeat.series[0].samples[0].reset_at, + normalize_sample_reset(old_reset, 2 * DAY, repeat) + ); + assert!(matches!( + after_known_repeat.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 2, + .. + }) if reset_at == old_reset + )); + + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(old_reset), + 40.0, + repeat + HOUR, + None, + None, + repeat + HOUR + 10, + ), + HistoryOutcome::LearningDuration + ); + let after_repeat = read_store(&path); + assert_eq!(after_repeat.series[0].active_reset_at, Some(old_reset)); + assert_eq!(after_repeat.series[0].last_activity_at, repeat + HOUR); + assert_eq!(after_repeat.series[0].samples.len(), 1); + assert_eq!( + after_repeat.series[0].samples[0].reset_at, + normalize_sample_reset(old_reset, 2 * DAY, repeat) + ); + assert!(matches!( + after_repeat.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 2, + .. + }) if reset_at == old_reset + )); + assert!(!directory + .join(format!("quota-pace-history-v3.corrupt-{later}.json")) + .exists()); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn known_reset_jitter_stays_in_normalized_cycle_and_keeps_partial_history() { + let (directory, path) = temp_path("known-reset-jitter"); + let now = 1_000_000; + let duration = DAY; + let reset = now + duration; + let jittered_reset = reset - 1; + + assert_eq!( + record( + &path, + "acct", + Some(reset), + 10.0, + now, + provider(reset, duration), + None, + ), + HistoryOutcome::Ready { + duration_seconds: duration, + source: DurationSource::Provider, + sampled: true, + } + ); + let before = read_store(&path); + let before_series = &before.series[0]; + assert_eq!(before_series.samples.len(), 1); + assert_eq!( + normalize_reset(before_series.active_reset_at.unwrap(), duration), + before_series.samples[0].reset_at + ); + + assert_eq!( + record( + &path, + "acct", + Some(jittered_reset), + 10.5, + now + 60, + provider(jittered_reset, duration), + None, + ), + HistoryOutcome::Ready { + duration_seconds: duration, + source: DurationSource::Provider, + sampled: false, + } + ); + let after = read_store(&path); + let after_series = &after.series[0]; + assert_eq!(after_series.samples.len(), 1); + assert_eq!(after_series.samples[0].used_percent, 10.0); + assert_eq!( + normalize_reset(after_series.active_reset_at.unwrap(), duration), + after_series.samples[0].reset_at + ); + assert_eq!( + after_series.samples[0].reset_at, + normalize_reset(reset, duration) + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn later_backward_observation_restarts_learning_after_newer_commit() { + let (directory, path) = temp_path("later-backward"); + let now = 19_000_000; + let old_reset = now + 2 * DAY; + let new_reset = old_reset + 7 * DAY; + let later = now + HOUR; + + assert!(matches!( + record_at_lock_time( + &path, + "acct", + Some(new_reset), + 10.0, + now, + provider(new_reset, 9 * DAY), + None, + now, + ), + HistoryOutcome::Ready { sampled: true, .. } + )); + assert_eq!( + record_at_lock_time( + &path, + "acct", + Some(old_reset), + 20.0, + later, + None, + None, + later + 10, + ), + HistoryOutcome::LearningDuration + ); + + let store = read_store(&path); + assert_eq!(store.series[0].active_reset_at, None); + assert_eq!(store.series[0].last_activity_at, later); + assert!(store.series[0].samples.is_empty()); + assert!(matches!( + store.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == old_reset + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn ready_history_requires_matching_active_reset() { + let now = 15_000_000; + let reset = now + DAY; + let confirmed_at = now + 10 * 60; + for (label, active_reset_at) in [ + ("ready-active-mismatch", Some(reset + 1)), + ("ready-active-none", None), + ] { + let (directory, path) = temp_path(label); + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![SeriesState { + provider_id: "copilot".into(), + account_scope: "acct".into(), + window_key: "premium_interactions.v1".into(), + active_reset_at, + last_activity_at: confirmed_at, + rollover: Some(ObservedState::Ready { + cycle_started_at: now, + reset_at: reset, + duration_seconds: DAY, + confirmed_at, + last_seen_at: confirmed_at, + }), + samples: Vec::new(), + }], + }; + let bytes = serde_json::to_vec_pretty(&store).unwrap(); + fs::write(&path, &bytes).unwrap(); + + assert_eq!( + record(&path, "acct", Some(reset), 10.0, now, None, None), + HistoryOutcome::LearningDuration + ); + assert_eq!( + fs::read(directory.join(format!("quota-pace-history-v3.corrupt-{now}.json"))) + .unwrap(), + bytes + ); + let recovered = read_store(&path); + assert_eq!(recovered.series[0].active_reset_at, None); + assert!(recovered.series[0].samples.is_empty()); + assert!(matches!( + recovered.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 1, + .. + }) if reset_at == reset + )); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn validate_sample_requires_current_cycle_bounds() { + let reset = 13_000_000; + let sample = |sampled_at| QuotaSample { + reset_at: reset, + duration_seconds: DAY, + duration_source: DurationSource::Provider, + used_percent: 10.0, + sampled_at, + origin: SampleOrigin::LiveV3, + }; + assert!(validate_sample(&sample(reset - DAY))); + assert!(validate_sample(&sample(reset - 1))); + assert!(validate_sample(&sample(reset))); + assert!(!validate_sample(&sample(reset - DAY - 1))); + assert!(!validate_sample(&sample(reset + 1))); + } + + #[test] + fn invalid_series_key_never_creates_store() { + let (directory, path) = temp_path("key"); + let invalid = SeriesKey::new("provider", "", "window.v1"); + let result = record_observation_at_path( + invalid, + Some(10_000 + DAY), + 10.0, + 10_000, + None, + None, + &path, + ); + assert_eq!(result, Err(HistoryError::InvalidSeriesKey)); + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn known_observation_does_not_fake_observed_ready() { + let (directory, path) = temp_path("known-then-observed"); + let now = 11_000_000; + let reset = now + 5 * HOUR; + assert!(matches!( + record( + &path, + "acct", + Some(reset), + 10.0, + now, + provider(reset, 5 * HOUR), + None + ), + HistoryOutcome::Ready { sampled: true, .. } + )); + assert_eq!( + record(&path, "acct", Some(reset), 20.0, now + HOUR, None, None), + HistoryOutcome::LearningDuration + ); + let store = read_store(&path); + assert_eq!(store.series[0].samples.len(), 1); + assert_eq!(store.series[0].samples[0].duration_seconds, 5 * HOUR); + assert_eq!(store.series[0].active_reset_at, Some(reset)); + assert!(matches!( + store.series[0].rollover, + Some(ObservedState::Watching { + reset_at, + consecutive_count: 2, + .. + }) if reset_at == reset + )); + + let changed_reset = reset + DAY; + assert_eq!( + record( + &path, + "acct", + Some(changed_reset), + 20.0, + now + HOUR, + None, + None, + ), + HistoryOutcome::LearningDuration + ); + assert_eq!(read_store(&path).series[0].active_reset_at, None); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn batch_result_reports_complete_cycles_after_retention() { + let (directory, path) = temp_path("batch-complete-cycles"); + let now = 8_000_000_000_i64; + let duration = DAY; + let current_reset = now + duration; + let key = SeriesKey::new("fixture", "scope", "window.v1"); + let mut series = seeded_series( + &key.provider_id, + &key.account_scope, + &key.window_key, + current_reset, + duration, + 35, + ); + series.last_activity_at = now; + series.rollover = Some(ObservedState::Watching { + reset_at: current_reset, + first_seen_at: now, + last_seen_at: now, + consecutive_count: 1, + }); + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![series], + }; + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + + let results = record_observations_at_path_and_evaluate( + std::slice::from_ref(&key), + &[observation(key.clone(), current_reset, 40.0, duration)], + now, + &path, + ) + .unwrap(); + let (_, _, complete_cycles) = results[0].as_ref().unwrap(); + assert_eq!(*complete_cycles, retention_limits(duration).0); + + let retained = read_store(&path); + let retained_cycles = historical_cycles( + &retained.series[0], + normalize_reset(current_reset, duration), + now, + ); + assert_eq!(retained_cycles.len(), *complete_cycles); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn generic_evaluator_has_phase_invariant_expected_and_exact_confidence_gates() { + let durations = [5 * HOUR, 7 * DAY, 28 * DAY, 29 * DAY, 30 * DAY, 31 * DAY]; + for duration in durations { + let now = 1_000_000_000; + let current_reset = now + duration; + let key = SeriesKey::new("fixture", "opaque", "quota.v1"); + let expected_cycles = if duration < DAY { 6 } else { 3 }; + let mature_cycles = if duration < DAY { 36 } else { 5 }; + let mut series = seeded_series( + &key.provider_id, + &key.account_scope, + &key.window_key, + current_reset, + duration, + mature_cycles, + ); + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![series.clone()], + }; + let three_store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![{ + series.samples.truncate(expected_cycles * 8); + series + }], + }; + let expected = evaluate_current(&three_store, &key, current_reset, duration, 45.0, now) + .unwrap_or_else(|| { + panic!("three complete cycles pass expected gate for {duration}") + }); + assert!(expected.expected_percent.is_finite()); + assert!(expected.run_out_probability.is_none()); + + let mature = evaluate_current(&store, &key, current_reset, duration, 45.0, now) + .expect("five complete cycles pass expected and risk gates"); + assert!(mature + .run_out_probability + .is_some_and(|probability| (0.0..=1.0).contains(&probability))); + } + } + + #[test] + fn evaluator_partial_coverage_gap_and_exact_half_tie_are_fail_closed() { + assert_eq!( + crate::agent_history::weighted_median(&[10.0, 20.0], &[1.0, 1.0]), + 10.0 + ); + let reset = 2_000_000; + let duration = 7 * DAY; + let mut samples = complete_cycle(reset, duration, 80.0); + samples.truncate(5); + assert!(cycle_profile(reset, &samples, reset + 1).is_none()); + + let gapped = [0.01, 0.10, 0.20, 0.30, 0.40, 0.99] + .into_iter() + .map(|phase| { + quota_sample( + reset, + duration, + phase, + phase * 80.0 + 1.0, + SampleOrigin::LiveV3, + ) + }) + .collect::>(); + assert!(cycle_profile(reset, &gapped, reset + 1).is_none()); + + let middle_only = [0.30, 0.40, 0.50, 0.60, 0.70, 0.80] + .into_iter() + .map(|phase| { + quota_sample( + reset, + duration, + phase, + phase * 80.0 + 1.0, + SampleOrigin::LiveV3, + ) + }) + .collect::>(); + assert!(cycle_profile(reset, &middle_only, reset + 1).is_none()); + } + + #[test] + fn retention_keeps_latest_cycle_horizon_and_current_partial_only() { + let duration = 7 * DAY; + let now = 3_000_000_000; + let current_reset = now + duration; + let mut series = seeded_series( + "provider", + "scope", + "window.v1", + current_reset, + duration, + 12, + ); + let current = complete_cycle(current_reset, duration, 70.0) + .into_iter() + .take(2) + .collect::>(); + let other_partial = quota_sample( + current_reset + duration, + duration, + 0.5, + 35.0, + SampleOrigin::LiveV3, + ); + let stale_reset = current_reset - 20 * duration; + series.samples.extend(current.clone()); + series.samples.push(other_partial.clone()); + series + .samples + .extend(complete_cycle(stale_reset, duration, 70.0)); + retain_series(&mut series, now); + let groups = grouped_samples(&series.samples); + assert_eq!(groups.len(), RETENTION_MIN_CYCLES + 1); + assert_eq!( + series.samples.len(), + RETENTION_MIN_CYCLES * 8 + current.len() + ); + assert!(current.iter().all(|sample| series.samples.contains(sample))); + assert!(!series.samples.contains(&other_partial)); + } + + #[test] + fn deterministic_series_eviction_uses_activity_then_full_key_order() { + let now = 4_000_000_000; + let mut store = Store::default(); + for index in 0..=MAX_SERIES { + store.series.push(SeriesState { + provider_id: "provider".into(), + account_scope: format!("scope-{index:04}"), + window_key: "window.v1".into(), + active_reset_at: None, + last_activity_at: now, + rollover: None, + samples: vec![QuotaSample { + reset_at: now - DAY, + duration_seconds: DAY, + duration_source: DurationSource::Provider, + used_percent: 10.0, + sampled_at: now - DAY, + origin: SampleOrigin::LiveV3, + }], + }); + } + store.series.sort_by(series_order); + let active = BTreeSet::from([SeriesKey::new("provider", "scope-active", "window.v1")]); + evict_inactive_series(&mut store, &active, now).unwrap(); + assert_eq!(store.series.len(), MAX_SERIES); + assert!(!store + .series + .iter() + .any(|series| series.account_scope == "scope-0000")); + assert!(store + .series + .iter() + .any(|series| series.account_scope == format!("scope-{MAX_SERIES:04}"))); + } + + #[test] + fn active_capacity_overflow_rolls_back_without_replacing_last_valid_store() { + let (directory, path) = temp_path("capacity-rollback"); + let now = 5_000_000_000; + let reset = now + DAY; + let mut store = Store::default(); + for index in 0..MAX_SERIES { + store.series.push(SeriesState { + provider_id: "copilot".into(), + account_scope: format!("active-{index:04}"), + window_key: "premium_interactions.v1".into(), + active_reset_at: Some(reset), + last_activity_at: now, + rollover: Some(ObservedState::Watching { + reset_at: reset, + first_seen_at: now, + last_seen_at: now, + consecutive_count: 1, + }), + samples: Vec::new(), + }); + } + store.series.sort_by(series_order); + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + let before = fs::read(&path).unwrap(); + let result = record_observation_at_path( + key("new-active"), + Some(reset), + 10.0, + now, + provider(reset, DAY), + None, + &path, + ); + assert_eq!(result, Err(HistoryError::StoreCapacity)); + assert_eq!(fs::read(&path).unwrap(), before); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn batch_emitted_existing_without_observation_stays_active() { + let (directory, path) = temp_path("batch-emitted-active"); + let now = 5_250_000_000_i64; + let emitted = SeriesKey::new("provider", "scope-0000", "window.v1"); + let mut store = Store::default(); + for index in 0..=MAX_SERIES { + store.series.push(batch_series( + SeriesKey::new("provider", format!("scope-{index:04}"), "window.v1"), + now, + None, + )); + } + store.series.sort_by(series_order); + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + + let results = record_observations_at_path_and_evaluate( + std::slice::from_ref(&emitted), + &[], + now, + &path, + ) + .unwrap(); + assert!(results.is_empty()); + let retained = read_store(&path); + assert_eq!(retained.series.len(), MAX_SERIES); + assert!(retained.series.iter().any(|series| series.key() == emitted)); + assert!(!retained + .series + .iter() + .any(|series| series.account_scope == "scope-0001")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn batch_observation_key_protects_existing_history_without_explicit_emission() { + let (directory, path) = temp_path("batch-observation-active"); + let now = 5_275_000_000_i64; + let existing = SeriesKey::new("provider", "scope-0000", "window.v1"); + let mut store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![batch_series(existing.clone(), now, None)], + }; + for index in 1..=MAX_SERIES { + store.series.push(batch_series( + SeriesKey::new("provider", format!("scope-{index:04}"), "window.v1"), + now, + None, + )); + } + store.series.sort_by(series_order); + let before = store + .series + .iter() + .find(|series| series.key() == existing) + .unwrap() + .samples + .clone(); + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + + let results = record_observations_at_path_and_evaluate( + &[], + &[observation(existing.clone(), now + DAY, 80.0, DAY)], + now, + &path, + ) + .unwrap(); + assert!(results[0].is_ok()); + + let retained = read_store(&path); + let existing = retained + .series + .iter() + .find(|series| series.key() == existing) + .unwrap(); + assert_eq!(retained.series.len(), MAX_SERIES); + assert_eq!(existing.samples.len(), before.len() + 1); + assert!(existing.samples[..before.len()] + .iter() + .zip(before.iter()) + .all(|(actual, expected)| actual == expected)); + assert!(!retained + .series + .iter() + .any(|series| series.account_scope == "scope-0001")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn stale_rollover_only_series_releases_capacity_after_fifty_six_days() { + let now = 5_290_000_000_i64; + let future_reset = now + 90 * DAY; + let idle = now - 57 * DAY; + for (label, candidate) in [("watching", false), ("candidate", true)] { + let (directory, path) = temp_path(&format!("stale-rollover-{label}")); + let stale_key = SeriesKey::new("provider", format!("stale-{label}"), "window.v1"); + let mut store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![rollover_only_series( + stale_key.clone(), + idle, + future_reset, + candidate, + )], + }; + for index in 0..MAX_SERIES - 1 { + store.series.push(batch_series( + SeriesKey::new("provider", format!("active-{index:04}"), "window.v1"), + now, + Some(now + DAY), + )); + } + store.series.sort_by(series_order); + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + + let new_key = SeriesKey::new("provider", format!("new-{label}"), "window.v1"); + let results = record_observations_at_path_and_evaluate( + &[], + &[observation(new_key.clone(), now + DAY, 10.0, DAY)], + now, + &path, + ) + .unwrap(); + assert!(results[0].is_ok()); + let retained = read_store(&path); + assert_eq!(retained.series.len(), MAX_SERIES); + assert!(!retained + .series + .iter() + .any(|series| series.key() == stale_key)); + assert!(retained.series.iter().any(|series| series.key() == new_key)); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn rollover_only_retention_keeps_fifty_five_day_idle_and_emitted_stale_key_for_poll() { + let now = 5_300_000_000_i64; + let future_reset = now + 90 * DAY; + let (directory, path) = temp_path("rollover-boundary-55d"); + let key = SeriesKey::new("provider", "boundary-55d", "window.v1"); + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![rollover_only_series( + key.clone(), + now - 55 * DAY, + future_reset, + false, + )], + }; + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + record_observations_at_path_and_evaluate(&[], &[], now, &path).unwrap(); + let retained = read_store(&path); + assert_eq!(retained.series.len(), 1); + assert!(retained.series[0].rollover.is_some()); + fs::remove_dir_all(directory).unwrap(); + + for (label, candidate) in [("watching", false), ("candidate", true)] { + let (directory, path) = temp_path(&format!("rollover-boundary-{label}")); + let key = SeriesKey::new("provider", format!("boundary-{label}"), "window.v1"); + let stale_store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![rollover_only_series( + key.clone(), + now - 57 * DAY, + future_reset, + candidate, + )], + }; + fs::write(&path, serde_json::to_vec_pretty(&stale_store).unwrap()).unwrap(); + record_observations_at_path_and_evaluate(std::slice::from_ref(&key), &[], now, &path) + .unwrap(); + let protected = read_store(&path); + assert_eq!(protected.series.len(), 1); + assert_eq!(protected.series[0].key(), key); + assert!(protected.series[0].samples.is_empty()); + assert!(protected.series[0].rollover.is_none()); + assert_eq!(protected.series[0].active_reset_at, None); + + record_observations_at_path_and_evaluate(&[], &[], now, &path).unwrap(); + assert!(read_store(&path).series.is_empty()); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn batch_existing_future_active_series_precedes_new_candidate() { + let (directory, path) = temp_path("batch-existing-active"); + let now = 5_300_000_000_i64; + let active = SeriesKey::new("provider", "active", "window.v1"); + let mut store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![batch_series(active.clone(), now, Some(now + DAY))], + }; + for index in 0..MAX_SERIES - 1 { + store.series.push(batch_series( + SeriesKey::new("provider", format!("inactive-{index:04}"), "window.v1"), + now, + None, + )); + } + store.series.sort_by(series_order); + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + let new_key = SeriesKey::new("provider", "new", "window.v1"); + let results = record_observations_at_path_and_evaluate( + &[], + &[observation(new_key.clone(), now + DAY, 20.0, DAY)], + now, + &path, + ) + .unwrap(); + assert!(results[0].is_ok()); + let retained = read_store(&path); + assert_eq!(retained.series.len(), MAX_SERIES); + assert!(retained.series.iter().any(|series| series.key() == active)); + assert!(retained.series.iter().any(|series| series.key() == new_key)); + assert!(!retained + .series + .iter() + .any(|series| series.account_scope == "inactive-0000")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn batch_new_candidates_admit_by_key_not_input_order() { + let now = 5_350_000_000_i64; + let active_keys = (0..MAX_SERIES - 2) + .map(|index| SeriesKey::new("provider", format!("active-{index:04}"), "window.v1")) + .collect::>(); + let base = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: active_keys + .iter() + .cloned() + .map(|key| batch_series(key, now, Some(now + DAY))) + .collect(), + }; + let new_a = SeriesKey::new("provider", "new-a", "window.v1"); + let new_b = SeriesKey::new("provider", "new-b", "window.v1"); + let new_c = SeriesKey::new("provider", "new-c", "window.v1"); + let cases = [ + ( + "batch-admission-reversed", + vec![ + observation(new_c.clone(), now + DAY, 30.0, DAY), + observation(new_b.clone(), now + DAY, 20.0, DAY), + observation(new_a.clone(), now + DAY, 10.0, DAY), + ], + ), + ( + "batch-admission-sorted", + vec![ + observation(new_a.clone(), now + DAY, 10.0, DAY), + observation(new_b.clone(), now + DAY, 20.0, DAY), + observation(new_c.clone(), now + DAY, 30.0, DAY), + ], + ), + ]; + let mut persisted_keys = Vec::new(); + for (label, observations) in cases { + let (directory, path) = temp_path(label); + fs::write(&path, serde_json::to_vec_pretty(&base).unwrap()).unwrap(); + let results = + record_observations_at_path_and_evaluate(&active_keys, &observations, now, &path) + .unwrap(); + if observations[0].key == new_c { + assert!(matches!(&results[0], Err(HistoryError::StoreCapacity))); + assert!(results[1].is_ok()); + assert!(results[2].is_ok()); + } else { + assert!(results[0].is_ok()); + assert!(results[1].is_ok()); + assert!(matches!(&results[2], Err(HistoryError::StoreCapacity))); + } + let store = read_store(&path); + assert_eq!(store.series.len(), MAX_SERIES); + assert!(store.series.iter().any(|series| series.key() == new_a)); + assert!(store.series.iter().any(|series| series.key() == new_b)); + assert!(!store.series.iter().any(|series| series.key() == new_c)); + persisted_keys.push( + store + .series + .iter() + .map(SeriesState::key) + .collect::>(), + ); + fs::remove_dir_all(directory).unwrap(); + } + assert_eq!(persisted_keys[0], persisted_keys[1]); + } + + #[test] + fn batch_save_failure_preserves_pre_transaction_bytes() { + let (directory, path) = temp_path("batch-save-failure"); + let now = 5_400_000_000_i64; + let existing = batch_series( + SeriesKey::new("provider", "existing", "window.v1"), + now, + None, + ); + let store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![existing], + }; + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + let before = fs::read(&path).unwrap(); + let observations = vec![ + observation( + SeriesKey::new("provider", "batch-a", "window.v1"), + now + DAY, + 10.0, + DAY, + ), + observation( + SeriesKey::new("provider", "batch-b", "window.v1"), + now + DAY, + 20.0, + DAY, + ), + ]; + let result = record_observations_at_path_and_evaluate_with_clock_and_save( + &[], + &observations, + now, + &path, + || now, + |_path, _store| Err(io::Error::other("injected batch save failure")), + ); + assert!(matches!(result, Err(HistoryError::AtomicSave))); + assert_eq!(fs::read(&path).unwrap(), before); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn batch_duplicate_observation_key_fails_closed_without_mutation() { + let (directory, path) = temp_path("batch-duplicate"); + let now = 5_450_000_000_i64; + let store = Store::default(); + fs::write(&path, serde_json::to_vec_pretty(&store).unwrap()).unwrap(); + let before = fs::read(&path).unwrap(); + let item = observation( + SeriesKey::new("provider", "duplicate", "window.v1"), + now + DAY, + 10.0, + DAY, + ); + let result = + record_observations_at_path_and_evaluate(&[], &[item.clone(), item], now, &path); + assert_eq!(result, Err(HistoryError::InvalidSeriesKey)); + assert_eq!(fs::read(&path).unwrap(), before); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn same_phase_bucket_requires_one_point_usage_delta() { + let now = 5_500_000_000; + let duration = DAY; + let reset = now + duration; + let mut series = SeriesState::new(&key("sample-throttle"), now); + assert!(add_sample_if_new( + &mut series, + reset, + duration, + DurationSource::Provider, + 10.0, + now, + )); + assert!(!add_sample_if_new( + &mut series, + reset, + duration, + DurationSource::Provider, + 10.9, + now + 60, + )); + assert!(add_sample_if_new( + &mut series, + reset, + duration, + DurationSource::Provider, + 11.0, + now + 60, + )); + assert!(!add_sample_if_new( + &mut series, + reset, + duration, + DurationSource::Provider, + 11.9, + now + 60, + )); + assert!(add_sample_if_new( + &mut series, + reset, + duration, + DurationSource::Provider, + 12.0, + now + 60, + )); + assert_eq!(series.samples.len(), 1); + assert_eq!(series.samples[0].used_percent, 12.0); + } + + #[test] + fn codex_v2_migration_is_account_bound_idempotent_and_read_only() { + let (directory, v3_path) = temp_path("migration"); + let v2_path = directory.join(LEGACY_V2_FILE_NAME); + let now = 6_000_000_000; + let duration = 10_080 * 60; + let reset = now - 2 * duration; + let sampled = reset - duration / 2; + let mut legacy = serde_json::json!({ + "schemaVersion": 2, + "samples": [ + {"accountKey": " acct ", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 40.0, "sampledAt": sampled}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 30.0, "sampledAt": reset - duration + duration / 10}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 35.0, "sampledAt": reset - duration + duration / 4}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 45.0, "sampledAt": reset - duration + duration * 2 / 5}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 55.0, "sampledAt": reset - duration + duration * 3 / 5}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 65.0, "sampledAt": reset - duration + duration * 3 / 4}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 72.0, "sampledAt": reset - duration + duration * 9 / 10}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 78.0, "sampledAt": reset - duration + duration * 99 / 100}, + {"accountKey": "other", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 90.0, "sampledAt": sampled}, + {"accountKey": "acct@example.com", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 90.0, "sampledAt": sampled}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 300, "usedPercent": 20.0, "sampledAt": sampled}, + {"accountKey": "acct", "resetsAt": reset, "windowMinutes": 10080, "usedPercent": 0.0, "sampledAt": sampled} + ] + }); + fs::write(&v2_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + let v2_before = fs::read(&v2_path).unwrap(); + let v2_mtime = fs::metadata(&v2_path).unwrap().modified().unwrap(); + + let live = quota_sample(reset, duration, 0.5, 99.0, SampleOrigin::LiveV3); + let existing = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![SeriesState { + provider_id: "codex".into(), + account_scope: "opaque-scope".into(), + window_key: "main.weekly.v1".into(), + active_reset_at: None, + last_activity_at: sampled, + rollover: None, + samples: vec![live], + }], + }; + fs::write(&v3_path, serde_json::to_vec_pretty(&existing).unwrap()).unwrap(); + + let first = + migrate_codex_v2_at_paths("acct", "opaque-scope", now, &v2_path, &v3_path).unwrap(); + assert_eq!(first.imported_samples, 7); + assert_eq!(fs::read(&v2_path).unwrap(), v2_before); + assert_eq!( + fs::metadata(&v2_path).unwrap().modified().unwrap(), + v2_mtime + ); + let migrated = read_store(&v3_path); + assert_eq!(migrated.series.len(), 1); + assert_eq!( + migrated.series[0].last_activity_at, + reset - duration + duration * 99 / 100 + ); + assert!(migrated.series[0] + .samples + .iter() + .any(|sample| sample.origin == SampleOrigin::LiveV3 && sample.used_percent == 99.0)); + assert!( + migrated.series[0] + .samples + .iter() + .any(|sample| sample.origin == SampleOrigin::ImportedV2 + && sample.used_percent == 30.0) + ); + + let bytes_after_first = fs::read(&v3_path).unwrap(); + let second = + migrate_codex_v2_at_paths("acct", "opaque-scope", now, &v2_path, &v3_path).unwrap(); + assert_eq!(second.imported_samples, 0); + assert_eq!(fs::read(&v3_path).unwrap(), bytes_after_first); + + legacy["samples"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "accountKey": "acct", + "resetsAt": reset, + "windowMinutes": 10080, + "usedPercent": 50.0, + "sampledAt": reset - duration + duration * 55 / 100 + })); + fs::write(&v2_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + let third = + migrate_codex_v2_at_paths("acct", "opaque-scope", now, &v2_path, &v3_path).unwrap(); + assert_eq!(third.imported_samples, 1); + assert_ne!(fs::read(&v3_path).unwrap(), bytes_after_first); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn migration_collision_losers_do_not_advance_activity() { + let (directory, v3_path) = temp_path("migration-collision-loser"); + let v2_path = directory.join(LEGACY_V2_FILE_NAME); + let now = 6_500_000_000_i64; + let duration = 10_080 * 60; + let reset = now - 2 * duration; + let live = quota_sample(reset, duration, 0.5, 99.0, SampleOrigin::LiveV3); + let candidate_sampled_at = live.sampled_at + 60; + fs::write( + &v2_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "samples": [{ + "accountKey": "acct", + "resetsAt": reset, + "windowMinutes": 10080, + "usedPercent": 20.0, + "sampledAt": candidate_sampled_at + }] + })) + .unwrap(), + ) + .unwrap(); + let existing = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![SeriesState { + provider_id: "codex".into(), + account_scope: "scope".into(), + window_key: "main.weekly.v1".into(), + active_reset_at: None, + last_activity_at: live.sampled_at, + rollover: None, + samples: vec![live.clone()], + }], + }; + fs::write(&v3_path, serde_json::to_vec_pretty(&existing).unwrap()).unwrap(); + let before = fs::read(&v3_path).unwrap(); + let outcome = migrate_codex_v2_at_paths("acct", "scope", now, &v2_path, &v3_path).unwrap(); + assert_eq!(outcome.imported_samples, 0); + assert_eq!( + read_store(&v3_path).series[0].last_activity_at, + live.sampled_at + ); + assert_eq!(fs::read(&v3_path).unwrap(), before); + assert!(!directory + .join(format!("quota-pace-history-v3.corrupt-{now}.json")) + .exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn migration_stale_caller_uses_post_lock_validation_clock() { + let (directory, v3_path) = temp_path("migration-stale-caller"); + let v2_path = directory.join(LEGACY_V2_FILE_NAME); + let committed_now = 7_500_000_000_i64; + let stale_now = committed_now - 60; + let duration = 10_080 * 60; + let reset = committed_now - 2 * duration; + let imported = complete_cycle(reset, duration, 80.0); + let legacy_samples = imported + .iter() + .map(|sample| { + serde_json::json!({ + "accountKey": "acct", + "resetsAt": reset, + "windowMinutes": 10080, + "usedPercent": sample.used_percent, + "sampledAt": sample.sampled_at + }) + }) + .collect::>(); + fs::write( + &v2_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "samples": legacy_samples + })) + .unwrap(), + ) + .unwrap(); + let existing = complete_cycle(reset - duration, duration, 60.0); + fs::write( + &v3_path, + serde_json::to_vec_pretty(&Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![SeriesState { + provider_id: "codex".into(), + account_scope: "scope".into(), + window_key: "main.weekly.v1".into(), + active_reset_at: None, + last_activity_at: committed_now, + rollover: None, + samples: existing, + }], + }) + .unwrap(), + ) + .unwrap(); + + let outcome = migrate_codex_v2_at_paths_with_clock( + "acct", + "scope", + stale_now, + &v2_path, + &v3_path, + || committed_now + 1, + ) + .unwrap(); + assert_eq!(outcome.imported_samples, 8); + let store = read_store(&v3_path); + assert_eq!(store.series.len(), 1); + assert_eq!(store.series[0].samples.len(), 16); + assert!(store.series[0] + .samples + .iter() + .any(|sample| sample.origin == SampleOrigin::LiveV3)); + assert!(store.series[0] + .samples + .iter() + .any(|sample| sample.origin == SampleOrigin::ImportedV2)); + assert!(!directory + .join(format!("quota-pace-history-v3.corrupt-{stale_now}.json")) + .exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn migration_corrupt_inputs_and_v1_sentinel_are_left_untouched() { + let (directory, v3_path) = temp_path("migration-corrupt"); + let v2_path = directory.join(LEGACY_V2_FILE_NAME); + let v1_path = directory.join("codex-weekly-history.json"); + let v1_bytes = b"legacy-v1-sentinel"; + fs::write(&v1_path, v1_bytes).unwrap(); + fs::write(&v2_path, b"not-json").unwrap(); + let v3_before = b"existing-v3-bytes"; + fs::write(&v3_path, v3_before).unwrap(); + let outcome = + migrate_codex_v2_at_paths("acct", "scope", 7_000_000_000, &v2_path, &v3_path).unwrap(); + assert_eq!(outcome.imported_samples, 0); + assert_eq!(fs::read(&v2_path).unwrap(), b"not-json"); + assert_eq!(fs::read(&v3_path).unwrap(), v3_before); + assert_eq!(fs::read(&v1_path).unwrap(), v1_bytes); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn corrupt_v3_migration_quarantines_then_rebuilds_atomically() { + let (directory, v3_path) = temp_path("migration-v3-corrupt"); + let v2_path = directory.join(LEGACY_V2_FILE_NAME); + let now = 8_000_000_000; + let reset = now - 2 * 10_080 * 60; + fs::write( + &v2_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "samples": [{ + "accountKey": "acct", + "resetsAt": reset, + "windowMinutes": 10080, + "usedPercent": 25.0, + "sampledAt": reset - 10_080 * 60 / 2 + }] + })) + .unwrap(), + ) + .unwrap(); + let corrupt = b"corrupt-v3-evidence"; + fs::write(&v3_path, corrupt).unwrap(); + let outcome = migrate_codex_v2_at_paths("acct", "scope", now, &v2_path, &v3_path).unwrap(); + assert_eq!(outcome.imported_samples, 1); + assert_eq!( + fs::read(directory.join(format!("quota-pace-history-v3.corrupt-{now}.json"))).unwrap(), + corrupt + ); + assert_eq!(read_store(&v3_path).series[0].account_scope, "scope"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn global_sample_cap_evicts_old_completed_cycles_but_protects_current_partial() { + let now = 9_000_000_000; + let duration = DAY; + let cycle_count = MAX_SAMPLES / MAX_SAMPLES_PER_CYCLE + 1; + let mut samples = Vec::with_capacity(cycle_count * MAX_SAMPLES_PER_CYCLE + 8); + for offset in 1..=cycle_count { + samples.extend(complete_cycle( + now - offset as i64 * duration, + duration, + 80.0, + )); + } + let current_reset = now + duration; + let current = complete_cycle(current_reset, duration, 40.0); + samples.extend(current.clone()); + let series = SeriesState { + provider_id: "provider".into(), + account_scope: "scope".into(), + window_key: "window.v1".into(), + active_reset_at: Some(current_reset), + last_activity_at: now, + rollover: Some(ObservedState::Watching { + reset_at: current_reset, + first_seen_at: now, + last_seen_at: now, + consecutive_count: 1, + }), + samples, + }; + let mut store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![series], + }; + evict_old_completed_samples(&mut store, now).unwrap(); + let retained = &store.series[0]; + assert!(retained.samples.len() <= MAX_SAMPLES); + assert!(current + .iter() + .all(|sample| retained.samples.contains(sample))); + assert!(retained + .samples + .iter() + .any(|sample| sample.reset_at == current[0].reset_at)); + } + + #[test] + fn global_sample_eviction_tie_uses_series_key_order() { + let now = 12_000_000_000_i64; + let duration = DAY; + let cycles_per_series = MAX_SAMPLES / (2 * 8) + 1; + let make_series = |provider_id: &str| { + let mut samples = Vec::with_capacity(cycles_per_series * 8); + for offset in 1..=cycles_per_series { + samples.extend(complete_cycle( + now - offset as i64 * duration, + duration, + 80.0, + )); + } + SeriesState { + provider_id: provider_id.into(), + account_scope: "scope".into(), + window_key: "window.v1".into(), + active_reset_at: None, + last_activity_at: now, + rollover: None, + samples, + } + }; + let mut store = Store { + schema_version: HISTORY_SCHEMA_VERSION, + series: vec![make_series("b"), make_series("a")], + }; + let oldest_reset = normalize_reset(now - cycles_per_series as i64 * duration, duration); + let next_oldest_reset = + normalize_reset(now - (cycles_per_series as i64 - 1) * duration, duration); + evict_old_completed_samples(&mut store, now).unwrap(); + assert!( + store + .series + .iter() + .map(|series| series.samples.len()) + .sum::() + <= MAX_SAMPLES + ); + let series_a = store + .series + .iter() + .find(|series| series.provider_id == "a") + .unwrap(); + let series_b = store + .series + .iter() + .find(|series| series.provider_id == "b") + .unwrap(); + assert!(!series_a + .samples + .iter() + .any(|sample| normalize_reset(sample.reset_at, duration) == oldest_reset)); + assert!(series_a + .samples + .iter() + .any(|sample| normalize_reset(sample.reset_at, duration) == next_oldest_reset)); + assert!(!series_b + .samples + .iter() + .any(|sample| normalize_reset(sample.reset_at, duration) == oldest_reset)); + assert!(series_b + .samples + .iter() + .any(|sample| normalize_reset(sample.reset_at, duration) == next_oldest_reset)); + } + + #[test] + fn same_origin_collision_uses_timestamp_usage_then_serialized_tuple() { + let reset = 11_000_000_000; + let duration = 7 * DAY; + let base = quota_sample(reset, duration, 0.5, 50.0, SampleOrigin::ImportedV2); + let mut later = base.clone(); + later.sampled_at += 1; + assert_eq!(choose_sample(base.clone(), later.clone()), later); + + let mut higher = base.clone(); + higher.used_percent = 51.0; + assert_eq!(choose_sample(base.clone(), higher.clone()), higher); + + let mut contract = base.clone(); + contract.duration_source = DurationSource::Contract; + let expected = + if serde_json::to_vec(&contract).unwrap() < serde_json::to_vec(&base).unwrap() { + contract.clone() + } else { + base.clone() + }; + assert_eq!(choose_sample(base, contract), expected); + } + + #[test] + fn migration_two_first_runs_merge_without_duplicate_series() { + let (directory, v3_path) = temp_path("migration-two-process"); + let v2_path = directory.join(LEGACY_V2_FILE_NAME); + let now = 10_000_000_000_i64; + let duration = 10_080 * 60; + let reset = now - 2 * duration; + let phases = [0.01, 0.10, 0.25, 0.40, 0.60, 0.75, 0.90, 0.99]; + let legacy_samples = phases + .into_iter() + .map(|phase| { + serde_json::json!({ + "accountKey": "acct", + "resetsAt": reset, + "windowMinutes": 10080, + "usedPercent": phase * 80.0 + 1.0, + "sampledAt": reset - duration + (phase * duration as f64) as i64 + }) + }) + .collect::>(); + fs::write( + &v2_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "samples": legacy_samples + })) + .unwrap(), + ) + .unwrap(); + let left_v2 = v2_path.clone(); + let left_v3 = v3_path.clone(); + let right_v2 = v2_path.clone(); + let right_v3 = v3_path.clone(); + let left = std::thread::spawn(move || { + migrate_codex_v2_at_paths("acct", "opaque", now, &left_v2, &left_v3).unwrap() + }); + let right = std::thread::spawn(move || { + migrate_codex_v2_at_paths("acct", "opaque", now, &right_v2, &right_v3).unwrap() + }); + let outcomes = [left.join().unwrap(), right.join().unwrap()]; + assert_eq!( + outcomes + .iter() + .map(|outcome| outcome.imported_samples) + .sum::(), + 8 + ); + let store = read_store(&v3_path); + assert_eq!(store.series.len(), 1); + assert_eq!(store.series[0].samples.len(), 8); + assert!(store.series[0] + .samples + .iter() + .all(|sample| sample.origin == SampleOrigin::ImportedV2)); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/tb_core_ffi/src/agent_usage.rs b/crates/tb_core_ffi/src/agent_usage.rs index f01493c4..30664383 100644 --- a/crates/tb_core_ffi/src/agent_usage.rs +++ b/crates/tb_core_ffi/src/agent_usage.rs @@ -1,10 +1,19 @@ +use crate::agent_account_scope::{ + self, AccountScope, AccountScopeError, AuthoritativeIdKind, RefreshCheckpoint, + RefreshScopeTransaction, +}; use crate::agent_antigravity; use crate::agent_copilot; use crate::agent_grok; -use crate::agent_history; +use crate::agent_quota_duration::{DurationEvidence, DurationSource, DurationUnavailableReason}; +use crate::agent_quota_history::{ + BatchObservationResult, HistoricalPace, HistoryError, HistoryOutcome, QuotaObservation, + SeriesKey, +}; use chrono::{DateTime, SecondsFormat, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; @@ -48,6 +57,8 @@ pub struct AgentUsageSnapshot { source: String, updated_at: String, identity: Option, + #[serde(skip)] + pub(crate) account_scope: Result, windows: Vec, credits: Option, error: Option, @@ -71,20 +82,47 @@ pub struct HistoricalPacePayload { pub(crate) run_out_probability: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum PaceState { + LearningDuration, + LearningHistory, + Available, + Unavailable, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] +struct PaceStatusPayload { + state: PaceState, + #[serde(skip_serializing_if = "Option::is_none")] + window_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration_source: Option, + complete_cycles: usize, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +#[derive(Debug, Clone)] pub struct UsageWindow { + card_id: String, label: String, used_percent: f64, remaining_percent: f64, resets_at: Option, reset_text: Option, - /// Total length of this rate-limit window in minutes. Lets the frontend - /// derive a usage *pace* (expected vs actual at this point in the window). + /// Legacy compatibility only. Wire serialization derives this from + /// `duration_seconds`; provider adapters must never use this as identity. window_minutes: Option, - /// Rust-owned coherent historical expected/ETA/lasts/risk projection. - /// Missing means the frontend must use its linear fallback. - #[serde(skip_serializing_if = "Option::is_none")] + window_key: Option, + duration_seconds: Option, + duration_source: Option, + provider_duration: Option, + contract_duration: Option, + pace_status: PaceStatusPayload, historical_pace: Option, } @@ -95,9 +133,49 @@ pub struct CreditsSnapshot { unlimited: bool, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageWindowWire<'a> { + card_id: &'a str, + label: &'a str, + used_percent: f64, + remaining_percent: f64, + #[serde(skip_serializing_if = "Option::is_none")] + resets_at: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + reset_text: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + window_minutes: Option, + pace_status: &'a PaceStatusPayload, + #[serde(skip_serializing_if = "Option::is_none")] + historical_pace: Option<&'a HistoricalPacePayload>, +} + +impl Serialize for UsageWindow { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.validate_wire().map_err(serde::ser::Error::custom)?; + UsageWindowWire { + card_id: &self.card_id, + label: &self.label, + used_percent: self.used_percent, + remaining_percent: self.remaining_percent, + resets_at: self.resets_at.as_deref(), + reset_text: self.reset_text.as_deref(), + window_minutes: self.duration_seconds.map(|seconds| seconds / 60), + pace_status: &self.pace_status, + historical_pace: self.historical_pace.as_ref(), + } + .serialize(serializer) + } +} + impl UsageWindow { /// Build a window from a "remaining fraction" (0..1) — the shape Antigravity - /// reports per model. Used-percent is derived; pace/window fields stay empty. + /// reports per model. Used-percent is derived; identity and duration are + /// attached by the provider adapter before the snapshot is emitted. pub(crate) fn from_fraction( label: String, remaining_fraction: f64, @@ -113,8 +191,9 @@ impl UsageWindow { ) } - /// Build a window from an absolute used-percent (0..100), with optional - /// window length for pace. Clamps used into range and derives remaining. + /// Build a window from an absolute used-percent (0..100), with an optional + /// legacy duration hint. The hint is retained only for existing tests and + /// converted to exact seconds before any wire serialization. pub(crate) fn from_used_percent( label: String, used_percent: f64, @@ -124,15 +203,264 @@ impl UsageWindow { ) -> Self { let used = used_percent.clamp(0.0, 100.0); let remaining = (100.0 - used).clamp(0.0, 100.0); - UsageWindow { + let duration_seconds = window_minutes + .filter(|minutes| *minutes > 0) + .and_then(|minutes| minutes.checked_mul(60)); + let mut window = UsageWindow { + card_id: "row.unassigned.v1".to_string(), label, used_percent: used, remaining_percent: remaining, resets_at: resets_at.map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)), reset_text: resets_at.map(|d| reset_text(d, now)), window_minutes, + window_key: None, + duration_seconds, + duration_source: duration_seconds.map(|_| DurationSource::Contract), + provider_duration: None, + contract_duration: duration_seconds.map(DurationEvidence::contract), + pace_status: PaceStatusPayload { + state: PaceState::Unavailable, + window_key: None, + duration_seconds: None, + duration_source: None, + complete_cycles: 0, + reason: Some("windowIdentity".to_string()), + }, historical_pace: None, + }; + window.refresh_initial_pace_status(); + window + } + + /// Preserve the raw provider reading until identity is attached so the + /// generic adapter can classify invalid evidence. `with_identity` then + /// restores finite display percentages before any wire serialization. + pub(crate) fn from_provider_used_percent( + label: String, + used_percent: f64, + resets_at: Option>, + now: DateTime, + ) -> Self { + let mut window = Self::from_used_percent(label, used_percent, resets_at, now, None); + window.used_percent = used_percent; + window.remaining_percent = 100.0 - used_percent; + window + } + + pub(crate) fn from_provider_fraction( + label: String, + remaining_fraction: f64, + resets_at: Option>, + now: DateTime, + ) -> Self { + Self::from_provider_used_percent(label, (1.0 - remaining_fraction) * 100.0, resets_at, now) + } + + pub(crate) fn try_from_provider_used_percent( + label: String, + used_percent: f64, + resets_at: Option>, + now: DateTime, + ) -> Option { + (used_percent.is_finite() && (0.0..=100.0).contains(&used_percent)) + .then(|| Self::from_provider_used_percent(label, used_percent, resets_at, now)) + } + + pub(crate) fn try_from_provider_fraction( + label: String, + remaining_fraction: f64, + resets_at: Option>, + now: DateTime, + ) -> Option { + (remaining_fraction.is_finite() && (0.0..=1.0).contains(&remaining_fraction)) + .then(|| Self::from_provider_fraction(label, remaining_fraction, resets_at, now)) + } + + /// Attach provider-semantic presentation and history identity plus the + /// frozen provider/contract duration evidence. + pub(crate) fn with_identity( + mut self, + card_id: impl Into, + window_key: Option, + provider_duration: Option, + contract_duration: Option, + ) -> Self { + let invalid_reading = + !self.used_percent.is_finite() || !(0.0..=100.0).contains(&self.used_percent); + if invalid_reading { + self.used_percent = if self.used_percent.is_finite() { + self.used_percent.clamp(0.0, 100.0) + } else { + 0.0 + }; + self.remaining_percent = 100.0 - self.used_percent; + } + self.card_id = card_id.into(); + self.window_key = window_key; + self.provider_duration = provider_duration; + self.contract_duration = contract_duration; + self.duration_seconds = self + .provider_duration + .or(self.contract_duration) + .map(|evidence| evidence.duration_seconds) + .filter(|duration| *duration > 0); + self.duration_source = if self.provider_duration.is_some() { + Some(DurationSource::Provider) + } else if self.contract_duration.is_some() { + Some(DurationSource::Contract) + } else { + None + }; + self.window_minutes = self.duration_seconds.map(|seconds| seconds / 60); + self.refresh_initial_pace_status(); + if invalid_reading && self.window_key.is_some() { + self.unavailable("invalidEvidence"); + } + self + } + + fn refresh_initial_pace_status(&mut self) { + if self.window_key.is_none() { + self.duration_seconds = None; + self.duration_source = None; + self.window_minutes = None; + self.pace_status = PaceStatusPayload { + state: PaceState::Unavailable, + window_key: None, + duration_seconds: None, + duration_source: None, + complete_cycles: 0, + reason: Some("windowIdentity".to_string()), + }; + self.historical_pace = None; + return; + } + if self.resets_at.is_none() { + self.duration_seconds = None; + self.duration_source = None; + self.window_minutes = None; + self.pace_status = PaceStatusPayload { + state: PaceState::Unavailable, + window_key: self.window_key.clone(), + duration_seconds: None, + duration_source: None, + complete_cycles: 0, + reason: Some("missingReset".to_string()), + }; + self.historical_pace = None; + return; + } + let state = if self.duration_seconds.is_some() { + PaceState::LearningHistory + } else { + PaceState::LearningDuration + }; + self.pace_status = PaceStatusPayload { + state, + window_key: self.window_key.clone(), + duration_seconds: self.duration_seconds, + duration_source: self.duration_source, + complete_cycles: 0, + reason: None, + }; + self.historical_pace = None; + } + + pub(crate) fn unavailable(&mut self, reason: impl Into) { + let reason = reason.into(); + self.duration_seconds = None; + self.duration_source = None; + self.window_minutes = None; + self.historical_pace = None; + self.pace_status = PaceStatusPayload { + state: PaceState::Unavailable, + window_key: self.window_key.clone(), + duration_seconds: None, + duration_source: None, + complete_cycles: 0, + reason: Some(reason), + }; + } + + fn validate_wire(&self) -> Result<(), String> { + if self.card_id.trim().is_empty() { + return Err("pace cardId must be non-empty".to_string()); + } + if self.window_key != self.pace_status.window_key { + return Err("pace windowKey internal and nested values differ".to_string()); + } + if self.duration_seconds != self.pace_status.duration_seconds { + return Err("pace durationSeconds internal and nested values differ".to_string()); + } + if self.duration_source != self.pace_status.duration_source { + return Err("pace durationSource internal and nested values differ".to_string()); + } + if self.window_minutes != self.duration_seconds.map(|seconds| seconds / 60) { + return Err("pace windowMinutes must derive from durationSeconds".to_string()); + } + if self.duration_seconds.is_none() + && self.duration_source.is_some() + && !(self.pace_status.state == PaceState::LearningDuration + && self.duration_source == Some(DurationSource::Observed)) + { + return Err("pace durationSource requires a duration".to_string()); + } + if let Some(window_key) = self.pace_status.window_key.as_deref() { + if window_key.trim().is_empty() { + return Err("pace windowKey must be non-empty".to_string()); + } + } + let identity_unavailable = self.pace_status.state == PaceState::Unavailable + && self.pace_status.reason.as_deref() == Some("windowIdentity"); + if self.pace_status.window_key.is_none() != identity_unavailable { + return Err("pace windowKey identity invariant failed".to_string()); + } + if let Some(duration) = self.pace_status.duration_seconds { + if duration <= 0 { + return Err("pace durationSeconds must be positive".to_string()); + } + if self.pace_status.duration_source.is_none() { + return Err("pace durationSource is required with durationSeconds".to_string()); + } + } + match self.pace_status.state { + PaceState::Available => { + if self.pace_status.duration_seconds.is_none() || self.historical_pace.is_none() { + return Err("available pace requires duration and historicalPace".to_string()); + } + } + PaceState::LearningHistory => { + if self.pace_status.duration_seconds.is_none() || self.historical_pace.is_some() { + return Err("learningHistory pace invariant failed".to_string()); + } + } + PaceState::LearningDuration => { + if self.pace_status.duration_seconds.is_some() || self.historical_pace.is_some() { + return Err("learningDuration pace invariant failed".to_string()); + } + } + PaceState::Unavailable => { + if self.historical_pace.is_some() || self.pace_status.reason.as_deref().is_none() { + return Err("unavailable pace invariant failed".to_string()); + } + } + } + if let Some(historical) = &self.historical_pace { + if !historical.expected_used_percent.is_finite() + || !(0.0..=100.0).contains(&historical.expected_used_percent) + || historical + .eta_seconds + .is_some_and(|eta| !eta.is_finite() || eta < 0.0) + || historical.run_out_probability.is_some_and(|probability| { + !probability.is_finite() || !(0.0..=1.0).contains(&probability) + }) + || (historical.eta_seconds.is_none() != historical.will_last_to_reset) + { + return Err("historicalPace contains contradictory values".to_string()); + } } + Ok(()) } #[cfg(test)] @@ -144,6 +472,38 @@ impl UsageWindow { pub(crate) fn remaining_for_test(&self) -> f64 { self.remaining_percent } + + #[cfg(test)] + pub(crate) fn resets_at_for_test(&self) -> Option<&str> { + self.resets_at.as_deref() + } + + #[cfg(test)] + pub(crate) fn window_minutes_for_test(&self) -> Option { + self.duration_seconds.map(|seconds| seconds / 60) + } + + #[cfg(test)] + pub(crate) fn pace_window_key_for_test(&self) -> Option<&str> { + self.pace_status.window_key.as_deref() + } + + #[cfg(test)] + pub(crate) fn pace_reason_for_test(&self) -> Option<&str> { + self.pace_status.reason.as_deref() + } +} + +#[derive(Debug, Clone)] +struct CredentialSlot { + semantic_source: &'static str, + canonical_location: String, +} + +#[derive(Debug, Clone)] +struct ResolvedClaudeToken { + access_token: String, + scope_slot: CredentialSlot, } #[derive(Debug, Clone)] @@ -155,6 +515,18 @@ struct CodexCredentials { last_refresh: Option>, auth_path: PathBuf, raw_json: Value, + scope_slot: CredentialSlot, +} + +impl CodexCredentials { + fn scope_marker(&self) -> &[u8] { + self.refresh_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + .unwrap_or_else(|| self.access_token.trim()) + .as_bytes() + } } #[derive(Debug, Clone)] @@ -171,6 +543,32 @@ struct ClaudeCredentials { /// Full credentials JSON as loaded, so a write-back preserves fields we /// don't model (merge-update rather than overwrite). raw_root: Option, + scope_slot: CredentialSlot, +} + +impl ClaudeCredentials { + fn scope_marker(&self) -> Option<&[u8]> { + match self.source { + ClaudeCredentialSource::Keychain | ClaudeCredentialSource::File => self + .refresh_token + .as_deref() + .filter(|token| !token.is_empty()) + .map(str::as_bytes), + ClaudeCredentialSource::Environment => Some(self.access_token.as_bytes()), + } + } + + fn resolve_account_scope(&self) -> Result { + let marker = self + .scope_marker() + .ok_or(AccountScopeError::NoTrustedEvidence)?; + agent_account_scope::resolve_credential( + "claude", + self.scope_slot.semantic_source, + &self.scope_slot.canonical_location, + marker, + ) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -212,9 +610,9 @@ struct CodexUsageResponse { #[derive(Debug, Deserialize)] struct CodexRateLimit { - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] primary_window: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] secondary_window: Option, } @@ -245,45 +643,45 @@ struct CodexCredits { #[derive(Debug, Deserialize, Default)] struct ClaudeUsageResponse { - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] five_hour: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_oauth_apps: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_opus: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_sonnet: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_design: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_claude_design: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] claude_design: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] design: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_omelette: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] omelette: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] omelette_promotional: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_routines: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_claude_routines: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] claude_routines: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] routines: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] routine: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] seven_day_cowork: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] cowork: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_raw")] extra_usage: Option, } @@ -295,6 +693,13 @@ struct ClaudeWindow { resets_at: Option, } +impl ClaudeWindow { + fn has_valid_utilization(&self) -> bool { + self.utilization + .is_some_and(|used| used.is_finite() && (0.0..=100.0).contains(&used)) + } +} + #[derive(Debug, Deserialize)] struct ClaudeExtraUsage { #[serde(default)] @@ -312,7 +717,7 @@ struct ClaudeExtraUsage { #[derive(Debug, Deserialize)] struct ClaudeRefreshResponse { access_token: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")] refresh_token: Option, expires_in: i64, } @@ -344,38 +749,43 @@ pub async fn run() -> AgentUsagePayload { async fn fetch_grok() -> Option { let now = Utc::now(); - match agent_grok::fetch(now).await? { - Ok(data) => Some(AgentUsageSnapshot { + let mut snapshot = match agent_grok::fetch(now).await? { + Ok(data) => AgentUsageSnapshot { client_id: "grok".to_string(), source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: data.identity, + account_scope: data.account_scope, windows: data.windows, credits: None, error: None, - }), - Err(error) => Some(AgentUsageSnapshot { + }, + Err(error) => AgentUsageSnapshot { client_id: "grok".to_string(), source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows: Vec::new(), credits: None, error: Some(error), - }), - } + }, + }; + enrich_snapshot(&mut snapshot, now.timestamp()); + Some(snapshot) } async fn fetch_copilot() -> Option { // No opencode Copilot auth → no card at all (rather than an error row). - crate::opencode_integrations::github_copilot_token()?; + let credential = crate::opencode_integrations::github_copilot_credential()?; let now = Utc::now(); - Some(match agent_copilot::fetch(now).await { + let mut snapshot = match agent_copilot::fetch(now, credential).await { Ok(data) => AgentUsageSnapshot { client_id: "copilot".to_string(), source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: data.identity, + account_scope: data.account_scope, windows: data.windows, credits: None, error: None, @@ -385,21 +795,25 @@ async fn fetch_copilot() -> Option { source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows: Vec::new(), credits: None, error: Some(error), }, - }) + }; + enrich_snapshot(&mut snapshot, now.timestamp()); + Some(snapshot) } async fn fetch_antigravity() -> AgentUsageSnapshot { let now = Utc::now(); - match agent_antigravity::fetch(now).await { + let mut snapshot = match agent_antigravity::fetch(now).await { Ok(fetched) => AgentUsageSnapshot { client_id: "antigravity".to_string(), source: fetched.source, updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: fetched.identity, + account_scope: fetched.account_scope, windows: fetched.windows, credits: None, error: None, @@ -409,11 +823,14 @@ async fn fetch_antigravity() -> AgentUsageSnapshot { source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows: Vec::new(), credits: None, error: Some(error), }, - } + }; + enrich_snapshot(&mut snapshot, now.timestamp()); + snapshot } async fn fetch_codex() -> AgentUsageSnapshot { @@ -424,6 +841,7 @@ async fn fetch_codex() -> AgentUsageSnapshot { source: "oauth".to_string(), updated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows: Vec::new(), credits: None, error: Some(error), @@ -485,7 +903,11 @@ fn claude_gate_record_success(snapshot: &AgentUsageSnapshot) { /// While the gate is closed, prefer the cached snapshot (its `updated_at` /// stays honest); with nothing cached yet, surface a countdown error. fn claude_gate_fallback(blocked_until: DateTime, now: DateTime) -> AgentUsageSnapshot { - if let Some(snapshot) = lock_gate().last_good.clone() { + if let Some(mut snapshot) = lock_gate().last_good.clone() { + // A cached 429 response is not current account-scope evidence. Keeping + // the stale scope here would let the next enrichment write history for + // an account that was not authenticated by this poll. + snapshot.account_scope = Err(AccountScopeError::NoTrustedEvidence); return snapshot; } let wait_secs = (blocked_until - now).num_seconds().max(0); @@ -494,6 +916,7 @@ fn claude_gate_fallback(blocked_until: DateTime, now: DateTime) -> Age source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows: Vec::new(), credits: None, error: Some(format!( @@ -521,18 +944,24 @@ async fn fetch_claude() -> AgentUsageSnapshot { if let Some(blocked_until) = claude_gate_blocked_until(now) { return claude_gate_fallback(blocked_until, now); } + match fetch_claude_inner().await { - Ok(snapshot) => { + Ok(mut snapshot) => { + enrich_snapshot(&mut snapshot, now.timestamp()); + // Cache the display-ready snapshot. A later 429 fallback returns it + // without another enrichment pass, so no history write occurs and + // the last-good typed pace remains intact. claude_gate_record_success(&snapshot); snapshot } Err(error) => { // A 429 inside fetch_claude_inner arms the gate; fall back to the - // cached snapshot rather than blanking the card. + // cached, already-enriched snapshot rather than blanking the card. let now = Utc::now(); if let Some(blocked_until) = claude_gate_blocked_until(now) { return claude_gate_fallback(blocked_until, now); } + // "unconfigured" == no credential at all, so the UI shows a setup // prompt; every other error is a real failure of a present credential. let source = if error.as_str() == CLAUDE_UNCONFIGURED_ERROR { @@ -540,21 +969,25 @@ async fn fetch_claude() -> AgentUsageSnapshot { } else { "oauth" }; - AgentUsageSnapshot { + let mut snapshot = AgentUsageSnapshot { client_id: "claude".to_string(), source: source.to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), windows: Vec::new(), credits: None, error: Some(error), - } + }; + enrich_snapshot(&mut snapshot, now.timestamp()); + snapshot } } } async fn fetch_codex_inner() -> Result { let mut credentials = load_codex_credentials()?; + let mut refreshed_scope = None; if credentials_needs_refresh(credentials.last_refresh) { if credentials .refresh_token @@ -566,7 +999,9 @@ async fn fetch_codex_inner() -> Result { "Codex OAuth token needs refresh but auth.json has no refresh token.".to_string(), ); } - credentials = refresh_codex_credentials(credentials).await?; + let refreshed = refresh_codex_credentials(&credentials.auth_path).await?; + credentials = refreshed.0; + refreshed_scope = Some(refreshed.1); } let client = reqwest::Client::builder() @@ -579,7 +1014,12 @@ async fn fetch_codex_inner() -> Result { .bearer_auth(&credentials.access_token) .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, "TokenBar"); - if let Some(account_id) = credentials.account_id.as_deref().filter(|s| !s.is_empty()) { + let request_account_id = credentials + .account_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(account_id) = request_account_id { request = request.header("ChatGPT-Account-Id", account_id); } @@ -605,6 +1045,25 @@ async fn fetch_codex_inner() -> Result { let usage: CodexUsageResponse = serde_json::from_str(&body).map_err(|e| format!("decode Codex usage response: {}", e))?; let now = Utc::now(); + let account_scope = resolve_codex_account_scope( + refreshed_scope, + request_account_id, + |account_id| { + agent_account_scope::resolve_authoritative( + "codex", + AuthoritativeIdKind::OpaqueId, + account_id, + ) + }, + || { + agent_account_scope::resolve_credential( + "codex", + credentials.scope_slot.semantic_source, + &credentials.scope_slot.canonical_location, + credentials.scope_marker(), + ) + }, + ); let identity = Some(AgentIdentity { email: credentials.id_token.as_deref().and_then(jwt_email), plan: usage.plan_type.as_deref().map(clean_plan).or_else(|| { @@ -615,29 +1074,59 @@ async fn fetch_codex_inner() -> Result { .map(clean_plan) }), }); - let mut windows = codex_windows( + let windows = codex_windows( usage.rate_limit.as_ref(), usage.additional_rate_limits.as_deref(), now, ); - let account_key = codex_account_key(&credentials, identity.as_ref()); - enrich_codex_weekly_history(&mut windows, account_key.as_deref(), now); if windows.is_empty() && usage.credits.as_ref().and_then(|c| c.balance).is_none() { return Err("Codex usage API returned no rate-limit windows.".to_string()); } - Ok(AgentUsageSnapshot { + // Legacy v2 migration is deliberately gated on the successful request that + // actually carried this account header and on the accepted scope result. + if let (Some(request_account_id), Ok(scope)) = (request_account_id, &account_scope) { + let _ = crate::agent_quota_history::migrate_codex_v2( + request_account_id, + scope.as_str(), + now.timestamp(), + ); + } + + let mut snapshot = AgentUsageSnapshot { client_id: "codex".to_string(), source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity, + account_scope, windows, credits: usage.credits.map(|credits| CreditsSnapshot { remaining: credits.balance, unlimited: credits.unlimited, }), error: None, - }) + }; + enrich_snapshot(&mut snapshot, now.timestamp()); + Ok(snapshot) +} + +fn resolve_codex_account_scope( + refreshed_scope: Option>, + request_account_id: Option<&str>, + resolve_authoritative: ResolveAuthoritative, + resolve_credential: ResolveCredential, +) -> Result +where + ResolveAuthoritative: FnOnce(&str) -> Result, + ResolveCredential: FnOnce() -> Result, +{ + if let Some(Err(error)) = refreshed_scope.as_ref() { + return Err(*error); + } + if let Some(account_id) = request_account_id { + return resolve_authoritative(account_id); + } + refreshed_scope.unwrap_or_else(resolve_credential) } async fn fetch_claude_inner() -> Result { @@ -647,8 +1136,12 @@ async fn fetch_claude_inner() -> Result { // the account Claude Code is actually spending against, read from the // ratelimit headers. (This is why the harvest runs even for /login users.) if let Some(token) = resolve_claude_code_oauth_token().await { - return claude_header_snapshot(&claude_credentials_from_access_token(token), Utc::now()) - .await; + return claude_header_snapshot( + &claude_credentials_from_access_token(token), + Utc::now(), + None, + ) + .await; } // A stored full login (TokenBar env override / Keychain / file) uses the @@ -670,8 +1163,12 @@ async fn fetch_claude_inner() -> Result { // Last resort: the tokenbar-claude-oauth-token Keychain item reads limits // straight from the ratelimit headers (no oauth/usage GET, no 429 gate). if let Some(token) = resolve_claude_keychain_token() { - return claude_header_snapshot(&claude_credentials_from_access_token(token), Utc::now()) - .await; + return claude_header_snapshot( + &claude_credentials_from_access_token(token), + Utc::now(), + None, + ) + .await; } Err(deferred_error.unwrap_or_else(|| CLAUDE_UNCONFIGURED_ERROR.to_string())) @@ -680,8 +1177,11 @@ async fn fetch_claude_inner() -> Result { async fn fetch_claude_oauth_usage( mut credentials: ClaudeCredentials, ) -> Result { + let mut refreshed_scope = None; if claude_credentials_expired(&credentials) { - credentials = refresh_claude_credentials(&credentials).await?; + let refreshed = refresh_claude_credentials(&credentials).await?; + credentials = refreshed.0; + refreshed_scope = Some(refreshed.1); } if !credentials.scopes.is_empty() @@ -692,7 +1192,7 @@ async fn fetch_claude_oauth_usage( { // Inference-only token declared explicit non-user:profile scopes — skip // the (guaranteed-403) oauth/usage GET and read limits from headers. - return claude_header_snapshot(&credentials, Utc::now()).await; + return claude_header_snapshot(&credentials, Utc::now(), refreshed_scope).await; } let client = reqwest::Client::builder() @@ -733,7 +1233,7 @@ async fn fetch_claude_oauth_usage( // Any other 403 keeps the actionable re-auth error (and skips the probe, // so we don't spend an inference call on an unrelated denial). if body.contains("user:profile") { - return claude_header_snapshot(&credentials, Utc::now()).await; + return claude_header_snapshot(&credentials, Utc::now(), refreshed_scope).await; } return Err( "Claude OAuth usage was denied. Run `claude logout && claude login` to grant user:profile." @@ -757,6 +1257,7 @@ async fn fetch_claude_oauth_usage( if windows.is_empty() { return Err("Claude usage API returned no rate-limit windows.".to_string()); } + let account_scope = refreshed_scope.unwrap_or_else(|| credentials.resolve_account_scope()); Ok(AgentUsageSnapshot { client_id: "claude".to_string(), @@ -770,6 +1271,7 @@ async fn fetch_claude_oauth_usage( ]) .map(clean_plan), }), + account_scope, windows, credits: claude_credits(usage.extra_usage.as_ref()), error: None, @@ -813,7 +1315,9 @@ fn refresh_cached_windows(windows: &[UsageWindow], now: DateTime) -> Option async fn fetch_claude_via_headers(access_token: &str) -> Result, String> { { let now = Utc::now(); - let guard = CLAUDE_HEADER_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let guard = CLAUDE_HEADER_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()); if let Some((fetched_at, token, windows)) = guard.as_ref() { if token == access_token && (now - *fetched_at).num_seconds() < CLAUDE_HEADER_TTL_SECS { if let Some(refreshed) = refresh_cached_windows(windows, now) { @@ -855,7 +1359,9 @@ async fn fetch_claude_via_headers(access_token: &str) -> Result return Err("Claude header probe returned no unified rate-limit headers.".to_string()); } { - let mut guard = CLAUDE_HEADER_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = CLAUDE_HEADER_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()); *guard = Some((Utc::now(), access_token.to_string(), windows.clone())); } return Ok(windows); @@ -877,8 +1383,10 @@ async fn fetch_claude_via_headers(access_token: &str) -> Result async fn claude_header_snapshot( credentials: &ClaudeCredentials, now: DateTime, + account_scope: Option>, ) -> Result { let windows = fetch_claude_via_headers(&credentials.access_token).await?; + let account_scope = account_scope.unwrap_or_else(|| credentials.resolve_account_scope()); Ok(AgentUsageSnapshot { client_id: "claude".to_string(), source: "setup-token".to_string(), @@ -891,6 +1399,7 @@ async fn claude_header_snapshot( ]) .map(clean_plan), }), + account_scope, windows, credits: None, error: None, @@ -898,8 +1407,11 @@ async fn claude_header_snapshot( } fn load_codex_credentials() -> Result { - let auth_path = codex_home().join("auth.json"); - let raw = fs::read_to_string(&auth_path) + load_codex_credentials_from(&codex_home().join("auth.json")) +} + +fn load_codex_credentials_from(auth_path: &Path) -> Result { + let raw = fs::read_to_string(auth_path) .map_err(|_| "Codex auth.json not found. Run `codex` to log in.".to_string())?; let raw_json: Value = serde_json::from_str(&raw).map_err(|e| format!("decode Codex auth.json: {}", e))?; @@ -934,8 +1446,16 @@ fn load_codex_credentials() -> Result { id_token, account_id, last_refresh, - auth_path, + auth_path: auth_path.to_path_buf(), raw_json, + scope_slot: CredentialSlot { + semantic_source: "codex-auth-json", + canonical_location: agent_account_scope::canonical_file_location( + auth_path, + Some("tokens"), + ) + .map_err(|_| "Codex auth location cannot be scoped safely.".to_string())?, + }, }) } @@ -957,14 +1477,16 @@ fn load_claude_login_credentials() -> Result, String> return Ok(Some(credentials)); } if let Some(raw) = load_claude_credentials_from_keychain()? { - if let Ok(credentials) = parse_claude_credentials_data(&raw, ClaudeCredentialSource::Keychain) + if let Ok(credentials) = + parse_claude_credentials_data(&raw, ClaudeCredentialSource::Keychain) { return Ok(Some(credentials)); } } match fs::read_to_string(claude_credentials_path()) { Ok(raw) => { - if let Ok(credentials) = parse_claude_credentials_data(&raw, ClaudeCredentialSource::File) + if let Ok(credentials) = + parse_claude_credentials_data(&raw, ClaudeCredentialSource::File) { return Ok(Some(credentials)); } @@ -989,27 +1511,57 @@ fn load_claude_login_credentials() -> Result, String> /// login-shell harvest of the user's `~/.zshrc` (so a plain export a /// Finder-launched GUI app never inherits is still found). Per Claude Code's /// auth precedence this outranks a stored subscription `/login`. -async fn resolve_claude_code_oauth_token() -> Option { - if let Some(token) = claude_direct_env_token() { - return Some(token); +async fn resolve_claude_code_oauth_token() -> Option { + if let Some(access_token) = claude_direct_env_token() { + return Some(ResolvedClaudeToken { + access_token, + scope_slot: CredentialSlot { + semantic_source: "claude-code-environment", + canonical_location: "CLAUDE_CODE_OAUTH_TOKEN".to_string(), + }, + }); } - harvest_shell_env_token().await + harvest_shell_env_token() + .await + .map(|access_token| ResolvedClaudeToken { + access_token, + scope_slot: CredentialSlot { + semantic_source: "claude-code-login-shell", + canonical_location: "CLAUDE_CODE_OAUTH_TOKEN".to_string(), + }, + }) } /// The `tokenbar-claude-oauth-token` Keychain item (a TokenBar-specific setup /// token). A last-resort fallback, below the stored `/login`. -fn resolve_claude_keychain_token() -> Option { - load_claude_raw_token_from_keychain().ok().flatten() +fn resolve_claude_keychain_token() -> Option { + load_claude_raw_token_from_keychain() + .ok() + .flatten() + .map(|access_token| ResolvedClaudeToken { + access_token, + scope_slot: CredentialSlot { + semantic_source: "claude-setup-keychain", + canonical_location: CLAUDE_RAW_TOKEN_KEYCHAIN_SERVICE.to_string(), + }, + }) } fn load_claude_credentials_from_environment() -> Result, String> { - let token = std::env::var("TOKENBAR_CLAUDE_OAUTH_TOKEN") - .or_else(|_| std::env::var("TOKCAT_CLAUDE_OAUTH_TOKEN")) - .or_else(|_| std::env::var("CODEXBAR_CLAUDE_OAUTH_TOKEN")) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let Some(access_token) = token else { + let token = [ + "TOKENBAR_CLAUDE_OAUTH_TOKEN", + "TOKCAT_CLAUDE_OAUTH_TOKEN", + "CODEXBAR_CLAUDE_OAUTH_TOKEN", + ] + .into_iter() + .find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| (name, value)) + }); + let Some((source_name, access_token)) = token else { return Ok(None); }; let scopes = std::env::var("TOKENBAR_CLAUDE_OAUTH_SCOPES") @@ -1030,6 +1582,10 @@ fn load_claude_credentials_from_environment() -> Result Result { + match source { + ClaudeCredentialSource::Keychain => Ok(CredentialSlot { + semantic_source: "claude-login-keychain", + canonical_location: CLAUDE_KEYCHAIN_SERVICE.to_string(), + }), + ClaudeCredentialSource::File => Ok(CredentialSlot { + semantic_source: "claude-login-file", + canonical_location: agent_account_scope::canonical_file_location( + &claude_credentials_path(), + Some("claudeAiOauth"), + ) + .map_err(|_| "Claude credential location cannot be scoped safely.".to_string())?, + }), + ClaudeCredentialSource::Environment => { + Err("environment credentials require an explicit account-scope slot".to_string()) + } + } +} + #[cfg(target_os = "macos")] fn load_claude_credentials_from_keychain() -> Result, String> { let output = std::process::Command::new("/usr/bin/security") @@ -1094,9 +1671,9 @@ fn load_claude_credentials_from_keychain() -> Result, String> { /// Used by the setup-token delivery paths (env var, shell harvest, raw keychain); /// empty scopes make `fetch_claude_inner` skip the scope guard and reach the /// header fallback on the resulting oauth/usage 403. -fn claude_credentials_from_access_token(access_token: String) -> ClaudeCredentials { +fn claude_credentials_from_access_token(token: ResolvedClaudeToken) -> ClaudeCredentials { ClaudeCredentials { - access_token, + access_token: token.access_token, refresh_token: None, expires_at: None, scopes: Vec::new(), @@ -1106,6 +1683,7 @@ fn claude_credentials_from_access_token(access_token: String) -> ClaudeCredentia // to, so treat it as read-only — save_claude_credentials skips it. source: ClaudeCredentialSource::Environment, raw_root: None, + scope_slot: token.scope_slot, } } @@ -1281,12 +1859,21 @@ fn load_claude_raw_token_from_keychain() -> Result, String> { } async fn refresh_codex_credentials( - credentials: CodexCredentials, -) -> Result { - let refresh_token = credentials - .refresh_token - .as_deref() - .ok_or_else(|| "Codex auth.json has no refresh token.".to_string())?; + auth_path: &Path, +) -> Result<(CodexCredentials, Result), String> { + let refresh = agent_account_scope::begin_refresh("codex") + .map_err(|_| "Codex credential refresh lock is unavailable.".to_string())?; + refresh_codex_credentials_with( + auth_path, + &refresh, + request_codex_refresh, + save_codex_credentials, + |_| Ok(()), + ) + .await +} + +async fn request_codex_refresh(refresh_token: String) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -1312,40 +1899,99 @@ async fn refresh_codex_credentials( if !status.is_success() { return Err("Codex OAuth refresh failed. Run `codex` to log in again.".to_string()); } - let json: Value = - serde_json::from_str(&body).map_err(|e| format!("decode Codex refresh response: {}", e))?; + serde_json::from_str(&body).map_err(|e| format!("decode Codex refresh response: {}", e)) +} + +async fn refresh_codex_credentials_with( + auth_path: &Path, + refresh: &R, + request: Request, + save: Save, + mut checkpoint: Checkpoint, +) -> Result<(CodexCredentials, Result), String> +where + R: RefreshScopeTransaction + ?Sized, + Request: FnOnce(String) -> RequestFuture, + RequestFuture: std::future::Future>, + Save: FnOnce(&CodexCredentials) -> Result<(), String>, + Checkpoint: FnMut(RefreshCheckpoint) -> Result<(), String>, +{ + // Another TokenBar process may have refreshed while this caller waited. + // Reload the exact request-bearing record only after the refresh lock. + let credentials = load_codex_credentials_from(auth_path)?; + checkpoint(RefreshCheckpoint::Reloaded)?; + if !credentials_needs_refresh(credentials.last_refresh) { + let scope = refresh.resolve_current( + credentials.scope_slot.semantic_source, + &credentials.scope_slot.canonical_location, + credentials.scope_marker(), + ); + return Ok((credentials, scope)); + } + + let refresh_token = credentials + .refresh_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + .ok_or_else(|| "Codex auth.json has no refresh token.".to_string())? + .to_string(); + let old_marker = credentials.scope_marker().to_vec(); + let json = request(refresh_token).await?; + checkpoint(RefreshCheckpoint::NetworkReturned)?; + let response = json.as_object(); let refreshed = CodexCredentials { - access_token: json - .get("access_token") - .and_then(Value::as_str) - .unwrap_or(&credentials.access_token) - .to_string(), - refresh_token: json - .get("refresh_token") - .and_then(Value::as_str) - .map(str::to_string) + access_token: response + .and_then(|tokens| string_key(tokens, "access_token", "accessToken")) + .unwrap_or(credentials.access_token), + refresh_token: response + .and_then(|tokens| string_key(tokens, "refresh_token", "refreshToken")) .or(credentials.refresh_token), - id_token: json - .get("id_token") - .and_then(Value::as_str) - .map(str::to_string) + id_token: response + .and_then(|tokens| string_key(tokens, "id_token", "idToken")) .or(credentials.id_token), account_id: credentials.account_id, last_refresh: Some(Utc::now()), auth_path: credentials.auth_path, raw_json: credentials.raw_json, + scope_slot: credentials.scope_slot, }; - save_codex_credentials(&refreshed)?; - Ok(refreshed) + let marker_rotated = refreshed.scope_marker() != old_marker.as_slice(); + let scope = refresh.transfer( + refreshed.scope_slot.semantic_source, + &refreshed.scope_slot.canonical_location, + &old_marker, + refreshed.scope_marker(), + ); + checkpoint(RefreshCheckpoint::MetadataHandled)?; + // A rotated marker may reach disk only after its lineage transfer is durable. + // The refreshed access token remains usable in memory for this poll. + if marker_rotated && scope.is_err() { + return Ok((refreshed, scope)); + } + save(&refreshed)?; + checkpoint(RefreshCheckpoint::CredentialsPersisted)?; + Ok((refreshed, scope)) } async fn refresh_claude_credentials( - credentials: &ClaudeCredentials, -) -> Result { - let refresh_token = credentials.refresh_token.as_deref().ok_or_else(|| { - "Claude OAuth token is expired and has no refresh token. Run `claude`.".to_string() - })?; + original: &ClaudeCredentials, +) -> Result<(ClaudeCredentials, Result), String> { + let refresh = agent_account_scope::begin_refresh("claude") + .map_err(|_| "Claude credential refresh lock is unavailable.".to_string())?; + refresh_claude_credentials_with( + original, + &refresh, + reload_claude_credentials, + request_claude_refresh, + save_claude_credentials, + |_| Ok(()), + ) + .await +} + +async fn request_claude_refresh(refresh_token: String) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -1359,7 +2005,7 @@ async fn refresh_claude_credentials( ) .body(form_urlencoded(&[ ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), + ("refresh_token", &refresh_token), ("client_id", CLAUDE_CLIENT_ID), ])) .send() @@ -1373,54 +2019,143 @@ async fn refresh_claude_credentials( if !status.is_success() { return Err("Claude OAuth refresh failed. Run `claude` to re-authenticate.".to_string()); } - let token_response: ClaudeRefreshResponse = serde_json::from_str(&body) - .map_err(|e| format!("decode Claude refresh response: {}", e))?; - let refreshed = ClaudeCredentials { - access_token: token_response.access_token, - refresh_token: token_response - .refresh_token - .or_else(|| credentials.refresh_token.clone()), - expires_at: Some(Utc::now() + chrono::Duration::seconds(token_response.expires_in)), - scopes: credentials.scopes.clone(), + serde_json::from_str(&body).map_err(|e| format!("decode Claude refresh response: {}", e)) +} + +async fn refresh_claude_credentials_with( + original: &ClaudeCredentials, + refresh: &R, + reload: Reload, + request: Request, + save: Save, + mut checkpoint: Checkpoint, +) -> Result<(ClaudeCredentials, Result), String> +where + R: RefreshScopeTransaction + ?Sized, + Reload: FnOnce(&ClaudeCredentials) -> Result, + Request: FnOnce(String) -> RequestFuture, + RequestFuture: std::future::Future>, + Save: FnOnce(&ClaudeCredentials) -> Result<(), String>, + Checkpoint: FnMut(RefreshCheckpoint) -> Result<(), String>, +{ + let credentials = reload(original)?; + checkpoint(RefreshCheckpoint::Reloaded)?; + if !claude_credentials_expired(&credentials) { + let scope = match credentials.scope_marker() { + Some(marker) => refresh.resolve_current( + credentials.scope_slot.semantic_source, + &credentials.scope_slot.canonical_location, + marker, + ), + None => Err(AccountScopeError::NoTrustedEvidence), + }; + return Ok((credentials, scope)); + } + + let refresh_token = credentials + .refresh_token + .as_deref() + .filter(|token| !token.is_empty()) + .ok_or_else(|| { + "Claude OAuth token is expired and has no refresh token. Run `claude`.".to_string() + })? + .to_string(); + let old_marker = refresh_token.as_bytes().to_vec(); + let token_response = request(refresh_token).await?; + checkpoint(RefreshCheckpoint::NetworkReturned)?; + let refreshed = ClaudeCredentials { + access_token: token_response.access_token, + refresh_token: token_response + .refresh_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .or_else(|| credentials.refresh_token.clone()), + expires_at: Some(Utc::now() + chrono::Duration::seconds(token_response.expires_in)), + scopes: credentials.scopes.clone(), rate_limit_tier: credentials.rate_limit_tier.clone(), subscription_type: credentials.subscription_type.clone(), source: credentials.source, raw_root: credentials.raw_root.clone(), + scope_slot: credentials.scope_slot.clone(), }; - // Anthropic rotates refresh tokens: the token we just spent is now dead. - // Persist the new pair back to the shared store, or the next refresh — by - // TokenBar *or* the Claude CLI — fails with a stale token, forcing a manual - // `claude logout && claude login`. Best-effort: a write failure shouldn't - // sink this usage fetch, but it's worth surfacing in logs. - if let Err(error) = save_claude_credentials(&refreshed) { + let new_marker = refreshed.scope_marker(); + let marker_rotated = new_marker.is_some_and(|marker| marker != old_marker.as_slice()); + let scope = match new_marker { + Some(new_marker) => refresh.transfer( + refreshed.scope_slot.semantic_source, + &refreshed.scope_slot.canonical_location, + &old_marker, + new_marker, + ), + None => Err(AccountScopeError::NoTrustedEvidence), + }; + checkpoint(RefreshCheckpoint::MetadataHandled)?; + // A rotated marker may reach the shared provider store only after its + // lineage transfer is durable. The new access token remains usable in + // memory for this poll. + if marker_rotated && scope.is_err() { + return Ok((refreshed, scope)); + } + if let Err(error) = save(&refreshed) { eprintln!("tb_core_ffi: failed to persist refreshed Claude credentials: {error}"); } - Ok(refreshed) + checkpoint(RefreshCheckpoint::CredentialsPersisted)?; + Ok((refreshed, scope)) +} + +fn reload_claude_credentials(original: &ClaudeCredentials) -> Result { + match original.source { + ClaudeCredentialSource::Keychain => { + let raw = load_claude_credentials_from_keychain()?.ok_or_else(|| { + "Claude Keychain credentials disappeared during refresh.".to_string() + })?; + parse_claude_credentials_data(&raw, ClaudeCredentialSource::Keychain) + } + ClaudeCredentialSource::File => { + let raw = fs::read_to_string(claude_credentials_path()) + .map_err(|e| format!("reload Claude credentials file: {e}"))?; + parse_claude_credentials_data(&raw, ClaudeCredentialSource::File) + } + ClaudeCredentialSource::Environment => { + Err("Claude environment credentials cannot be refreshed in place.".to_string()) + } + } } /// Merge the rotated access/refresh tokens back into the credentials store they /// came from, preserving every other field the Claude CLI wrote. fn save_claude_credentials(credentials: &ClaudeCredentials) -> Result<(), String> { - if credentials.source == ClaudeCredentialSource::Environment { - return Ok(()); - } - - let data = merge_claude_credentials_json(credentials)?; match credentials.source { - ClaudeCredentialSource::Keychain => save_claude_credentials_to_keychain(&data), - ClaudeCredentialSource::File => atomic_write(&claude_credentials_path(), &data), + ClaudeCredentialSource::Keychain => { + save_claude_credentials_to_keychain(&merge_claude_credentials_json(credentials)?) + } + ClaudeCredentialSource::File => { + save_claude_credentials_to_file(credentials, &claude_credentials_path()) + } ClaudeCredentialSource::Environment => Ok(()), } } +fn save_claude_credentials_to_file( + credentials: &ClaudeCredentials, + path: &Path, +) -> Result<(), String> { + atomic_write(path, &merge_claude_credentials_json(credentials)?) +} + /// Replace `path` atomically: write a sibling temp file, then rename over the /// target. A crash or partial write leaves the original credentials intact /// rather than a truncated file that would break both TokenBar and the Claude /// CLI (the rename is atomic within one filesystem). fn atomic_write(path: &Path, data: &str) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| format!("credentials path {} has no parent directory", path.display()))?; + let parent = path.parent().ok_or_else(|| { + format!( + "credentials path {} has no parent directory", + path.display() + ) + })?; fs::create_dir_all(parent).map_err(|e| format!("create {}: {}", parent.display(), e))?; let file_name = path @@ -1596,70 +2331,217 @@ fn save_codex_credentials(credentials: &CodexCredentials) -> Result<(), String> } raw["last_refresh"] = Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)); let data = - serde_json::to_vec_pretty(&raw).map_err(|e| format!("encode Codex auth.json: {}", e))?; - fs::write(&credentials.auth_path, data).map_err(|e| format!("save Codex auth.json: {}", e)) + serde_json::to_string_pretty(&raw).map_err(|e| format!("encode Codex auth.json: {}", e))?; + atomic_write(&credentials.auth_path, &data).map_err(|e| format!("save Codex auth.json: {}", e)) } -/// Stable per-account key for scoping historical samples, so switching ChatGPT -/// accounts doesn't mix usage curves. Prefer the account id, fall back to email. -/// Unknown owners are intentionally rejected rather than sharing one default -/// bucket across logins. -fn codex_account_key( - credentials: &CodexCredentials, - identity: Option<&AgentIdentity>, -) -> Option { - credentials - .account_id - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - .map(str::to_string) - .or_else(|| { - identity - .and_then(|i| i.email.as_deref()) - .map(str::trim) - .filter(|email| !email.is_empty()) - .map(str::to_string) - }) +fn enrich_snapshot(snapshot: &mut AgentUsageSnapshot, now: i64) { + enrich_snapshot_with(snapshot, now, |active_keys, observations, now| { + crate::agent_quota_history::record_observations_and_evaluate(active_keys, observations, now) + }); } -/// Record the live Codex weekly reading and, once enough past weeks exist, fill -/// the window's nested historical projection so the frontend can use one -/// backend-owned expected/ETA/lasts/risk result alongside the linear fallback. -fn enrich_codex_weekly_history( - windows: &mut [UsageWindow], - account_key: Option<&str>, - now: DateTime, -) { - let Some(account_key) = account_key.filter(|key| !key.trim().is_empty()) else { +fn enrich_snapshot_with(snapshot: &mut AgentUsageSnapshot, now: i64, mut record: F) +where + F: FnMut( + &[SeriesKey], + &[QuotaObservation], + i64, + ) -> Result, HistoryError>, +{ + let mut card_ids = HashSet::new(); + let mut window_keys = HashSet::new(); + snapshot.windows.retain(|window| { + let card_is_unique = !card_ids.contains(&window.card_id); + let key_is_unique = window + .window_key + .as_ref() + .is_none_or(|window_key| !window_keys.contains(window_key)); + if !card_is_unique || !key_is_unique { + return false; + } + card_ids.insert(window.card_id.clone()); + if let Some(window_key) = window.window_key.as_ref() { + window_keys.insert(window_key.clone()); + } + true + }); + + let Ok(account_scope) = snapshot.account_scope.as_ref() else { + for window in &mut snapshot.windows { + if window.window_key.is_some() { + window.unavailable("accountScope"); + } + } return; }; - for window in windows.iter_mut() { - if !window.label.eq_ignore_ascii_case("Weekly") { + let account_scope = account_scope.as_str(); + let mut active_keys = Vec::new(); + let mut observations = Vec::new(); + let mut mapped_indices = Vec::new(); + + for (index, window) in snapshot.windows.iter_mut().enumerate() { + let Some(window_key) = window.window_key.as_deref() else { + // The provider already classified this card as windowIdentity. + continue; + }; + let key = SeriesKey::new(snapshot.client_id.clone(), account_scope, window_key); + active_keys.push(key.clone()); + if matches!(window.pace_status.state, PaceState::Unavailable) { + // Emission protects existing history from capacity eviction, but + // missing reset and other typed early rejects never record a sample. continue; } - let (Some(resets_str), Some(minutes)) = - (window.resets_at.as_deref(), window.window_minutes) + let Some(reset_at) = window + .resets_at + .as_deref() + .and_then(parse_datetime) + .map(|reset| reset.timestamp()) else { + window.unavailable("invalidEvidence"); continue; }; - let Some(resets_at) = parse_datetime(resets_str) else { + if reset_at <= now + || !window.used_percent.is_finite() + || !(0.0..=100.0).contains(&window.used_percent) + { + window.unavailable("invalidEvidence"); continue; - }; - if let Some(pace) = agent_history::record_and_evaluate( - account_key, - resets_at.timestamp(), - minutes, - window.used_percent, - now.timestamp(), - ) { - window.historical_pace = Some(HistoricalPacePayload { - expected_used_percent: pace.expected_percent, - eta_seconds: pace.eta_seconds, - will_last_to_reset: pace.will_last_to_reset, - run_out_probability: pace.run_out_probability, - }); } + observations.push(QuotaObservation { + key, + reset_at: Some(reset_at), + used_percent: window.used_percent, + provider: window.provider_duration, + contract: window.contract_duration, + }); + mapped_indices.push(index); + } + + if active_keys.is_empty() { + return; + } + + let results = match record(&active_keys, &observations, now) { + Ok(results) if results.len() == mapped_indices.len() => results, + Ok(_) => { + for index in mapped_indices { + snapshot.windows[index].unavailable("history"); + } + return; + } + Err(error) => { + let reason = if error == HistoryError::StoreCapacity { + "storeCapacity" + } else { + "history" + }; + for index in mapped_indices { + snapshot.windows[index].unavailable(reason); + } + return; + } + }; + + for (index, result) in mapped_indices.into_iter().zip(results) { + let window = &mut snapshot.windows[index]; + match result { + Ok(( + HistoryOutcome::Ready { + duration_seconds, + source, + .. + }, + historical, + complete_cycles, + )) => { + window.duration_seconds = Some(duration_seconds); + window.duration_source = Some(source); + window.window_minutes = Some(duration_seconds / 60); + match historical { + Some(pace) if historical_pace_is_coherent(&pace) => { + window.pace_status = PaceStatusPayload { + state: PaceState::Available, + window_key: window.window_key.clone(), + duration_seconds: Some(duration_seconds), + duration_source: Some(source), + complete_cycles, + reason: None, + }; + window.historical_pace = Some(historical_pace_payload(pace)); + } + Some(_) => { + window.unavailable("history"); + } + None => { + window.pace_status = PaceStatusPayload { + state: PaceState::LearningHistory, + window_key: window.window_key.clone(), + duration_seconds: Some(duration_seconds), + duration_source: Some(source), + complete_cycles, + reason: None, + }; + window.historical_pace = None; + } + } + } + Ok((HistoryOutcome::LearningDuration, None, _)) => { + window.duration_seconds = None; + window.duration_source = Some(DurationSource::Observed); + window.window_minutes = None; + window.pace_status = PaceStatusPayload { + state: PaceState::LearningDuration, + window_key: window.window_key.clone(), + duration_seconds: None, + duration_source: Some(DurationSource::Observed), + complete_cycles: 0, + reason: None, + }; + window.historical_pace = None; + } + Ok((HistoryOutcome::Unavailable(reason), _, _)) => { + window.unavailable(duration_unavailable_reason(reason)); + } + Err(error) => { + window.unavailable(if error == HistoryError::StoreCapacity { + "storeCapacity" + } else { + "history" + }); + } + Ok((HistoryOutcome::LearningDuration, Some(_), _)) => { + window.unavailable("history"); + } + } + } +} + +fn duration_unavailable_reason(reason: DurationUnavailableReason) -> &'static str { + match reason { + DurationUnavailableReason::MissingReset => "missingReset", + DurationUnavailableReason::InvalidEvidence => "invalidEvidence", + } +} + +fn historical_pace_is_coherent(pace: &HistoricalPace) -> bool { + pace.expected_percent.is_finite() + && (0.0..=100.0).contains(&pace.expected_percent) + && pace + .eta_seconds + .is_none_or(|eta| eta.is_finite() && eta >= 0.0) + && pace + .run_out_probability + .is_none_or(|probability| probability.is_finite() && (0.0..=1.0).contains(&probability)) + && (pace.eta_seconds.is_none() == pace.will_last_to_reset) +} + +fn historical_pace_payload(pace: HistoricalPace) -> HistoricalPacePayload { + HistoricalPacePayload { + expected_used_percent: pace.expected_percent, + eta_seconds: pace.eta_seconds, + will_last_to_reset: pace.will_last_to_reset, + run_out_probability: pace.run_out_probability, } } @@ -1669,39 +2551,96 @@ fn codex_windows( now: DateTime, ) -> Vec { let mut windows = Vec::new(); + let mut emitted_card_ids = HashSet::new(); if let Some(rate_limit) = rate_limit { - let mut primary = rate_limit.primary_window.clone(); - let mut secondary = rate_limit.secondary_window.clone(); - if role(primary.as_ref()) == Some("weekly") && role(secondary.as_ref()) != Some("weekly") { - std::mem::swap(&mut primary, &mut secondary); - } - - if let Some(window) = primary { - windows.push(map_window("Session", window, now)); - } - if let Some(window) = secondary { - windows.push(map_window("Weekly", window, now)); + let mut main = [ + ("primary", rate_limit.primary_window.clone()), + ("secondary", rate_limit.secondary_window.clone()), + ]; + main.sort_by_key(|(_, window)| { + window + .as_ref() + .map_or(2, |window| match window.limit_window_seconds { + 18_000 => 0, + 604_800 => 1, + _ => 2, + }) + }); + for (slot, window) in main + .into_iter() + .filter_map(|(slot, window)| window.map(|window| (slot, window))) + { + let semantic = match window.limit_window_seconds { + 18_000 => Some(("Session", "main.session.v1")), + 604_800 => Some(("Weekly", "main.weekly.v1")), + _ => None, + }; + let (label, window_key) = semantic.unwrap_or(("Unknown", "")); + let card_id = if window_key.is_empty() { + format!("row.main.{slot}.v1") + } else { + window_key.to_string() + }; + let Some(mapped) = map_window_with_identity( + label, + window, + now, + card_id.clone(), + (!window_key.is_empty()).then(|| window_key.to_string()), + ) else { + continue; + }; + if !emitted_card_ids.insert(card_id) { + continue; + } + windows.push(mapped); } } - let mut seen = windows - .iter() - .map(|w| w.label.clone()) - .collect::>(); + let mut anonymous_slots = HashSet::new(); for extra in additional_rate_limits.unwrap_or(&[]) { + let source = additional_limit_source(extra); + let digest = source.map(sha256_hex); let Some(rate_limit) = extra.rate_limit.as_ref() else { continue; }; - let Some(window) = rate_limit - .primary_window - .clone() - .or_else(|| rate_limit.secondary_window.clone()) - else { - continue; - }; - let label = additional_limit_label(extra); - if seen.insert(label.clone()) { - windows.push(map_window(&label, window, now)); + for (slot, window) in [ + ("primary", rate_limit.primary_window.clone()), + ("secondary", rate_limit.secondary_window.clone()), + ] + .into_iter() + .filter_map(|(slot, window)| window.map(|window| (slot, window))) + { + let Some(digest) = digest.as_deref() else { + let Some(mapped) = map_window_with_identity( + "Unknown", + window, + now, + format!("row.additional.unknown.{slot}.v1"), + None, + ) else { + continue; + }; + if anonymous_slots.insert(slot) { + windows.push(mapped); + } + continue; + }; + let label = additional_limit_label(extra); + let window_key = format!("additional.{digest}.{slot}.v1"); + let Some(mapped) = map_window_with_identity( + &label, + window, + now, + window_key.clone(), + Some(window_key.clone()), + ) else { + continue; + }; + if !emitted_card_ids.insert(window_key) { + continue; + } + windows.push(mapped); } } windows @@ -1709,18 +2648,62 @@ fn codex_windows( fn claude_windows(usage: &ClaudeUsageResponse, now: DateTime) -> Vec { let mut windows = Vec::new(); - push_claude_window(&mut windows, "Session", usage.five_hour.as_ref(), now); - push_claude_window(&mut windows, "Weekly", usage.seven_day.as_ref(), now); + push_claude_window( + &mut windows, + "Session", + "session.v1", + DurationEvidence::contract(300 * 60), + usage.five_hour.as_ref(), + now, + ); + push_claude_window( + &mut windows, + "Weekly", + "weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), + usage.seven_day.as_ref(), + now, + ); push_claude_window( &mut windows, "OAuth Apps", + "oauth_apps.weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), usage.seven_day_oauth_apps.as_ref(), now, ); - push_claude_window(&mut windows, "Sonnet", usage.seven_day_sonnet.as_ref(), now); - push_claude_window(&mut windows, "Opus", usage.seven_day_opus.as_ref(), now); - push_claude_window(&mut windows, "Designs", usage.design_window(), now); - push_claude_window(&mut windows, "Daily Routines", usage.routines_window(), now); + push_claude_window( + &mut windows, + "Sonnet", + "sonnet.weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), + usage.seven_day_sonnet.as_ref(), + now, + ); + push_claude_window( + &mut windows, + "Opus", + "opus.weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), + usage.seven_day_opus.as_ref(), + now, + ); + push_claude_window( + &mut windows, + "Designs", + "design.weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), + usage.design_window(), + now, + ); + push_claude_window( + &mut windows, + "Daily Routines", + "routines.weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), + usage.routines_window(), + now, + ); if let Some(extra) = claude_extra_usage_window(usage.extra_usage.as_ref()) { windows.push(extra); } @@ -1740,7 +2723,7 @@ impl ClaudeUsageResponse { ] .into_iter() .flatten() - .next() + .find(|window| window.has_valid_utilization()) } fn routines_window(&self) -> Option<&ClaudeWindow> { @@ -1755,37 +2738,44 @@ impl ClaudeUsageResponse { ] .into_iter() .flatten() - .next() + .find(|window| window.has_valid_utilization()) } } fn push_claude_window( windows: &mut Vec, label: &str, + window_key: &str, + contract_duration: DurationEvidence, window: Option<&ClaudeWindow>, now: DateTime, ) { - if let Some(mapped) = window.and_then(|window| map_claude_window(label, window, now)) { + if let Some(mapped) = window + .and_then(|window| map_claude_window(label, window_key, contract_duration, window, now)) + { windows.push(mapped); } } fn map_claude_window( label: &str, + window_key: &str, + contract_duration: DurationEvidence, window: &ClaudeWindow, now: DateTime, ) -> Option { - let used = window.utilization?.clamp(0.0, 100.0); + let used = window.utilization?; let resets_at = window.resets_at.as_deref().and_then(parse_datetime); - Some(UsageWindow { - label: label.to_string(), - used_percent: used, - remaining_percent: (100.0 - used).max(0.0), - resets_at: resets_at.map(|date| date.to_rfc3339_opts(SecondsFormat::Millis, true)), - reset_text: resets_at.map(|date| reset_text(date, now)), - window_minutes: claude_window_minutes(label), - historical_pace: None, - }) + UsageWindow::try_from_provider_used_percent(label.to_string(), used, resets_at, now).map( + |window| { + window.with_identity( + window_key, + Some(window_key.to_string()), + None, + Some(contract_duration), + ) + }, + ) } /// Parse the `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}` response @@ -1805,16 +2795,20 @@ fn parse_unified_ratelimit_windows( headers.get(name)?.to_str().ok()?.trim().parse::().ok() }; let mut windows = Vec::new(); - if let Some(window) = unified_ratelimit_window( + if let Some(window) = unified_ratelimit_window_with_identity( "Session", + "session.v1", + DurationEvidence::contract(300 * 60), read_f64("anthropic-ratelimit-unified-5h-utilization"), read_i64("anthropic-ratelimit-unified-5h-reset"), now, ) { windows.push(window); } - if let Some(window) = unified_ratelimit_window( + if let Some(window) = unified_ratelimit_window_with_identity( "Weekly", + "weekly.v1", + DurationEvidence::contract(7 * 24 * 60 * 60), read_f64("anthropic-ratelimit-unified-7d-utilization"), read_i64("anthropic-ratelimit-unified-7d-reset"), now, @@ -1828,25 +2822,50 @@ fn parse_unified_ratelimit_windows( /// (mirrors `map_claude_window`); reset is optional. `utilization_fraction` is /// 0..1 (scaled ×100); `reset_epoch_seconds` is Unix seconds (like the Codex /// `map_window` epoch handling). -fn unified_ratelimit_window( +fn unified_ratelimit_window_with_identity( label: &str, + window_key: &str, + contract_duration: DurationEvidence, utilization_fraction: Option, reset_epoch_seconds: Option, now: DateTime, ) -> Option { - let used = (utilization_fraction? * 100.0).clamp(0.0, 100.0); + let used = utilization_fraction? * 100.0; let resets_at = reset_epoch_seconds .filter(|seconds| *seconds > 0) .and_then(|seconds| Utc.timestamp_opt(seconds, 0).single()); - Some(UsageWindow { - label: label.to_string(), - used_percent: used, - remaining_percent: (100.0 - used).max(0.0), - resets_at: resets_at.map(|date| date.to_rfc3339_opts(SecondsFormat::Millis, true)), - reset_text: resets_at.map(|date| reset_text(date, now)), - window_minutes: claude_window_minutes(label), - historical_pace: None, - }) + UsageWindow::try_from_provider_used_percent(label.to_string(), used, resets_at, now).map( + |window| { + window.with_identity( + window_key, + Some(window_key.to_string()), + None, + Some(contract_duration), + ) + }, + ) +} + +#[cfg(test)] +fn unified_ratelimit_window( + label: &str, + utilization_fraction: Option, + reset_epoch_seconds: Option, + now: DateTime, +) -> Option { + let (window_key, duration) = if label.eq_ignore_ascii_case("Session") { + ("session.v1", DurationEvidence::contract(300 * 60)) + } else { + ("weekly.v1", DurationEvidence::contract(7 * 24 * 60 * 60)) + }; + unified_ratelimit_window_with_identity( + label, + window_key, + duration, + utilization_fraction, + reset_epoch_seconds, + now, + ) } fn claude_extra_usage_window(extra: Option<&ClaudeExtraUsage>) -> Option { @@ -1871,15 +2890,20 @@ fn claude_extra_usage_window(extra: Option<&ClaudeExtraUsage>) -> Option None, }; - Some(UsageWindow { - label: "Extra usage".to_string(), - used_percent: used.clamp(0.0, 100.0), - remaining_percent: (100.0 - used).max(0.0), - resets_at: None, - reset_text, - window_minutes: None, - historical_pace: None, - }) + let mut window = UsageWindow::try_from_provider_used_percent( + "Extra usage".to_string(), + used, + None, + Utc::now(), + )? + .with_identity( + "extra_usage.v1", + Some("extra_usage.v1".to_string()), + None, + None, + ); + window.reset_text = reset_text; + Some(window) } fn claude_credits(extra: Option<&ClaudeExtraUsage>) -> Option { @@ -1948,36 +2972,38 @@ fn clean_limit_label(value: &str) -> String { .join(" ") } -fn map_window(label: &str, window: CodexWindow, now: DateTime) -> UsageWindow { - let resets_at = if window.reset_at > 0 { - Utc.timestamp_opt(window.reset_at, 0).single() - } else { - None - }; - let used = window.used_percent.clamp(0.0, 100.0); - UsageWindow { - label: label.to_string(), - used_percent: used, - remaining_percent: (100.0 - used).max(0.0), - resets_at: resets_at.map(|date| date.to_rfc3339_opts(SecondsFormat::Millis, true)), - reset_text: resets_at.map(|date| reset_text(date, now)), - window_minutes: (window.limit_window_seconds > 0).then_some(window.limit_window_seconds / 60), - historical_pace: None, - } +fn map_window_with_identity( + label: &str, + window: CodexWindow, + now: DateTime, + card_id: impl Into, + window_key: Option, +) -> Option { + let resets_at = (window.reset_at != 0) + .then(|| Utc.timestamp_opt(window.reset_at, 0).single()) + .flatten(); + let provider_duration = (window.limit_window_seconds != 0) + .then(|| DurationEvidence::provider(window.reset_at, window.limit_window_seconds)); + UsageWindow::try_from_provider_used_percent( + label.to_string(), + window.used_percent, + resets_at, + now, + ) + .map(|window| window.with_identity(card_id, window_key, provider_duration, None)) } -/// Standard Claude window lengths by label, since the API doesn't report them: -/// the session bucket is 5h, everything else is the 7-day weekly family. -fn claude_window_minutes(label: &str) -> Option { - Some(if label.eq_ignore_ascii_case("Session") { 300 } else { 10_080 }) +fn additional_limit_source(limit: &CodexAdditionalRateLimit) -> Option { + first_non_empty([ + limit.metered_feature.as_deref(), + limit.limit_name.as_deref(), + ]) + .map(str::to_string) } -fn role(window: Option<&CodexWindow>) -> Option<&'static str> { - match window?.limit_window_seconds { - 18_000 => Some("session"), - 604_800 => Some("weekly"), - _ => None, - } +fn sha256_hex(value: String) -> String { + let digest = Sha256::digest(value.trim().as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() } pub(crate) fn reset_text(reset: DateTime, now: DateTime) -> String { @@ -2087,11 +3113,11 @@ fn string_key( snake_case: &str, camel_case: &str, ) -> Option { - map.get(snake_case) - .or_else(|| map.get(camel_case)) - .and_then(Value::as_str) + [snake_case, camel_case] + .into_iter() + .filter_map(|key| map.get(key).and_then(Value::as_str)) .map(str::trim) - .filter(|s| !s.is_empty()) + .find(|value| !value.is_empty()) .map(str::to_string) } @@ -2158,6 +3184,30 @@ pub(crate) fn clean_plan(value: impl AsRef) -> String { .join(" ") } +pub(crate) fn deserialize_optional_raw<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + let raw = Option::>::deserialize(deserializer)?; + Ok(raw.and_then(|raw| serde_json::from_str(raw.get()).ok())) +} + +fn deserialize_optional_non_empty_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value + .as_ref() + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string)) +} + fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -2173,6 +3223,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::agent_account_scope::test_support::TestRefreshScope; #[test] fn parses_retry_after_seconds_and_http_date() { @@ -2190,6 +3241,133 @@ mod tests { assert!(parse_retry_after(None).is_none()); } + #[test] + fn string_key_uses_first_valid_snake_or_camel_alias() { + let cases = [ + ( + "snake priority", + serde_json::json!({ + "snake_key": " snake-value ", + "camelKey": "camel-value" + }), + Some("snake-value"), + ), + ( + "snake missing", + serde_json::json!({ "camelKey": " camel-value " }), + Some("camel-value"), + ), + ( + "snake null", + serde_json::json!({ "snake_key": null, "camelKey": "camel-value" }), + Some("camel-value"), + ), + ( + "snake empty", + serde_json::json!({ "snake_key": "", "camelKey": "camel-value" }), + Some("camel-value"), + ), + ( + "snake whitespace", + serde_json::json!({ "snake_key": " \t\n ", "camelKey": "camel-value" }), + Some("camel-value"), + ), + ( + "snake non-string", + serde_json::json!({ + "snake_key": { "unexpected": true }, + "camelKey": "camel-value" + }), + Some("camel-value"), + ), + ( + "both invalid", + serde_json::json!({ "snake_key": false, "camelKey": " " }), + None, + ), + ]; + + for (label, value, expected) in cases { + let map = value.as_object().unwrap(); + assert_eq!( + string_key(map, "snake_key", "camelKey").as_deref(), + expected, + "{label}" + ); + } + } + + #[test] + fn claude_refresh_response_ignores_invalid_optional_refresh_token() { + let cases = [ + ( + "valid", + serde_json::json!({ + "access_token": "new-access", + "refresh_token": " new-refresh ", + "expires_in": 3600 + }), + Some("new-refresh"), + ), + ( + "missing", + serde_json::json!({ "access_token": "new-access", "expires_in": 3600 }), + None, + ), + ( + "null", + serde_json::json!({ + "access_token": "new-access", + "refresh_token": null, + "expires_in": 3600 + }), + None, + ), + ( + "empty", + serde_json::json!({ + "access_token": "new-access", + "refresh_token": "", + "expires_in": 3600 + }), + None, + ), + ( + "whitespace", + serde_json::json!({ + "access_token": "new-access", + "refresh_token": " \t\n ", + "expires_in": 3600 + }), + None, + ), + ( + "non-string", + serde_json::json!({ + "access_token": "new-access", + "refresh_token": { "unexpected": true }, + "expires_in": 3600 + }), + None, + ), + ]; + + for (label, value, expected) in cases { + let response: ClaudeRefreshResponse = serde_json::from_value(value).unwrap(); + assert_eq!(response.access_token, "new-access", "{label}"); + assert_eq!(response.expires_in, 3_600, "{label}"); + assert_eq!(response.refresh_token.as_deref(), expected, "{label}"); + } + assert!(serde_json::from_value::( + serde_json::json!({ "expires_in": 3600 }) + ) + .is_err()); + assert!(serde_json::from_value::( + serde_json::json!({ "access_token": "new-access" }) + ) + .is_err()); + } + // Single test for the whole gate lifecycle — the gate is a process-wide // static, so split tests would race under the parallel test runner. #[test] @@ -2211,24 +3389,49 @@ mod tests { let later = now + chrono::Duration::seconds(301); assert!(claude_gate_blocked_until(later).is_none()); - // Success caches the snapshot; a later 429 serves it instead. - let snapshot = AgentUsageSnapshot { + // Success caches the display-ready snapshot; a later OAuth 429 serves + // those rows unchanged while dropping the stale account scope. The + // fetch path returns this fallback without another enrichment/history + // pass, preserving the last-good typed pace. + let scope = TestRefreshScope::new("claude", "cached-429"); + let account_scope = scope + .resolve_current("fixture", "cached-429", b"cached-429-marker") + .unwrap(); + let mut snapshot = AgentUsageSnapshot { client_id: "claude".to_string(), source: "oauth".to_string(), updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), identity: None, - windows: vec![UsageWindow { - label: "Session".to_string(), - used_percent: 20.0, - remaining_percent: 80.0, - resets_at: None, - reset_text: None, - window_minutes: Some(300), - historical_pace: None, - }], + account_scope: Ok(account_scope), + windows: vec![UsageWindow::from_provider_used_percent( + "Session".to_string(), + 20.0, + Some(now + chrono::Duration::hours(5)), + now, + ) + .with_identity( + "session.v1", + Some("session.v1".to_string()), + None, + Some(DurationEvidence::contract(300 * 60)), + )], credits: None, error: None, }; + snapshot.windows[0].pace_status = PaceStatusPayload { + state: PaceState::Available, + window_key: Some("session.v1".to_string()), + duration_seconds: Some(300 * 60), + duration_source: Some(DurationSource::Contract), + complete_cycles: 6, + reason: None, + }; + snapshot.windows[0].historical_pace = Some(HistoricalPacePayload { + expected_used_percent: 35.0, + eta_seconds: Some(1_800.0), + will_last_to_reset: false, + run_out_probability: Some(0.42), + }); claude_gate_record_success(&snapshot); assert!(claude_gate_blocked_until(later).is_none()); claude_gate_record_rate_limit(Some(later + chrono::Duration::seconds(60)), later); @@ -2236,9 +3439,23 @@ mod tests { let fallback = claude_gate_fallback(until, later); assert!(fallback.error.is_none()); assert_eq!(fallback.windows.len(), 1); + assert!(matches!( + &fallback.account_scope, + Err(AccountScopeError::NoTrustedEvidence) + )); + assert_eq!(fallback.windows[0].pace_status.state, PaceState::Available); + assert_eq!(fallback.windows[0].pace_status.complete_cycles, 6); + assert_eq!( + fallback.windows[0] + .historical_pace + .as_ref() + .map(|pace| pace.expected_used_percent), + Some(35.0) + ); // Leave the gate clean for any other test touching the static. claude_gate_record_success(&snapshot); + scope.cleanup(); } #[test] @@ -2260,8 +3477,62 @@ mod tests { assert_eq!(windows.len(), 2); assert_eq!(windows[0].label, "Session"); assert_eq!(windows[0].remaining_percent, 92.0); + assert_eq!(windows[0].window_minutes, Some(300)); assert_eq!(windows[1].label, "Weekly"); assert_eq!(windows[1].remaining_percent, 65.0); + assert_eq!(windows[1].window_minutes, Some(10_080)); + } + + #[test] + fn stage0_freezes_codex_duration_roles_and_unknown_window_baseline() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let reversed = CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 35.0, + reset_at: 1_700_172_800, + limit_window_seconds: 604_800, + }), + secondary_window: Some(CodexWindow { + used_percent: 8.0, + reset_at: 1_700_005_400, + limit_window_seconds: 18_000, + }), + }; + let windows = codex_windows(Some(&reversed), None, now); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].label, "Session", "codex.main.18000.session"); + assert_eq!(windows[0].card_id, "main.session.v1"); + assert_eq!(windows[0].window_key.as_deref(), Some("main.session.v1")); + assert_eq!(windows[0].window_minutes, Some(300)); + assert_eq!(windows[1].label, "Weekly", "codex.main.604800.weekly"); + assert_eq!(windows[1].card_id, "main.weekly.v1"); + assert_eq!(windows[1].window_key.as_deref(), Some("main.weekly.v1")); + assert_eq!(windows[1].window_minutes, Some(10_080)); + + let unknown_rate_limit = CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 10.0, + reset_at: now.timestamp() + 3_600, + limit_window_seconds: 3_600, + }), + secondary_window: None, + }; + let unknown = codex_windows(Some(&unknown_rate_limit), None, now); + assert_eq!(unknown.len(), 1); + let unknown = &unknown[0]; + assert_eq!(unknown.card_id, "row.main.primary.v1"); + assert_eq!(unknown.window_key, None); + assert_eq!(unknown.window_minutes, None); + assert_eq!(unknown.pace_status.state, PaceState::Unavailable); + assert_eq!( + unknown.pace_status.reason.as_deref(), + Some("windowIdentity") + ); + let wire = serde_json::to_value(unknown).unwrap(); + assert_eq!(wire["cardId"], "row.main.primary.v1"); + assert!(wire["paceStatus"].get("windowKey").is_none()); + assert_eq!(wire["paceStatus"]["state"], "unavailable"); + assert_eq!(wire["paceStatus"]["reason"], "windowIdentity"); } #[test] @@ -2273,7 +3544,14 @@ mod tests { Some(now + chrono::Duration::hours(12)), now, Some(10_080), + ) + .with_identity( + "weekly.v1", + Some("weekly.v1".to_string()), + None, + Some(DurationEvidence::contract(10_080 * 60)), ); + window.pace_status.state = PaceState::Available; window.historical_pace = Some(HistoricalPacePayload { expected_used_percent: 55.0, eta_seconds: Some(3_600.0), @@ -2293,42 +3571,156 @@ mod tests { } #[test] - fn codex_history_account_key_is_identified_and_prefers_account_id() { - let credentials = CodexCredentials { - access_token: String::new(), - refresh_token: None, + fn stage1_credential_markers_follow_the_frozen_provider_routes() { + let slot = CredentialSlot { + semantic_source: "fixture", + canonical_location: "fixture".to_string(), + }; + let codex = CodexCredentials { + access_token: "codex-access".to_string(), + refresh_token: Some("codex-refresh".to_string()), id_token: None, - account_id: Some("acct-id".to_string()), + account_id: None, last_refresh: None, auth_path: PathBuf::new(), raw_json: Value::Null, + scope_slot: slot.clone(), }; - let identity = AgentIdentity { - email: Some("user@example.com".to_string()), - plan: None, + assert_eq!(codex.scope_marker(), b"codex-refresh"); + let mut codex_access_only = codex.clone(); + codex_access_only.refresh_token = None; + assert_eq!(codex_access_only.scope_marker(), b"codex-access"); + + let claude_login = ClaudeCredentials { + access_token: "claude-access".to_string(), + refresh_token: Some("claude-refresh".to_string()), + expires_at: None, + scopes: Vec::new(), + rate_limit_tier: None, + subscription_type: None, + source: ClaudeCredentialSource::File, + raw_root: None, + scope_slot: slot.clone(), }; assert_eq!( - codex_account_key(&credentials, Some(&identity)).as_deref(), - Some("acct-id") + claude_login.scope_marker(), + Some(b"claude-refresh".as_slice()) ); - - let mut email_only = credentials.clone(); - email_only.account_id = None; + let mut login_without_refresh = claude_login.clone(); + login_without_refresh.refresh_token = None; + assert_eq!(login_without_refresh.scope_marker(), None); + + let claude_setup = ClaudeCredentials { + source: ClaudeCredentialSource::Environment, + scope_slot: slot, + ..login_without_refresh + }; assert_eq!( - codex_account_key(&email_only, Some(&identity)).as_deref(), - Some("user@example.com") + claude_setup.scope_marker(), + Some(b"claude-access".as_slice()) ); - let unknown = AgentIdentity { - email: None, - plan: None, - }; - assert!(codex_account_key(&email_only, Some(&unknown)).is_none()); - assert!(codex_account_key(&email_only, None).is_none()); } #[test] - fn maps_codex_additional_model_limits() { - let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + fn codex_scope_precedence_keeps_refresh_failure_sticky() { + let scope_store = TestRefreshScope::new("codex", "codex-scope-precedence"); + let refresh_scope = scope_store + .resolve_current("fixture", "refresh", b"refresh-marker") + .unwrap(); + let authoritative_scope = scope_store + .resolve_current("fixture", "authoritative", b"authoritative-marker") + .unwrap(); + let credential_scope = scope_store + .resolve_current("fixture", "credential", b"credential-marker") + .unwrap(); + let authoritative_calls = std::cell::Cell::new(0); + let credential_calls = std::cell::Cell::new(0); + + let resolved = resolve_codex_account_scope( + Some(Err(AccountScopeError::MetadataWrite)), + Some("acct-id"), + |_| { + authoritative_calls.set(authoritative_calls.get() + 1); + Ok(authoritative_scope.clone()) + }, + || { + credential_calls.set(credential_calls.get() + 1); + Ok(credential_scope.clone()) + }, + ); + assert_eq!(resolved, Err(AccountScopeError::MetadataWrite)); + assert_eq!(authoritative_calls.get(), 0); + assert_eq!(credential_calls.get(), 0); + + let resolved = resolve_codex_account_scope( + Some(Err(AccountScopeError::MetadataRead)), + None, + |_| { + authoritative_calls.set(authoritative_calls.get() + 1); + Ok(authoritative_scope.clone()) + }, + || { + credential_calls.set(credential_calls.get() + 1); + Ok(credential_scope.clone()) + }, + ); + assert_eq!(resolved, Err(AccountScopeError::MetadataRead)); + assert_eq!(authoritative_calls.get(), 0); + assert_eq!(credential_calls.get(), 0); + + let resolved = resolve_codex_account_scope( + Some(Ok(refresh_scope.clone())), + Some("acct-id"), + |_| { + authoritative_calls.set(authoritative_calls.get() + 1); + Ok(authoritative_scope.clone()) + }, + || { + credential_calls.set(credential_calls.get() + 1); + Ok(credential_scope.clone()) + }, + ); + assert_eq!(resolved.unwrap(), authoritative_scope); + assert_eq!(authoritative_calls.get(), 1); + assert_eq!(credential_calls.get(), 0); + + let resolved = resolve_codex_account_scope( + Some(Ok(refresh_scope.clone())), + None, + |_| { + authoritative_calls.set(authoritative_calls.get() + 1); + Ok(authoritative_scope.clone()) + }, + || { + credential_calls.set(credential_calls.get() + 1); + Ok(credential_scope.clone()) + }, + ); + assert_eq!(resolved.unwrap(), refresh_scope); + assert_eq!(authoritative_calls.get(), 1); + assert_eq!(credential_calls.get(), 0); + + let resolved = resolve_codex_account_scope( + None, + None, + |_| { + authoritative_calls.set(authoritative_calls.get() + 1); + Ok(authoritative_scope.clone()) + }, + || { + credential_calls.set(credential_calls.get() + 1); + Ok(credential_scope.clone()) + }, + ); + assert_eq!(resolved.unwrap(), credential_scope); + assert_eq!(authoritative_calls.get(), 1); + assert_eq!(credential_calls.get(), 1); + scope_store.cleanup(); + } + + #[test] + fn maps_codex_additional_model_limits() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); let extra = CodexAdditionalRateLimit { limit_name: Some("gpt-5.2-codex-spark".to_string()), metered_feature: None, @@ -2347,6 +3739,173 @@ mod tests { assert_eq!(windows[0].remaining_percent, 59.0); } + #[test] + fn stage0_freezes_codex_additional_identity_baseline() { + let metered_only = CodexAdditionalRateLimit { + limit_name: None, + metered_feature: Some("gpt-5.2-codex-spark".to_string()), + rate_limit: None, + }; + assert_eq!( + additional_limit_label(&metered_only), + "Codex Spark", + "codex.additional.metered-feature.primary" + ); + + let named = CodexAdditionalRateLimit { + limit_name: Some("named-limit".to_string()), + metered_feature: Some("metered-feature".to_string()), + rate_limit: None, + }; + assert_eq!( + additional_limit_label(&named), + "Named Limit", + "display label remains separate from the metered-feature identity" + ); + + let anonymous = CodexAdditionalRateLimit { + limit_name: None, + metered_feature: None, + rate_limit: None, + }; + assert_eq!(additional_limit_source(&anonymous), None); + + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let both_slots = CodexAdditionalRateLimit { + limit_name: Some("named-limit".to_string()), + metered_feature: Some(" metered-feature ".to_string()), + rate_limit: Some(CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 10.0, + reset_at: 1_700_003_600, + limit_window_seconds: 18_000, + }), + secondary_window: Some(CodexWindow { + used_percent: 20.0, + reset_at: 1_700_086_400, + limit_window_seconds: 604_800, + }), + }), + }; + assert_eq!( + additional_limit_source(&both_slots).as_deref(), + Some("metered-feature") + ); + let windows = codex_windows(None, Some(&[both_slots]), now); + assert_eq!( + windows.len(), + 2, + "codex.additional.primary-secondary emits both semantic slots" + ); + let digest = sha256_hex("metered-feature".to_string()); + let primary_key = format!("additional.{digest}.primary.v1"); + let secondary_key = format!("additional.{digest}.secondary.v1"); + assert_eq!(windows[0].card_id, primary_key); + assert_eq!( + windows[0].window_key.as_deref(), + Some(windows[0].card_id.as_str()) + ); + assert_eq!(windows[1].card_id, secondary_key); + assert_eq!( + windows[1].window_key.as_deref(), + Some(windows[1].card_id.as_str()) + ); + } + + #[test] + fn codex_unknown_and_anonymous_windows_are_structural_and_skip_history() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let unknown_main = CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 5.0, + reset_at: now.timestamp() + 3_600, + limit_window_seconds: 3_600, + }), + secondary_window: None, + }; + let anonymous = |primary_used: f64, secondary_used: f64| CodexAdditionalRateLimit { + limit_name: None, + metered_feature: None, + rate_limit: Some(CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: primary_used, + reset_at: now.timestamp() + 7_200, + limit_window_seconds: 7_200, + }), + secondary_window: Some(CodexWindow { + used_percent: secondary_used, + reset_at: now.timestamp() + 86_400, + limit_window_seconds: 86_400, + }), + }), + }; + let windows = codex_windows( + Some(&unknown_main), + Some(&[anonymous(10.0, 20.0), anonymous(30.0, 40.0)]), + now, + ); + assert_eq!(windows.len(), 3); + assert_eq!( + windows + .iter() + .map(|window| window.card_id.as_str()) + .collect::>(), + vec![ + "row.main.primary.v1", + "row.additional.unknown.primary.v1", + "row.additional.unknown.secondary.v1" + ] + ); + assert_eq!( + windows + .iter() + .map(|window| window.used_percent) + .collect::>(), + vec![5.0, 10.0, 20.0], + "duplicate anonymous slots keep the provider-order first row" + ); + for window in &windows[1..] { + assert_eq!(window.label_for_test(), "Unknown"); + assert_ne!(window.label_for_test(), "Codex extra limit"); + } + for window in &windows { + assert_eq!(window.window_key, None); + assert_eq!(window.pace_status.state, PaceState::Unavailable); + assert_eq!(window.pace_status.reason.as_deref(), Some("windowIdentity")); + } + + let scope = TestRefreshScope::new("codex", "unknown-windows"); + let account_scope = scope + .resolve_current("fixture", "unknown-windows", b"marker") + .unwrap(); + let mut snapshot = AgentUsageSnapshot { + client_id: "codex".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows, + credits: None, + error: None, + }; + let history_calls = std::cell::Cell::new(0); + enrich_snapshot_with(&mut snapshot, now.timestamp(), |_, _, _| { + history_calls.set(history_calls.get() + 1); + Ok(Vec::new()) + }); + assert_eq!(history_calls.get(), 0); + + let wire = serde_json::to_value(&snapshot).unwrap(); + let rows = wire["windows"].as_array().unwrap(); + assert_eq!(rows.len(), 3); + for row in rows { + assert!(row["paceStatus"].get("windowKey").is_none()); + assert_eq!(row["paceStatus"]["state"], "unavailable"); + assert_eq!(row["paceStatus"]["reason"], "windowIdentity"); + } + scope.cleanup(); + } + #[test] fn parses_claude_credentials_file() { let raw = r#"{ @@ -2359,8 +3918,7 @@ mod tests { "subscriptionType": "pro" } }"#; - let credentials = - parse_claude_credentials_data(raw, ClaudeCredentialSource::File).unwrap(); + let credentials = parse_claude_credentials_data(raw, ClaudeCredentialSource::File).unwrap(); assert_eq!(credentials.access_token, "access"); assert_eq!(credentials.refresh_token.as_deref(), Some("refresh")); assert_eq!(credentials.scopes, vec!["user:profile"]); @@ -2456,6 +4014,96 @@ mod tests { assert_eq!(windows[3].remaining_percent, 100.0); } + #[test] + fn stage4_claude_json_and_headers_share_canonical_duration_contracts() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let reset = Some("2026-07-24T00:00:00Z".to_string()); + let window = |utilization| ClaudeWindow { + utilization: Some(utilization), + resets_at: reset.clone(), + }; + let usage = ClaudeUsageResponse { + five_hour: Some(window(5.0)), + seven_day: Some(window(10.0)), + seven_day_oauth_apps: Some(window(15.0)), + seven_day_sonnet: Some(window(20.0)), + seven_day_opus: Some(window(25.0)), + ..Default::default() + }; + let windows = claude_windows(&usage, now); + let contracts = windows + .iter() + .map(|window| { + ( + window.card_id.as_str(), + window.pace_status.window_key.as_deref(), + window.duration_seconds, + window.duration_source, + window.pace_status.state, + ) + }) + .collect::>(); + assert_eq!( + contracts, + vec![ + ( + "session.v1", + Some("session.v1"), + Some(18_000), + Some(DurationSource::Contract), + PaceState::LearningHistory, + ), + ( + "weekly.v1", + Some("weekly.v1"), + Some(604_800), + Some(DurationSource::Contract), + PaceState::LearningHistory, + ), + ( + "oauth_apps.weekly.v1", + Some("oauth_apps.weekly.v1"), + Some(604_800), + Some(DurationSource::Contract), + PaceState::LearningHistory, + ), + ( + "sonnet.weekly.v1", + Some("sonnet.weekly.v1"), + Some(604_800), + Some(DurationSource::Contract), + PaceState::LearningHistory, + ), + ( + "opus.weekly.v1", + Some("opus.weekly.v1"), + Some(604_800), + Some(DurationSource::Contract), + PaceState::LearningHistory, + ), + ] + ); + + let headers = header_map(&[ + ("anthropic-ratelimit-unified-5h-utilization", "0.11"), + ("anthropic-ratelimit-unified-5h-reset", "1783111200"), + ("anthropic-ratelimit-unified-7d-utilization", "0.6"), + ("anthropic-ratelimit-unified-7d-reset", "1783504800"), + ]); + let header_windows = parse_unified_ratelimit_windows(&headers, now); + assert_eq!(header_windows.len(), 2); + for (window, expected_key, expected_duration) in [ + (&header_windows[0], "session.v1", 18_000), + (&header_windows[1], "weekly.v1", 604_800), + ] { + assert_eq!(window.card_id, expected_key); + assert_eq!(window.pace_status.window_key.as_deref(), Some(expected_key)); + assert_eq!(window.duration_seconds, Some(expected_duration)); + assert_eq!(window.duration_source, Some(DurationSource::Contract)); + assert_eq!(window.pace_status.state, PaceState::LearningHistory); + } + } + #[test] fn decodes_claude_alias_windows_without_duplicate_error() { let raw = r#"{ @@ -2475,6 +4123,315 @@ mod tests { ); } + #[test] + fn stage4_claude_weekly_alias_groups_share_canonical_contracts() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let design_aliases = [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]; + for alias in design_aliases { + let raw = + format!(r#"{{"{alias}":{{"utilization":12,"resets_at":"2026-07-24T00:00:00Z"}}}}"#); + let usage: ClaudeUsageResponse = serde_json::from_str(&raw).unwrap(); + let windows = claude_windows(&usage, now); + assert_eq!(windows.len(), 1, "claude.design.aliases: {alias}"); + assert_eq!( + windows[0].label, "Designs", + "claude.design.aliases: {alias}" + ); + assert_eq!(windows[0].card_id, "design.weekly.v1", "{alias}"); + assert_eq!( + windows[0].pace_status.window_key.as_deref(), + Some("design.weekly.v1"), + "{alias}" + ); + assert_eq!(windows[0].duration_seconds, Some(604_800), "{alias}"); + assert_eq!( + windows[0].duration_source, + Some(DurationSource::Contract), + "{alias}" + ); + assert_eq!(windows[0].pace_status.state, PaceState::LearningHistory); + } + + let routines_aliases = [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]; + for alias in routines_aliases { + let raw = + format!(r#"{{"{alias}":{{"utilization":12,"resets_at":"2026-07-24T00:00:00Z"}}}}"#); + let usage: ClaudeUsageResponse = serde_json::from_str(&raw).unwrap(); + let windows = claude_windows(&usage, now); + assert_eq!(windows.len(), 1, "claude.routines.aliases: {alias}"); + assert_eq!( + windows[0].label, "Daily Routines", + "claude.routines.aliases: {alias}" + ); + assert_eq!(windows[0].card_id, "routines.weekly.v1", "{alias}"); + assert_eq!( + windows[0].pace_status.window_key.as_deref(), + Some("routines.weekly.v1"), + "{alias}" + ); + assert_eq!(windows[0].duration_seconds, Some(604_800), "{alias}"); + assert_eq!( + windows[0].duration_source, + Some(DurationSource::Contract), + "{alias}" + ); + assert_eq!(windows[0].pace_status.state, PaceState::LearningHistory); + } + } + + #[test] + fn stage0_freezes_claude_named_windows_and_invalid_baseline() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let raw = r#"{ + "five_hour": { "utilization": 5, "resets_at": "2026-07-18T00:00:00Z" }, + "seven_day": { "utilization": 10, "resets_at": "2026-07-19T00:00:00Z" }, + "seven_day_oauth_apps": { "utilization": 15, "resets_at": "2026-07-20T00:00:00Z" }, + "seven_day_sonnet": { "utilization": 20, "resets_at": "2026-07-21T00:00:00Z" }, + "seven_day_opus": { "utilization": 25, "resets_at": "2026-07-22T00:00:00Z" } + }"#; + let usage: ClaudeUsageResponse = serde_json::from_str(raw).unwrap(); + let windows = claude_windows(&usage, now); + let mapped: Vec<_> = windows + .iter() + .map(|window| (window.label.as_str(), window.window_minutes)) + .collect(); + assert_eq!( + mapped, + vec![ + ("Session", Some(300)), + ("Weekly", Some(10_080)), + ("OAuth Apps", Some(10_080)), + ("Sonnet", Some(10_080)), + ("Opus", Some(10_080)), + ], + "claude.named-window-contracts" + ); + + let out_of_range = UsageWindow::from_used_percent( + "Out of range".to_string(), + 150.0, + Some(now - chrono::Duration::seconds(1)), + now, + Some(-1), + ); + assert_eq!( + out_of_range.used_percent, 100.0, + "invalid.out-of-range captures the current clamping baseline" + ); + assert!( + out_of_range.resets_at.is_some(), + "invalid.expired-reset captures the current emitted baseline" + ); + assert_eq!( + out_of_range.window_minutes, None, + "invalid.contradictory-duration is not emitted as legacy duration" + ); + + let non_finite = + UsageWindow::from_used_percent("Non-finite".to_string(), f64::NAN, None, now, None); + assert!( + non_finite.used_percent.is_nan(), + "invalid.non-finite captures the current emitted baseline" + ); + } + + #[test] + fn stage4_claude_extra_usage_is_active_without_recording_an_observation() { + let window = claude_extra_usage_window(Some(&ClaudeExtraUsage { + is_enabled: true, + monthly_limit: Some(10_000.0), + used_credits: Some(2_500.0), + utilization: None, + currency: Some("USD".to_string()), + })) + .unwrap(); + assert_eq!(window.label, "Extra usage"); + assert_eq!(window.card_id, "extra_usage.v1"); + assert_eq!(window.used_percent, 25.0); + assert!(window.resets_at.is_none()); + assert_eq!( + window.pace_status.window_key.as_deref(), + Some("extra_usage.v1") + ); + assert_eq!(window.pace_status.state, PaceState::Unavailable); + assert_eq!(window.pace_status.reason.as_deref(), Some("missingReset")); + assert!(window.duration_seconds.is_none()); + assert!(window.historical_pace.is_none()); + + let scope = TestRefreshScope::new("claude", "extra-usage"); + let account_scope = scope + .resolve_current("fixture", "extra-usage", b"extra-usage-marker") + .unwrap(); + let expected_scope = account_scope.as_str().to_string(); + let mut snapshot = AgentUsageSnapshot { + client_id: "claude".to_string(), + source: "oauth".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![window], + credits: None, + error: None, + }; + let calls = std::cell::Cell::new(0); + enrich_snapshot_with(&mut snapshot, 1_700_000_000, |active, observations, _| { + calls.set(calls.get() + 1); + assert_eq!( + active, + &[SeriesKey::new("claude", &expected_scope, "extra_usage.v1")] + ); + assert!(observations.is_empty()); + Ok(Vec::new()) + }); + assert_eq!(calls.get(), 1); + assert_eq!( + snapshot.windows[0].pace_status.state, + PaceState::Unavailable + ); + assert_eq!( + snapshot.windows[0].pace_status.reason.as_deref(), + Some("missingReset") + ); + scope.cleanup(); + } + + #[test] + fn stage4_emitted_unavailable_series_survives_capacity_admission() { + let scope = TestRefreshScope::new("claude", "emitted-capacity"); + let account_scope = scope + .resolve_current("fixture", "capacity", b"capacity-marker") + .unwrap(); + let account_scope_value = account_scope.as_str().to_string(); + let history_path = scope + .root() + .join(crate::agent_quota_history::HISTORY_FILE_NAME); + let seed_now = 1_800_000_000_i64; + let seed_reset = seed_now + 86_400; + let weekly_key = SeriesKey::new("claude", &account_scope_value, "weekly.v1"); + let mut seeded_keys = vec![weekly_key.clone()]; + seeded_keys.extend( + (0..crate::agent_quota_history::MAX_SERIES - 1).map(|index| { + SeriesKey::new( + "claude", + &account_scope_value, + format!("zzzz.{index:04}.v1"), + ) + }), + ); + for (sample_index, sampled_at) in [ + seed_now, + seed_now + 86_400 / 5, + seed_now + 2 * 86_400 / 5, + seed_now + 3 * 86_400 / 5, + seed_now + 4 * 86_400 / 5, + seed_reset - 1, + ] + .into_iter() + .enumerate() + { + let seeded_observations = seeded_keys + .iter() + .cloned() + .map(|key| QuotaObservation { + key, + reset_at: Some(seed_reset), + used_percent: 10.0 + sample_index as f64 * 10.0, + provider: None, + contract: Some(DurationEvidence::contract(86_400)), + }) + .collect::>(); + let seeded = crate::agent_quota_history::record_observations_at_path_and_evaluate( + &seeded_keys, + &seeded_observations, + sampled_at, + &history_path, + ) + .unwrap(); + assert_eq!(seeded.len(), crate::agent_quota_history::MAX_SERIES); + } + + let now = seed_reset + 15 * 60 + 1; + let now_date = Utc.timestamp_opt(now, 0).single().unwrap(); + let mut weekly = + UsageWindow::from_provider_used_percent("Weekly".to_string(), 20.0, None, now_date) + .with_identity( + "weekly.v1", + Some("weekly.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ); + weekly.unavailable("missingReset"); + let new_window = UsageWindow::from_provider_used_percent( + "New quota".to_string(), + 5.0, + Some(Utc.timestamp_opt(now + 86_400, 0).single().unwrap()), + now_date, + ) + .with_identity( + "new.v1", + Some("new.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ); + let mut snapshot = AgentUsageSnapshot { + client_id: "claude".to_string(), + source: "oauth".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![weekly, new_window], + credits: None, + error: None, + }; + + enrich_snapshot_with( + &mut snapshot, + now, + |active, observations, transaction_now| { + assert_eq!(active.len(), 2); + assert!(active.contains(&weekly_key)); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].key.window_key, "new.v1"); + crate::agent_quota_history::record_observations_at_path_and_evaluate( + active, + observations, + transaction_now, + &history_path, + ) + }, + ); + + let store: Value = serde_json::from_slice(&fs::read(&history_path).unwrap()).unwrap(); + let series = store["series"].as_array().unwrap(); + assert_eq!(series.len(), crate::agent_quota_history::MAX_SERIES); + assert!(series.iter().any(|entry| { + entry["providerId"] == "claude" + && entry["accountScope"] == account_scope_value + && entry["windowKey"] == "weekly.v1" + })); + assert_eq!( + snapshot.windows[0].pace_status.reason.as_deref(), + Some("missingReset") + ); + scope.cleanup(); + } + fn header_map(pairs: &[(&'static str, &'static str)]) -> reqwest::header::HeaderMap { let mut headers = reqwest::header::HeaderMap::new(); for (name, value) in pairs { @@ -2550,7 +4507,7 @@ mod tests { } #[test] - fn unified_window_scales_and_clamps_fraction() { + fn unified_window_rejects_invalid_fraction_before_wire() { let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); let zero = unified_ratelimit_window("Session", Some(0.0), None, now).unwrap(); assert!((zero.used_percent - 0.0).abs() < 1e-9); @@ -2558,13 +4515,151 @@ mod tests { let full = unified_ratelimit_window("Session", Some(1.0), None, now).unwrap(); assert!((full.used_percent - 100.0).abs() < 1e-9); assert!((full.remaining_percent - 0.0).abs() < 1e-9); - let over = unified_ratelimit_window("Session", Some(1.5), None, now).unwrap(); - assert!((over.used_percent - 100.0).abs() < 1e-9); - assert!((over.remaining_percent - 0.0).abs() < 1e-9); + + assert!(unified_ratelimit_window("Session", Some(1.5), None, now).is_none()); + assert!(unified_ratelimit_window("Session", Some(f64::NAN), None, now).is_none()); + assert!(parse_unified_ratelimit_windows( + &header_map(&[ + ("anthropic-ratelimit-unified-5h-utilization", "NaN"), + ("anthropic-ratelimit-unified-5h-reset", "1700003600"), + ]), + now, + ) + .is_empty()); + // None utilization -> no window assert!(unified_ratelimit_window("Session", None, Some(1_783_111_200), now).is_none()); } + #[test] + fn provider_adapters_reject_invalid_percentages_before_wire() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + assert!(map_claude_window( + "Session", + "session.v1", + DurationEvidence::contract(300 * 60), + &ClaudeWindow { + utilization: Some(150.0), + resets_at: None, + }, + now, + ) + .is_none()); + assert!(claude_extra_usage_window(Some(&ClaudeExtraUsage { + is_enabled: true, + monthly_limit: None, + used_credits: None, + utilization: Some(f64::NAN), + currency: None, + })) + .is_none()); + assert!(map_window_with_identity( + "Weekly", + CodexWindow { + used_percent: -1.0, + reset_at: 1_700_003_600, + limit_window_seconds: 604_800, + }, + now, + "main.weekly.v1", + Some("main.weekly.v1".to_string()), + ) + .is_none()); + + let valid_duplicate = codex_windows( + Some(&CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 150.0, + reset_at: 1_700_003_600, + limit_window_seconds: 18_000, + }), + secondary_window: Some(CodexWindow { + used_percent: 20.0, + reset_at: 1_700_003_600, + limit_window_seconds: 18_000, + }), + }), + None, + now, + ); + assert_eq!(valid_duplicate.len(), 1); + assert_eq!(valid_duplicate[0].card_id, "main.session.v1"); + assert_eq!(valid_duplicate[0].used_percent, 20.0); + } + + #[test] + fn provider_payloads_isolate_malformed_percentage_rows() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + for invalid in ["1e400", r#""NaN""#] { + let codex: CodexUsageResponse = serde_json::from_str(&format!( + r#"{{ + "rate_limit": {{ + "primary_window": {{ + "used_percent": {invalid}, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }}, + "secondary_window": {{ + "used_percent": 20, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }} + }} + }}"# + )) + .unwrap(); + let codex_windows = codex_windows(codex.rate_limit.as_ref(), None, now); + assert_eq!(codex_windows.len(), 1); + assert_eq!(codex_windows[0].card_id, "main.session.v1"); + assert_eq!(codex_windows[0].used_percent, 20.0); + + let claude: ClaudeUsageResponse = serde_json::from_str(&format!( + r#"{{ + "five_hour": {{ + "utilization": {invalid}, + "resets_at": "2023-11-15T00:13:20Z" + }}, + "seven_day": {{ + "utilization": 20, + "resets_at": "2023-11-21T22:13:20Z" + }}, + "seven_day_design": {{ + "utilization": {invalid}, + "resets_at": "2023-11-21T22:13:20Z" + }}, + "design": {{ + "utilization": 30, + "resets_at": "2023-11-21T22:13:20Z" + }}, + "seven_day_routines": {{ + "utilization": {invalid}, + "resets_at": "2023-11-21T22:13:20Z" + }}, + "routines": {{ + "utilization": 40, + "resets_at": "2023-11-21T22:13:20Z" + }}, + "extra_usage": {{ + "is_enabled": true, + "utilization": {invalid} + }} + }}"# + )) + .unwrap(); + let claude_windows = claude_windows(&claude, now); + assert_eq!(claude_windows.len(), 3); + assert!(claude_windows + .iter() + .any(|window| window.card_id == "weekly.v1" && window.used_percent == 20.0)); + assert!(claude_windows + .iter() + .any(|window| window.card_id == "design.weekly.v1" && window.used_percent == 30.0)); + assert!(claude_windows.iter().any( + |window| window.card_id == "routines.weekly.v1" && window.used_percent == 40.0 + )); + } + } + #[test] fn reads_claude_code_oauth_token_via_lookup() { let token = claude_token_from_lookup(|key| match key { @@ -2594,4 +4689,1366 @@ mod tests { let after = base + chrono::Duration::seconds(3700); assert!(refresh_cached_windows(std::slice::from_ref(&window), after).is_none()); } + + struct RecordingRefreshScope<'a> { + inner: &'a TestRefreshScope, + transfers: Mutex, Vec)>>, + } + + impl<'a> RecordingRefreshScope<'a> { + fn new(inner: &'a TestRefreshScope) -> Self { + Self { + inner, + transfers: Mutex::new(Vec::new()), + } + } + + fn transfers(&self) -> Vec<(Vec, Vec)> { + self.transfers.lock().unwrap().clone() + } + } + + impl RefreshScopeTransaction for RecordingRefreshScope<'_> { + fn resolve_current( + &self, + semantic_source: &str, + canonical_location: &str, + marker: &[u8], + ) -> Result { + self.inner + .resolve_current(semantic_source, canonical_location, marker) + } + + fn transfer( + &self, + semantic_source: &str, + canonical_location: &str, + old_marker: &[u8], + new_marker: &[u8], + ) -> Result { + self.transfers + .lock() + .unwrap() + .push((old_marker.to_vec(), new_marker.to_vec())); + self.inner + .transfer(semantic_source, canonical_location, old_marker, new_marker) + } + } + + fn checkpoint_at( + target: Option, + ) -> impl FnMut(RefreshCheckpoint) -> Result<(), String> { + move |checkpoint| { + if Some(checkpoint) == target { + Err("injected crash".to_string()) + } else { + Ok(()) + } + } + } + + async fn codex_test_response(refresh_token: String) -> Result { + assert_eq!(refresh_token, "codex-old-refresh"); + Ok(serde_json::json!({ + "access_token": "codex-new-access", + "refresh_token": "codex-new-refresh" + })) + } + + fn setup_codex_refresh( + tag: &str, + ) -> (TestRefreshScope, PathBuf, AccountScope, Vec, String) { + let scope = TestRefreshScope::new("codex", tag); + let path = scope.root().join("codex/auth.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + serde_json::to_vec_pretty(&serde_json::json!({ + "tokens": { + "access_token": " codex-old-access ", + "refresh_token": " codex-old-refresh ", + "id_token": " codex-old-id " + } + })) + .unwrap(), + ) + .unwrap(); + let credentials = load_codex_credentials_from(&path).unwrap(); + let location = credentials.scope_slot.canonical_location.clone(); + let old_scope = scope + .resolve_current( + credentials.scope_slot.semantic_source, + &location, + credentials.scope_marker(), + ) + .unwrap(); + let metadata = scope.metadata_bytes(); + (scope, path, old_scope, metadata, location) + } + + async fn run_codex_refresh( + scope: &TestRefreshScope, + path: &Path, + crash: Option, + ) -> Result<(CodexCredentials, Result), String> { + refresh_codex_credentials_with( + path, + scope, + codex_test_response, + save_codex_credentials, + checkpoint_at(crash), + ) + .await + } + + #[tokio::test] + async fn codex_refresh_canonicalizes_tokens_before_transfer_and_reload() { + for (tag, refresh_value) in [ + ("missing", None), + ("null", Some(Value::Null)), + ("empty", Some(Value::String(String::new()))), + ("whitespace", Some(Value::String(" \t\n ".to_string()))), + ( + "non-string", + Some(serde_json::json!({ "unexpected": true })), + ), + ] { + let (scope, path, old_scope, _, _) = + setup_codex_refresh(&format!("codex-canonical-{tag}")); + let recording = RecordingRefreshScope::new(&scope); + let mut response = serde_json::json!({ + "access_token": { "unexpected": true }, + "accessToken": " codex-new-access ", + "id_token": " \t\n ", + "idToken": " codex-new-id " + }); + if let Some(refresh_value) = refresh_value { + response + .as_object_mut() + .unwrap() + .insert("refresh_token".to_string(), refresh_value); + } + + let (refreshed, scope_outcome) = refresh_codex_credentials_with( + &path, + &recording, + move |refresh_token| async move { + assert_eq!(refresh_token, "codex-old-refresh"); + Ok(response) + }, + save_codex_credentials, + checkpoint_at(None), + ) + .await + .unwrap(); + + assert_eq!(refreshed.access_token, "codex-new-access", "{tag}"); + assert_eq!( + refreshed.refresh_token.as_deref(), + Some("codex-old-refresh"), + "{tag}" + ); + assert_eq!(refreshed.id_token.as_deref(), Some("codex-new-id"), "{tag}"); + assert_eq!(scope_outcome.unwrap(), old_scope, "{tag}"); + assert_eq!( + recording.transfers(), + vec![(b"codex-old-refresh".to_vec(), b"codex-old-refresh".to_vec())], + "{tag}" + ); + + let stored: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + stored["tokens"]["refresh_token"], + Value::String("codex-old-refresh".to_string()), + "{tag}" + ); + let reloaded = load_codex_credentials_from(&path).unwrap(); + assert_eq!(reloaded.access_token, refreshed.access_token, "{tag}"); + assert_eq!(reloaded.refresh_token, refreshed.refresh_token, "{tag}"); + assert_eq!(reloaded.id_token, refreshed.id_token, "{tag}"); + assert_eq!(reloaded.scope_marker(), refreshed.scope_marker(), "{tag}"); + assert_eq!( + scope + .resolve_current( + reloaded.scope_slot.semantic_source, + &reloaded.scope_slot.canonical_location, + reloaded.scope_marker(), + ) + .unwrap(), + old_scope, + "{tag}" + ); + scope.cleanup(); + } + + let (scope, path, old_scope, _, _) = setup_codex_refresh("codex-canonical-aliases"); + let recording = RecordingRefreshScope::new(&scope); + let response = serde_json::json!({ + "access_token": " \t ", + "accessToken": false, + "refresh_token": null, + "refreshToken": " codex-new-refresh ", + "id_token": { "unexpected": true }, + "idToken": "" + }); + let (refreshed, scope_outcome) = refresh_codex_credentials_with( + &path, + &recording, + move |refresh_token| async move { + assert_eq!(refresh_token, "codex-old-refresh"); + Ok(response) + }, + save_codex_credentials, + checkpoint_at(None), + ) + .await + .unwrap(); + + assert_eq!(refreshed.access_token, "codex-old-access"); + assert_eq!( + refreshed.refresh_token.as_deref(), + Some("codex-new-refresh") + ); + assert_eq!(refreshed.id_token.as_deref(), Some("codex-old-id")); + assert_eq!(scope_outcome.unwrap(), old_scope); + assert_eq!( + recording.transfers(), + vec![(b"codex-old-refresh".to_vec(), b"codex-new-refresh".to_vec())] + ); + let stored: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + stored["tokens"]["refresh_token"], + Value::String("codex-new-refresh".to_string()) + ); + let reloaded = load_codex_credentials_from(&path).unwrap(); + assert_eq!(reloaded.access_token, refreshed.access_token); + assert_eq!(reloaded.refresh_token, refreshed.refresh_token); + assert_eq!(reloaded.id_token, refreshed.id_token); + assert_eq!(reloaded.scope_marker(), refreshed.scope_marker()); + scope.cleanup(); + } + + #[tokio::test] + async fn codex_refresh_crash_boundaries_and_scope_gate_use_production_sequence() { + for boundary in [ + RefreshCheckpoint::Reloaded, + RefreshCheckpoint::NetworkReturned, + RefreshCheckpoint::MetadataHandled, + RefreshCheckpoint::CredentialsPersisted, + ] { + let (scope, path, old_scope, before, location) = setup_codex_refresh("codex-crash"); + assert_eq!( + run_codex_refresh(&scope, &path, Some(boundary)) + .await + .unwrap_err(), + "injected crash" + ); + let stored = load_codex_credentials_from(&path).unwrap(); + assert_eq!( + stored.refresh_token.as_deref(), + Some(if boundary == RefreshCheckpoint::CredentialsPersisted { + "codex-new-refresh" + } else { + "codex-old-refresh" + }) + ); + if matches!( + boundary, + RefreshCheckpoint::Reloaded | RefreshCheckpoint::NetworkReturned + ) { + assert_eq!(scope.metadata_bytes(), before); + } else { + assert_ne!(scope.metadata_bytes(), before); + assert_eq!( + scope + .resolve_current("codex-auth-json", &location, b"codex-old-refresh") + .unwrap(), + old_scope + ); + assert_eq!( + scope + .resolve_current("codex-auth-json", &location, b"codex-new-refresh") + .unwrap(), + old_scope + ); + } + scope.cleanup(); + } + + let (scope, path, old_scope, before, location) = setup_codex_refresh("codex-metadata-fail"); + scope.fail_metadata_save(); + let (refreshed, scope_outcome) = run_codex_refresh(&scope, &path, None).await.unwrap(); + assert_eq!(refreshed.access_token, "codex-new-access"); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + let persisted = load_codex_credentials_from(&path).unwrap(); + assert_eq!(persisted.access_token, "codex-old-access"); + assert_eq!( + persisted.refresh_token.as_deref(), + Some("codex-old-refresh") + ); + assert_eq!( + scope + .resolve_current("codex-auth-json", &location, persisted.scope_marker()) + .unwrap(), + old_scope + ); + scope.cleanup(); + + let (scope, path, _old_scope, before, _) = + setup_codex_refresh("codex-metadata-fail-unchanged"); + scope.fail_metadata_save(); + let (refreshed, scope_outcome) = refresh_codex_credentials_with( + &path, + &scope, + |refresh_token| async move { + assert_eq!(refresh_token, "codex-old-refresh"); + Ok(serde_json::json!({ "access_token": "codex-new-access" })) + }, + save_codex_credentials, + checkpoint_at(None), + ) + .await + .unwrap(); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + assert_eq!( + refreshed.refresh_token.as_deref(), + Some("codex-old-refresh") + ); + let persisted = load_codex_credentials_from(&path).unwrap(); + assert_eq!(persisted.access_token, "codex-new-access"); + assert_eq!( + persisted.refresh_token.as_deref(), + Some("codex-old-refresh") + ); + scope.cleanup(); + + let (scope, path, old_scope, _, location) = setup_codex_refresh("codex-success"); + let (_, scope_outcome) = run_codex_refresh(&scope, &path, None).await.unwrap(); + assert_eq!(scope_outcome.unwrap(), old_scope); + assert_eq!( + scope + .resolve_current("codex-auth-json", &location, b"codex-new-refresh") + .unwrap(), + old_scope + ); + scope.cleanup(); + } + + async fn claude_test_response(refresh_token: String) -> Result { + assert_eq!(refresh_token, "claude-old-refresh"); + Ok(ClaudeRefreshResponse { + access_token: "claude-new-access".to_string(), + refresh_token: Some("claude-new-refresh".to_string()), + expires_in: 3_600, + }) + } + + fn setup_claude_refresh( + tag: &str, + ) -> ( + TestRefreshScope, + PathBuf, + ClaudeCredentials, + AccountScope, + Vec, + String, + ) { + let scope = TestRefreshScope::new("claude", tag); + let path = scope.root().join("claude/.credentials.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let raw = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "claude-old-access", + "refreshToken": "claude-old-refresh", + "expiresAt": 0 + } + }) + .to_string(); + fs::write(&path, &raw).unwrap(); + let mut credentials = + parse_claude_credentials_data(&raw, ClaudeCredentialSource::File).unwrap(); + credentials.scope_slot = CredentialSlot { + semantic_source: "claude-login-file", + canonical_location: agent_account_scope::canonical_file_location( + &path, + Some("claudeAiOauth"), + ) + .unwrap(), + }; + let location = credentials.scope_slot.canonical_location.clone(); + let old_scope = scope + .resolve_current( + credentials.scope_slot.semantic_source, + &location, + credentials.scope_marker().unwrap(), + ) + .unwrap(); + let metadata = scope.metadata_bytes(); + (scope, path, credentials, old_scope, metadata, location) + } + + async fn run_claude_refresh( + scope: &TestRefreshScope, + path: &Path, + original: &ClaudeCredentials, + crash: Option, + ) -> Result<(ClaudeCredentials, Result), String> { + let reload_path = path.to_path_buf(); + let save_path = path.to_path_buf(); + refresh_claude_credentials_with( + original, + scope, + move |template| { + let raw = fs::read_to_string(&reload_path) + .map_err(|error| format!("reload Claude test credentials: {error}"))?; + let mut credentials = + parse_claude_credentials_data(&raw, ClaudeCredentialSource::File)?; + credentials.scope_slot = template.scope_slot.clone(); + Ok(credentials) + }, + claude_test_response, + move |credentials| save_claude_credentials_to_file(credentials, &save_path), + checkpoint_at(crash), + ) + .await + } + + fn stored_claude_refresh_token(path: &Path) -> Option { + parse_claude_credentials_data( + &fs::read_to_string(path).unwrap(), + ClaudeCredentialSource::File, + ) + .unwrap() + .refresh_token + } + + #[tokio::test] + async fn claude_refresh_invalid_new_refresh_preserves_old_marker_and_store() { + for (tag, refresh_value) in [ + ("claude-invalid-refresh-empty", serde_json::json!("")), + ( + "claude-invalid-refresh-non-string", + serde_json::json!({ "unexpected": true }), + ), + ] { + let (scope, path, original, old_scope, _, location) = setup_claude_refresh(tag); + let response: ClaudeRefreshResponse = serde_json::from_value(serde_json::json!({ + "access_token": "claude-new-access", + "refresh_token": refresh_value, + "expires_in": 3600 + })) + .unwrap(); + let reload_path = path.clone(); + let save_path = path.clone(); + let (refreshed, scope_outcome) = refresh_claude_credentials_with( + &original, + &scope, + move |template| { + let raw = fs::read_to_string(&reload_path) + .map_err(|error| format!("reload Claude test credentials: {error}"))?; + let mut credentials = + parse_claude_credentials_data(&raw, ClaudeCredentialSource::File)?; + credentials.scope_slot = template.scope_slot.clone(); + Ok(credentials) + }, + move |refresh_token| async move { + assert_eq!(refresh_token, "claude-old-refresh"); + Ok(response) + }, + move |credentials| save_claude_credentials_to_file(credentials, &save_path), + checkpoint_at(None), + ) + .await + .unwrap(); + + assert_eq!(refreshed.access_token, "claude-new-access"); + assert_eq!( + refreshed.refresh_token.as_deref(), + Some("claude-old-refresh") + ); + assert_eq!(scope_outcome.unwrap(), old_scope); + assert_eq!( + scope + .resolve_current("claude-login-file", &location, b"claude-old-refresh") + .unwrap(), + old_scope + ); + assert_eq!( + stored_claude_refresh_token(&path).as_deref(), + Some("claude-old-refresh") + ); + let stored: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + stored["claudeAiOauth"]["refreshToken"], + Value::String("claude-old-refresh".to_string()) + ); + scope.cleanup(); + } + } + + #[tokio::test] + async fn claude_refresh_crash_boundaries_and_scope_gate_use_production_sequence() { + for boundary in [ + RefreshCheckpoint::Reloaded, + RefreshCheckpoint::NetworkReturned, + RefreshCheckpoint::MetadataHandled, + RefreshCheckpoint::CredentialsPersisted, + ] { + let (scope, path, original, old_scope, before, location) = + setup_claude_refresh("claude-crash"); + assert_eq!( + run_claude_refresh(&scope, &path, &original, Some(boundary)) + .await + .unwrap_err(), + "injected crash" + ); + assert_eq!( + stored_claude_refresh_token(&path).as_deref(), + Some(if boundary == RefreshCheckpoint::CredentialsPersisted { + "claude-new-refresh" + } else { + "claude-old-refresh" + }) + ); + if matches!( + boundary, + RefreshCheckpoint::Reloaded | RefreshCheckpoint::NetworkReturned + ) { + assert_eq!(scope.metadata_bytes(), before); + } else { + assert_ne!(scope.metadata_bytes(), before); + assert_eq!( + scope + .resolve_current("claude-login-file", &location, b"claude-old-refresh") + .unwrap(), + old_scope + ); + assert_eq!( + scope + .resolve_current("claude-login-file", &location, b"claude-new-refresh") + .unwrap(), + old_scope + ); + } + scope.cleanup(); + } + + let (scope, path, original, old_scope, before, location) = + setup_claude_refresh("claude-metadata-fail"); + scope.fail_metadata_save(); + let (refreshed, scope_outcome) = run_claude_refresh(&scope, &path, &original, None) + .await + .unwrap(); + assert_eq!(refreshed.access_token, "claude-new-access"); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + assert_eq!( + stored_claude_refresh_token(&path).as_deref(), + Some("claude-old-refresh") + ); + assert_eq!( + scope + .resolve_current("claude-login-file", &location, b"claude-old-refresh") + .unwrap(), + old_scope + ); + scope.cleanup(); + + let (scope, path, original, _old_scope, before, _) = + setup_claude_refresh("claude-metadata-fail-unchanged"); + scope.fail_metadata_save(); + let reload_path = path.clone(); + let save_path = path.clone(); + let (refreshed, scope_outcome) = refresh_claude_credentials_with( + &original, + &scope, + move |template| { + let raw = fs::read_to_string(&reload_path) + .map_err(|error| format!("reload Claude test credentials: {error}"))?; + let mut credentials = + parse_claude_credentials_data(&raw, ClaudeCredentialSource::File)?; + credentials.scope_slot = template.scope_slot.clone(); + Ok(credentials) + }, + |refresh_token| async move { + assert_eq!(refresh_token, "claude-old-refresh"); + Ok(ClaudeRefreshResponse { + access_token: "claude-new-access".to_string(), + refresh_token: None, + expires_in: 3_600, + }) + }, + move |credentials| save_claude_credentials_to_file(credentials, &save_path), + checkpoint_at(None), + ) + .await + .unwrap(); + assert_eq!(scope_outcome, Err(AccountScopeError::MetadataWrite)); + assert_eq!(scope.metadata_bytes(), before); + assert_eq!( + refreshed.refresh_token.as_deref(), + Some("claude-old-refresh") + ); + let persisted = parse_claude_credentials_data( + &fs::read_to_string(&path).unwrap(), + ClaudeCredentialSource::File, + ) + .unwrap(); + assert_eq!(persisted.access_token, "claude-new-access"); + assert_eq!( + persisted.refresh_token.as_deref(), + Some("claude-old-refresh") + ); + scope.cleanup(); + + let (scope, path, original, old_scope, _, location) = + setup_claude_refresh("claude-success"); + let (_, scope_outcome) = run_claude_refresh(&scope, &path, &original, None) + .await + .unwrap(); + assert_eq!(scope_outcome.unwrap(), old_scope); + assert_eq!( + scope + .resolve_current("claude-login-file", &location, b"claude-new-refresh") + .unwrap(), + old_scope + ); + scope.cleanup(); + } + + #[test] + fn stage4_codex_and_claude_matrix_assigns_semantic_keys() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let rate_limit = CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 8.0, + reset_at: now.timestamp() + 18_000, + limit_window_seconds: 18_000, + }), + secondary_window: Some(CodexWindow { + used_percent: 35.0, + reset_at: now.timestamp() + 604_800, + limit_window_seconds: 604_800, + }), + }; + let codex = codex_windows(Some(&rate_limit), None, now); + assert_eq!( + codex[0].pace_status.window_key.as_deref(), + Some("main.session.v1") + ); + assert_eq!( + codex[1].pace_status.window_key.as_deref(), + Some("main.weekly.v1") + ); + + let claude = ClaudeUsageResponse { + five_hour: Some(ClaudeWindow { + utilization: Some(10.0), + resets_at: Some("2026-07-18T00:00:00Z".to_string()), + }), + seven_day: Some(ClaudeWindow { + utilization: Some(20.0), + resets_at: Some("2026-07-19T00:00:00Z".to_string()), + }), + ..Default::default() + }; + let claude = claude_windows(&claude, now); + assert_eq!( + claude[0].pace_status.window_key.as_deref(), + Some("session.v1") + ); + assert_eq!( + claude[1].pace_status.window_key.as_deref(), + Some("weekly.v1") + ); + assert_eq!(claude[0].window_minutes_for_test(), Some(300)); + assert_eq!(claude[1].window_minutes_for_test(), Some(10_080)); + } + + #[test] + fn stage4_duplicate_snapshot_rows_are_removed_before_history_and_wire() { + let scope = TestRefreshScope::new("stage4", "duplicate-rows"); + let account_scope = scope + .resolve_current("fixture", "duplicate", b"duplicate-marker") + .unwrap(); + let now = 1_700_000_000; + let reset = Utc.timestamp_opt(now + 86_400, 0).single().unwrap(); + let make_window = |label: &str, card_id: &str, window_key: &str, used: f64| { + UsageWindow::from_provider_used_percent( + label.to_string(), + used, + Some(reset), + Utc.timestamp_opt(now, 0).single().unwrap(), + ) + .with_identity( + card_id, + Some(window_key.to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ) + }; + let mut snapshot = AgentUsageSnapshot { + client_id: "fixture".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![ + make_window("First", "shared-card.v1", "first.v1", 10.0), + make_window("Duplicate key", "second-card.v1", "first.v1", 20.0), + make_window("Duplicate card", "shared-card.v1", "third.v1", 30.0), + ], + credits: None, + error: None, + }; + + enrich_snapshot_with(&mut snapshot, now, |active, observations, _| { + assert_eq!(active.len(), 1); + assert_eq!(observations.len(), 1); + assert_eq!(active[0].window_key, "first.v1"); + assert_eq!(observations[0].used_percent, 10.0); + Ok(vec![Ok((HistoryOutcome::LearningDuration, None, 0))]) + }); + + assert_eq!(snapshot.windows.len(), 1); + assert_eq!(snapshot.windows[0].label_for_test(), "First"); + let wire = serde_json::to_value(&snapshot).unwrap(); + let rows = wire["windows"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["cardId"], "shared-card.v1"); + assert_eq!(rows[0]["paceStatus"]["windowKey"], "first.v1"); + scope.cleanup(); + } + + #[test] + fn stage4_chained_identity_collisions_keep_only_actual_uniques() { + let scope = TestRefreshScope::new("stage4", "chained-collisions"); + let account_scope = scope + .resolve_current("fixture", "chained", b"chained-marker") + .unwrap(); + let now = 1_700_000_000; + let reset = Utc.timestamp_opt(now + 86_400, 0).single().unwrap(); + let make_window = |label: &str, card_id: &str, window_key: &str, used: f64| { + UsageWindow::from_provider_used_percent( + label.to_string(), + used, + Some(reset), + Utc.timestamp_opt(now, 0).single().unwrap(), + ) + .with_identity( + card_id, + Some(window_key.to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ) + }; + let mut snapshot = AgentUsageSnapshot { + client_id: "fixture".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![ + make_window("A/X", "a.v1", "x.v1", 10.0), + make_window("A/Y", "a.v1", "y.v1", 20.0), + make_window("C/Y", "c.v1", "y.v1", 30.0), + make_window("B/X", "b.v1", "x.v1", 40.0), + make_window("B/Z", "b.v1", "z.v1", 50.0), + ], + credits: None, + error: None, + }; + + enrich_snapshot_with(&mut snapshot, now, |active, observations, _| { + assert_eq!(active.len(), 3); + assert_eq!(observations.len(), 3); + assert_eq!(active[0].window_key, "x.v1"); + assert_eq!(active[1].window_key, "y.v1"); + assert_eq!(active[2].window_key, "z.v1"); + assert_eq!( + observations + .iter() + .map(|observation| observation.used_percent) + .collect::>(), + vec![10.0, 30.0, 50.0] + ); + Ok(vec![ + Ok((HistoryOutcome::LearningDuration, None, 0)), + Ok((HistoryOutcome::LearningDuration, None, 0)), + Ok((HistoryOutcome::LearningDuration, None, 0)), + ]) + }); + + assert_eq!(snapshot.windows.len(), 3); + assert_eq!( + snapshot + .windows + .iter() + .map(UsageWindow::label_for_test) + .collect::>(), + vec!["A/X", "C/Y", "B/Z"] + ); + let wire = serde_json::to_value(&snapshot).unwrap(); + let rows = wire["windows"].as_array().unwrap(); + assert_eq!( + rows.iter() + .map(|row| row["cardId"].as_str().unwrap()) + .collect::>(), + vec!["a.v1", "c.v1", "b.v1"] + ); + assert_eq!( + rows.iter() + .map(|row| row["paceStatus"]["windowKey"].as_str().unwrap()) + .collect::>(), + vec!["x.v1", "y.v1", "z.v1"] + ); + scope.cleanup(); + } + + #[test] + fn stage4_batch_maps_results_once_without_network() { + let scope = TestRefreshScope::new("stage4", "batch-map"); + let account_scope = scope + .resolve_current("fixture", "batch", b"batch-marker") + .unwrap(); + let now = 1_700_000_000; + let reset = Utc.timestamp_opt(now + 86_400, 0).single().unwrap(); + let mut snapshot = AgentUsageSnapshot { + client_id: "fixture".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![ + UsageWindow::from_provider_used_percent( + "First".to_string(), + 20.0, + Some(reset), + Utc.timestamp_opt(now, 0).single().unwrap(), + ) + .with_identity( + "first.v1", + Some("first.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ), + UsageWindow::from_provider_used_percent( + "Second".to_string(), + 40.0, + Some(reset), + Utc.timestamp_opt(now, 0).single().unwrap(), + ) + .with_identity( + "second.v1", + Some("second.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ), + ], + credits: None, + error: None, + }; + let calls = std::cell::Cell::new(0); + enrich_snapshot_with(&mut snapshot, now, |active, observations, _| { + calls.set(calls.get() + 1); + assert_eq!(active.len(), 2); + assert_eq!(observations.len(), 2); + assert_eq!(active[0].window_key, "first.v1"); + assert_eq!(active[1].window_key, "second.v1"); + Ok(vec![ + Ok((HistoryOutcome::LearningDuration, None, 0)), + Ok(( + HistoryOutcome::Ready { + duration_seconds: 86_400, + source: DurationSource::Contract, + sampled: true, + }, + Some(HistoricalPace { + expected_percent: 42.0, + eta_seconds: Some(900.0), + will_last_to_reset: false, + run_out_probability: Some(0.25), + }), + 4, + )), + ]) + }); + assert_eq!( + calls.get(), + 1, + "one snapshot means one batch and no new request" + ); + assert_eq!( + snapshot.windows[0].pace_status.state, + PaceState::LearningDuration + ); + assert_eq!(snapshot.windows[1].pace_status.state, PaceState::Available); + assert_eq!(snapshot.windows[1].window_minutes_for_test(), Some(1_440)); + let wire = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(wire["windows"][0]["paceStatus"]["completeCycles"], 0); + assert_eq!(wire["windows"][1]["paceStatus"]["completeCycles"], 4); + scope.cleanup(); + } + + #[test] + fn stage4_learning_history_uses_batch_complete_cycles() { + let scope = TestRefreshScope::new("stage4", "learning-history"); + let account_scope = scope + .resolve_current("fixture", "learning", b"learning-marker") + .unwrap(); + let now = 1_700_000_000; + let reset = Utc.timestamp_opt(now + 86_400, 0).single().unwrap(); + let mut snapshot = AgentUsageSnapshot { + client_id: "fixture".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![UsageWindow::from_provider_used_percent( + "Weekly".to_string(), + 20.0, + Some(reset), + Utc.timestamp_opt(now, 0).single().unwrap(), + ) + .with_identity( + "weekly.v1", + Some("weekly.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + )], + credits: None, + error: None, + }; + + enrich_snapshot_with(&mut snapshot, now, |_, _, _| { + Ok(vec![Ok(( + HistoryOutcome::Ready { + duration_seconds: 86_400, + source: DurationSource::Contract, + sampled: true, + }, + None, + 2, + ))]) + }); + + assert_eq!( + snapshot.windows[0].pace_status.state, + PaceState::LearningHistory + ); + assert_eq!(snapshot.windows[0].pace_status.complete_cycles, 2); + let wire = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(wire["windows"][0]["paceStatus"]["completeCycles"], 2); + scope.cleanup(); + } + + #[test] + fn stage4_incoherent_historical_result_is_typed_unavailable() { + let scope = TestRefreshScope::new("stage4", "incoherent-history"); + let account_scope = scope + .resolve_current("fixture", "incoherent", b"incoherent-marker") + .unwrap(); + let now = 1_700_000_000; + let reset = Utc.timestamp_opt(now + 86_400, 0).single().unwrap(); + let mut snapshot = AgentUsageSnapshot { + client_id: "fixture".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Ok(account_scope), + windows: vec![UsageWindow::from_provider_used_percent( + "Weekly".to_string(), + 20.0, + Some(reset), + Utc.timestamp_opt(now, 0).single().unwrap(), + ) + .with_identity( + "weekly.v1", + Some("weekly.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + )], + credits: None, + error: None, + }; + + enrich_snapshot_with(&mut snapshot, now, |_, _, _| { + Ok(vec![Ok(( + HistoryOutcome::Ready { + duration_seconds: 86_400, + source: DurationSource::Contract, + sampled: true, + }, + Some(HistoricalPace { + expected_percent: 42.0, + eta_seconds: Some(900.0), + will_last_to_reset: true, + run_out_probability: Some(0.25), + }), + 4, + ))]) + }); + + assert_eq!( + snapshot.windows[0].pace_status.state, + PaceState::Unavailable + ); + assert_eq!( + snapshot.windows[0].pace_status.reason.as_deref(), + Some("history") + ); + let wire = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(wire["windows"][0]["paceStatus"]["state"], "unavailable"); + assert!(wire["windows"][0].get("historicalPace").is_none()); + scope.cleanup(); + } + + #[test] + fn stage4_historical_eta_and_will_last_are_exactly_coherent() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let reset = now + chrono::Duration::days(1); + let base = + UsageWindow::from_provider_used_percent("Daily".to_string(), 30.0, Some(reset), now) + .with_identity( + "daily.v1", + Some("daily.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ); + let cases = [ + ( + "will-last", + HistoricalPace { + expected_percent: 42.0, + eta_seconds: None, + will_last_to_reset: true, + run_out_probability: Some(0.1), + }, + true, + ), + ( + "will-run-out", + HistoricalPace { + expected_percent: 42.0, + eta_seconds: Some(900.0), + will_last_to_reset: false, + run_out_probability: Some(0.25), + }, + true, + ), + ( + "will-last-with-eta", + HistoricalPace { + expected_percent: 42.0, + eta_seconds: Some(900.0), + will_last_to_reset: true, + run_out_probability: Some(0.25), + }, + false, + ), + ( + "will-run-out-without-eta", + HistoricalPace { + expected_percent: 42.0, + eta_seconds: None, + will_last_to_reset: false, + run_out_probability: Some(0.25), + }, + false, + ), + ]; + + for (label, pace, expected) in cases { + assert_eq!(historical_pace_is_coherent(&pace), expected, "{label}"); + let mut window = base.clone(); + window.pace_status.state = PaceState::Available; + window.historical_pace = Some(historical_pace_payload(pace)); + assert_eq!(serde_json::to_value(&window).is_ok(), expected, "{label}"); + } + } + + #[test] + fn stage4_scope_error_is_sticky_and_skips_history() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let mut snapshot = AgentUsageSnapshot { + client_id: "fixture".to_string(), + source: "fixture".to_string(), + updated_at: String::new(), + identity: None, + account_scope: Err(AccountScopeError::MetadataWrite), + windows: vec![ + UsageWindow::from_provider_used_percent( + "Session".to_string(), + 20.0, + Some(now + chrono::Duration::hours(5)), + now, + ) + .with_identity( + "session.v1", + Some("session.v1".to_string()), + None, + Some(DurationEvidence::contract(300 * 60)), + ), + UsageWindow::from_provider_used_percent( + "Unknown".to_string(), + 30.0, + Some(now + chrono::Duration::hours(5)), + now, + ) + .with_identity("row.unknown.v1", None, None, None), + ], + credits: None, + error: None, + }; + let calls = std::cell::Cell::new(0); + enrich_snapshot_with(&mut snapshot, now.timestamp(), |_, _, _| { + calls.set(calls.get() + 1); + Ok(Vec::new()) + }); + assert_eq!(calls.get(), 0); + assert_eq!( + snapshot.windows[0].pace_status.reason.as_deref(), + Some("accountScope") + ); + assert_eq!( + snapshot.windows[0].pace_status.state, + PaceState::Unavailable + ); + assert_eq!( + snapshot.windows[1].pace_status.reason.as_deref(), + Some("windowIdentity") + ); + assert!(snapshot.windows[1].pace_status.window_key.is_none()); + assert!(serde_json::to_value(&snapshot).is_ok()); + } + + #[test] + fn stage4_wire_rejects_internal_nested_drift_and_preserves_observed_learning() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let reset = now + chrono::Duration::days(1); + let base = + UsageWindow::from_provider_used_percent("Daily".to_string(), 30.0, Some(reset), now) + .with_identity( + "daily.v1", + Some("daily.v1".to_string()), + None, + Some(DurationEvidence::contract(86_400)), + ); + + let mut key_drift = base.clone(); + key_drift.pace_status.window_key = Some("other.v1".to_string()); + assert!(serde_json::to_value(&key_drift).is_err()); + + let mut duration_drift = base.clone(); + duration_drift.pace_status.duration_seconds = Some(3_600); + assert!(serde_json::to_value(&duration_drift).is_err()); + + let mut source_drift = base.clone(); + source_drift.pace_status.duration_source = Some(DurationSource::Provider); + assert!(serde_json::to_value(&source_drift).is_err()); + + let mut minutes_drift = base.clone(); + minutes_drift.window_minutes = Some(1); + assert!(serde_json::to_value(&minutes_drift).is_err()); + + let mut learning = + UsageWindow::from_provider_used_percent("Learning".to_string(), 30.0, Some(reset), now) + .with_identity("learning.v1", Some("learning.v1".to_string()), None, None); + learning.duration_source = Some(DurationSource::Observed); + learning.pace_status.duration_source = Some(DurationSource::Observed); + let wire = serde_json::to_value(&learning).unwrap(); + assert_eq!(wire["paceStatus"]["state"], "learningDuration"); + assert_eq!(wire["paceStatus"]["durationSource"], "observed"); + assert!(wire["paceStatus"].get("durationSeconds").is_none()); + } + + #[test] + fn stage4_wire_rejects_available_without_historical_pace() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let mut window = UsageWindow::from_provider_used_percent( + "Weekly".to_string(), + 30.0, + Some(now + chrono::Duration::days(7)), + now, + ) + .with_identity( + "weekly.v1", + Some("weekly.v1".to_string()), + None, + Some(DurationEvidence::contract(7 * 24 * 60 * 60)), + ); + window.pace_status.state = PaceState::Available; + window.historical_pace = None; + assert!(serde_json::to_value(&window).is_err()); + } + + #[test] + fn provider_quota_pace_v3_fixture_locks_production_serializer() { + fn window( + card_id: &str, + label: &str, + used_percent: f64, + resets_at: Option<&str>, + window_key: Option<&str>, + state: PaceState, + duration_seconds: Option, + duration_source: Option, + complete_cycles: usize, + reason: Option<&str>, + historical_pace: Option, + ) -> UsageWindow { + UsageWindow { + card_id: card_id.to_string(), + label: label.to_string(), + used_percent, + remaining_percent: 100.0 - used_percent, + resets_at: resets_at.map(|value| value.to_string()), + reset_text: None, + window_minutes: duration_seconds.map(|seconds| seconds / 60), + window_key: window_key.map(|value| value.to_string()), + duration_seconds, + duration_source, + provider_duration: None, + contract_duration: None, + pace_status: PaceStatusPayload { + state, + window_key: window_key.map(|value| value.to_string()), + duration_seconds, + duration_source, + complete_cycles, + reason: reason.map(|value| value.to_string()), + }, + historical_pace, + } + } + + let payload = AgentUsagePayload { + generated_at: "2026-07-10T12:00:00.000Z".to_string(), + agents: vec![AgentUsageSnapshot { + client_id: "provider-fixture.invalid".to_string(), + source: "fixture.invalid".to_string(), + updated_at: "2026-07-10T12:00:00.000Z".to_string(), + identity: None, + account_scope: Err(AccountScopeError::NoTrustedEvidence), + windows: vec![ + window( + "ahead.invalid", + "Ahead quota", + 72.0, + Some("2026-07-10T15:00:00Z"), + Some("quota.ahead.invalid"), + PaceState::Available, + Some(18_000), + Some(DurationSource::Provider), + 5, + None, + Some(HistoricalPacePayload { + expected_used_percent: 32.0, + eta_seconds: Some(3_600.0), + will_last_to_reset: false, + run_out_probability: Some(0.75), + }), + ), + window( + "behind.invalid", + "Behind quota", + 28.0, + Some("2026-07-15T12:00:00Z"), + Some("quota.behind.invalid"), + PaceState::Available, + Some(604_800), + Some(DurationSource::Contract), + 7, + None, + Some(HistoricalPacePayload { + expected_used_percent: 56.0, + eta_seconds: None, + will_last_to_reset: true, + run_out_probability: Some(0.2), + }), + ), + window( + "learning-history.invalid", + "Learning history", + 40.0, + Some("2026-07-10T15:00:00Z"), + Some("quota.learning-history.invalid"), + PaceState::LearningHistory, + Some(18_000), + Some(DurationSource::Provider), + 2, + None, + None, + ), + window( + "learning-duration.invalid", + "Learning duration", + 40.0, + Some("2026-07-10T15:00:00Z"), + Some("quota.learning-duration.invalid"), + PaceState::LearningDuration, + None, + Some(DurationSource::Observed), + 0, + None, + None, + ), + window( + "missing-reset.invalid", + "Missing reset", + 50.0, + None, + Some("quota.missing-reset.invalid"), + PaceState::Unavailable, + None, + None, + 0, + Some("missingReset"), + None, + ), + window( + "shared-first.invalid", + "Shared label", + 10.0, + Some("2026-07-10T15:00:00Z"), + Some("quota.shared-first.invalid"), + PaceState::LearningHistory, + Some(18_000), + Some(DurationSource::Provider), + 2, + None, + None, + ), + window( + "shared-second.invalid", + "Shared label", + 20.0, + Some("2026-07-10T15:00:00Z"), + Some("quota.shared-second.invalid"), + PaceState::LearningHistory, + Some(18_000), + Some(DurationSource::Provider), + 2, + None, + None, + ), + ], + credits: None, + error: None, + }], + opencode_subscriptions: Vec::new(), + }; + + let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../Fixtures/CrossCheck/provider-quota-pace-v3.json"); + let fixture: Value = serde_json::from_str( + &fs::read_to_string(&fixture_path) + .unwrap_or_else(|error| panic!("read {}: {error}", fixture_path.display())), + ) + .unwrap_or_else(|error| panic!("decode {}: {error}", fixture_path.display())); + assert_eq!(fixture["schemaVersion"], 3); + assert_eq!(fixture["payload"], serde_json::to_value(payload).unwrap()); + } } diff --git a/crates/tb_core_ffi/src/lib.rs b/crates/tb_core_ffi/src/lib.rs index 5d3da575..18f730f4 100644 --- a/crates/tb_core_ffi/src/lib.rs +++ b/crates/tb_core_ffi/src/lib.rs @@ -14,10 +14,13 @@ //! names (TokenBar-tokcat/src-tauri/src/*.rs) with the Tauri command plumbing //! stripped; keep them diffable against the originals. +mod agent_account_scope; mod agent_antigravity; mod agent_copilot; mod agent_grok; mod agent_history; +mod agent_quota_duration; +mod agent_quota_history; mod agent_usage; mod agents_report; mod hourly_report; @@ -395,9 +398,7 @@ pub extern "C" fn tb_agent_usage() -> *mut c_char { // the providers that already succeeded — and could cut off the legitimate // expired-token path (sequential refresh + fetch, up to ~60s). let payload = RUNTIME.block_on(agent_usage::run()); - envelope( - serde_json::to_value(payload).map_err(|e| format!("serialize agent usage: {}", e)), - ) + envelope(serde_json::to_value(payload).map_err(|e| format!("serialize agent usage: {}", e))) }) } diff --git a/crates/tb_core_ffi/src/opencode_integrations.rs b/crates/tb_core_ffi/src/opencode_integrations.rs index 379351da..2ce422e7 100644 --- a/crates/tb_core_ffi/src/opencode_integrations.rs +++ b/crates/tb_core_ffi/src/opencode_integrations.rs @@ -59,27 +59,50 @@ fn subscription_label(provider: &str) -> String { } fn auth_path() -> Option { - std::env::var_os("HOME") - .map(|home| PathBuf::from(home).join(".local/share/opencode/auth.json")) + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share/opencode/auth.json")) } -/// The durable GitHub OAuth token opencode stored for its github-copilot login -/// (its `refresh` field), used to query Copilot quota. `None` if opencode isn't -/// authed against Copilot. -pub fn github_copilot_token() -> Option { - let raw = std::fs::read_to_string(auth_path()?).ok()?; +pub(crate) struct GitHubCopilotCredential { + pub(crate) request_token: String, + pub(crate) marker: Vec, + pub(crate) semantic_source: &'static str, + pub(crate) canonical_location: String, +} + +/// The durable GitHub OAuth credential opencode stored for its Copilot login. +/// A non-empty refresh string is both the request token and lineage marker; +/// missing or invalid refresh values fall back to a non-empty access string. +pub(crate) fn github_copilot_credential() -> Option { + let path = auth_path()?; + let raw = std::fs::read_to_string(&path).ok()?; let json = serde_json::from_str::(&raw).ok()?; + github_copilot_credential_from(&path, &json) +} + +fn github_copilot_credential_from( + path: &std::path::Path, + json: &serde_json::Value, +) -> Option { let entry = json.get("github-copilot")?; if entry.get("type").and_then(|t| t.as_str()) != Some("oauth") { return None; } - entry - .get("refresh") - .or_else(|| entry.get("access")) - .and_then(|t| t.as_str()) + let token = ["refresh", "access"] + .into_iter() + .filter_map(|key| entry.get(key).and_then(serde_json::Value::as_str)) .map(str::trim) - .filter(|t| !t.is_empty()) - .map(str::to_string) + .find(|token| !token.is_empty())? + .to_string(); + Some(GitHubCopilotCredential { + request_token: token.clone(), + marker: token.into_bytes(), + semantic_source: "opencode-auth-json", + canonical_location: crate::agent_account_scope::canonical_file_location( + path, + Some("github-copilot"), + ) + .ok()?, + }) } #[cfg(test)] @@ -91,6 +114,101 @@ mod tests { assert_eq!(subscription_label("openai"), "Codex"); assert_eq!(subscription_label("github-copilot"), "Copilot"); assert_eq!(subscription_label("anthropic"), "Claude"); - assert_eq!(subscription_label("minimax-coding-plan"), "Minimax-coding-plan"); + assert_eq!( + subscription_label("minimax-coding-plan"), + "Minimax-coding-plan" + ); + } + + #[test] + fn copilot_lineage_marker_uses_first_valid_refresh_or_access() { + let path = std::env::temp_dir().join("fixture-opencode-auth.json"); + let cases = [ + ( + "refresh preferred", + serde_json::json!({ + "github-copilot": { + "type": "oauth", + "refresh": " refresh-marker ", + "access": "access-marker" + } + }), + Some("refresh-marker"), + ), + ( + "refresh missing", + serde_json::json!({ + "github-copilot": { "type": "oauth", "access": " access-marker " } + }), + Some("access-marker"), + ), + ( + "refresh null", + serde_json::json!({ + "github-copilot": { + "type": "oauth", + "refresh": null, + "access": "access-marker" + } + }), + Some("access-marker"), + ), + ( + "refresh empty", + serde_json::json!({ + "github-copilot": { + "type": "oauth", + "refresh": "", + "access": "access-marker" + } + }), + Some("access-marker"), + ), + ( + "refresh whitespace", + serde_json::json!({ + "github-copilot": { + "type": "oauth", + "refresh": " \t\n ", + "access": "access-marker" + } + }), + Some("access-marker"), + ), + ( + "refresh non-string", + serde_json::json!({ + "github-copilot": { + "type": "oauth", + "refresh": { "unexpected": true }, + "access": "access-marker" + } + }), + Some("access-marker"), + ), + ( + "both invalid", + serde_json::json!({ + "github-copilot": { + "type": "oauth", + "refresh": false, + "access": " " + } + }), + None, + ), + ]; + + for (label, json, expected) in cases { + let credential = github_copilot_credential_from(&path, &json); + match expected { + Some(expected) => { + let credential = credential.unwrap_or_else(|| panic!("{label}")); + assert_eq!(credential.request_token, expected, "{label}"); + assert_eq!(credential.marker, expected.as_bytes(), "{label}"); + } + None => assert!(credential.is_none(), "{label}"), + } + } } } diff --git a/docs/knowledge/README.md b/docs/knowledge/README.md index ae88b8d4..0013419b 100644 --- a/docs/knowledge/README.md +++ b/docs/knowledge/README.md @@ -4,7 +4,7 @@ id: kb-index kind: index scope: repository read_when: before any TokenBar task or handoff -last_verified: 2026-07-16 +last_verified: 2026-07-17 sources: ["README.md", "CONTRIBUTING.md", "AGENTS.md", "Makefile", "Package.swift", ".github/workflows/ci.yml", ".github/workflows/pages.yml", ".github/workflows/release.yml", ".github/workflows/update-install-count.yml"] --- @@ -36,7 +36,7 @@ sources: ["README.md", "CONTRIBUTING.md", "AGENTS.md", "Makefile", "Package.swif | 架構、FFI、資料流 | [`architecture.md`](architecture.md) | [`verification.md`](verification.md) | | 分支、PR、merge、授權 | [`workflow.md`](workflow.md) | [`communication.md`](communication.md) | | 測試、fixture、cache、跨語言契約 | [`verification.md`](verification.md) | [`architecture.md`](architecture.md) | -| Codex Weekly historical pace | [`plans/codex-historical-pace-v2.md`](plans/codex-historical-pace-v2.md) | [`architecture.md`](architecture.md)、[`verification.md`](verification.md) | +| Provider quota pace/historical calculation | [`plans/provider-quota-pace.md`](plans/provider-quota-pace.md) | [`plans/codex-historical-pace-v2.md`](plans/codex-historical-pace-v2.md)、[`architecture.md`](architecture.md)、[`verification.md`](verification.md) | | tokscale sync 或 vendor patch | [`vendor-tokscale.md`](vendor-tokscale.md) | [`vendor/README.md`](../../vendor/README.md) | | Sparkle、appcast、Homebrew、Pages | [`release.md`](release.md) | [`workflow.md`](workflow.md) | | 維護期優先順序 | [`current-state.md`](current-state.md) | [`history/README.md`](history/README.md) | @@ -74,6 +74,7 @@ docs/knowledge/ └── plans/ ├── README.md ├── codex-historical-pace-v2.md + ├── provider-quota-pace.md └── tokscale-alignment.md ``` diff --git a/docs/knowledge/architecture.md b/docs/knowledge/architecture.md index 17d6a00c..054e574f 100644 --- a/docs/knowledge/architecture.md +++ b/docs/knowledge/architecture.md @@ -4,8 +4,8 @@ id: kb-architecture kind: canonical scope: repository read_when: changing Rust parsing, the C ABI, Swift models, reports, cache, or filters -last_verified: 2026-07-16 -sources: ["Package.swift", "Makefile", "Sources/CTB/include/ctb.h", "crates/tb_core_ffi", "Sources/TokenBarCore", "Sources/TokenBar", "vendor/README.md"] +last_verified: 2026-07-17 +sources: ["Package.swift", "Makefile", "Sources/CTB/include/ctb.h", "crates/tb_core_ffi", "crates/tb_core_ffi/src/agent_account_scope.rs", "crates/tb_core_ffi/src/agent_quota_duration.rs", "crates/tb_core_ffi/src/agent_quota_history.rs", "Sources/TokenBarCore", "Sources/TokenBar", "docs/knowledge/plans/provider-quota-pace.md", "vendor/README.md"] --- # Runtime architecture and data flow @@ -112,13 +112,14 @@ Pricing and quota are separate flows. Vendored tokscale pricing resolves model c | Token counts, model cost, active days | Rust core and FFI reports | Decode and display; do not reprice raw sessions | | Price freshness | Vendored pricing service | Show the timestamp supplied by the model report | | OAuth or subscription windows | Rust FFI provider modules | Preserve last good data when refresh fails and display an actionable error only when no good value exists | -| Tray quota selection | Swift `QuotaResolver` | Select from already decoded windows; do not make a second provider request | -| Linear pace projections | Swift `TokenBarCore` | Derive elapsed/duration fallback from the current provider window | -| Codex Weekly historical pace | Rust FFI v2 history evaluator | Decode one optional nested expected/ETA/will-last/risk result; derive display stage only, and use Linear when absent | +| Quota account scope | Rust `agent_account_scope` | Treat the opaque scope as history identity only;do not derive it from labels、paths or token hashes | +| Duration lifecycle and historical pace | Rust `agent_quota_duration` and `agent_quota_history` | Decode typed `paceStatus` and the optional coherent expected/ETA/will-last/risk result;do not recompute historical output | +| Tray quota selection | Swift `QuotaResolver` | Select from already decoded windows by `clientId|cardId`;do not make a second provider request | +| Linear pace policy | Swift `TokenBarCore` | Use Rust-owned positive `durationSeconds` only for explicit Linear mode or `learningHistory`;never revive `learningDuration`、`unavailable` or legacy payloads from `windowMinutes` | Authoritative provider-reported costs use the vendored cost-provenance contract. The local cache schema must be bumped whenever serialized message output changes, while report-time-only arithmetic changes do not require a cache bump. -Codex Weekly history uses the dedicated `codex-weekly-history-v2.json` store. Rust normalizes and validates raw quota samples, admits only complete account-scoped weeks, and owns the coherent historical projection. The legacy v1 file is not a migration input and remains untouched; during the v2 learning period, Swift receives no historical result and uses the Linear calculation. +Provider-wide history uses `quota-pace-history-v3.json` keyed by provider、opaque account scope and semantic window key. Rust owns duration evidence、sampling、retention、expected pace、ETA、will-last and risk;Swift owns mode selection、display stage and copy. The installation HMAC key is an exact 32-byte owner-only file in the hardened Application Support directory;the directory is `0700`、the file is `0600`、creation is atomic and cross-process locked、and every resolution reloads the persisted winner without a process cache. The old development Keychain item is ignored. Codex v2 remains read-only migration input for byte-exact current-account matches;v1 is never imported and both legacy files remain untouched. Pricing metadata is refreshable rather than frozen for the process lifetime; the current refresh cadence is approximately one hour. When provider-hinted lookup selects an entry without cache rates, local cache-rate backfill preserves the correct cache pricing. diff --git a/docs/knowledge/current-state.md b/docs/knowledge/current-state.md index 9e8f4fb7..3dae0382 100644 --- a/docs/knowledge/current-state.md +++ b/docs/knowledge/current-state.md @@ -4,8 +4,8 @@ id: kb-current-state kind: canonical scope: repository read_when: starting work, triaging an issue, or deciding whether an upstream item is urgent -last_verified: 2026-07-16 -sources: ["public GitHub main history", "public issue #45", "vendor/README.md", "docs/knowledge/history/README.md", "docs/knowledge/plans/tokscale-alignment.md", "docs/knowledge/plans/codex-historical-pace-v2.md"] +last_verified: 2026-07-17 +sources: ["public GitHub main history", "public issue #45", "vendor/README.md", "docs/knowledge/history/README.md", "docs/knowledge/plans/tokscale-alignment.md", "docs/knowledge/plans/codex-historical-pace-v2.md", "docs/knowledge/plans/provider-quota-pace.md"] --- # Current state @@ -57,7 +57,7 @@ Setup-token quota fallback is shipped: when profile usage is unavailable, provid | Workstream | Status | Public surface | |---|---|---| -| Codex historical pace v2 | Native implementation complete: clean v2 history leaves v1 untouched, Rust owns one coherent expected/ETA/will-last/risk result, and Swift falls back to Linear while learning; Windows nested DTO parity remains a downstream handoff | [`plans/codex-historical-pace-v2.md`](plans/codex-historical-pace-v2.md) | +| Provider-wide quota pace | Mac implementation is complete through Stage 6 on the task branch:provider/contract/observed duration、generic v3 history、five provider adapters、typed Swift lifecycle/card-ID selection/Historical-only deficit presentation,以及 Rust serializer-locked cross-port fixture。Stage 7 live smoke揭露ad-hoc build無法穩定存取legacy Keychain ACL;尚未出貨的account-scope installation key已改為hardened Application Support內的exact 32-byte、directory `0700`/file `0600`、atomic且cross-process locked file,舊開發item只忽略。Security regressions、Rust workspace tests/Clippy、Rust→Swift build、Swift selftest、docs gates、storage fresh verifier與重新授權的monitored live smoke已通過;smoke未顯示authorization UI,live storage metadata為directory `0700`/file `0600`/exact 32 bytes。明示 `FIXTURE` 的 deterministic popover 已完成 Historical/Linear/Off 驗收:learningDuration 與 typed unavailable 不產生 projection、learningHistory 只使用灰色 Linear estimate、只有 available historical deficit 帶橘色 pace marker/文案;quota 長條的低餘額黃色維持獨立健康訊號。最終 post-GUI fresh verifier 已回傳 `CONFIRMED`,Windows port/parity維持 pending | [`plans/provider-quota-pace.md`](plans/provider-quota-pace.md)、[`plans/codex-historical-pace-v2.md`](plans/codex-historical-pace-v2.md) | | Copilot upstream follow-up | Assessment complete: merged PR #880 is equivalent to the local M10-E trace-scoped hierarchy and cache invalidation; no additional code or schema port is needed | [issue #879](https://github.com/junhoyeo/tokscale/issues/879), [PR #880](https://github.com/junhoyeo/tokscale/pull/880) | | Rolling tokscale alignment | The current correctness batch is integrated through M14; the inventory remains active and future work stays selective, not wholesale | [issue #45](https://github.com/Nanako0129/TokenBar/issues/45), [`plans/tokscale-alignment.md`](plans/tokscale-alignment.md) | | Day-bar empty-today behavior | Parked, because changing the right edge changes the visible chart and needs a focused fixture plus UI verification | No public commitment beyond the maintenance note | diff --git a/docs/knowledge/plans/README.md b/docs/knowledge/plans/README.md index 7c75c114..c9506e28 100644 --- a/docs/knowledge/plans/README.md +++ b/docs/knowledge/plans/README.md @@ -4,8 +4,8 @@ id: kb-plan-index kind: index scope: repository read_when: selecting or resuming a planned work item -last_verified: 2026-07-16 -sources: ["docs/knowledge/current-state.md", "docs/knowledge/vendor-tokscale.md", "public issue #45"] +last_verified: 2026-07-17 +sources: ["docs/knowledge/current-state.md", "docs/knowledge/vendor-tokscale.md", "docs/knowledge/plans/provider-quota-pace.md", "public issue #45"] --- # Plan registry @@ -16,7 +16,8 @@ sources: ["docs/knowledge/current-state.md", "docs/knowledge/vendor-tokscale.md" | Plan | Status | Scope | |---|---|---| -| [`codex-historical-pace-v2.md`](codex-historical-pace-v2.md) | active | Clean-start v2 store and coherent Codex Weekly historical pace evaluation | +| [`provider-quota-pace.md`](provider-quota-pace.md) | active | Mac implementation is complete through the Rust-locked cross-port fixture;Stage 7 integration and Windows parity remain pending | +| [`codex-historical-pace-v2.md`](codex-historical-pace-v2.md) | superseded | Implemented Codex Weekly v2 foundation retained as migration and evaluator evidence | | [`tokscale-alignment.md`](tokscale-alignment.md) | active | Rolling selective alignment and correctness order | Historical or superseded private plans remain classified in [`../migration-ledger.md`](../migration-ledger.md); they are not copied wholesale into the public tree. diff --git a/docs/knowledge/plans/codex-historical-pace-v2.md b/docs/knowledge/plans/codex-historical-pace-v2.md index 5456343a..57e2e2ac 100644 --- a/docs/knowledge/plans/codex-historical-pace-v2.md +++ b/docs/knowledge/plans/codex-historical-pace-v2.md @@ -1,11 +1,13 @@ --- -status: active +status: superseded id: kb-plan-codex-historical-pace-v2 kind: plan scope: repository read_when: implementing or reviewing Codex weekly historical pace v2 -last_verified: 2026-07-16 -sources: ["crates/tb_core_ffi/src/agent_history.rs", "crates/tb_core_ffi/src/agent_usage.rs", "Sources/TokenBarCore/AgentUsage.swift", "Sources/TokenBarCore/UsagePace.swift", "Sources/CrossCheckHarness/main.swift", "docs/knowledge/architecture.md", "docs/knowledge/verification.md", "public CodexBar PR #901", "public CodexBar PR #1581"] +last_verified: 2026-07-17 +sources: ["crates/tb_core_ffi/src/agent_history.rs", "crates/tb_core_ffi/src/agent_usage.rs", "Sources/TokenBarCore/AgentUsage.swift", "Sources/TokenBarCore/UsagePace.swift", "Sources/CrossCheckHarness/main.swift", "docs/knowledge/architecture.md", "docs/knowledge/verification.md", "docs/knowledge/plans/provider-quota-pace.md", "public CodexBar PR #901", "public CodexBar PR #1581"] +superseded_by: provider-quota-pace.md +superseded_on: 2026-07-17 --- # Codex historical pace v2 clean-start plan @@ -14,6 +16,8 @@ sources: ["crates/tb_core_ffi/src/agent_history.rs", "crates/tb_core_ffi/src/age 這份計畫修正 Codex Weekly historical pace 會因不穩定的 reset timestamp 與不完整歷史群組而錯誤顯示 `Lasts until reset` 的問題。實作採用新的 v2 history store,完全不讀取、改寫或刪除既有 v1 history;所有使用者從乾淨資料重新學習,資料不足時自動使用 Linear pace。 +> **後續範圍:** 本計畫已成為 Codex Weekly 的 implemented foundation,不是 provider-wide completion。所有 provider quota cards 的 duration、identity、learning state 與 generic v3 history 由 [`provider-quota-pace.md`](provider-quota-pace.md) 接手。 + > **已鎖定決策:** v1 不做 migration。v2 只從新版收集的可信 raw samples 建立 historical curve;舊檔保留作為 rollback 與診斷材料,但不再影響新版本的計算。 ## 目錄 diff --git a/docs/knowledge/plans/provider-quota-pace.md b/docs/knowledge/plans/provider-quota-pace.md new file mode 100644 index 00000000..f61b2f1b --- /dev/null +++ b/docs/knowledge/plans/provider-quota-pace.md @@ -0,0 +1,566 @@ +--- +status: active +id: kb-plan-provider-quota-pace +kind: plan +scope: repository +read_when: implementing or reviewing pace duration and historical pace for provider quota cards +last_verified: 2026-07-17 +sources: ["crates/tb_core_ffi/src/agent_history.rs", "crates/tb_core_ffi/src/agent_usage.rs", "crates/tb_core_ffi/src/agent_antigravity.rs", "crates/tb_core_ffi/src/agent_copilot.rs", "crates/tb_core_ffi/src/agent_grok.rs", "Sources/TokenBarCore/AgentUsage.swift", "Sources/TokenBarCore/UsagePace.swift", "Sources/TokenBar/TrayAnimator.swift", "Sources/TokenBar/DashboardModel.swift", "docs/knowledge/plans/codex-historical-pace-v2.md", "docs/knowledge/architecture.md", "docs/knowledge/verification.md", "official GitHub Copilot billing documentation", "official Claude usage credits documentation"] +--- + +# Provider-wide quota pace plan + +## 文件目的 + +這份計畫把 pace 的修正單位從「Codex Weekly 特例」改成「每一張 provider recurring quota card」。Codex、Claude、Grok、Antigravity 與 Copilot 的額度 window 都必須先取得可信的 account scope、stable window key、reset 與 duration,才能進入同一套 Linear/Historical 計算;缺少 duration 或歷史時必須顯示可理解的學習狀態,不得永久或無聲地退回 Linear。 + +[`codex-historical-pace-v2.md`](codex-historical-pace-v2.md) 保留為已落地的 Codex Weekly evaluator 基礎與 migration 證據,但不再代表 provider-wide outcome。這份新計畫取代它作為後續實作與驗收的 active design。 + +> **核心結果:** pace duration 屬於 quota window,不屬於 provider 特例。任何已顯示、具有 recurring percentage quota 語意的 card,都必須走同一個 duration lifecycle;無法證明 duration 時,UI 必須明示原因,而不是顯示看似真實的 pace。 +> +> **Implementation checkpoint(2026-07-17):** Mac Stages 0–6 已在任務分支落地:secure account scope、duration lifecycle、generic v3 history、五個 provider adapters、typed Swift lifecycle/selection/presentation,以及 Rust serializer 鎖定的跨語言 fixture 均已通過各自的 hermetic gate。Stage 7 首次 live smoke 揭露 legacy file-Keychain ACL 在 ad-hoc rebuild 下仍會顯示授權 UI;使用者因此核准把尚未出貨的 installation key改為 hardened owner-only file。0600 storage security regressions、完整 Rust workspace tests/Clippy、Rust→Swift build、Swift selftest、docs gates與storage修正的fresh verifier均已通過;full workspace `cargo fmt --all -- --check`仍只命中既有out-of-scope `hourly_report.rs`、`model_report.rs`與vendor formatting。重新授權的monitored live smoke已exit 0,過程未出現`SecurityAgent`/`authorizationhost`,live storage metadata確認directory `0700`、file `0600`且exact 32 bytes;print-only hourly/agents drift probes顯示小幅mismatch;smoke contract不因此fail,且該diagnostic不涉及account-scope storage,但根因未在本scope內判定。人工 popover UX 已使用明示 `FIXTURE` 的 deterministic `--demo` payload 完成:Historical mode 同時呈現 `Learning reset duration`、灰色 `Learning history · Linear estimate`、只有 backend `available` historical deficit 帶橘色 marker/文案與 risk,以及 typed `unavailable(missingReset)`;Linear mode 移除 historical risk 與橘色 deficit 語意,Off mode 移除全部 pace marker/footer。Quota 長條本身的綠/黃/紅健康門檻維持獨立,不能誤當 historical deficit 色。各 capture process 皆在截圖後刻意終止,並且沒有 `SecurityAgent`/`authorizationhost`。最終 post-GUI fresh verifier 已回傳 `CONFIRMED`;Windows port/parity維持 pending,本 checkpoint 沒有寫入 Windows repository。 + +## 目錄 + +- [目標與非目標](#目標與非目標) +- [目前缺口](#目前缺口) +- [Card eligibility contract](#card-eligibility-contract) +- [Account and window identity](#account-and-window-identity) +- [Duration contract](#duration-contract) +- [Pace state and wire contract](#pace-state-and-wire-contract) +- [Generic historical evaluator](#generic-historical-evaluator) +- [Storage and migration](#storage-and-migration) +- [Provider adapter matrix](#provider-adapter-matrix) +- [執行階段](#執行階段) +- [驗收條件](#驗收條件) +- [交付與驗證](#交付與驗證) +- [風險、相依與停止條件](#風險相依與停止條件) +- [授權邊界](#授權邊界) + +--- + +## 目標與非目標 + +### Objective + +完成後,Mac app 的 Historical 設定會套用到所有 eligible provider quota cards,而不是只在 Codex Weekly 有 backend result。每張 card 的 pace 由同一組 Rust-owned inputs 驅動:`providerId + accountScope + windowKey + reset + duration + usedPercent`。Swift 只負責 decode、模式選擇與呈現,不得自行猜 duration 或重算 Historical ETA/will-last/risk。 + +### Scope + +| 類別 | 本計畫包含 | 本計畫不包含 | +|---|---|---| +| Providers | Codex、Claude、Grok、Antigravity、Copilot 的所有 recurring percentage windows | 新增 provider 或重新設計 provider authentication | +| Duration | Provider-reported、frozen contract、observed rollover 三種可信來源 | 從 display label、剩餘百分比或模糊 calendar 假設 duration | +| History | Generic cycle-normalized store、sampling、confidence、evaluation 與 Codex v2 import | 從 local token/cost history 回填 subscription quota | +| Account scope | Authoritative ID 優先、安全 credential-lineage fallback、切換帳號隔離 | 把 raw email、access token 或 token hash寫入 history | +| UX | 明確 learning/available/unavailable 狀態、所有 eligible cards 的 pace 文案與顏色 | History management UI 或手動編輯 history | +| Cross-language | Rust JSON、C contract comment、Swift decoder/presentation、Windows handoff fixture | 未經另行授權修改 TokenBar-Windows | +| Integration | 可審查的 Mac 實作與完整本機驗證計畫 | Push、PR、merge、tag、appcast 或 Homebrew release | + +`OpenCode` 只提供 Copilot authentication,不是獨立 quota provider。Antigravity local IDE與 remote OAuth都是同一 provider,但 current auth evidence不能安全證明兩條 route屬於同一 account;因此兩邊都支援 pace,卻保持 account-scope隔離,直到 authenticated provider ID能證明同一 owner。安全 fragmentation優先於跨帳號污染。 + +## 目前缺口 + +現在的 nested `historicalPace` 只有 Codex refresh 會填入,而且 enrichment 只選 `Weekly`。其他 provider mapper 都把 `historicalPace` 初始化成 `nil`;Swift Historical mode 遇到 `nil` 便直接使用 Linear,因此 UI 看似支援 Historical,實際上 Claude、Grok、Antigravity 與 Copilot 永遠不會學到個人曲線。 + +| Surface | 現況 | 必須修正成 | +|---|---|---| +| Duration | Codex/Claude/Grok 部分 window 有 `windowMinutes`;Antigravity/Copilot 沒有 | 每張 eligible card 有可信 duration 或明確 `learningDuration` | +| Identity | Codex 有 account ID/email;其他 provider 不一致 | 每個 sample 都有不跨帳號混用的 opaque account scope | +| Window key | 多數 mapper 只有易變的 display label | Provider adapter 提供 stable semantic key,label 只供 UI | +| History | Store 與 cadence 固定為 Codex Weekly | 依 cycle duration 正規化、保留與判斷 confidence | +| Wire state | `historicalPace == nil` 同時表示 learning、unsupported、legacy 與 error | Required `paceStatus` 區分每個狀態 | +| Presentation | Historical 缺資料時 silent Linear fallback | 只有 `learningHistory` 可暫用 Linear,且文案必須明示 | + +## Card eligibility contract + +Rust provider adapter 必須先把每個 emitted window 分類,分類結果是 wire fixture 的一部分,不得由 Swift 依 label 猜測。 + +| Classification | Definition | Required behavior | +|---|---|---| +| `recurringQuota` | 有 bounded `0...100` utilization、下一次 reset,且 reset 後 quota 重新開始 | 必須進入 duration、sampling 與 Historical lifecycle | +| `recurringQuotaMissingReset` | 百分比看似 recurring,但 provider payload 沒有 reset | 顯示 `unavailable(missingReset)`;不能假設月初 | +| `nonRecurringCap` | Spend/credit cap 沒有可證明的 recurring reset | 不計算 pace,顯示 cap 語意;不得偽裝成 quota pace | +| `invalid` | 非 finite、越界、expired reset 或 contradictory bounds | 不 record;保留 last good card,或依既有 provider error contract 顯示錯誤 | + +Claude `extra_usage` 目前只有 monthly cap 與 utilization,沒有 reset timestamp。官方說明確認它是 [monthly spending cap](https://support.claude.com/en/articles/12429409-manage-usage-credits-for-paid-claude-plans),但沒有承諾 calendar boundary;因此本版本把它鎖定為 `recurringQuotaMissingReset`,不以「Monthly」文字推導 duration。這是唯一允許不顯示 pace 的正常 emitted percentage card,而且原因必須可見、可測,不得被歸類為「其他 provider 尚未支援」。若未來 payload 增加 reset,adapter 依 schema version 升級為 `recurringQuota`。 + +## Account and window identity + +History series 使用 `SeriesKey { providerId, accountScope, windowKey }`。Duration 不進入 key:每個 cycle 保存自己的 duration,讓 28/29/30/31 天的同一 monthly quota 能在 phase-normalized curve 中比較。若 provider 真正改變 quota 語意,adapter 必須 bump `windowKey` version,而不是靠 duration 偶然切開 history。 + +### Account scope resolver + +Security review 與 2026-07-17 live prompt 後的修訂已把 Mac protocol 鎖定如下。Implementation 不得自行換成 token hash、path/slot-only identity、account-scope Keychain或額外 provider endpoint;這個 trust-boundary stage只能交給 `security-executor`。 + +| Priority | Evidence | `accountScope` | Failure behavior | +|---|---|---|---| +| 1 | 從同一 authenticated response或實際發出 request 的 credential chain取得的 immutable ID/email | Domain-separated HMAC of provider、kind與 normalized identifier | Evidence 改變即建立新 series;不以另一份 unrelated local state補標 | +| 2 | Credential marker lineage | HMAC of provider與 random 128-bit lineage ID | Unseen external replacement建立新 lineage;已知 app refresh明確 transfer | +| 3 | No trusted evidence | None | `unavailable(accountScope)`;不得讀寫 provider history | + +Antigravity local IDE 的 email 來自 authenticated `GetUserStatus`,可走 authoritative route。Remote OAuth quota 使用 Google credential,但目前 email 來自另一份 `google_accounts.active` state,兩者未綁定;remote 必須走 credential lineage,且該 local email不得再用來標示或 scope remote quota。Local/remote history 只有在未來同一 authenticated response證明相同 provider ID,或有明確 trusted binding 時才能 merge;目前安全地分開學習。 + +| Current provider route | Frozen account evidence | +|---|---| +| Codex | 成功 usage response 前實際送出的 `ChatGPT-Account-Id`,缺少時使用 lineage;ID-token email 只供 presentation | +| Claude | Current payload沒有 bound owner ID;所有 login/setup-token paths使用 lineage | +| Grok | Current billing response沒有 owner ID;`auth.x.ai` entry的 email只供 presentation,history使用 lineage | +| Antigravity local IDE | Authenticated `GetUserStatus` email;缺席時 fail closed | +| Antigravity remote OAuth | Google credential lineage;忽略 unbound active-email state | +| Copilot | OpenCode GitHub credential lineage;本 Plan不新增 `/user` request | + +#### Installation key and HMAC + +| Item | Frozen behavior | +|---|---| +| Key generation | `SecRandomCopyBytes` 產生 32 bytes;concurrent first creation在 account-scope process mutex與 `fs2` file lock內完成,所有 caller重新讀取並驗證同一 persisted winner | +| Key storage | Application Support 的 `quota-account-scope-installation-key-v1.bin`;exact 32-byte binary、directory mode `0700`、file mode `0600`、write/`sync_all`/atomic replace後 sync parent directory | +| API boundary | Account-scope不使用 Keychain、`security` CLI或process argv;既有開發用 `com.nyanako.tokenbar.account-scope.v1` item只忽略,不讀取、刪除、更新或遷移 | +| HMAC | HMAC-SHA256,完整 32-byte output以 unpadded base64url保存;不得截短到 128 bits以下 | +| Authoritative scope | `HMAC(K, encode("scope-id-v1", provider, kind, normalizedIdentifier))` | +| Credential fingerprint | `HMAC(K, encode("credential-v1", provider, rawCredentialMarker))` | +| Slot digest | `HMAC(K, encode("slot-v1", provider, semanticSource, canonicalLocation))` | +| Lineage scope | 先用 `SecRandomCopyBytes` 產生 128-bit random lineage ID,再算 `HMAC(K, encode("scope-lineage-v1", provider, lineageId))` | +| Metadata MAC key | `HMAC(K, encode("metadata-key-v1"))`;只用於 authenticated metadata envelope | + +`encode` 對每個 byte field 依序寫入 unsigned 32-bit big-endian length,再寫 exact bytes;domain 也是第一個 length-prefixed field。Text fields 先轉 UTF-8;credential marker 與 random lineage 保留 raw bytes。這個 encoding 套用到所有 HMAC 與 known vectors,不使用分隔字串或無長度 concatenation。 + +Raw identifier 與 credential marker只在記憶體短暫存在。Email normalization是 trim加 ASCII lowercase;opaque provider ID只 trim,保留 byte case。History只保存最後的 `accountScope`;單獨取得metadata/history而未取得installation-key file時,不能對low-entropy email做離線猜測。`0600`/`0700`邊界保護其他本機使用者;已能以相同UID任意讀取Application Support的惡意程式不在此權限模型內。 + +#### Credential markers and secure metadata + +Credential marker 固定依下表選擇;同 provider不得由 worker另選較方便但會旋轉的欄位。 + +| Provider/route | Marker | +|---|---| +| Codex | Refresh token,缺少時 access token | +| Claude full login | Refresh token | +| Claude env/shell/raw setup-token | Access/setup token | +| Grok | Exact `auth.x.ai` entry的 refresh token | +| Antigravity remote | Google refresh token;若未來 provider回傳 replacement,走 refresh transfer | +| Antigravity local IDE | Authenticated provider ID/email;兩者皆無就 fail closed | +| Copilot | OpenCode `github-copilot.refresh`,缺少時 `access` | + +Application Support 的 `quota-account-scope-v1.json` 使用 authenticated envelope: + +```text +schemaVersion = 1 +payloadBytesBase64 +payloadMac + +payload.bindings[] = { + provider, + slotDigest, + credentialFingerprint, + randomLineageId +} +payload.currentFingerprintBySlot +``` + +Metadata 不保存 raw token、email、account ID、path、display label或 plain SHA-256。Fingerprint binding immutable;相同 provider credential出現在另一個 source可重用 lineage;同 slot出現未知 fingerprint會建立新 lineage。Any conflicting existing binding fails closed,不自動 merge。 + +Installation-key 的 read/first-create/key-loss recovery與metadata的 load → MAC verify → mutate → atomic save共用 account-scope process mutex與 `fs2` exclusive lock;temp file mode `0600`,write/`sync_all`/atomic replace後 sync parent directory。每次scope resolution都從persisted key file重讀,不使用process cache。不得同時持有account-scope lock與v3 history lock;network不得在account-scope/v3 lock內執行。Provider refresh lock是唯一可跨network request持有的file lock。順序固定為:先完成installation-key transaction並釋放account-scope lock;refresh若需要則依下列refresh lock → metadata lock順序完成lineage transfer;最後才取得v3 lock寫history。 + +#### Refresh transaction and recovery + +App-controlled refresh 的 cross-process lock 固定為 Application Support 的 `quota-auth-refresh-.lock`,以 owner-only mode 開啟。Lock ordering 只能是 refresh lock → metadata lock;不得在持有 metadata/v3 lock 時反向取得 refresh lock。流程固定依序: + +1. 先在account-scope lock內讀取或recover installation-key file並釋放該lock,再取得provider refresh process/file lock;key error保留為typed unavailable,但不得阻止既有credential refresh本身。 +2. 取得 refresh lock 後重新載入 exact current auth record並計算 `F_old`。 +3. 持有 refresh lock 執行既有 refresh request,從回傳 credential 計算 `F_new`;此時不持有 metadata/v3 lock。 +4. 在 metadata transaction 內確認 `F_old` lineage 無 conflict,並把 `F_old`、`F_new` 綁到同一 lineage;persist 後立即釋放 metadata lock。 +5. 仍持有 refresh lock 時 persist provider credentials,完成後釋放 refresh lock。 +6. Quota fetch 成功後,才以已持久化 scope 寫 v3 history。 + +Crash before metadata save不會以 new credential寫 history;crash between metadata and credential save時 old/new fingerprints都能回到同 lineage;credential save後 crash則下一次仍可 resolve。Metadata write失敗可以讓 auth refresh繼續,但本 poll pace必須是 `unavailable(accountScope)`,不得寫 history。 + +| Failure/restore | Required result | +|---|---| +| Existing key無法讀取、不是real regular file、mode不是exact `0600`、inode在驗證期間被替換,或長度不是exact 32 bytes | Typed unavailable;不得replace key、quarantine metadata或讀寫history | +| Key file不存在,且沒有metadata/v3 artifacts | 在account-scope lock內建立owner-only key並atomic replace;重新讀取persisted winner後可在同一poll建立metadata | +| Key file不存在,但canonical metadata、既有`.orphaned-*` metadata evidence或v3已存在 | 若canonical metadata存在,先byte-preserving rename到unique `.orphaned-[.].json`;既有orphaned evidence只作保守存在性判定,不讀取、覆寫或刪除;成功後才建立key;v3原位保留為orphaned scopes;建立並重讀winner後,first poll回`unavailable(accountScope)`,下一poll建立fresh metadata | +| Metadata syntax/schema/MAC invalid | 使用existing valid key先byte-preserving rename到unique `.corrupt-[.].json`;該poll unavailable;下一poll建立fresh metadata | +| Any quarantine failure | 不建立或replace key,不建立/overwrite metadata,不讀寫v3;保留原始或已rollback的證據 | +| Key atomic-write failure | Replace前不得留下partial key或temp;replace後的parent-directory sync failure可留下exact `0600` winner,但本poll仍typed unavailable,下一次resolution重新讀取並驗證該winner;不得讀寫v3 | +| Account-scope lock/metadata save failure或binding conflict | Typed unavailable;保留最後有效metadata與history | +| Normal app reinstall | Application Support仍在時恢復同installation key、metadata與scopes;若Application Support被移除則視為新installation | +| Consistent full restore | Installation key、metadata、v3三者一致才恢復 | +| Explicit full purge | 必須一起刪除installation-key file、metadata、v3與legacy v2;本Plan不新增purge UI,也不宣稱APFS secure erase | + +Retained v2 仍含 legacy raw Codex account key,因此分類為 legacy-sensitive rollback data;「沒有 raw identifier」只適用新 metadata與 v3。這份 Plan保留 v2 bytes/mtime/path,不隱瞞或假稱已清除;v2 retirement必須是 rollback window結束後的獨立明確決策。 + +Security fixtures必須覆蓋HMAC known vectors/domain separation、different-installation unlinkability、各credential source、refresh每一步crash injection、same-slot replacement、two-process create/transfer conflict、key exact-length/mode/symlink/non-regular/inode-replacement防護、key-loss orphan recovery、MAC/lock/atomic-write/quarantine failures、Antigravity stale active-email mismatch,以及metadata/v3 byte scan不含fixture raw values或其plain SHA-256。 + +現行release是ad-hoc signed,因此restrictive Keychain ACL沒有跨rebuild/update的stable code identity。未來只有在release chain採用穩定Developer ID signing後,才可另立migration plan評估升級回Keychain;migration必須匯入並驗證與現有file完全相同的32 bytes,成功前file維持source of truth,絕不能生成新key造成`accountScope`與history斷代。舊開發用Keychain item不屬於migration input。 + +### Stable window keys + +`windowKey` 來自 provider schema 的 field、period type、quota class 或 model ID。Display label 可以 localized 或改名,不得成為 history identity。Antigravity mapper 必須保留 model ID;若同一 model 有多個 bucket,先依現有 binding-limit 規則選出 card,再用 model ID 建立唯一 series。 + +## Duration contract + +每個 eligible card 都走相同 resolver,但 resolver 只接受三種有證據的來源,優先序固定為 `provider` → `contract` → `observed`。前一種 route 缺少必要欄位時,只要仍有可信 future reset 就可進入下一種;沒有 reset 則直接 `unavailable(missingReset)`。 + +| `durationSource` | Source | Examples | Trust rule | +|---|---|---|---| +| `provider` | 同一 payload 的 cycle start/end 或 explicit duration | Codex `limit_window_seconds`;Grok period start/end | Bounds finite、end later than start、current reset matches end | +| `contract` | Provider schema field 或已驗證 calendar rule 的 frozen semantic duration | Claude 5h/7d schema aliases;Copilot first-of-month reset | 用 schema key,不用 display label;fixture 鎖定 aliases 與 calendar edge | +| `observed` | TokenBar 實際看到相鄰 reset rollover | Antigravity model;Grok缺少 period start 的 fallback;非標準 Copilot reset | 只有通過下列 rollover state machine 才可使用 | + +### Observed rollover state machine + +```mermaid +stateDiagram-v2 + [*] --> Watching: first stable reset R0 + Watching --> NearBoundary: R0 - now <= 15m + Watching --> Watching: duplicate R0 + Watching --> Watching: reset slides or moves backward / replace baseline + NearBoundary --> Candidate: first R1 > R0 after R0 + NearBoundary --> Watching: boundary missed by more than 15m + Candidate --> Ready: next poll repeats R1 + Candidate --> Watching: R1 changes, reverses, or is implausible + Ready --> NearBoundary: current R1 approaches + Ready --> Watching: later boundary is missed / keep last completed cycles only +``` + +Accepted observed duration is exactly `R1 - R0`, where the app saw the stable old reset within 15 minutes before expiry, saw the new reset within 15 minutes after expiry, and confirmed that new reset on the next poll. The existing 5-minute background poll and 1-minute visible-window poll make this reachable; sleep or app downtime may miss the boundary, in which case the card remains `learningDuration` for the new cycle. + +| Edge case | Required transition | +|---|---| +| Duplicate reset | No-op; never manufactures a boundary | +| Sliding reset | Invalidate candidate and watch the newest stable reset | +| Backward reset | Invalidate current duration; preserve completed history but do not sample current cycle | +| Missed one or more cycles | Do not divide reset delta or guess cycle count; restart watching | +| Monthly 28–31 days | Accept each exact adjacent delta as that cycle's duration; never replace it with a 30-day average | +| Boundary seen but new reset not stable | Stay learning; no current-cycle samples | + +Raw readings collected before duration becomes ready are not retroactively turned into curve samples. Once `R1 - R0` is accepted, `R0` is the exact start of the current cycle and sampling begins from that boundary. + +### Durable rollover state + +Observed state is not a second file. It lives inside the same v3 `SeriesState` as samples and is mutated under the same process mutex、inter-process lock與 atomic save。The persisted union is: + +| State | Required fields | Restart behavior | +|---|---|---| +| `watching` | `resetAt`、`firstSeenAt`、`lastSeenAt`、`consecutiveCount` | Continue counting the same normalized reset;two consecutive polls make it stable | +| `candidate` | `oldResetAt`、`oldSeenAt`、`newResetAt`、`firstNewSeenAt` | Same `newResetAt` on the next poll becomes ready;any other reset replaces baseline | +| `ready` | `cycleStartedAt`、`resetAt`、`durationSeconds`、`confirmedAt`、`lastSeenAt` | Current cycle may sample immediately;later near-boundary observation can create the next candidate | + +`SeriesState` is keyed by the full `providerId + accountScope + windowKey` and also persists optional `activeResetAt` plus `lastActivityAt`。`activeResetAt` is updated only from a valid provider/contract card reset,or from `ready.resetAt` after observed confirmation。`lastActivityAt` is the maximum accepted sample timestamp or rollover-observation timestamp;it never advances merely because the store was loaded。All timestamps are Unix seconds;durations must be positive and at most 400 days;`consecutiveCount` is capped at 2。`nearBoundary` is derived from `lastSeenAt` and is not separately serialized。A duplicate observation only advances `lastSeenAt`/stability;a missing card does not mutate state。 + +When a candidate confirms, state transition and the first duration-ready sample are one v3 transaction。A crash before rename leaves the old candidate;a crash after rename leaves ready state plus the sample。Corrupt v3 follows the store quarantine rule and restarts observed learning;a new account scope naturally gets separate state,while old scope state remains isolated until retention removes it。 + +Copilot has one immediate contract route:when `quota_reset_date` is exactly UTC midnight on day 1,duration is the difference between that reset and the previous UTC calendar-month boundary。This matches the provider's documented [first-of-month reset](https://docs.github.com/en/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests) and naturally produces 28/29/30/31-day cycles。Any non-first-of-month reset must use the observed state machine;it must not be rounded to 30 days。 + +## Pace state and wire contract + +`UsageWindow` 新增 required `cardId` 與 required nested `paceStatus`。`cardId` 只負責 provider 內的 presentation row identity;`windowKey` 才能識別可學習的 history series。Positive `durationSeconds` 是新 pace 唯一精確 duration;`windowMinutes` 保留 legacy compatibility,由 `durationSeconds / 60` 整數除法導出,Swift v3 calculation 不得再讀它。現有 `historicalPace` 仍是 Rust evaluator 的 coherent result。Swift decoder 對舊 payload 缺少 `paceStatus` 的情況建立 internal `legacyMissing`,不得把它與 learning 混為一談。 + +| `paceStatus.state` | `durationSeconds` | `historicalPace` | Historical mode presentation | +|---|---:|---:|---| +| `learningDuration` | `nil` | `nil` | `Learning reset duration`;不顯示假 pace 或 ETA | +| `learningHistory` | required positive | `nil` | 明示 `Learning history · Linear estimate`,暫用 Linear | +| `available` | required positive | required | 使用 Rust historical expected/ETA/will-last/risk | +| `unavailable` | optional | `nil` | 顯示 typed reason,例如 `missingReset`、`nonRecurring`、`accountScope`、`storeCapacity` | + +`paceStatus` 至少包含 `state`、optional `windowKey`、`durationSeconds`、`durationSource`、`completeCycles` 與 optional `reason`。`windowKey` 只有 `unavailable(windowIdentity)` 可為 `null`,其他 state 必須 non-empty。`durationSource` 在 `learningDuration` 可為 `observed`,在 `unavailable` 可缺席。Rust serialization tests 必須鎖定下列不變量: + +```text +available <=> durationSeconds > 0 && historicalPace != nil +learningHistory => durationSeconds > 0 && historicalPace == nil +learningDuration => durationSeconds == nil && historicalPace == nil +unavailable => historicalPace == nil +windowKey == nil <=> unavailable(windowIdentity) +``` + +`cardId` 必須在同一 provider snapshot 內唯一且不讀 display label。Identified cards 使用 `cardId = windowKey`;無 semantic history key 時,adapter 使用 structural presentation ID:Codex main slots 為 `row.main..v1`,anonymous additional limit 為 `row.additional.unknown..v1`(同 slot 只 emit provider-order 第一筆),unknown Grok period 為 `row.billing.unknown.v1`,Antigravity CLI missing-model rows 為 `row.cli.config..v1`。Swift row key 固定為 `providerId + ":" + cardId`;同 snapshot 若仍 collision,後一筆 fail closed 不 render,不得 suffix display label。Localization 或 duplicate labels 不再影響 row identity。 + +Linear setting 也使用同一個 exact `durationSeconds`。Historical setting 只有在 `learningHistory` 可以暫時使用 Linear,而且 UI 必須明示;`learningDuration`、`unavailable`、`legacyMissing` 都不能 silent fallback。Settings 文案要從「weekly curve」改成「learns each quota window's usage pattern」。 + +黃色 ahead/deficit 狀態只有在 `available` 時能宣稱是 historical comparison;`learningHistory` 的 Linear estimate 必須以不同文案標示,避免把測試用 reverse 或硬編文字誤認為真實 historical result。 + +## Generic historical evaluator + +Codex v2 的 coherent Rust result、current-actual shift、capped-demand extension 與 expected/ETA/will-last/risk ownership 保留,但 sampling、coverage、retention 與 recency 改為 cycle-aware。 + +### Sample and cycle model + +每筆 sample 保存 `sampledAt`、`resetAt`、`durationSeconds`、`durationSource` 與 bounded `usedPercent`。對該 cycle 的 phase 定義為: + +```text +u = clamp(1 - (resetAt - sampledAt) / durationSeconds, 0, 1) +``` + +| Rule | Frozen behavior | +|---|---| +| Reset normalization | Quantum `q = clamp(duration / 100, 60s, 300s)`;同一 provider reset 的小 jitter 收斂,但 observed rollover 的原始 boundary 另行保留 | +| Phase buckets | 每 cycle 48 格;`phaseBucket = min(floor(u * 48), 47)`;reset 改變、進入新 phase bucket,或 usage 改變至少 1 percentage point 才接受 | +| Sample cap | 每 cycle 最多 48 筆;同 series/normalized reset/phase bucket dedupe | +| Valid sample | Finite、`0 < usedPercent <= 100`、positive duration、sample 位於 cycle bounds;zero reading 可供當下 Linear 顯示但不持久化 | +| Complete cycle | 至少 6 個 distinct phase buckets,起點/終點 coverage 成立,且最大 phase gap 不超過 `0.30` | + +Coverage boundary 使用 `b = min(0.10, 24h / duration)`;complete cycle 必須滿足 `uMin <= b` 與 `uMax >= 1 - b`。最大 gap 在排序後的 `[0, distinct observed phases..., 1]` 上計算。這讓 5h、7d 與 monthly window 都用相同比例規則,又不要求 monthly app 在 reset 後數分鐘內一定在線。 + +### Retention and confidence + +`nominalDuration` 是同 series 已完成 cycles 的 duration median,只用於 retention/recency,不覆蓋 current cycle 的 exact duration,也不進入 series key。奇數筆取中間值;偶數筆取兩個中間值的 arithmetic mean;尚無 complete cycle 時使用 current accepted exact duration(observed route 即 `ready.durationSeconds`)。 + +| Decision | Formula | +|---|---| +| Retained completed cycles | `R = clamp(max(8, ceil(28d / nominalDuration)), 8, 128)`;time horizon `H = clamp(max(56d, R * nominalDuration), 56d, 400d)` | +| Per-series sample cap | 最多 `R` 個 completed cycles,加一個 current incomplete cycle;每 cycle 最多 48 samples | +| Recency basis | `ageSeconds = max(0, currentResetAt - historicalResetAt)`;`ageCycles = ageSeconds / nominalDuration`;`tauCycles = clamp(max(3, 7d / nominalDuration), 3, 64)`;`weight = exp(-ageCycles / tauCycles)` | +| Effective samples | `nEff = sum(weight)^2 / sum(weight^2)` | +| Observation span | `latest historical resetAt - earliest historical cycleStartedAt`;只計 complete historical cycles,不包含 current partial cycle | +| Historical expected gate | 至少 3 complete cycles、`nEff >= 2.5`、observation span `>= max(2 * nominalDuration, 24h)` | +| Historical risk gate | 至少 5 complete cycles、`nEff >= 4`、observation span `>= max(4 * nominalDuration, 7d)` | + +Retention 在每次 locked v3 transaction 完成 duration transition/record 後、atomic save 前執行,並以該 transaction 的 `now` 決定所有 boundary。每個 series 先依 normalized reset分組,規則固定為: + +| Group or state | Deterministic retention | +|---|---| +| Completed cycle | 同時滿足「依 `resetAt` 最新的 `R` 個」以及 `resetAt >= now - H` 才保留;其餘整 cycle刪除 | +| Current incomplete cycle | 只保留一組:其 reset必須等於 persisted `activeResetAt`;observed route 還必須等於 `ready.resetAt`。最多 48 samples | +| Other incomplete groups | 立即整組刪除,包括 expired、superseded、future fragment與 repeated partial-reset churn | +| `watching`/`candidate`/`ready` | Tracked old reset超過 boundary 15 minutes仍未完成合法 adjacent transition即清除;candidate也必須在 `oldResetAt + 15m` 前由 next poll確認。下一個 reading從 fresh `watching` 開始 | +| Rollover-only series | 沒有 samples/completed cycles且 `lastActivityAt < now - 56d` 時刪除;空 series立即刪除 | + +Persisted `activeResetAt` 由最近一次 provider/contract route驗證成功的 normalized reset更新;observed route只有 ready後才可設定,因此 learning-duration readings不會留下 incomplete sample group。它在其他 provider transaction中仍能識別該 series的唯一 current group,但一旦早於 `now - 15m` 就先清除,舊 partial也不再受 current-cycle保護。Cleanup 不把 expired partial cycle升格為 completed cycle,也不從 reset delta猜漏掉幾個 cycles。 + +Store 另有兩個全域 hard bounds:最多 512 個 series、65,536 個 samples。Pruning 先套用 invalid/incomplete/stale state與 per-series規則;sample仍超額時,按 `(resetAt, providerId, accountScope, windowKey)` 升冪整批刪除全域最舊 completed cycles,直到回到上限。Current incomplete cycle不是這個全域 sample eviction的候選。 + +新增 series將超過 512 時,先移除 inactive series;排序固定為 `(lastActivityAt, providerId, accountScope, windowKey)` 升冪。Active 定義為本次 provider snapshot有 emit,或仍持有 future/15-minute grace內的唯一 current incomplete reset或 rollover reset;目前 active series不得被 eviction。若同一 transaction 的 active candidates仍超過剩餘容量,既有 active series優先,new candidates依完整 `SeriesKey`升冪依序 admission;其餘 cards回傳 `unavailable(storeCapacity)`,且不得 mutate v3。若只剩 current incomplete samples仍無法降到 65,536,也拒絕本次 offending sample並保留 transaction 前的最後有效 store;不得為了寫入而 eviction current active data。 + +Retention fixtures 必須覆蓋 repeated partial-reset churn、sliding/backward reset、stale rollover-only state、abandoned account scopes、28–31-day horizon、short-cycle `R = 128`、global sample eviction tie ordering、active-series protection與 hard-cap overflow。 + +每個 complete cycle 先依自己的 duration 映射到 `u`,再重建為 169-point monotonic curve。Monthly cycle 長度改變不會造成 series fragmentation;evaluator 仍以 current exact duration 把 phase crossing 轉回 ETA seconds。3–4 個可信 cycles 可以產生 expected/ETA,risk 仍為 `nil`;達 risk gate 後才公開 probability。 + +Expected 與 risk arithmetic 保留 Codex v2 行為,但把 week weights 換成上表的 cycle-aware weights。令 `u_i = i / 168`,`i = 0...168`,並固定: + +```text +lambda = clamp((nEff - 2) / 6, 0, 1) +historical_i = recencyWeightedMedian(completedCycleCurve_i, weight) +linear_i = 100 * u_i +expected_i = clamp(lambda * historical_i + (1 - lambda) * linear_i, 0, 100) +``` + +169 個 `expected_i` 算完後再由左到右做 cumulative maximum,不能把 Linear baseline 當成上限。若 `totalWeight` 非 finite 或 `<= 0`,不產生 historical result,card 保持 `learningHistory`。 + +Risk/ETA 對每條 completed-cycle curve 依序套用以下 frozen order:若 curve 在最後一格前第一次到達 100%,從 cap point 起以 `slope = valueAtCap / uAtCap` 延伸未截斷 demand;在 current phase 算 `shift = actual - curve(uNow)`,且 shifted curve 在 crossing search 前不得 clamp。`shiftedEnd >= 100` 的 cycle 把自己的 weight 加到 `weightedRunOutMass`,並以線性 interpolation 找出第一個 `>= 100` 的 crossing candidate。接著固定: + +```text +smoothed = clamp((weightedRunOutMass + 0.5) / (totalWeight + 1), 0, 1) +willLastToReset = smoothed < 0.5 +``` + +只有通過上表 exact Historical risk gate 時,`runOutProbability = Some(smoothed)`;通過 expected gate但未通過 risk gate時為 `nil`。一旦已有 historical result且 current actual `>= 100`,一律覆蓋為 `runOutProbability = Some(1)`、`willLastToReset = false`、`etaSeconds = Some(0)`。其他情況若 `willLastToReset == false` 卻沒有 crossing candidate,必須改回 `true`,不能輸出 `false + nil`。ETA 是各 candidate 的 `(crossingU - uNow) * currentDurationSeconds`,clamp 到 non-negative 後用相同 weighted-median tie rule彙總。 + +Weighted median 沿用 v2 的 deterministic tie rule:依 value 升冪排序,累積 weight 第一次 `>= totalWeight / 2` 就取該值,因此 exact half 選 lower value;total weight 為零時同樣排序並取 index `len / 2`。Expected grid 與 ETA candidates 都使用此規則。 + +## Storage and migration + +Generic store 使用 `quota-pace-history-v3.json`,schema version 固定為 `3`。Top level 是排序後的 `series[]`;每個 `SeriesState` 保存 `providerId`、opaque `accountScope`、`windowKey`、optional `activeResetAt`、`lastActivityAt`、optional rollover state與 `samples[]`。Sample 固定包含 `resetAt`、`durationSeconds`、`durationSource`、`usedPercent`、`sampledAt` 與 `origin`(`liveV3` 或 `importedV2`)。v1 永不讀取;既有 `codex-weekly-history-v2.json` 是唯一 migration input,且 bytes、mtime 與 pathname 必須保持不變。 + +| Concern | Required behavior | +|---|---| +| Serialization | Account scope先在獨立 metadata transaction resolve並釋放其 lock;接著 process mutex加 `fs2::FileExt::lock_exclusive` 鎖住 app-data `quota-pace-v3.lock`,包住 load → import → duration transition → record → atomic save → evaluate;所有 error path 都釋放 lock | +| Atomic save | 重用現有 same-directory unique temp、flush/sync與 `tokscale_core::fs_atomic::replace_file`;失敗保留最後一份有效 v3;Unix file/lock mode 限制為 owner-only | +| v2 import eligibility | 只在 successful Codex usage fetch 內執行;只接受 schema `2`、`windowMinutes == 10_080`、valid reset/sample bounds/`0 < usedPercent <= 100`,且 raw `accountKey` matches the accepted request account ID;其他 record skip,不修改 v2 | +| Current-account match | 只有本次成功 request 實際送出的 `ChatGPT-Account-Id` 可做 trimmed byte-exact match;ID-token email 不作 import binding;不得從 legacy string shape 猜 ID/email,也不得批次匯入未登入帳號 | +| v2 conversion | `providerId = codex`、current resolved opaque `accountScope`、`windowKey = main.weekly.v1`、`durationSeconds = windowMinutes * 60`、`activeResetAt = null`、`lastActivityAt = max(imported sampledAt)`;reset保留 v2既有 nearest-300s normalization | +| Canonical sample key | `(providerId, accountScope, windowKey, normalizedResetAt, phaseBucket)`,其中 phase bucket 使用 v3 的 48-bucket formula | +| Collision precedence | 同 key 時 `liveV3` 永遠勝過 `importedV2`;同 origin 取較晚 `sampledAt`;timestamp也相同時取較高 finite `usedPercent`;最後以完整 serialized tuple升冪作 deterministic tie-break | +| Idempotency | 每次啟動可重新 merge v2;canonical key dedupe 是 correctness rule,整檔 SHA-256 digest 只作 skip optimization,不作「已完成」marker | +| Existing v3 | Merge 新的合法 v2 samples,不清空其他 provider/account series | +| Corrupt v2 | Leave untouched、skip import、保留既有 v3;不得寫「已完成」marker | +| Corrupt v3 | 依 v2 既有 quarantine contract 保留原始 bytes;quarantine 成功後才能重建,失敗則本次完全不寫 | +| Interrupted migration | 正式 v3 只能是 migration 前或完整 migration 後版本,不得出現 partial JSON | +| Concurrent first run | 第二個 process 取得同一 `fs2` lock 後重新讀正式 v3;account scope已在先前獨立 metadata transaction持久化,merge不得 lost update或建立 duplicate lineage | +| Rollback app adds v2 samples | 新版下次啟動重新 merge;v2 仍然不被改寫 | + +Import 不會為 v2 raw key 自行建立 scope。它只使用成功 request 已接受並持久化的 account-ID scope;因此 v2 中的其他 accounts 與 email-keyed records 保持未匯入。這會安全地捨棄部分 legacy continuity,但不會把 stale email history 掛到另一個帳號。 + +Migration fixtures 必須包含 empty/existing/corrupt v3、valid/corrupt v2、accepted current-ID match、email-only skip、multiple legacy accounts only-current-ID-imported、same-bucket collisions、rollback 新增 v2 sample、save interruption 與 two-process first run。每個 fixture 都逐 byte/mtime 驗證 v2 未變,並鎖定 sorted v3 JSON。V3 history 與新 metadata 不得保存 raw provider identifiers、credential material、display labels 或 UI copy;fixture 同時明示 retained v2 仍是 legacy-sensitive。 + +## Provider adapter matrix + +### Codex and Claude mappings + +| Provider source field | Display card | Stable `windowKey` | Duration route | +|---|---|---|---| +| Codex recognized 18,000-second main window | Session | `main.session.v1` | Provider explicit seconds | +| Codex recognized 604,800-second main window | Weekly | `main.weekly.v1` | Provider explicit seconds | +| Codex additional limit `primary_window`/`secondary_window` | Existing cleaned label | `additional...v1` | Provider explicit seconds | +| Claude JSON `five_hour`/5h header | Session | `session.v1` | Contract 300 minutes | +| Claude JSON `seven_day`/7d header | Weekly | `weekly.v1` | Contract 10,080 minutes | +| Claude `seven_day_oauth_apps` | OAuth Apps | `oauth_apps.weekly.v1` | Contract 10,080 minutes | +| Claude `seven_day_sonnet` | Sonnet | `sonnet.weekly.v1` | Contract 10,080 minutes | +| Claude `seven_day_opus` | Opus | `opus.weekly.v1` | Contract 10,080 minutes | +| Claude design aliases | Designs | `design.weekly.v1` | Contract 10,080 minutes | +| Claude routines aliases | Daily Routines | `routines.weekly.v1` | Contract 10,080 minutes | +| Claude `extra_usage` | Extra usage | `extra_usage.v1` | `unavailable(missingReset)` | + +Codex main mapping retains the current role normalization:18,000 seconds always maps Session and 604,800 seconds always maps Weekly,regardless of primary/secondary order。An unrecognized main duration may still render,但 its pace is `unavailable(windowIdentity)` until a semantic key fixture is added。 + +For Codex additional limits,`sourceDigest` is lowercase hex SHA-256 of trimmed `metered_feature` when present,otherwise trimmed `limit_name`;`slot` is `primary` or `secondary` before selection。Both identity fields missing means typed `unavailable(windowIdentity)`,not the shared `Codex extra limit` label。Digesting avoids persisting provider display text;different raw identities safely fragment rather than collide。 + +Claude design aliases are frozen in current first-match order:`seven_day_design`、`seven_day_claude_design`、`claude_design`、`design`、`seven_day_omelette`、`omelette`、`omelette_promotional`。Routines aliases are `seven_day_routines`、`seven_day_claude_routines`、`claude_routines`、`routines`、`routine`、`seven_day_cowork`、`cowork`。Every alias in each group maps to the one semantic key shown above;alias source and display label never enter history。 + +### Grok, Antigravity, and Copilot mappings + +| Provider source field | Display card | Stable `windowKey` | Duration route | +|---|---|---|---| +| Grok `period_type` containing `WEEKLY` | Weekly | `billing.weekly.v1` | Period start/end;observed fallback when only end exists | +| Grok `period_type` containing `MONTHLY` | Monthly | `billing.monthly.v1` | Period start/end;observed fallback when only end exists | +| Antigravity CLI `modelOrAlias.model` | Provider label or model ID | `model..v1` | Observed | +| Antigravity OAuth `models` object key | Provider display name or object key | `model..v1` | Observed | +| Antigravity quota bucket `modelId` | Model ID | `model..v1` | Observed | +| Copilot `quota_snapshots.premium_interactions` | Premium | `premium_interactions.v1` | Calendar-month contract;observed fallback | +| Copilot `quota_snapshots.chat` | Chat | `chat.v1` | Calendar-month contract;observed fallback | + +Unknown Grok period type must not default to Weekly;it renders `unavailable(windowIdentity)`。For Antigravity,`exactModelId` is Unicode-trimmed and otherwise byte-exact。CLI、OAuth object與 bucket values merge only when those exact IDs match;there is no label heuristic。CLI config lacking `modelOrAlias.model` may still display its label but is `unavailable(windowIdentity)`。When duplicate rows share an exact model ID in one payload,choose the lowest remaining fraction;ties choose the earliest future reset,then source order,so the binding card is deterministic。 + +Copilot Premium/Chat share reset and account scope but never share window history。A zero-entitlement placeholder remains non-card and therefore has no pace state。No new GitHub identity endpoint is part of this Plan;absence of user ID goes through the frozen lineage protocol。 + +Stage 0 no longer discovers mappings。It turns every row and every reject rule above into old-fail/new-pass fixtures;a newly observed provider field outside this matrix is a stop condition requiring a Plan revision,not an implementation-time naming choice。 + +## 執行階段 + +以下階段只有在本 Plan 經核准後才能開始。主 session 擁有整體 contract、integration 與 diff review;各寫入階段採 exclusive file ownership,不讓兩個 worker 同時修改 `agent_usage.rs` 或 wire models。 + +| Stage | Exclusive ownership and primary files | Work | Exit gate | +|---|---|---|---| +| 0. Freeze capability fixtures | Main session;provider modules與 dedicated fixtures | 把 frozen source-field matrix、aliases、unknown-key rejects與 current silent-fallback behavior變成 old-fail fixtures | Matrix每一列與 reject rule都有 case ID;本階段不再做 product discovery | +| 1. Secure account scope | `security-executor`;new account-scope module與provider auth hooks | 實作owner-only installation-key file、HMAC、authenticated metadata、lineage transfer與fail-closed recovery | Approved security protocol逐項有fixture;storage path/permission attacks fail closed;Antigravity stale email不能scope/label remote quota | +| 2. V3 shell and duration lifecycle | `executor`;new duration/v3 store modules、provider-neutral `UsageWindow` internals | 建立 locked atomic `SeriesState` store,實作 provider/contract/observed resolver與 durable rollover state | Restart/corruption/account-isolation加5h/7d/monthly/missed-boundary fixtures綠燈 | +| 3. Generic history and migration | `executor`;v3 store/evaluator modules與 legacy `agent_history.rs` reader | 加 cycle-aware sampling/retention/confidence、current-account-only v2 import與 coherent evaluator | Exact migration collision matrix與5h/7d/monthly evaluator fixtures綠燈;v1/v2 unchanged proofs成立 | +| 4. Provider adapters | `executor`;`agent_usage.rs`、Antigravity/Copilot/Grok modules | 為每個 card 注入 account scope、stable key、duration與 v3 enrichment | Provider matrix逐列有 serialized fixture;Codex 不再是特殊 enrichment entry point | +| 5. Wire and Mac UX | `executor`;`ctb.h`、Swift models、`UsagePace`、settings/quota card views | 加 `paceStatus`、移除 silent fallback、更新 learning/available文案與顏色 | Rust JSON 可由 Swift decode;yellow ahead 僅由真實 evaluator fixture 驅動 | +| 6. Cross-port handoff | Main session;CrossCheckHarness、canonical docs、fixture artifact | 跑完整 baseline、列出 intended wire delta、準備 Windows DTO/state-machine handoff | 非 pace cases 零回歸;Windows 尚未 port 時明確標為 pending,不改 Windows repo | +| 7. Integrated verification | Main session,加 fresh `verifier` | 執行 full gates、人工 local UX與 adversarial edge cases | Verifier 回傳 `CONFIRMED`;任何 `REFUTED` 回到 owning stage | + +### Stage 6 checkpoint and Windows handoff + +Mac-owned [`provider-quota-pace-v3.json`](../../../Fixtures/CrossCheck/provider-quota-pace-v3.json) 由 Rust production serializer test 鎖定,再由 Swift production `AgentUsagePayload`/`UsageWindow` decoder、`UsagePace` 與 `QuotaResolver` 執行。Fixture 包含 7 張 lifecycle windows 與 12 個 projection/selection/legacy/malformed cases;所有資料皆為 `.invalid` synthetic identifiers,不含 credential、account ID、email 或本機 path。 + +| Surface | Windows port requirement | +|---|---| +| DTO | `UsageWindow` 加 required v3 `cardId`/`paceStatus` 與 optional coherent `historicalPace`;整個 `paceStatus` key 缺失才是 internal legacy,present-null/unknown/矛盾資料必須 decode failure | +| Duration | Pace 只讀 positive `durationSeconds`;`windowMinutes` 只是 `durationSeconds / 60` compatibility output,不能恢復 legacy Linear fallback | +| Mode policy | Historical 只在 `available` 使用 backend result;`learningHistory` 才可明示 Linear estimate;`learningDuration`/`unavailable`/legacy 無 pace;Off 一律無 marker | +| Selection | Persisted identity 改為 `clientId|cardId`;舊 label 只有唯一 match 才遷移;well-formed unmatched/ambiguous explicit selection 保留並 resolve `nil`,讓 transient partial payload 使用該來源 last-good,而非靜默切到 Auto;只有 malformed/empty selection 回 Auto;duplicate card ID 保留第一張並 fail closed | +| Presentation | Deficit/yellow gate 必須同時是 Historical basis、`available` 與 deficit stage;Linear 或 learning estimate 不得偽裝成 learned Historical | +| Cross-check | Windows harness 未來讀同一份 v3 fixture 並與 Mac actual output 對拍;在 DTO、state machine、selection 與 presentation 全部移植前不得宣稱 parity | + +`crosscheck-harness` 的 no-selector legacy run 目前可完整產生 42 pace+74 format cases;74 個非 pace cases 與既有 C# reference 零差異。28 個 legacy pace cases 存在 intended mismatch,來源是 v3 strict decoder、typed lifecycle 與 no-silent-fallback contract;Windows status 明確為 **port/parity pending**。 + +### Change budget + +| Boundary | Budget | +|---|---| +| Runtime modules | 最多新增 account scope、duration、generic history 三個 focused Rust modules;超出時先重審 ownership | +| Provider network calls | 零新增;若未來要取 stable ID,先停止並另列 latency/privacy/failure plan | +| Dependencies | `hmac = 0.12`(new lock entry);direct `sha2 = 0.10`、`base64 = 0.22`、`fs2 = 0.4`與 target-macOS `security-framework = 3.7`;除 HMAC package外其餘版本已在 lockfile | +| Swift surface | 只改 quota model、pace calculation、settings copy與 quota card presentation,不擴大 dashboard architecture | +| Vendor | 零修改;這是 app-owned quota flow,不做 tokscale sync | +| Cross-repo | TokenBar-Windows 零寫入;只產生 handoff與 fixtures | + +## 驗收條件 + +### Provider and duration acceptance + +| Case | Required result | +|---|---| +| Codex every recurring window | Positive provider duration 的 card 全部有 pace lifecycle,不再只選 Weekly | +| Claude every 5h/7d schema field | 依 field key 得到 300/10,080 minutes;JSON與 header aliases 不分裂 history | +| Grok exact period | Weekly與 variable monthly 使用實際 start/end,不 hardcode 7/30 days | +| Antigravity every identified model | Exact model ID series;相鄰 rollover 後 duration ready,未觀察前明示 learning;缺 ID card typed unavailable | +| Copilot Premium/Chat | 各自 stable key,共用 account scope;first-of-month calendar duration立即可用,其他 reset走 observed lifecycle | +| Missing reset | 不產生 pace/sample;顯示 typed reason,不用 label 猜 duration | +| Provider alias | Claude schema aliases與 exact Antigravity model IDs依 frozen mapping收斂;Antigravity local/remote account scope在未證明同 owner前刻意分開 | + +### Identity, history, and migration acceptance + +| Case | Required result | +|---|---| +| Credential refresh | Same account history continues across app-controlled rotation;每個 transaction crash point有 fail-closed proof | +| Account switch | Same auth slot切換帳號後不可讀到前一個 account series | +| No safe identity | Fail closed with visible status;history 不使用 provider-only default key | +| Antigravity identity binding | Remote OAuth不得使用 unbound `google_accounts.active` email;local/remote未證明同 owner前不 merge | +| Duration variance | 28–31 day cycles保留各自 duration,phase curve可共同評估 | +| Short cycles | 5h history可在 bounded retention與 observation-span gate後達到 expected/risk confidence | +| Partial cycle | 少於 6 phase buckets、缺起點/終點或 gap 過大時不算 complete | +| Bounded store | Repeated partial resets與 abandoned accounts會依 deterministic retention移除;series/sample hard caps與 tie ordering有 fixtures,capacity failure不破壞 last valid v3 | +| V2 migration | 只匯入本次成功 request 接受的 Codex account-ID records;其他 ID records 等待各自登入,email-keyed records 保持 legacy-only;v2 bytes/mtime/path 不變 | +| Corruption/concurrency | 保留 evidence、無 partial file、無 lost update、multiple accounts不互相覆蓋 | + +### Wire and UX acceptance + +| Case | Required result | +|---|---| +| Historical available | Card 使用 Rust historical expected、ETA、will-last與risk;ahead狀態可呈現真正黃色 | +| Learning duration | 顯示 `Learning reset duration`,不顯示 deficit、projected empty或 lasts | +| Learning history | 明示 Linear estimate;不得看起來像已啟用 Historical | +| Unavailable/legacy payload | 顯示 typed unavailable/update state,不 silent Linear | +| Linear setting | 所有 duration-ready cards 使用相同 exact `paceStatus.durationSeconds`;`windowMinutes`只做 legacy decode | +| Cross-language | Rust fixture、Swift decoder與 C contract 對 optional/required fields完全一致 | +| Settings copy | 不再宣稱只學 weekly curve,也不宣稱尚未 ready 的 provider 已有 history | + +## 交付與驗證 + +每個 provider 先用 hermetic fixture 證明 duration/identity/rollover,再跑 generic evaluator。Live provider refresh 只作 smoke;因本機剛好沒有遇到 rollover而看不到變化,不能取代 observed-duration tests。 + +| Evidence | Minimum proof | +|---|---| +| Old-fail/new-pass | 既有 Claude/Grok/Antigravity/Copilot mapper serializes `historicalPace: null`;新 fixture進入對應 lifecycle | +| Duration truth | Provider bounds/contract field/adjacent rollover三條 route各有 positive與reject cases | +| Generic evaluator | 5h、7d、weekly、28–31d monthly phase-invariance與 confidence fixtures | +| Migration | V2 sentinel bytes/mtime、idempotent merge、corrupt inputs、interruption與 two-process contention | +| Security | Raw identifiers/credential material不出現在新 metadata、v3、logs、errors或 serialized fixtures;retained v2的 legacy-sensitive status另行斷言 | +| Presentation | UI-free text/color tests加 local popover驗收;yellow historical state由 injected backend result產生,不硬改 display text | +| Cross-port | 完整 baseline、nested `paceStatus` fixture與 Windows semantic delta;不偽造 Windows PASS | + +Runtime 實作完成後從 repository root 執行: + +```bash +cargo fmt --all -- --check +cargo test +cargo clippy --workspace --all-targets +make build +swift run TokenBar --selftest +swift run TokenBar --smoke +swift run TokenBar --open-popover +``` + +Canonical docs 每個 checkpoint 執行: + +```bash +python3 scripts/check_knowledge.py --self-test +python3 -m py_compile scripts/check_knowledge.py +python3 scripts/check_knowledge.py +make check-docs +git diff --check +``` + +Local UX 驗收必須實際切換 Linear/Historical,覆蓋至少一張 `learningDuration`、一張 `learningHistory` 與一張 injected `available` card。Injected fixture 必須標示為 fixture;真實 provider card 只有在 store 達 confidence gate後才可作為 live historical proof。 + +## 風險、相依與停止條件 + +| Risk or dependency | Impact | Mitigation/stop condition | +|---|---|---| +| Provider 沒有 stable account ID | History 可能混帳號或碎裂 | 使用 secure lineage;若 security review 無法證明隔離,停止該 adapter,不得降級 default key | +| Reset-only provider長時間未跨 boundary | Card 暫時沒有 pace | 顯示 learning;不以 median或 calendar猜測 | +| Claude Extra usage沒有 reset | 無法計算 elapsed phase | 已鎖定 typed unavailable;未來只有 provider payload提供 reset並更新 Plan後才能啟用 | +| Provider schema alias不穩定 | History可能分裂或串錯 | Stable field/model/period fixtures;無 stable key時停止 migration | +| Short-cycle sample量增大 | Store膨脹、I/O增加 | 48 buckets、`R`/`H` retention、512-series與65,536-sample hard caps;overflow typed unavailable且保留 last valid store | +| Cross-language state drift | Swift再次 silent fallback | Required `paceStatus` invariants與 Rust/Swift shared fixtures | +| V2 import破壞既有學習 | Codex使用者重新等待或 evidence遺失 | V2 read-only、idempotent merge、atomic v3與 byte/mtime assertions | +| 新 identity endpoint | 新 privacy/latency/failure surface | 預設不新增;若必要,Stage 0停止並先更新 Plan與 security review | +| Windows尚未同步 | Downstream DTO與呈現不一致 | Mac可完成但 parity標為 pending;另行授權跨 repo port後才能宣稱全平台完成 | + +任何一張 eligible card 沒有 stable key、safe account scope 或可信 duration path 時,implementation 必須停在該 provider 的 Stage 0/1/2 gate,回來更新這份 Plan;不得以 label、token hash、30-day constant或 silent Linear 來「完成」matrix。 + +## 授權邊界 + +核准這份 Plan 只授權在隔離 worktree 依 Stage 0–7 實作與驗證 Mac repository。它不授權 commit、push、PR、merge、tag、release,也不授權寫入 TokenBar-Windows。每一個 integration 動作仍依 [`../workflow.md`](../workflow.md) 取得獨立明確指令。 diff --git a/docs/knowledge/release.md b/docs/knowledge/release.md index 665e2117..b4e262b8 100644 --- a/docs/knowledge/release.md +++ b/docs/knowledge/release.md @@ -3,9 +3,9 @@ status: active id: kb-release kind: canonical scope: repository -read_when: changing release scripts, appcast, Sparkle, Homebrew, Pages, or post-release notes -last_verified: 2026-07-14 -sources: [".github/workflows/release.yml", ".github/workflows/pages.yml", ".github/workflows/update-install-count.yml", "appcast.xml", "Makefile", "public release history"] +read_when: changing release scripts, code signing, appcast, Sparkle, Homebrew, Pages, or post-release notes +last_verified: 2026-07-17 +sources: [".github/workflows/release.yml", ".github/workflows/pages.yml", ".github/workflows/update-install-count.yml", "scripts/bundle.sh", "appcast.xml", "Makefile", "docs/knowledge/plans/provider-quota-pace.md", "public release history"] --- # Release and delivery @@ -18,6 +18,7 @@ sources: [".github/workflows/release.yml", ".github/workflows/pages.yml", ".gith - [Delivery map](#delivery-map) - [Application release](#application-release) +- [Code signing and local secret storage](#code-signing-and-local-secret-storage) - [Sparkle and appcast](#sparkle-and-appcast) - [Migration principle](#migration-principle) - [Legacy and beta migration](#legacy-and-beta-migration) @@ -57,6 +58,12 @@ The release workflow is tag-driven. It validates and bundles the native app, pro > **授權邊界:** 發版是不可逆的公開狀態變更。除非使用者明確要求,不能自行 tag、push appcast、改 Release body、更新 cask 或發佈 asset。 +## Code signing and local secret storage + +目前SwiftPM與release bundle都是ad-hoc signed;這能驗證bundle完整性流程,但不提供跨rebuild/update穩定的Developer ID designated requirement。因此provider pace的account-scope installation key不得依賴restrictive Keychain ACL,現行source of truth是hardened Application Support目錄內的exact 32-byte owner-only file(directory `0700`、file `0600`)。既有開發用Keychain item不讀取、不刪除、不更新、不遷移。 + +若未來release chain採用穩定Developer ID signing,可將「Developer ID-gated account-scope Keychain migration」由parked狀態另立plan。Migration必須先匯入並驗證與file完全相同的32 bytes,成功前file仍是source of truth;不得生成新key,否則既有`accountScope`與quota history會斷代。採用Developer ID本身不自動授權實作、發版或刪除舊storage。 + ## Sparkle and appcast The feed is a single multi-item appcast. Stable items are channel-less; prerelease items carry the beta channel. The generator reads the existing feed, preserves prior items and signatures, and keeps a bounded history instead of overwriting the feed with one item. diff --git a/docs/knowledge/verification.md b/docs/knowledge/verification.md index 38739c62..b0839167 100644 --- a/docs/knowledge/verification.md +++ b/docs/knowledge/verification.md @@ -4,8 +4,8 @@ id: kb-verification kind: canonical scope: repository read_when: changing runtime code, running a local build or UX acceptance, parser output, cache behavior, FFI contracts, or this knowledge tree -last_verified: 2026-07-16 -sources: [".github/workflows/ci.yml", "Makefile", "Package.swift", "scripts/bundle.sh", "crates/tb_core_ffi/src/agent_history.rs", "docs/knowledge/plans/codex-historical-pace-v2.md", "AGENTS.md", "memory-derived hermetic verification practice", "memory-derived local build indexing incident"] +last_verified: 2026-07-17 +sources: [".github/workflows/ci.yml", "Makefile", "Package.swift", "scripts/bundle.sh", "crates/tb_core_ffi/src/agent_account_scope.rs", "crates/tb_core_ffi/src/agent_quota_history.rs", "crates/tb_core_ffi/src/agent_history.rs", "docs/knowledge/plans/provider-quota-pace.md", "docs/knowledge/plans/codex-historical-pace-v2.md", "AGENTS.md", "memory-derived hermetic verification practice", "memory-derived local build indexing incident"] --- # Verification contract @@ -49,7 +49,8 @@ sources: [".github/workflows/ci.yml", "Makefile", "Package.swift", "scripts/bund | Sibling-only write | 預設 fingerprint 不失效;完整 fingerprint、mtime probe、prune 都失效 | | Provider cost | 缺失成本可估算;明確 provider-reported 成本不可被 stale pricing 覆蓋 | | Hidden client | non-empty partial selection 在 Rust fold 前排除未選 client;`nil`/empty clients 依 C ABI contract 代表 all clients;all-hidden 由 Swift lens strict membership 阻擋 | -| Quota history | Reset jitter、floating zero、partial/future-reset weeks、account isolation、corrupt recovery 與 current-actual shift 都以 temporary v2 store 驗證;live provider refresh 只作 smoke | +| Quota account scope | 以temporary Application Support驗證exact 32-byte key、directory `0700`/file `0600`、每次reload、cross-process winner、symlink/non-regular/inode swap fail-closed、key-loss orphan recovery、atomic failure與raw-value scan;不得呼叫真實Keychain或provider credential | +| Quota history | Reset jitter、floating zero、duration lifecycle、partial/future-reset cycles、active-series capacity、account isolation、corrupt recovery與current-actual shift都以temporary v3 store驗證;Codex v2只驗byte-exact current-account migration,live provider refresh只作smoke | | Overflow input | old arithmetic fails or wraps in the targeted site;new saturating path remains bounded | | Cache schema | 舊版本 cache 不被當成新 layout 靜默接受;新 layout 可重建並 reload | @@ -79,12 +80,15 @@ swift run TokenBar --smoke `cargo test` and `cargo clippy --workspace --all-targets` are local full code-change gates; this document does not claim that the current CI workflow runs them. The `--all-targets` flag is required because `vendor/tokscale-core/src/lib.rs` declares `#![deny(clippy::all)]`, so a test-only lint can fail the gate even when the library target itself is clean. +Live account-scope smoke必須在hermetic security suite通過後才執行,且每次重新執行都需要當次明確授權。若出現任何Keychain或credential授權視窗,立即停止process並把smoke判為失敗;不得要求輸入登入密碼、讀取secret或用真實credential診斷。 + | Gate | Expected evidence | |---|---| | Rust | Release static library builds from the current source | | Swift | SwiftPM links against the freshly built library from repository root | | Selftest | UI-free TokenBarCore assertions pass | -| Smoke | Every C ABI entry point decodes or reports an intentional error envelope | +| Smoke | Every C ABI entry point decodes or reports an intentional error envelope;account-scope path不得存取Keychain或顯示credential authorization UI | +| Account-scope storage | Hermetic security tests先證明permission、path、locking、atomicity與recovery;live smoke只驗證shipping data flow不彈授權UI,不取代fixture correctness | | Relink safety | If Rust changed without Swift source changes, the stale executable is removed before linking | | Rust format | For Rust changes, run `cargo fmt --all -- --check` on the touched scope; vendor formatting policy may be intentionally separate | | Local Rust tests | `cargo test` passes across workspace crates and test targets | @@ -94,6 +98,8 @@ swift run TokenBar --smoke 不需要 `.app` bundle 語意的人工 UI 檢查,優先從 repository root 執行 `swift run TokenBar --open-popover`。只有 icon、`Info.plist`、`LSUIElement`、Sparkle、autostart 或安裝路徑等 bundle-only 行為,才以 `make bundle` 產生的 `dist/TokenBar.app` 驗收。 +Provider quota pace 以 `swift run TokenBar --demo --open-popover` 提供 deterministic 人工驗收面;snapshot badge 明示 `FIXTURE`,且 `DemoUsageDataSource` 不呼叫 live FFI、不讀寫 quota cache。Historical/Linear/Off 都要實際呈現;驗收時必須區分低 remaining 觸發的 quota 長條黃/紅健康色,與只有 `available` historical deficit 才可使用的 pace marker/footer 橘色。 + > **本機 bundle 邊界:** `dist/TokenBar.app` 是暫時的驗收產物,不是第二份安裝。日常使用與正式更新的 source of truth 仍是 `/Applications/TokenBar.app`。 [`scripts/bundle.sh`](../../scripts/bundle.sh) 會在組裝 app 前建立 `dist/.metadata_never_index`,避免 Spotlight 主動索引本機 bundle。但這個 marker 不會回溯刪除既有 Spotlight metadata;實際啟動 `dist/TokenBar.app` 也可能讓 LaunchServices 註冊它。因此本機 UX 驗收完成、且不再需要該 bundle 作為 release artifact 時,應撤銷這個特定 app 的註冊並刪除生成物,不要以重設整個 Launchpad database 作為第一步。 @@ -147,7 +153,7 @@ A source reader that consumes secondary files must be verified as one unit. The | Client filter | Non-empty selected IDs reach Rust before mixed buckets are folded; `nil`/empty client lists mean all clients per `ctb.h`; the Swift lens strict-membership check blocks all-hidden views | | Arithmetic | Rust report totals, FFI mappers, Swift models, and live-rate consumers use bounded arithmetic where required | | Stale-data policy | A failed refresh retains the last good value instead of blanking a working card | -| Historical pace | Rust 的 optional nested result 同時擁有 expected、ETA、will-last 與 risk;Swift 只能導出 stage/文字,result 缺席時才使用 Linear | +| Historical pace | Rust 的 typed `paceStatus` 擁有 lifecycle/duration,optional nested result 同時擁有 expected、ETA、will-last 與 risk;Swift 只能導出 mode policy、stage 與文字。只有 `learningHistory` 可明示使用 exact-duration Linear estimate,`learningDuration`/`unavailable`/legacy 不得 silent fallback | | Lifecycle | Closing a popover or settings window cancels its tasks and stops background rendering | ## Cross-port fixture cross-check @@ -156,14 +162,16 @@ Windows port([Nanako0129/TokenBar-Windows](https://github.com/Nanako0129/Token | 項目 | 內容 | |---|---| -| Swift harness | [`Sources/CrossCheckHarness/main.swift`](../../Sources/CrossCheckHarness/main.swift),`TZ=Asia/Taipei swift run crosscheck-harness `;經 symlink 編入 app target 的 `Format.swift`,測的是 shipping 程式碼 | -| 契約與 fixture | Windows repo 的 `crosscheck/`(README=schema 契約;fixture 以 FFI wire 編碼,兩邊都用 production decoder,無自製映射) | +| Swift harness | [`Sources/CrossCheckHarness/main.swift`](../../Sources/CrossCheckHarness/main.swift),`TZ=Asia/Taipei swift run crosscheck-harness [usage-pace|format|provider-quota-pace-v3]`;selector 省略時維持 legacy complete run,所有路徑使用 shipping 程式碼 | +| 契約與 fixture | Windows repo 的 legacy `crosscheck/` 保留既有 116-case reference;provider v3 handoff 由 Mac-owned [`provider-quota-pace-v3.json`](../../Fixtures/CrossCheck/provider-quota-pace-v3.json) 提供,Rust production serializer 鎖定 payload,Swift/未來 Windows 都必須用 production decoder,無自製 wire mapping | | 比對 | Windows repo 的 `crosscheck/diff.py`:字串逐 byte、數字 epsilon 1e-9、缺鍵視同 null | | 執行時機 | `Sources/TokenBarCore` 邏輯或 `Format` 語意變更後;Windows repo 每次 re-sync 或 delta 移植後 | > 首輪實績(2026-07-16):首跑 115 案例抓到 4 條 printf 捨入 seam 的真實漂移——C# 側以 `Math.Round` 預捨入模擬 `%.nf` 會把非 midpoint 的近半值重新量化;printf 對二進位真值做正確捨入。教訓:**模擬 printf 的中介捨入層一律可疑**。修正與後續 comparator 強化(整數精確比對、bool 嚴格比對、Int64 邊界案例——fixture 現為 116 案)都記錄在 Windows repo。 > Historical pace v2 checkpoint(2026-07-16):116-case legacy baseline 已重跑,非 historical cases 全數一致。27 個 field differences 只分布在 9 個使用舊 top-level historical scalars 的 cases:`historical-expected-clamped`、`historical-runout-exact-half`、`historical-runout-high-keeps-eta`、`historical-runout-low-forces-lasts`、`historical-with-expected`、`runout-risk-certain`、`runout-risk-clamped-above-one`、`runout-risk-half-percent-rounds-up`、`runout-risk-thirty`。這些是 nested contract 取代 scalar contract 的 intended mismatch;Windows 新增 nested fixture/DTO 並完成 semantic port 前,不得宣稱 historical parity。 +> +> Provider-wide v3 checkpoint(2026-07-17):no-selector Mac harness 以 production decoder 完整產生 42 pace+74 format cases,不再因單一 malformed legacy row 中止。Format 74 cases 與現有 C# reference 零差異;pace 有 43 個 field differences,分布在 28/42 cases。差異來自三個 intended contract 變更:整個 `paceStatus` 缺失不再以 `windowMinutes` 恢復 Linear、舊 top-level historical/risk scalars 不再驅動結果,以及 2 個越界百分比 rows 現在明示 `rejected: true`。Mac-owned v3 fixture 另有 7 張 lifecycle windows 與 12 個 projection/selection/legacy/malformed cases,payload 已由 Rust serializer test 鎖定;Windows DTO/state machine/selection/presentation port 完成前,狀態維持 **port/parity pending**。 ## Documentation checks