From 29932a0b858cf05e15c41f7f3794287aa64b9f02 Mon Sep 17 00:00:00 2001 From: Roshan Warrier Date: Wed, 1 Apr 2026 14:38:17 +0530 Subject: [PATCH] Add proactive auto-switch policy --- README.md | 12 +- src/auto.zig | 423 +++++++++++++++++++++++++++++++----- src/cli.zig | 41 +++- src/registry.zig | 16 ++ src/tests/auto_test.zig | 125 +++++++++++ src/tests/cli_bdd_test.zig | 81 ++++++- src/tests/main_test.zig | 1 + src/tests/registry_test.zig | 4 + 8 files changed, 638 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 24575ae..d7d3301 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | Command | Description | |---------|-------------| | `codex-auth config auto enable\|disable` | Enable or disable background auto-switching | +| `codex-auth config auto --policy ` | Select the auto-switch policy | | `codex-auth config auto [--5h <%>] [--weekly <%>]` | Set auto-switch thresholds | | `codex-auth config api enable\|disable` | Enable or disable both usage refresh and team name refresh API calls | @@ -232,16 +233,25 @@ codex-auth config auto disable Adjust thresholds: ```shell +codex-auth config auto --policy auto +codex-auth config auto --policy threshold codex-auth config auto --5h 12 +codex-auth config auto --policy auto --5h 12 --weekly 8 codex-auth config auto --5h 12 --weekly 8 codex-auth config auto --weekly 8 ``` -When auto-switching is enabled, a long-running background watcher refreshes the active account's usage and silently switches accounts when: +`threshold` is the default policy. It keeps the current behavior and only switches after the active account crosses the configured 5h or weekly floor. + +`auto` is an opt-in proactive policy. It ranks all managed accounts using current 5h remaining, weekly remaining, and time until each reset. It prefers accounts with safe remaining headroom whose unused capacity will reset sooner, while avoiding accounts that are already close to exhaustion. + +When auto-switching is enabled with the default `threshold` policy, a long-running background watcher refreshes the active account's usage and silently switches accounts when: - 5h remaining drops below the configured 5h threshold (default `10%`), or - weekly remaining drops below the configured weekly threshold (default `5%`) +When `--policy auto` is enabled, the watcher may switch earlier even if the current account is still above those thresholds, if another account has more perishable safe capacity to burn first. + The managed background worker is long-running on all supported platforms: - Linux/WSL: persistent `systemd --user` service diff --git a/src/auto.zig b/src/auto.zig index 29fed68..9419e36 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -31,6 +31,7 @@ pub const RuntimeState = enum { running, stopped, unknown }; pub const Status = struct { enabled: bool, runtime: RuntimeState, + policy: registry.AutoSwitchPolicy, threshold_5h_percent: u8, threshold_weekly_percent: u8, api_usage_enabled: bool, @@ -53,12 +54,28 @@ const CandidateScore = struct { const candidate_upkeep_refresh_limit: usize = 1; const candidate_switch_validation_limit: usize = 3; +const auto_policy_min_5h_hours: f64 = 0.25; +const auto_policy_min_weekly_hours: f64 = 1.0; +const auto_policy_reserve_5h_fraction: f64 = 0.20; +const auto_policy_reserve_weekly_fraction: f64 = 0.10; +const auto_policy_dead_fraction: f64 = 0.01; +const auto_policy_missing_reset_hours: f64 = 1_000_000_000.0; const CandidateEntry = struct { account_key: []const u8, score: CandidateScore, }; +const AutoPolicyCandidateScore = struct { + tier: u8, + primary: f64, + secondary: f64, + health: f64, + burn: f64, + last_usage_at: i64, + created_at: i64, +}; + const CandidateIndex = struct { heap: std.ArrayListUnmanaged(CandidateEntry) = .empty, positions: std.StringHashMapUnmanaged(usize) = .empty, @@ -540,6 +557,7 @@ pub fn getStatus(allocator: std.mem.Allocator, codex_home: []const u8) !Status { return .{ .enabled = reg.auto_switch.enabled, .runtime = queryRuntimeState(allocator), + .policy = reg.auto_switch.policy, .threshold_5h_percent = reg.auto_switch.threshold_5h_percent, .threshold_weekly_percent = reg.auto_switch.threshold_weekly_percent, .api_usage_enabled = reg.api.usage, @@ -561,6 +579,10 @@ fn writeStatusWithColor(out: *std.Io.Writer, status: Status, use_color: bool) !v try out.writeAll(@tagName(status.runtime)); try out.writeAll("\n"); + try out.writeAll("policy: "); + try out.writeAll(@tagName(status.policy)); + try out.writeAll("\n"); + try out.writeAll("thresholds: "); try out.print( "5h<{d}%, weekly<{d}%", @@ -1261,6 +1283,9 @@ fn applyLatestUsableSnapshotFromRolloutFile( } pub fn bestAutoSwitchCandidateIndex(reg: *registry.Registry, now: i64) ?usize { + if (reg.auto_switch.policy == .auto) { + return bestAutoPolicyCandidateIndex(reg, now); + } const active = reg.active_account_key orelse return null; var best_idx: ?usize = null; var best: ?CandidateScore = null; @@ -1318,10 +1343,12 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( ) !AutoSwitchAttempt { if (!reg.auto_switch.enabled) return .{ .refreshed_candidates = false, .switched = false }; const now = std.time.timestamp(); - if (refresh_state.current_reg == null and refresh_state.candidate_index.heap.items.len == 0) { - try refresh_state.candidate_index.rebuild(allocator, reg, now); - } else { - try refresh_state.candidate_index.rebuildIfScoreExpired(allocator, reg, now); + if (reg.auto_switch.policy == .threshold) { + if (refresh_state.current_reg == null and refresh_state.candidate_index.heap.items.len == 0) { + try refresh_state.candidate_index.rebuild(allocator, reg, now); + } else { + try refresh_state.candidate_index.rebuildIfScoreExpired(allocator, reg, now); + } } const active = reg.active_account_key orelse return .{ .refreshed_candidates = false, .switched = false }; const now_ns = std.time.nanoTimestamp(); @@ -1329,27 +1356,39 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( .refreshed_candidates = false, .switched = false, }; - const current = candidateScore(®.accounts.items[active_idx], now); - const should_switch_current = shouldSwitchCurrent(reg, now); + const current_threshold = candidateScore(®.accounts.items[active_idx], now); + const current_auto = autoPolicyScore(®.accounts.items[active_idx], ®.auto_switch, now); + const should_switch_current = if (reg.auto_switch.policy == .threshold) shouldSwitchCurrent(reg, now) else false; var changed = false; var refreshed_candidates = false; if (reg.api.usage and !should_switch_current) { - const upkeep = try refreshDaemonCandidateUpkeepWithUsageFetcher( - allocator, - codex_home, - reg, - refresh_state, - usage_fetcher, - now, - now_ns, - ); + const upkeep = if (reg.auto_switch.policy == .threshold) + try refreshDaemonCandidateUpkeepWithUsageFetcher( + allocator, + codex_home, + reg, + refresh_state, + usage_fetcher, + now, + now_ns, + ) + else + try refreshAutoPolicyCandidateUpkeepWithUsageFetcher( + allocator, + codex_home, + reg, + refresh_state, + usage_fetcher, + now, + now_ns, + ); refreshed_candidates = upkeep.attempted != 0; changed = upkeep.updated != 0; } - if (!should_switch_current) { + if (reg.auto_switch.policy == .threshold and !should_switch_current) { return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, @@ -1360,38 +1399,115 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( if (reg.api.usage) { var skipped_candidates = std.ArrayListUnmanaged([]const u8).empty; defer skipped_candidates.deinit(allocator); - const validation = try refreshDaemonSwitchCandidatesWithUsageFetcher( - allocator, - codex_home, - reg, - refresh_state, - usage_fetcher, - now, - now_ns, - &skipped_candidates, - ); + const validation = if (reg.auto_switch.policy == .threshold) + try refreshDaemonSwitchCandidatesWithUsageFetcher( + allocator, + codex_home, + reg, + refresh_state, + usage_fetcher, + now, + now_ns, + &skipped_candidates, + ) + else + try refreshAutoPolicySwitchCandidatesWithUsageFetcher( + allocator, + codex_home, + reg, + refresh_state, + usage_fetcher, + now, + now_ns, + &skipped_candidates, + ); refreshed_candidates = refreshed_candidates or validation.attempted != 0; changed = changed or validation.updated != 0; - const best_candidate_key = (try bestDaemonCandidateForSwitch(allocator, refresh_state, skipped_candidates.items, now_ns)) orelse return .{ + const best_candidate_key = if (reg.auto_switch.policy == .threshold) + (try bestDaemonCandidateForSwitch(allocator, refresh_state, skipped_candidates.items, now_ns)) + else blk: { + var ordered = try orderedAutoPolicyCandidateKeysAlloc(allocator, reg, now); + defer ordered.deinit(allocator); + var selected: ?[]const u8 = null; + for (ordered.items) |account_key| { + if (refresh_state.candidateIsRejected(account_key, now_ns)) continue; + if (keyIsSkipped(skipped_candidates.items, account_key)) continue; + selected = account_key; + break; + } + break :blk selected; + }; + const resolved_candidate_key = best_candidate_key orelse return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, .switched = false, }; - const candidate_idx = registry.findAccountIndexByAccountKey(reg, best_candidate_key) orelse return .{ + const candidate_idx = registry.findAccountIndexByAccountKey(reg, resolved_candidate_key) orelse return .{ + .refreshed_candidates = refreshed_candidates, + .state_changed = changed, + .switched = false, + }; + if (reg.auto_switch.policy == .threshold) { + const candidate = candidateScore(®.accounts.items[candidate_idx], now); + if (candidate.value <= current_threshold.value) { + return .{ + .refreshed_candidates = refreshed_candidates, + .state_changed = changed, + .switched = false, + }; + } + } else { + const candidate = autoPolicyScore(®.accounts.items[candidate_idx], ®.auto_switch, now); + if (!autoPolicyCandidateBetter(candidate, current_auto)) { + return .{ + .refreshed_candidates = refreshed_candidates, + .state_changed = changed, + .switched = false, + }; + } + } + + const previous_active_key = reg.accounts.items[active_idx].account_key; + const next_active_key = reg.accounts.items[candidate_idx].account_key; + try registry.activateAccountByKey(allocator, codex_home, reg, next_active_key); + if (reg.auto_switch.policy == .threshold) { + try refresh_state.candidate_index.handleActiveSwitch( + allocator, + reg, + previous_active_key, + next_active_key, + std.time.timestamp(), + ); + } + try refresh_state.markCandidateChecked(allocator, previous_active_key, now_ns); + refresh_state.clearCandidateChecked(next_active_key); + return .{ + .refreshed_candidates = refreshed_candidates, + .state_changed = true, + .switched = true, + }; + } + + if (reg.auto_switch.policy == .threshold) { + const candidate_entry = refresh_state.candidate_index.best() orelse return .{ + .refreshed_candidates = refreshed_candidates, + .state_changed = changed, + .switched = false, + }; + const candidate_idx = registry.findAccountIndexByAccountKey(reg, candidate_entry.account_key) orelse return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, .switched = false, }; const candidate = candidateScore(®.accounts.items[candidate_idx], now); - if (candidate.value <= current.value) { + if (candidate.value <= current_threshold.value) { return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, .switched = false, }; } - const previous_active_key = reg.accounts.items[active_idx].account_key; const next_active_key = reg.accounts.items[candidate_idx].account_key; try registry.activateAccountByKey(allocator, codex_home, reg, next_active_key); @@ -1411,18 +1527,13 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( }; } - const candidate_entry = refresh_state.candidate_index.best() orelse return .{ + const candidate_idx = bestAutoPolicyCandidateIndex(reg, now) orelse return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, .switched = false, }; - const candidate_idx = registry.findAccountIndexByAccountKey(reg, candidate_entry.account_key) orelse return .{ - .refreshed_candidates = refreshed_candidates, - .state_changed = changed, - .switched = false, - }; - const candidate = candidateScore(®.accounts.items[candidate_idx], now); - if (candidate.value <= current.value) { + const candidate = autoPolicyScore(®.accounts.items[candidate_idx], ®.auto_switch, now); + if (!autoPolicyCandidateBetter(candidate, current_auto)) { return .{ .refreshed_candidates = refreshed_candidates, .state_changed = changed, @@ -1433,13 +1544,6 @@ pub fn maybeAutoSwitchForDaemonWithUsageFetcher( const previous_active_key = reg.accounts.items[active_idx].account_key; const next_active_key = reg.accounts.items[candidate_idx].account_key; try registry.activateAccountByKey(allocator, codex_home, reg, next_active_key); - try refresh_state.candidate_index.handleActiveSwitch( - allocator, - reg, - previous_active_key, - next_active_key, - std.time.timestamp(), - ); try refresh_state.markCandidateChecked(allocator, previous_active_key, now_ns); refresh_state.clearCandidateChecked(next_active_key); return .{ @@ -1459,7 +1563,9 @@ fn maybeAutoSwitchWithUsageFetcherAndRefreshState( if (!reg.auto_switch.enabled) return .{ .refreshed_candidates = false, .switched = false }; const active = reg.active_account_key orelse return .{ .refreshed_candidates = false, .switched = false }; const now = std.time.timestamp(); - if (!shouldSwitchCurrent(reg, now)) return .{ .refreshed_candidates = false, .switched = false }; + if (reg.auto_switch.policy == .threshold and !shouldSwitchCurrent(reg, now)) { + return .{ .refreshed_candidates = false, .switched = false }; + } _ = refresh_state; const should_refresh_candidates = reg.api.usage; @@ -1473,17 +1579,28 @@ fn maybeAutoSwitchWithUsageFetcherAndRefreshState( .refreshed_candidates = refreshed_candidates, .switched = false, }; - const current = candidateScore(®.accounts.items[active_idx], now); const candidate_idx = bestAutoSwitchCandidateIndex(reg, now) orelse return .{ .refreshed_candidates = refreshed_candidates, .switched = false, }; - const candidate = candidateScore(®.accounts.items[candidate_idx], now); - if (candidate.value <= current.value) { - return .{ - .refreshed_candidates = refreshed_candidates, - .switched = false, - }; + if (reg.auto_switch.policy == .threshold) { + const current = candidateScore(®.accounts.items[active_idx], now); + const candidate = candidateScore(®.accounts.items[candidate_idx], now); + if (candidate.value <= current.value) { + return .{ + .refreshed_candidates = refreshed_candidates, + .switched = false, + }; + } + } else { + const current = autoPolicyScore(®.accounts.items[active_idx], ®.auto_switch, now); + const candidate = autoPolicyScore(®.accounts.items[candidate_idx], ®.auto_switch, now); + if (!autoPolicyCandidateBetter(candidate, current)) { + return .{ + .refreshed_candidates = refreshed_candidates, + .switched = false, + }; + } } try registry.activateAccountByKey(allocator, codex_home, reg, reg.accounts.items[candidate_idx].account_key); @@ -1624,6 +1741,83 @@ fn refreshDaemonSwitchCandidatesWithUsageFetcher( return summary; } +fn refreshAutoPolicyCandidateUpkeepWithUsageFetcher( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + refresh_state: *DaemonRefreshState, + usage_fetcher: anytype, + now: i64, + now_ns: i128, +) !CandidateRefreshSummary { + var ordered = try orderedAutoPolicyCandidateKeysAlloc(allocator, reg, now); + defer ordered.deinit(allocator); + + var summary: CandidateRefreshSummary = .{}; + for (ordered.items) |account_key| { + if (!refresh_state.candidateIsStale(account_key, now_ns)) break; + const result = try refreshDaemonCandidateUsageByKeyWithFetcher( + allocator, + codex_home, + reg, + refresh_state, + account_key, + usage_fetcher, + now_ns, + ); + summary.attempted += result.attempted; + summary.updated += result.updated; + if (result.visited) break; + } + return summary; +} + +fn refreshAutoPolicySwitchCandidatesWithUsageFetcher( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + refresh_state: *DaemonRefreshState, + usage_fetcher: anytype, + now: i64, + now_ns: i128, + skipped_keys: *std.ArrayListUnmanaged([]const u8), +) !CandidateRefreshSummary { + var summary: CandidateRefreshSummary = .{}; + var visited: usize = 0; + while (visited < candidate_switch_validation_limit) : (visited += 1) { + var ordered = try orderedAutoPolicyCandidateKeysAlloc(allocator, reg, now); + defer ordered.deinit(allocator); + + var selected_key: ?[]const u8 = null; + for (ordered.items) |account_key| { + if (refresh_state.candidateIsRejected(account_key, now_ns)) continue; + if (keyIsSkipped(skipped_keys.items, account_key)) continue; + selected_key = account_key; + break; + } + const best_account_key = selected_key orelse break; + if (!refresh_state.candidateIsStale(best_account_key, now_ns)) break; + + const result = try refreshDaemonCandidateUsageByKeyWithFetcher( + allocator, + codex_home, + reg, + refresh_state, + best_account_key, + usage_fetcher, + now_ns, + ); + summary.attempted += result.attempted; + summary.updated += result.updated; + if (result.disqualify_for_switch) { + try skipped_keys.append(allocator, best_account_key); + } + if (!result.visited) break; + } + + return summary; +} + const SingleCandidateRefreshResult = struct { visited: bool = false, attempted: usize = 0, @@ -1884,7 +2078,10 @@ fn disable(allocator: std.mem.Allocator, codex_home: []const u8) !void { try uninstallService(allocator, codex_home); } -pub fn applyThresholdConfig(cfg: *registry.AutoSwitchConfig, opts: cli.AutoThresholdOptions) void { +pub fn applyThresholdConfig(cfg: *registry.AutoSwitchConfig, opts: cli.AutoConfigOptions) void { + if (opts.policy) |policy| { + cfg.policy = policy; + } if (opts.threshold_5h_percent) |value| { cfg.threshold_5h_percent = value; } @@ -1893,7 +2090,7 @@ pub fn applyThresholdConfig(cfg: *registry.AutoSwitchConfig, opts: cli.AutoThres } } -fn configureThresholds(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.AutoThresholdOptions) !void { +fn configureThresholds(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.AutoConfigOptions) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); applyThresholdConfig(®.auto_switch, opts); @@ -1916,6 +2113,122 @@ fn candidateBetter(a: CandidateScore, b: CandidateScore) bool { return a.created_at > b.created_at; } +fn remainingFractionOrDefault(window: ?registry.RateLimitWindow, now: i64, default_value: f64) f64 { + const remaining = registry.remainingPercentAt(window, now) orelse return default_value; + return @as(f64, @floatFromInt(remaining)) / 100.0; +} + +fn hoursUntilResetOrDefault(window: ?registry.RateLimitWindow, now: i64, minimum_hours: f64, default_value: f64) f64 { + const resolved = window orelse return default_value; + const resets_at = resolved.resets_at orelse return default_value; + const seconds_until_reset = resets_at - now; + if (seconds_until_reset <= 0) return minimum_hours; + const hours = @as(f64, @floatFromInt(seconds_until_reset)) / 3600.0; + return @max(hours, minimum_hours); +} + +fn configuredHealthy5hFloor(cfg: *const registry.AutoSwitchConfig) f64 { + return @as(f64, @floatFromInt(cfg.threshold_5h_percent)) / 100.0; +} + +fn configuredHealthyWeeklyFloor(cfg: *const registry.AutoSwitchConfig) f64 { + return @as(f64, @floatFromInt(cfg.threshold_weekly_percent)) / 100.0; +} + +fn autoPolicyScore(rec: *const registry.AccountRecord, cfg: *const registry.AutoSwitchConfig, now: i64) AutoPolicyCandidateScore { + const window_5h = registry.resolveRateWindow(rec.last_usage, 300, true); + const window_week = registry.resolveRateWindow(rec.last_usage, 10080, false); + + const r5 = remainingFractionOrDefault(window_5h, now, 1.0); + const rw = remainingFractionOrDefault(window_week, now, 1.0); + const t5 = hoursUntilResetOrDefault(window_5h, now, auto_policy_min_5h_hours, auto_policy_missing_reset_hours); + const tw = hoursUntilResetOrDefault(window_week, now, auto_policy_min_weekly_hours, auto_policy_missing_reset_hours); + + const health = @min(r5, rw); + const burn = (r5 / @max(t5, auto_policy_min_5h_hours)) + (0.35 * (rw / @max(tw, auto_policy_min_weekly_hours))); + + const dead = r5 <= auto_policy_dead_fraction or rw <= auto_policy_dead_fraction; + const healthy = r5 >= configuredHealthy5hFloor(cfg) and rw >= configuredHealthyWeeklyFloor(cfg); + const reserve_ok = r5 >= auto_policy_reserve_5h_fraction and rw >= auto_policy_reserve_weekly_fraction; + + const tier: u8 = if (!dead and healthy and reserve_ok) + 4 + else if (!dead and healthy) + 3 + else if (!dead) + 2 + else + 1; + + const primary = if (tier >= 3) burn else health; + const secondary = if (tier >= 3) health else burn; + + return .{ + .tier = tier, + .primary = primary, + .secondary = secondary, + .health = health, + .burn = burn, + .last_usage_at = rec.last_usage_at orelse -1, + .created_at = rec.created_at, + }; +} + +fn autoPolicyCandidateBetter(a: AutoPolicyCandidateScore, b: AutoPolicyCandidateScore) bool { + if (a.tier != b.tier) return a.tier > b.tier; + if (a.primary != b.primary) return a.primary > b.primary; + if (a.secondary != b.secondary) return a.secondary > b.secondary; + if (a.last_usage_at != b.last_usage_at) return a.last_usage_at > b.last_usage_at; + return a.created_at > b.created_at; +} + +fn bestAutoPolicyCandidateIndex(reg: *registry.Registry, now: i64) ?usize { + const active = reg.active_account_key orelse return null; + var best_idx: ?usize = null; + var best: ?AutoPolicyCandidateScore = null; + for (reg.accounts.items, 0..) |*rec, idx| { + if (std.mem.eql(u8, rec.account_key, active)) continue; + const score = autoPolicyScore(rec, ®.auto_switch, now); + if (best == null or autoPolicyCandidateBetter(score, best.?)) { + best = score; + best_idx = idx; + } + } + return best_idx; +} + +fn orderedAutoPolicyCandidateKeysAlloc( + allocator: std.mem.Allocator, + reg: *registry.Registry, + now: i64, +) !std.ArrayList([]const u8) { + var ordered = try std.ArrayList([]const u8).initCapacity(allocator, reg.accounts.items.len); + const active = reg.active_account_key; + for (reg.accounts.items) |rec| { + if (active) |account_key| { + if (std.mem.eql(u8, rec.account_key, account_key)) continue; + } + try ordered.append(allocator, rec.account_key); + } + + const SortContext = struct { + reg: *registry.Registry, + now: i64, + }; + const Ctx = SortContext{ .reg = reg, .now = now }; + std.sort.block([]const u8, ordered.items, Ctx, struct { + fn lessThan(ctx: SortContext, lhs: []const u8, rhs: []const u8) bool { + const lhs_idx = registry.findAccountIndexByAccountKey(ctx.reg, lhs) orelse return false; + const rhs_idx = registry.findAccountIndexByAccountKey(ctx.reg, rhs) orelse return false; + return autoPolicyCandidateBetter( + autoPolicyScore(&ctx.reg.accounts.items[lhs_idx], &ctx.reg.auto_switch, ctx.now), + autoPolicyScore(&ctx.reg.accounts.items[rhs_idx], &ctx.reg.auto_switch, ctx.now), + ); + } + }.lessThan); + return ordered; +} + fn candidateScoreChangeAt(usage: ?registry.RateLimitSnapshot, now: i64) ?i64 { if (usage == null) return null; var next_change_at: ?i64 = null; diff --git a/src/cli.zig b/src/cli.zig index 3bbdcaf..b9ffedc 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -49,13 +49,14 @@ pub const RemoveOptions = struct { }; pub const CleanOptions = struct {}; pub const AutoAction = enum { enable, disable }; -pub const AutoThresholdOptions = struct { +pub const AutoConfigOptions = struct { + policy: ?registry.AutoSwitchPolicy, threshold_5h_percent: ?u8, threshold_weekly_percent: ?u8, }; pub const AutoOptions = union(enum) { action: AutoAction, - configure: AutoThresholdOptions, + configure: AutoConfigOptions, }; pub const ApiAction = enum { enable, disable }; pub const ConfigOptions = union(enum) { @@ -306,11 +307,20 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars if (std.mem.eql(u8, action, "disable")) return .{ .command = .{ .config = .{ .auto_switch = .{ .action = .disable } } } }; } + var policy: ?registry.AutoSwitchPolicy = null; var threshold_5h_percent: ?u8 = null; var threshold_weekly_percent: ?u8 = null; var i: usize = 3; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--policy")) { + if (i + 1 >= args.len) return usageErrorResult(allocator, .config, "missing value for `--policy`.", .{}); + if (policy != null) return usageErrorResult(allocator, .config, "duplicate `--policy` for `config auto`.", .{}); + policy = parseAutoPolicyArg(std.mem.sliceTo(args[i + 1], 0)) orelse + return usageErrorResult(allocator, .config, "`--policy` must be `threshold` or `auto`.", .{}); + i += 1; + continue; + } if (std.mem.eql(u8, arg, "--5h")) { if (i + 1 >= args.len) return usageErrorResult(allocator, .config, "missing value for `--5h`.", .{}); if (threshold_5h_percent != null) return usageErrorResult(allocator, .config, "duplicate `--5h` for `config auto`.", .{}); @@ -328,14 +338,15 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars continue; } if (std.mem.eql(u8, arg, "enable") or std.mem.eql(u8, arg, "disable")) { - return usageErrorResult(allocator, .config, "`config auto` cannot mix actions with threshold flags.", .{}); + return usageErrorResult(allocator, .config, "`config auto` cannot mix actions with config flags.", .{}); } return usageErrorResult(allocator, .config, "unknown argument `{s}` for `config auto`.", .{arg}); } - if (threshold_5h_percent == null and threshold_weekly_percent == null) { - return usageErrorResult(allocator, .config, "`config auto` requires an action or threshold flags.", .{}); + if (policy == null and threshold_5h_percent == null and threshold_weekly_percent == null) { + return usageErrorResult(allocator, .config, "`config auto` requires an action or config flags.", .{}); } return .{ .command = .{ .config = .{ .auto_switch = .{ .configure = .{ + .policy = policy, .threshold_5h_percent = threshold_5h_percent, .threshold_weekly_percent = threshold_weekly_percent, } } } } }; @@ -489,8 +500,13 @@ pub fn writeHelp( try out.writeAll("Auto Switch:"); if (use_color) try out.writeAll(ansi.reset); try out.print( - " {s} (5h<{d}%, weekly<{d}%)\n\n", - .{ if (auto_cfg.enabled) "ON" else "OFF", auto_cfg.threshold_5h_percent, auto_cfg.threshold_weekly_percent }, + " {s} (policy={s}, 5h<{d}%, weekly<{d}%)\n\n", + .{ + if (auto_cfg.enabled) "ON" else "OFF", + @tagName(auto_cfg.policy), + auto_cfg.threshold_5h_percent, + auto_cfg.threshold_weekly_percent, + }, ); if (use_color) try out.writeAll(ansi.bold); @@ -534,6 +550,7 @@ pub fn writeHelp( const config_details = [_]HelpEntry{ .{ .name = "auto enable", .description = "Enable background auto-switching" }, .{ .name = "auto disable", .description = "Disable background auto-switching" }, + .{ .name = "auto --policy ", .description = "Select the auto-switch policy" }, .{ .name = "auto --5h [--weekly ]", .description = "Configure auto-switch thresholds" }, .{ .name = "api enable", .description = "Enable usage and account APIs" }, .{ .name = "api disable", .description = "Disable usage and account APIs" }, @@ -563,6 +580,7 @@ pub fn writeHelp( try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[2].name, config_details[2].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[3].name, config_details[3].description); try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[4].name, config_details[4].description); + try writeHelpEntry(out, use_color, child_indent, config_detail_col, config_details[5].name, config_details[5].description); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.bold); @@ -579,6 +597,12 @@ fn parsePercentArg(raw: []const u8) ?u8 { return value; } +fn parseAutoPolicyArg(raw: []const u8) ?registry.AutoSwitchPolicy { + if (std.mem.eql(u8, raw, "threshold")) return .threshold; + if (std.mem.eql(u8, raw, "auto")) return .auto; + return null; +} + const HelpEntry = struct { name: []const u8, description: []const u8, @@ -714,7 +738,9 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { .config => { try out.writeAll(" codex-auth config auto enable\n"); try out.writeAll(" codex-auth config auto disable\n"); + try out.writeAll(" codex-auth config auto --policy \n"); try out.writeAll(" codex-auth config auto --5h [--weekly ]\n"); + try out.writeAll(" codex-auth config auto --policy --5h [--weekly ]\n"); try out.writeAll(" codex-auth config auto --weekly \n"); try out.writeAll(" codex-auth config api enable\n"); try out.writeAll(" codex-auth config api disable\n"); @@ -757,6 +783,7 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { .clean => try out.writeAll(" codex-auth clean\n"), .config => { try out.writeAll(" codex-auth config auto --5h 12 --weekly 8\n"); + try out.writeAll(" codex-auth config auto --policy auto\n"); try out.writeAll(" codex-auth config api enable\n"); }, .daemon => { diff --git a/src/registry.zig b/src/registry.zig index a1d5fca..a4c2998 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -12,6 +12,7 @@ pub const min_supported_schema_version: u32 = 2; pub const default_auto_switch_threshold_5h_percent: u8 = 10; pub const default_auto_switch_threshold_weekly_percent: u8 = 5; pub const account_name_refresh_lock_file_name = "account-name-refresh.lock"; +pub const AutoSwitchPolicy = enum { threshold, auto }; fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 { var buf = try allocator.alloc(u8, email.len); @@ -47,6 +48,7 @@ pub const RolloutSignature = struct { pub const AutoSwitchConfig = struct { enabled: bool = false, + policy: AutoSwitchPolicy = .threshold, threshold_5h_percent: u8 = default_auto_switch_threshold_5h_percent, threshold_weekly_percent: u8 = default_auto_switch_threshold_weekly_percent, }; @@ -2520,6 +2522,12 @@ fn parsePlanType(s: []const u8) ?PlanType { return .unknown; } +fn parseAutoSwitchPolicy(s: []const u8) ?AutoSwitchPolicy { + if (std.mem.eql(u8, s, "threshold")) return .threshold; + if (std.mem.eql(u8, s, "auto")) return .auto; + return null; +} + fn parseAuthMode(s: []const u8) ?AuthMode { if (std.mem.eql(u8, s, "chatgpt")) return .chatgpt; if (std.mem.eql(u8, s, "apikey")) return .apikey; @@ -2557,6 +2565,14 @@ fn parseAutoSwitch(allocator: std.mem.Allocator, cfg: *AutoSwitchConfig, v: std. else => {}, } } + if (obj.get("policy")) |policy| { + switch (policy) { + .string => |value| if (parseAutoSwitchPolicy(value)) |parsed| { + cfg.policy = parsed; + }, + else => {}, + } + } if (obj.get("threshold_5h_percent")) |threshold| { if (parseThresholdPercent(threshold)) |value| { cfg.threshold_5h_percent = value; diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index a558bdb..1808209 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -602,6 +602,72 @@ test "Scenario: Given free candidate with only a secondary weekly window when se try std.testing.expect(std.mem.eql(u8, reg.accounts.items[idx].email, "free@example.com")); } +test "Scenario: Given auto policy and two healthy candidates when selecting auto candidate then the most perishable safe capacity wins" { + const gpa = std.testing.allocator; + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.policy = .auto; + const now = std.time.timestamp(); + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = now + 3_600 }, + .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = now + 7 * 24 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 100); + try appendAccountWithUsage(gpa, ®, "perishable@example.com", .{ + .primary = .{ .used_percent = 40.0, .window_minutes = 300, .resets_at = now + 3_600 }, + .secondary = .{ .used_percent = 40.0, .window_minutes = 10080, .resets_at = now + 100 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 200); + try appendAccountWithUsage(gpa, ®, "steady@example.com", .{ + .primary = .{ .used_percent = 20.0, .window_minutes = 300, .resets_at = now + 10 * 3_600 }, + .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = now + 100 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 300); + const active_account_key = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_key); + try registry.setActiveAccountKey(gpa, ®, active_account_key); + + const idx = auto.bestAutoSwitchCandidateIndex(®, now) orelse return error.TestExpectedEqual; + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[idx].email, "perishable@example.com")); +} + +test "Scenario: Given auto policy and weak candidates when selecting auto candidate then survival beats burn" { + const gpa = std.testing.allocator; + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.policy = .auto; + const now = std.time.timestamp(); + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = now + 3_600 }, + .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = now + 7 * 24 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 100); + try appendAccountWithUsage(gpa, ®, "burny@example.com", .{ + .primary = .{ .used_percent = 40.0, .window_minutes = 300, .resets_at = now + 900 }, + .secondary = .{ .used_percent = 96.0, .window_minutes = 10080, .resets_at = now + 3_600 }, + .credits = null, + .plan_type = .pro, + }, 200); + try appendAccountWithUsage(gpa, ®, "safer@example.com", .{ + .primary = .{ .used_percent = 91.0, .window_minutes = 300, .resets_at = now + 10 * 3_600 }, + .secondary = .{ .used_percent = 50.0, .window_minutes = 10080, .resets_at = now + 100 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 300); + const active_account_key = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_key); + try registry.setActiveAccountKey(gpa, ®, active_account_key); + + const idx = auto.bestAutoSwitchCandidateIndex(®, now) orelse return error.TestExpectedEqual; + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[idx].email, "safer@example.com")); +} + test "Scenario: Given free account with only a weekly window when checking current then the free 5h guard does not misfire" { const gpa = std.testing.allocator; var reg = bdd.makeEmptyRegistry(); @@ -718,10 +784,12 @@ test "Scenario: Given threshold overrides when applying config then unspecified cfg.threshold_weekly_percent = 7; auto.applyThresholdConfig(&cfg, .{ + .policy = .auto, .threshold_5h_percent = 13, .threshold_weekly_percent = null, }); + try std.testing.expectEqual(registry.AutoSwitchPolicy.auto, cfg.policy); try std.testing.expect(cfg.threshold_5h_percent == 13); try std.testing.expect(cfg.threshold_weekly_percent == 7); } @@ -777,6 +845,60 @@ test "Scenario: Given better candidate when auto switch runs then auth and activ try std.testing.expect(std.mem.eql(u8, active_data, fresh_auth)); } +test "Scenario: Given auto policy and healthier current account thresholds when auto switch runs then it still switches proactively to the more perishable safe account" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.auto_switch.policy = .auto; + const now = std.time.timestamp(); + + try appendAccountWithUsage(gpa, ®, "active@example.com", .{ + .primary = .{ .used_percent = 40.0, .window_minutes = 300, .resets_at = now + 10 * 3_600 }, + .secondary = .{ .used_percent = 30.0, .window_minutes = 10080, .resets_at = now + 100 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 100); + try appendAccountWithUsage(gpa, ®, "candidate@example.com", .{ + .primary = .{ .used_percent = 50.0, .window_minutes = 300, .resets_at = now + 3_600 }, + .secondary = .{ .used_percent = 40.0, .window_minutes = 10080, .resets_at = now + 100 * 3_600 }, + .credits = null, + .plan_type = .pro, + }, 200); + const active_account_id = try bdd.accountKeyForEmailAlloc(gpa, "active@example.com"); + defer gpa.free(active_account_id); + try registry.setActiveAccountKey(gpa, ®, active_account_id); + + const active_auth = try bdd.authJsonWithEmailPlan(gpa, "active@example.com", "pro"); + defer gpa.free(active_auth); + const candidate_auth = try bdd.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); + defer gpa.free(candidate_auth); + + const active_account_path = try registry.accountAuthPath(gpa, codex_home, active_account_id); + defer gpa.free(active_account_path); + const candidate_account_id = try bdd.accountKeyForEmailAlloc(gpa, "candidate@example.com"); + defer gpa.free(candidate_account_id); + const candidate_account_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); + defer gpa.free(candidate_account_path); + const active_auth_path = try registry.activeAuthPath(gpa, codex_home); + defer gpa.free(active_auth_path); + + try std.fs.cwd().writeFile(.{ .sub_path = active_account_path, .data = active_auth }); + try std.fs.cwd().writeFile(.{ .sub_path = candidate_account_path, .data = candidate_auth }); + try std.fs.cwd().writeFile(.{ .sub_path = active_auth_path, .data = active_auth }); + + try std.testing.expect(try auto.maybeAutoSwitch(gpa, codex_home, ®)); + try std.testing.expect(reg.active_account_key != null); + try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, candidate_account_id)); +} + test "Scenario: Given API mode and unknown candidate usage when auto switching then it refreshes the candidate before switching" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -1588,6 +1710,7 @@ test "Scenario: Given status when rendering then auto and usage api settings are try auto.writeStatus(&aw.writer, .{ .enabled = true, .runtime = .running, + .policy = .auto, .threshold_5h_percent = 12, .threshold_weekly_percent = 8, .api_usage_enabled = false, @@ -1597,6 +1720,7 @@ test "Scenario: Given status when rendering then auto and usage api settings are const output = aw.written(); try std.testing.expect(std.mem.indexOf(u8, output, "auto-switch: ON") != null); try std.testing.expect(std.mem.indexOf(u8, output, "service: running") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "policy: auto") != null); try std.testing.expect(std.mem.indexOf(u8, output, "thresholds: 5h<12%, weekly<8%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "usage: local") != null); try std.testing.expect(std.mem.indexOf(u8, output, "account: disabled") != null); @@ -1611,6 +1735,7 @@ test "Scenario: Given api usage mode when rendering status body then risk warnin try auto.writeStatus(&aw.writer, .{ .enabled = true, .runtime = .running, + .policy = .threshold, .threshold_5h_percent = 12, .threshold_weekly_percent = 8, .api_usage_enabled = true, diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index bc43c76..929855f 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -224,6 +224,7 @@ test "Scenario: Given help when rendering then login and command help notes are var auto_cfg = registry.defaultAutoSwitchConfig(); var api_cfg = registry.defaultApiConfig(); auto_cfg.enabled = true; + auto_cfg.policy = .auto; auto_cfg.threshold_5h_percent = 12; auto_cfg.threshold_weekly_percent = 8; api_cfg.usage = true; @@ -232,7 +233,7 @@ test "Scenario: Given help when rendering then login and command help notes are try cli.writeHelp(&aw.writer, false, &auto_cfg, &api_cfg); const help = aw.written(); - try std.testing.expect(std.mem.indexOf(u8, help, "Auto Switch: ON (5h<12%, weekly<8%)") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Auto Switch: ON (policy=auto, 5h<12%, weekly<8%)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Usage API: ON (api)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Account API: ON") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--cpa []") != null); @@ -246,6 +247,7 @@ test "Scenario: Given help when rendering then login and command help notes are try std.testing.expect(std.mem.indexOf(u8, help, "config") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto enable") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto disable") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "auto --policy ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "auto --5h [--weekly ]") != null); try std.testing.expect(std.mem.indexOf(u8, help, "api enable") != null); try std.testing.expect(std.mem.indexOf(u8, help, "api disable") != null); @@ -358,6 +360,7 @@ test "Scenario: Given config auto 5h threshold when parsing then threshold confi .config => |opts| switch (opts) { .auto_switch => |auto_opts| switch (auto_opts) { .configure => |cfg| { + try std.testing.expect(cfg.policy == null); try std.testing.expect(cfg.threshold_5h_percent != null); try std.testing.expect(cfg.threshold_5h_percent.? == 12); try std.testing.expect(cfg.threshold_weekly_percent == null); @@ -383,6 +386,7 @@ test "Scenario: Given config auto thresholds together when parsing then both win .config => |opts| switch (opts) { .auto_switch => |auto_opts| switch (auto_opts) { .configure => |cfg| { + try std.testing.expect(cfg.policy == null); try std.testing.expect(cfg.threshold_5h_percent != null); try std.testing.expect(cfg.threshold_5h_percent.? == 12); try std.testing.expect(cfg.threshold_weekly_percent != null); @@ -464,6 +468,60 @@ test "Scenario: Given config auto action mixed with threshold flags when parsing try expectUsageError(result, .config, "cannot mix actions"); } +test "Scenario: Given config auto policy when parsing then policy configuration is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--policy", "auto" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .auto_switch => |auto_opts| switch (auto_opts) { + .configure => |cfg| { + try std.testing.expect(cfg.policy != null); + try std.testing.expectEqual(registry.AutoSwitchPolicy.auto, cfg.policy.?); + try std.testing.expect(cfg.threshold_5h_percent == null); + try std.testing.expect(cfg.threshold_weekly_percent == null); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given config auto policy with thresholds when parsing then all config values are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--policy", "auto", "--5h", "12", "--weekly", "8" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .config => |opts| switch (opts) { + .auto_switch => |auto_opts| switch (auto_opts) { + .configure => |cfg| { + try std.testing.expect(cfg.policy != null); + try std.testing.expectEqual(registry.AutoSwitchPolicy.auto, cfg.policy.?); + try std.testing.expect(cfg.threshold_5h_percent != null); + try std.testing.expect(cfg.threshold_5h_percent.? == 12); + try std.testing.expect(cfg.threshold_weekly_percent != null); + try std.testing.expect(cfg.threshold_weekly_percent.? == 8); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given config auto threshold percent out of range when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--weekly", "0" }; @@ -491,13 +549,31 @@ test "Scenario: Given config auto threshold without value when parsing then usag try expectUsageError(result, .config, "missing value for `--weekly`"); } +test "Scenario: Given config auto policy without value when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--policy" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .config, "missing value for `--policy`"); +} + +test "Scenario: Given config auto unknown policy when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--policy", "perishable" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .config, "`--policy` must be `threshold` or `auto`."); +} + test "Scenario: Given config auto threshold command without flags when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "config", "auto" }; var result = try cli.parseArgs(gpa, &args); defer cli.freeParseResult(gpa, &result); - try expectUsageError(result, .config, "requires an action or threshold flags"); + try expectUsageError(result, .config, "requires an action or config flags"); } test "Scenario: Given config auto threshold with weekly only when parsing then single-window config is preserved" { @@ -511,6 +587,7 @@ test "Scenario: Given config auto threshold with weekly only when parsing then s .config => |opts| switch (opts) { .auto_switch => |auto_opts| switch (auto_opts) { .configure => |cfg| { + try std.testing.expect(cfg.policy == null); try std.testing.expect(cfg.threshold_5h_percent == null); try std.testing.expect(cfg.threshold_weekly_percent != null); try std.testing.expect(cfg.threshold_weekly_percent.? == 9); diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index af80898..11fc2cc 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -315,6 +315,7 @@ test "Scenario: Given foreground commands when checking reconcile policy then co try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .list = .{} })); try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .config = .{ .auto_switch = .{ .action = .enable } } })); try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .config = .{ .auto_switch = .{ .configure = .{ + .policy = null, .threshold_5h_percent = 12, .threshold_weekly_percent = null, } } } })); diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index dda61eb..c7b1633 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -191,6 +191,7 @@ test "registry save/load" { const active_account_key = try accountKeyForEmailAlloc(gpa, "a@b.com"); defer gpa.free(active_account_key); try registry.setActiveAccountKey(gpa, ®, active_account_key); + reg.auto_switch.policy = .auto; reg.auto_switch.threshold_5h_percent = 12; reg.auto_switch.threshold_weekly_percent = 8; reg.api.usage = true; @@ -203,10 +204,12 @@ test "registry save/load" { const saved = try bdd.readFileAlloc(gpa, registry_path); defer gpa.free(saved); try std.testing.expect(std.mem.indexOf(u8, saved, "\"account\": true") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"policy\": \"auto\"") != null); var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); try std.testing.expect(loaded.accounts.items.len == 1); + try std.testing.expectEqual(registry.AutoSwitchPolicy.auto, loaded.auto_switch.policy); try std.testing.expect(loaded.auto_switch.threshold_5h_percent == 12); try std.testing.expect(loaded.auto_switch.threshold_weekly_percent == 8); try std.testing.expect(loaded.api.usage); @@ -425,6 +428,7 @@ test "registry load defaults missing auto threshold fields" { var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); try std.testing.expect(loaded.auto_switch.enabled); + try std.testing.expectEqual(registry.AutoSwitchPolicy.threshold, loaded.auto_switch.policy); try std.testing.expect(loaded.auto_switch.threshold_5h_percent == registry.default_auto_switch_threshold_5h_percent); try std.testing.expect(loaded.auto_switch.threshold_weekly_percent == registry.default_auto_switch_threshold_weekly_percent); try std.testing.expect(loaded.api.usage);