diff --git a/README.md b/README.md index 9040a9c..2594dc2 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | Command | Description | |---------|-------------| -| `codex-auth list` | List all accounts | +| `codex-auth list [--debug]` | List all accounts | | `codex-auth login [--device-auth]` | Run `codex login` (optionally with `--device-auth`), then add the current account | | `codex-auth switch []` | Switch active account interactively or by partial match | | `codex-auth remove` | Remove accounts with interactive multi-select | @@ -125,6 +125,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error ```shell codex-auth list +codex-auth list --debug ``` ### Switch Account @@ -135,7 +136,7 @@ Interactive: shows email, 5h, weekly, and last activity. codex-auth switch ``` -Before the picker opens, the current active account's usage is refreshed once so the selected row is not stale. The newly selected account is not refreshed after the switch completes. +Before the picker opens, `switch` refreshes usage for all stored accounts with a maximum concurrency of `3`, so the picker is based on a fresh cross-account snapshot. If a refresh returns a non-`200` HTTP status such as `401` or `403`, that row shows the status in both usage columns. If a stored account snapshot cannot make a ChatGPT usage request at all because the required auth fields are missing, that row shows `MissingAuth` instead of the previous usage values. No extra usage refresh is attempted after the switch completes. ![command switch](https://github.com/user-attachments/assets/48a86acf-2a6e-4206-a8c4-591989fdc0df) diff --git a/docs/api-refresh.md b/docs/api-refresh.md index 59287d7..ffb4be1 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -32,9 +32,11 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - `api.usage = true`: foreground refresh uses the usage API. - `api.usage = false`: foreground refresh reads only the newest local `~/.codex/sessions/**/rollout-*.jsonl`. -- `list` refreshes only the current active account before rendering. -- `switch` refreshes only the current active account before showing the picker so the currently selected row is not stale. -- `switch` does not refresh usage for the newly selected account after the switch completes. +- when `api.usage = true`, both `list` and pre-selection `switch` refresh all stored accounts before rendering, using stored auth snapshots under `accounts/` with a maximum concurrency of `3` +- when one of those per-account foreground usage requests returns a non-`200` HTTP status, the corresponding `list` / `switch` row shows that response status in both usage columns until a later successful refresh replaces it +- when a stored account snapshot cannot make a ChatGPT usage request because it is missing the required ChatGPT auth fields, the corresponding `list` / `switch` row shows `MissingAuth` in both usage columns until a later successful refresh replaces it +- when `api.usage = false`, foreground refresh still uses only the active local rollout data because local session files do not identify the other stored accounts +- `switch` does not refresh usage again after the new account is activated - the auto-switch daemon refreshes the current active account usage during each cycle when `auto_switch.enabled = true` - the auto-switch daemon may also refresh a small number of non-active candidate accounts from stored snapshots so it can score switch candidates - the daemon usage paths are cooldown-limited; see [docs/auto-switch.md](./auto-switch.md) for the broader runtime loop diff --git a/docs/implement.md b/docs/implement.md index da92334..91857f5 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -165,7 +165,9 @@ When switching: 2. The selected account’s `accounts/.auth.json` is copied to `~/.codex/auth.json`. 3. The registry’s `active_account_key` is updated to that account’s `record_key`. -The switch command refreshes the current active account's usage once before rendering account choices, so the picker does not show stale data for the currently selected account. It does not refresh the newly selected account after the switch completes. +When `api.usage = true`, the switch command refreshes usage for all stored accounts before rendering account choices, using a maximum concurrency of `3`. When a per-account foreground usage request returns a non-`200` HTTP status, the picker shows that status in both usage columns for that row. When a stored account snapshot cannot make a ChatGPT usage request because the required ChatGPT auth fields are missing, the picker shows `MissingAuth` in both usage columns for that row. No extra usage refresh is attempted after the switch completes. + +When `api.usage = false`, the switch command keeps the existing local-only behavior and can refresh only the active account from the newest local rollout data. Grouped account-name metadata refresh, when needed, now runs in the same foreground pre-selection phase as that usage refresh; see [docs/api-refresh.md](./api-refresh.md). diff --git a/src/cli.zig b/src/cli.zig index 26a43da..238c59e 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -31,7 +31,9 @@ fn stderrColorEnabled() bool { return std.fs.File.stderr().isTty(); } -pub const ListOptions = struct {}; +pub const ListOptions = struct { + debug: bool = false, +}; pub const LoginOptions = struct { device_auth: bool = false, }; @@ -130,7 +132,30 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } if (std.mem.eql(u8, cmd, "list")) { - return try parseSimpleCommandArgs(allocator, "list", .list, .{ .list = .{} }, args[2..]); + if (args.len == 3 and isHelpFlag(std.mem.sliceTo(args[2], 0))) { + return .{ .command = .{ .help = .list } }; + } + + var opts: ListOptions = .{}; + var i: usize = 2; + while (i < args.len) : (i += 1) { + const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--debug")) { + if (opts.debug) { + return usageErrorResult(allocator, .list, "duplicate `--debug` for `list`.", .{}); + } + opts.debug = true; + continue; + } + if (isHelpFlag(arg)) { + return usageErrorResult(allocator, .list, "`--help` must be used by itself for `list`.", .{}); + } + if (std.mem.startsWith(u8, arg, "-")) { + return usageErrorResult(allocator, .list, "unknown flag `{s}` for `list`.", .{arg}); + } + return usageErrorResult(allocator, .list, "unexpected argument `{s}` for `list`.", .{arg}); + } + return .{ .command = .{ .list = opts } }; } if (std.mem.eql(u8, cmd, "login")) { @@ -690,7 +715,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth --help\n"); try out.writeAll(" codex-auth help \n"); }, - .list => try out.writeAll(" codex-auth list\n"), + .list => try out.writeAll(" codex-auth list [--debug]\n"), .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); @@ -734,7 +759,10 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth import /path/to/auth.json --alias personal\n"); try out.writeAll(" codex-auth config auto enable\n"); }, - .list => try out.writeAll(" codex-auth list\n"), + .list => { + try out.writeAll(" codex-auth list\n"); + try out.writeAll(" codex-auth list --debug\n"); + }, .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); @@ -1053,19 +1081,36 @@ pub fn runCodexLogin(opts: LoginOptions) !void { } pub fn selectAccount(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 { + return selectAccountWithUsageOverrides(allocator, reg, null); +} + +pub fn selectAccountWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { return if (comptime builtin.os.tag == .windows) - selectWithNumbers(allocator, reg) + selectWithNumbers(allocator, reg, usage_overrides) else - selectInteractive(allocator, reg) catch selectWithNumbers(allocator, reg); + selectInteractive(allocator, reg, usage_overrides) catch selectWithNumbers(allocator, reg, usage_overrides); } pub fn selectAccountFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { + return selectAccountFromIndicesWithUsageOverrides(allocator, reg, indices, null); +} + +pub fn selectAccountFromIndicesWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + indices: []const usize, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { if (indices.len == 0) return null; if (indices.len == 1) return reg.accounts.items[indices[0]].account_key; return if (comptime builtin.os.tag == .windows) - selectWithNumbersFromIndices(allocator, reg, indices) + selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides) else - selectInteractiveFromIndices(allocator, reg, indices) catch selectWithNumbersFromIndices(allocator, reg, indices); + selectInteractiveFromIndices(allocator, reg, indices, usage_overrides) catch selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides); } pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize { @@ -1108,12 +1153,16 @@ fn accountIndexForSelectable(rows: *const SwitchRows, selectable_idx: usize) usi return rows.items[row_idx].account_index.?; } -fn selectWithNumbers(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 { +fn selectWithNumbers( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { var stdout: io_util.Stdout = undefined; stdout.init(); const out = stdout.out(); if (reg.accounts.items.len == 0) return null; - var rows = try buildSwitchRows(allocator, reg); + var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); const use_color = colorEnabled(); const active_idx = activeSelectableIndex(&rows); @@ -1138,13 +1187,18 @@ fn selectWithNumbers(allocator: std.mem.Allocator, reg: *registry.Registry) !?[] return accountIdForSelectable(&rows, reg, idx - 1); } -fn selectWithNumbersFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { +fn selectWithNumbersFromIndices( + allocator: std.mem.Allocator, + reg: *registry.Registry, + indices: []const usize, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { var stdout: io_util.Stdout = undefined; stdout.init(); const out = stdout.out(); if (indices.len == 0) return null; - var rows = try buildSwitchRowsFromIndices(allocator, reg, indices); + var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); const use_color = colorEnabled(); const active_idx = activeSelectableIndex(&rows); @@ -1169,9 +1223,14 @@ fn selectWithNumbersFromIndices(allocator: std.mem.Allocator, reg: *registry.Reg return accountIdForSelectable(&rows, reg, idx - 1); } -fn selectInteractiveFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { +fn selectInteractiveFromIndices( + allocator: std.mem.Allocator, + reg: *registry.Registry, + indices: []const usize, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { if (indices.len == 0) return null; - var rows = try buildSwitchRowsFromIndices(allocator, reg, indices); + var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); var tty = try std.fs.cwd().openFile("/dev/tty", .{}); @@ -1344,9 +1403,13 @@ fn isStrictRemoveSelectionLine(line: []const u8) bool { return true; } -fn selectInteractive(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]const u8 { +fn selectInteractive( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { if (reg.accounts.items.len == 0) return null; - var rows = try buildSwitchRows(allocator, reg); + var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); var tty = try std.fs.cwd().openFile("/dev/tty", .{}); @@ -1806,7 +1869,33 @@ const SwitchRows = struct { } }; +fn usageOverrideForAccount( + usage_overrides: ?[]const ?[]const u8, + account_idx: usize, +) ?[]const u8 { + const overrides = usage_overrides orelse return null; + if (account_idx >= overrides.len) return null; + return overrides[account_idx]; +} + +fn usageCellTextAlloc( + allocator: std.mem.Allocator, + window: ?registry.RateLimitWindow, + usage_override: ?[]const u8, +) ![]u8 { + if (usage_override) |value| return allocator.dupe(u8, value); + return formatRateLimitSwitchAlloc(allocator, window); +} + fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !SwitchRows { + return buildSwitchRowsWithUsageOverrides(allocator, reg, null); +} + +fn buildSwitchRowsWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +) !SwitchRows { var display = try display_rows.buildDisplayRows(allocator, reg, null); defer display.deinit(allocator); var rows = try allocator.alloc(SwitchRow, display.rows.len); @@ -1824,8 +1913,9 @@ fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !Switc const plan = if (registry.resolvePlan(&rec)) |p| @tagName(p) else "-"; const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); - const rate_5h_str = try formatRateLimitSwitchAlloc(allocator, rate_5h); - const rate_week_str = try formatRateLimitSwitchAlloc(allocator, rate_week); + const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const rate_5h_str = try usageCellTextAlloc(allocator, rate_5h, usage_override); + const rate_week_str = try usageCellTextAlloc(allocator, rate_week, usage_override); const last = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_usage_at, now); rows[i] = .{ .account_index = account_idx, @@ -1870,6 +1960,15 @@ fn buildSwitchRowsFromIndices( allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize, +) !SwitchRows { + return buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, null); +} + +fn buildSwitchRowsFromIndicesWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + indices: []const usize, + usage_overrides: ?[]const ?[]const u8, ) !SwitchRows { var display = try display_rows.buildDisplayRows(allocator, reg, indices); defer display.deinit(allocator); @@ -1888,8 +1987,9 @@ fn buildSwitchRowsFromIndices( const plan = if (registry.resolvePlan(&rec)) |p| @tagName(p) else "-"; const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); - const rate_5h_str = try formatRateLimitSwitchAlloc(allocator, rate_5h); - const rate_week_str = try formatRateLimitSwitchAlloc(allocator, rate_week); + const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const rate_5h_str = try usageCellTextAlloc(allocator, rate_5h, usage_override); + const rate_week_str = try usageCellTextAlloc(allocator, rate_week, usage_override); const last = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_usage_at, now); rows[i] = .{ .account_index = account_idx, @@ -2125,3 +2225,24 @@ test "Scenario: Given grouped accounts when rendering switch list then child row try std.testing.expect(std.mem.indexOf(u8, output, "01 Als's Workspace") != null); try std.testing.expect(std.mem.indexOf(u8, output, "02 free") != null); } + +test "Scenario: Given usage overrides when rendering switch list then failed rows show response status in both usage columns" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); + + const usage_overrides = [_]?[]const u8{ null, "401" }; + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, &usage_overrides); + defer rows.deinit(gpa); + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + try renderSwitchList(&writer, ®, rows.items, idx_width, rows.widths, null, false); + + const output = writer.buffered(); + try std.testing.expect(std.mem.count(u8, output, "401") >= 2); +} diff --git a/src/format.zig b/src/format.zig index 94e89a5..4249312 100644 --- a/src/format.zig +++ b/src/format.zig @@ -24,18 +24,62 @@ fn planDisplay(rec: *const registry.AccountRecord, missing: []const u8) []const } pub fn printAccounts(reg: *registry.Registry) !void { - try printAccountsTable(reg); + try printAccountsWithUsageOverrides(reg, null); } -fn printAccountsTable(reg: *registry.Registry) !void { +pub fn printAccountsWithUsageOverrides( + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +) !void { + try printAccountsTable(reg, usage_overrides); +} + +fn printAccountsTable(reg: *registry.Registry, usage_overrides: ?[]const ?[]const u8) !void { var stdout: io_util.Stdout = undefined; stdout.init(); const out = stdout.out(); - try writeAccountsTable(out, reg, colorEnabled()); + try writeAccountsTableWithUsageOverrides(out, reg, colorEnabled(), usage_overrides); try out.flush(); } fn writeAccountsTable(out: *std.Io.Writer, reg: *registry.Registry, use_color: bool) !void { + try writeAccountsTableWithUsageOverrides(out, reg, use_color, null); +} + +fn usageOverrideForAccount( + usage_overrides: ?[]const ?[]const u8, + account_idx: usize, +) ?[]const u8 { + const overrides = usage_overrides orelse return null; + if (account_idx >= overrides.len) return null; + return overrides[account_idx]; +} + +fn usageCellTextAlloc( + allocator: std.mem.Allocator, + window: ?registry.RateLimitWindow, + max_width: usize, + usage_override: ?[]const u8, +) ![]u8 { + if (usage_override) |value| return allocator.dupe(u8, value); + return formatRateLimitUiAlloc(window, max_width); +} + +fn usageCellFullTextAlloc( + allocator: std.mem.Allocator, + window: ?registry.RateLimitWindow, + usage_override: ?[]const u8, +) ![]u8 { + if (usage_override) |value| return allocator.dupe(u8, value); + return formatRateLimitFullAlloc(window); +} + +fn writeAccountsTableWithUsageOverrides( + out: *std.Io.Writer, + reg: *registry.Registry, + use_color: bool, + usage_overrides: ?[]const ?[]const u8, +) !void { const headers = [_][]const u8{ "ACCOUNT", "PLAN", "5H USAGE", "WEEKLY USAGE", "LAST ACTIVITY" }; var widths = [_]usize{ headers[0].len, @@ -59,9 +103,10 @@ fn writeAccountsTable(out: *std.Io.Writer, reg: *registry.Registry, use_color: b const plan = planDisplay(&rec, "-"); const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); - const rate_5h_str = try formatRateLimitFullAlloc(rate_5h); + const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const rate_5h_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_5h, usage_override); defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try formatRateLimitFullAlloc(rate_week); + const rate_week_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_week, usage_override); defer std.heap.page_allocator.free(rate_week_str); const last_str = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last_str); @@ -113,9 +158,10 @@ fn writeAccountsTable(out: *std.Io.Writer, reg: *registry.Registry, use_color: b const plan = planDisplay(&rec, "-"); const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); - const rate_5h_str = try formatRateLimitUiAlloc(rate_5h, widths[2]); + const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[2], usage_override); defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try formatRateLimitUiAlloc(rate_week, widths[3]); + const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[3], usage_override); defer std.heap.page_allocator.free(rate_week_str); const last = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last); @@ -685,3 +731,21 @@ test "writeAccountsTable shows zero-padded row numbers for selectable accounts" try std.testing.expect(std.mem.indexOf(u8, output, "01 Als's Workspace") != null); try std.testing.expect(std.mem.indexOf(u8, output, "02 free") != null); } + +test "writeAccountsTable shows usage override statuses for failed refreshes" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "user@example.com", "", .free); + + const usage_overrides = [_]?[]const u8{ null, "403" }; + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverrides(&writer, ®, false, &usage_overrides); + + const output = writer.buffered(); + try std.testing.expect(std.mem.count(u8, output, "403") >= 2); +} diff --git a/src/main.zig b/src/main.zig index da9870a..73e831e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,25 +2,93 @@ const std = @import("std"); const account_api = @import("account_api.zig"); const account_name_refresh = @import("account_name_refresh.zig"); const cli = @import("cli.zig"); +const display_rows = @import("display_rows.zig"); const registry = @import("registry.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); +const io_util = @import("io_util.zig"); +const usage_api = @import("usage_api.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; const account_name_refresh_only_env = "CODEX_AUTH_REFRESH_ACCOUNT_NAMES_ONLY"; const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH"; +const foreground_usage_refresh_concurrency: usize = 3; const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, access_token: []const u8, account_id: []const u8, ) anyerror!account_api.FetchResult; +const UsageFetchDetailedFn = *const fn ( + allocator: std.mem.Allocator, + auth_path: []const u8, +) anyerror!usage_api.UsageFetchResult; +const ForegroundUsagePoolInitFn = *const fn ( + pool: *std.Thread.Pool, + allocator: std.mem.Allocator, + n_jobs: usize, +) anyerror!void; const BackgroundRefreshLockAcquirer = *const fn ( allocator: std.mem.Allocator, codex_home: []const u8, ) anyerror!?account_name_refresh.BackgroundRefreshLock; +const ForegroundUsageWorkerResult = struct { + status_code: ?u16 = null, + missing_auth: bool = false, + error_name: ?[]const u8 = null, + snapshot: ?registry.RateLimitSnapshot = null, + + fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + if (self.snapshot) |*snapshot| { + registry.freeRateLimitSnapshot(allocator, snapshot); + self.snapshot = null; + } + } +}; + +pub const ForegroundUsageOutcome = struct { + attempted: bool = false, + status_code: ?u16 = null, + missing_auth: bool = false, + error_name: ?[]const u8 = null, + has_usage_windows: bool = false, + updated: bool = false, + unchanged: bool = false, +}; + +pub const ForegroundUsageRefreshState = struct { + usage_overrides: []?[]const u8, + outcomes: []ForegroundUsageOutcome, + attempted: usize = 0, + updated: usize = 0, + failed: usize = 0, + unchanged: usize = 0, + local_only_mode: bool = false, + + pub fn deinit(self: *ForegroundUsageRefreshState, allocator: std.mem.Allocator) void { + for (self.usage_overrides) |override| { + if (override) |value| allocator.free(value); + } + allocator.free(self.usage_overrides); + allocator.free(self.outcomes); + self.* = undefined; + } +}; + +const DebugUsageLabelState = struct { + labels: [][]const u8, + display_order: []usize, + + fn deinit(self: *DebugUsageLabelState, allocator: std.mem.Allocator) void { + for (self.labels) |label| allocator.free(@constCast(label)); + allocator.free(self.labels); + allocator.free(self.display_order); + self.* = undefined; + } +}; + pub fn main() !void { var exit_code: u8 = 0; runMain() catch |err| { @@ -184,6 +252,24 @@ pub fn loadHelpConfig(allocator: std.mem.Allocator, codex_home: []const u8) Help }; } +fn initForegroundUsageRefreshState( + allocator: std.mem.Allocator, + account_count: usize, +) !ForegroundUsageRefreshState { + const usage_overrides = try allocator.alloc(?[]const u8, account_count); + errdefer allocator.free(usage_overrides); + for (usage_overrides) |*slot| slot.* = null; + + const outcomes = try allocator.alloc(ForegroundUsageOutcome, account_count); + errdefer allocator.free(outcomes); + for (outcomes) |*outcome| outcome.* = .{}; + + return .{ + .usage_overrides = usage_overrides, + .outcomes = outcomes, + }; +} + fn maybeRefreshForegroundUsage( allocator: std.mem.Allocator, codex_home: []const u8, @@ -196,6 +282,347 @@ fn maybeRefreshForegroundUsage( } } +pub fn refreshForegroundUsageForDisplayWithApiFetcher( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + usage_fetcher: UsageFetchDetailedFn, +) !ForegroundUsageRefreshState { + return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( + allocator, + codex_home, + reg, + usage_fetcher, + initForegroundUsagePool, + ); +} + +pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + usage_fetcher: UsageFetchDetailedFn, + pool_init: ForegroundUsagePoolInitFn, +) !ForegroundUsageRefreshState { + var state = try initForegroundUsageRefreshState(allocator, reg.accounts.items.len); + errdefer state.deinit(allocator); + + if (!reg.api.usage) { + state.local_only_mode = true; + if (try auto.refreshActiveUsage(allocator, codex_home, reg)) { + try registry.saveRegistry(allocator, codex_home, reg); + } + return state; + } + + if (reg.accounts.items.len == 0) return state; + + const worker_results = try allocator.alloc(ForegroundUsageWorkerResult, reg.accounts.items.len); + defer { + for (worker_results) |*worker_result| worker_result.deinit(allocator); + allocator.free(worker_results); + } + for (worker_results) |*worker_result| worker_result.* = .{}; + + if (reg.accounts.items.len <= 1) { + runForegroundUsageRefreshWorkersSerially(allocator, codex_home, reg, usage_fetcher, worker_results); + } else { + var thread_safe_allocator: std.heap.ThreadSafeAllocator = .{ .child_allocator = allocator }; + const thread_allocator = thread_safe_allocator.allocator(); + var pool: std.Thread.Pool = undefined; + const pool_started = blk: { + pool_init( + &pool, + thread_allocator, + @min(reg.accounts.items.len, foreground_usage_refresh_concurrency), + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => break :blk false, + }; + break :blk true; + }; + + if (pool_started) { + defer pool.deinit(); + + var wait_group: std.Thread.WaitGroup = .{}; + for (reg.accounts.items, 0..) |_, idx| { + pool.spawnWg(&wait_group, foregroundUsageRefreshWorker, .{ + thread_allocator, + codex_home, + reg, + idx, + usage_fetcher, + worker_results, + }); + } + wait_group.wait(); + } else { + runForegroundUsageRefreshWorkersSerially(allocator, codex_home, reg, usage_fetcher, worker_results); + } + } + + var registry_changed = false; + for (worker_results, 0..) |*worker_result, idx| { + const outcome = &state.outcomes[idx]; + outcome.* = .{ + .attempted = true, + .status_code = worker_result.status_code, + .missing_auth = worker_result.missing_auth, + .error_name = worker_result.error_name, + .has_usage_windows = worker_result.snapshot != null, + }; + state.attempted += 1; + + if (worker_result.snapshot) |snapshot| { + if (registry.rateLimitSnapshotsEqual(reg.accounts.items[idx].last_usage, snapshot)) { + outcome.unchanged = true; + state.unchanged += 1; + worker_result.deinit(allocator); + } else { + registry.updateUsage(allocator, reg, reg.accounts.items[idx].account_key, snapshot); + worker_result.snapshot = null; + outcome.updated = true; + state.updated += 1; + registry_changed = true; + } + } else if (try setForegroundUsageOverrideForOutcome(allocator, &state.usage_overrides[idx], outcome.*)) { + state.failed += 1; + } else { + outcome.unchanged = true; + state.unchanged += 1; + } + } + + if (registry_changed) { + try registry.saveRegistry(allocator, codex_home, reg); + } + + return state; +} + +fn initForegroundUsagePool( + pool: *std.Thread.Pool, + allocator: std.mem.Allocator, + n_jobs: usize, +) !void { + try pool.init(.{ + .allocator = allocator, + .n_jobs = n_jobs, + }); +} + +fn runForegroundUsageRefreshWorkersSerially( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + usage_fetcher: UsageFetchDetailedFn, + results: []ForegroundUsageWorkerResult, +) void { + for (reg.accounts.items, 0..) |_, idx| { + foregroundUsageRefreshWorker(allocator, codex_home, reg, idx, usage_fetcher, results); + } +} + +fn foregroundUsageRefreshWorker( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + account_idx: usize, + usage_fetcher: UsageFetchDetailedFn, + results: []ForegroundUsageWorkerResult, +) void { + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const auth_path = registry.accountAuthPath(arena, codex_home, reg.accounts.items[account_idx].account_key) catch |err| { + results[account_idx] = .{ .error_name = @errorName(err) }; + return; + }; + + const fetch_result = usage_fetcher(arena, auth_path) catch |err| { + results[account_idx] = .{ .error_name = @errorName(err) }; + return; + }; + + var result: ForegroundUsageWorkerResult = .{ + .status_code = fetch_result.status_code, + .missing_auth = fetch_result.missing_auth, + }; + + if (fetch_result.snapshot) |snapshot| { + result.snapshot = registry.cloneRateLimitSnapshot(allocator, snapshot) catch |err| { + results[account_idx] = .{ + .status_code = fetch_result.status_code, + .missing_auth = fetch_result.missing_auth, + .error_name = @errorName(err), + }; + return; + }; + } + + results[account_idx] = result; +} + +fn setForegroundUsageOverrideForOutcome( + allocator: std.mem.Allocator, + slot: *?[]const u8, + outcome: ForegroundUsageOutcome, +) !bool { + if (outcome.error_name) |error_name| { + slot.* = try allocator.dupe(u8, error_name); + return true; + } + if (outcome.missing_auth) { + slot.* = try allocator.dupe(u8, "MissingAuth"); + return true; + } + if (outcome.status_code) |status_code| { + if (status_code != 200) { + slot.* = try std.fmt.allocPrint(allocator, "{d}", .{status_code}); + return true; + } + } + return false; +} + +fn buildDebugUsageLabelState( + allocator: std.mem.Allocator, + reg: *const registry.Registry, +) !DebugUsageLabelState { + var labels = try allocator.alloc([]const u8, reg.accounts.items.len); + errdefer allocator.free(labels); + for (reg.accounts.items, 0..) |rec, idx| { + labels[idx] = try allocator.dupe(u8, rec.email); + } + errdefer { + for (labels) |label| allocator.free(@constCast(label)); + } + + var display = try display_rows.buildDisplayRows(allocator, reg, null); + defer display.deinit(allocator); + var display_order = std.ArrayList(usize).empty; + defer display_order.deinit(allocator); + + for (display.rows) |row| { + const account_idx = row.account_index orelse continue; + const next_label = if (row.depth == 0) + try allocator.dupe(u8, row.account_cell) + else + try std.fmt.allocPrint(allocator, "{s} | {s}", .{ + reg.accounts.items[account_idx].email, + row.account_cell, + }); + allocator.free(@constCast(labels[account_idx])); + labels[account_idx] = next_label; + try display_order.append(allocator, account_idx); + } + + return .{ + .labels = labels, + .display_order = try display_order.toOwnedSlice(allocator), + }; +} + +fn debugStatusLabel(buf: *[32]u8, outcome: ForegroundUsageOutcome) []const u8 { + if (outcome.error_name) |error_name| return error_name; + if (outcome.missing_auth) return "MissingAuth"; + if (outcome.status_code) |status_code| { + return std.fmt.bufPrint(buf, "{d}", .{status_code}) catch "-"; + } + return if (outcome.has_usage_windows) "200" else "-"; +} + +fn outcomeHasNoUsageWindow(outcome: ForegroundUsageOutcome) bool { + return outcome.error_name == null and + !outcome.missing_auth and + !outcome.has_usage_windows and + outcome.status_code != null and + outcome.status_code.? == 200; +} + +fn formatRemainingPercentAlloc( + allocator: std.mem.Allocator, + window: ?registry.RateLimitWindow, +) ![]const u8 { + const remaining = registry.remainingPercentAt(window, std.time.timestamp()) orelse return allocator.dupe(u8, "-"); + return std.fmt.allocPrint(allocator, "{d}%", .{remaining}); +} + +fn printForegroundUsageDebug( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + state: *const ForegroundUsageRefreshState, +) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + + if (state.local_only_mode) { + try out.writeAll("[debug] usage refresh skipped: mode=local-only; only the active account can refresh from local rollout data\n"); + try out.flush(); + return; + } + + var label_state = try buildDebugUsageLabelState(allocator, reg); + defer label_state.deinit(allocator); + + try out.print( + "[debug] usage refresh start: accounts={d} concurrency={d}\n", + .{ + reg.accounts.items.len, + @min(reg.accounts.items.len, foreground_usage_refresh_concurrency), + }, + ); + + for (label_state.display_order) |account_idx| { + if (!state.outcomes[account_idx].attempted) continue; + try out.print("[debug] request usage: {s}\n", .{label_state.labels[account_idx]}); + } + + for (label_state.display_order) |account_idx| { + const outcome = state.outcomes[account_idx]; + if (!outcome.attempted) continue; + + var status_buf: [32]u8 = undefined; + try out.print( + "[debug] response usage: {s} status={s}", + .{ + label_state.labels[account_idx], + debugStatusLabel(&status_buf, outcome), + }, + ); + if (outcomeHasNoUsageWindow(outcome)) { + try out.writeAll(" result=no-usage-limits-window"); + } + try out.writeAll("\n"); + + if (outcome.updated) { + const rate_5h = registry.resolveRateWindow(reg.accounts.items[account_idx].last_usage, 300, true); + const rate_weekly = registry.resolveRateWindow(reg.accounts.items[account_idx].last_usage, 10080, false); + const rate_5h_text = try formatRemainingPercentAlloc(allocator, rate_5h); + defer allocator.free(rate_5h_text); + const rate_weekly_text = try formatRemainingPercentAlloc(allocator, rate_weekly); + defer allocator.free(rate_weekly_text); + try out.print( + "[debug] updated usage: {s} 5h={s} weekly={s}\n", + .{ + label_state.labels[account_idx], + rate_5h_text, + rate_weekly_text, + }, + ); + } + } + + try out.print( + "[debug] usage refresh done: attempted={d} updated={d} failed={d} unchanged={d}\n", + .{ state.attempted, state.updated, state.failed, state.unchanged }, + ); + try out.flush(); +} + pub fn maybeRefreshForegroundAccountNames( allocator: std.mem.Allocator, codex_home: []const u8, @@ -488,7 +915,6 @@ fn loadSingleFileImportAuthInfo( } fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ListOptions) !void { - _ = opts; if (isAccountNameRefreshOnlyMode()) return try runBackgroundAccountNameRefresh(allocator, codex_home, defaultAccountFetcher); var reg = try registry.loadRegistry(allocator, codex_home); @@ -496,9 +922,18 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } - try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); + var usage_state = try refreshForegroundUsageForDisplayWithApiFetcher( + allocator, + codex_home, + ®, + usage_api.fetchUsageForAuthPathDetailed, + ); + defer usage_state.deinit(allocator); try maybeRefreshForegroundAccountNames(allocator, codex_home, ®, .list, defaultAccountFetcher); - try format.printAccounts(®); + if (opts.debug) { + try printForegroundUsageDebug(allocator, ®, &usage_state); + } + try format.printAccountsWithUsageOverrides(®, usage_state.usage_overrides); } fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { @@ -569,7 +1004,13 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } - try maybeRefreshForegroundUsage(allocator, codex_home, ®, .switch_account); + var usage_state = try refreshForegroundUsageForDisplayWithApiFetcher( + allocator, + codex_home, + ®, + usage_api.fetchUsageForAuthPathDetailed, + ); + defer usage_state.deinit(allocator); try maybeRefreshForegroundAccountNames(allocator, codex_home, ®, .switch_account, defaultAccountFetcher); var selected_account_key: ?[]const u8 = null; @@ -585,11 +1026,16 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. if (matches.items.len == 1) { selected_account_key = reg.accounts.items[matches.items[0]].account_key; } else { - selected_account_key = try cli.selectAccountFromIndices(allocator, ®, matches.items); + selected_account_key = try cli.selectAccountFromIndicesWithUsageOverrides( + allocator, + ®, + matches.items, + usage_state.usage_overrides, + ); } if (selected_account_key == null) return; } else { - const selected = try cli.selectAccount(allocator, ®); + const selected = try cli.selectAccountWithUsageOverrides(allocator, ®, usage_state.usage_overrides); if (selected == null) return; selected_account_key = selected.?; } diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index bc43c76..f104910 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -166,6 +166,21 @@ test "Scenario: Given list with extra args when parsing then usage error is retu try expectUsageError(result, .list, "unexpected argument"); } +test "Scenario: Given list with debug flag when parsing then debug mode is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "list", "--debug" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .list => |opts| try std.testing.expect(opts.debug), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given login with removed no-login flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "login", "--no-login" }; @@ -263,7 +278,7 @@ test "Scenario: Given simple command help when rendering then examples are omitt const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); try std.testing.expect(std.mem.indexOf(u8, help, "List available accounts.") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--debug]\n") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Examples:") == null); } diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index d3cb894..a25a2f6 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -4,6 +4,7 @@ const auth_mod = @import("../auth.zig"); const display_rows = @import("../display_rows.zig"); const main_mod = @import("../main.zig"); const registry = @import("../registry.zig"); +const usage_api = @import("../usage_api.zig"); const bdd = @import("bdd_helpers.zig"); const shared_user_id = "user-ESYgcy2QkOGZc0NoxSlFCeVT"; @@ -12,6 +13,7 @@ const secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; const tertiary_account_id = "a4021fa5-998b-4774-989f-784fa69c367b"; const primary_record_key = shared_user_id ++ "::" ++ primary_account_id; const secondary_record_key = shared_user_id ++ "::" ++ secondary_account_id; +const tertiary_record_key = shared_user_id ++ "::" ++ tertiary_account_id; const standalone_team_user_id = "user-q2Lm6Nx8Vc4Rb7Ty1Hp9JkDs"; const standalone_team_account_id = "29a9c0cb-e840-45ec-97bf-d6c5f7e0f55b"; const standalone_team_record_key = standalone_team_user_id ++ "::" ++ standalone_team_account_id; @@ -176,6 +178,7 @@ fn writeAccountSnapshotWithIds( const account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); defer allocator.free(account_key); + try registry.ensureAccountsDir(allocator, codex_home); const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); defer allocator.free(auth_path); @@ -197,6 +200,7 @@ fn writeAccountSnapshotWithIdsAndLastRefresh( const account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); defer allocator.free(account_key); + try registry.ensureAccountsDir(allocator, codex_home); const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); defer allocator.free(auth_path); @@ -331,6 +335,193 @@ test "Scenario: Given foreground usage refresh targets when checking refresh pol try std.testing.expect(!main_mod.shouldRefreshForegroundUsage(.remove_account)); } +test "Scenario: Given api usage refresh for list and switch when refreshing foreground usage then all accounts are updated with status overlays" { + 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); + + const TestUsageFetcher = struct { + fn snapshot(plan: registry.PlanType, used_5h: f64, used_weekly: f64) registry.RateLimitSnapshot { + return .{ + .primary = .{ + .used_percent = used_5h, + .window_minutes = 300, + .resets_at = 1773491460, + }, + .secondary = .{ + .used_percent = used_weekly, + .window_minutes = 10080, + .resets_at = 1773749620, + }, + .credits = null, + .plan_type = plan, + }; + } + + fn fetch(allocator: std.mem.Allocator, auth_path: []const u8) !usage_api.UsageFetchResult { + var info = try auth_mod.parseAuthInfo(allocator, auth_path); + defer info.deinit(allocator); + + const account_id = info.chatgpt_account_id orelse return .{ + .snapshot = null, + .status_code = null, + .missing_auth = true, + }; + + if (std.mem.eql(u8, account_id, primary_account_id)) { + return .{ + .snapshot = snapshot(.team, 18, 39), + .status_code = 200, + }; + } + if (std.mem.eql(u8, account_id, secondary_account_id)) { + return .{ + .snapshot = null, + .status_code = 403, + }; + } + if (std.mem.eql(u8, account_id, tertiary_account_id)) { + return .{ + .snapshot = snapshot(.plus, 30, 55), + .status_code = 200, + }; + } + return .{ + .snapshot = null, + .status_code = 404, + }; + } + }; + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, tertiary_record_key, "user@example.com", "", .plus); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + + reg.accounts.items[2].last_usage = TestUsageFetcher.snapshot(.plus, 30, 55); + reg.accounts.items[2].last_usage_at = 10; + + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, secondary_account_id); + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "plus", shared_user_id, tertiary_account_id); + + var state = try main_mod.refreshForegroundUsageForDisplayWithApiFetcher( + gpa, + codex_home, + ®, + TestUsageFetcher.fetch, + ); + defer state.deinit(gpa); + + try std.testing.expect(!state.local_only_mode); + try std.testing.expectEqual(@as(usize, 3), state.attempted); + try std.testing.expectEqual(@as(usize, 1), state.updated); + try std.testing.expectEqual(@as(usize, 1), state.failed); + try std.testing.expectEqual(@as(usize, 1), state.unchanged); + + try std.testing.expect(state.usage_overrides[0] == null); + try std.testing.expectEqualStrings("403", state.usage_overrides[1].?); + try std.testing.expect(state.usage_overrides[2] == null); + + try std.testing.expect(state.outcomes[0].updated); + try std.testing.expectEqual(@as(?u16, 403), state.outcomes[1].status_code); + try std.testing.expect(state.outcomes[2].unchanged); + + try std.testing.expectEqual(@as(?registry.PlanType, .team), reg.accounts.items[0].last_usage.?.plan_type); + try std.testing.expectEqual(@as(f64, 18), reg.accounts.items[0].last_usage.?.primary.?.used_percent); + try std.testing.expectEqual(@as(f64, 55), reg.accounts.items[2].last_usage.?.secondary.?.used_percent); +} + +test "Scenario: Given thread pool init failure when refreshing foreground usage then it falls back to serial refresh" { + 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); + + const TestUsageFetcher = struct { + fn snapshot(plan: registry.PlanType, used_5h: f64, used_weekly: f64) registry.RateLimitSnapshot { + return .{ + .primary = .{ + .used_percent = used_5h, + .window_minutes = 300, + .resets_at = 1773491460, + }, + .secondary = .{ + .used_percent = used_weekly, + .window_minutes = 10080, + .resets_at = 1773749620, + }, + .credits = null, + .plan_type = plan, + }; + } + + fn fetch(allocator: std.mem.Allocator, auth_path: []const u8) !usage_api.UsageFetchResult { + var info = try auth_mod.parseAuthInfo(allocator, auth_path); + defer info.deinit(allocator); + + const account_id = info.chatgpt_account_id orelse return .{ + .snapshot = null, + .status_code = null, + .missing_auth = true, + }; + + if (std.mem.eql(u8, account_id, primary_account_id)) { + return .{ + .snapshot = snapshot(.team, 22, 41), + .status_code = 200, + }; + } + return .{ + .snapshot = null, + .status_code = 403, + }; + } + + fn failPoolInit( + pool: *std.Thread.Pool, + allocator: std.mem.Allocator, + n_jobs: usize, + ) !void { + _ = pool; + _ = allocator; + _ = n_jobs; + return error.ThreadQuotaExceeded; + } + }; + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, secondary_account_id); + + var state = try main_mod.refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( + gpa, + codex_home, + ®, + TestUsageFetcher.fetch, + TestUsageFetcher.failPoolInit, + ); + defer state.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 2), state.attempted); + try std.testing.expectEqual(@as(usize, 1), state.updated); + try std.testing.expectEqual(@as(usize, 1), state.failed); + try std.testing.expectEqualStrings("403", state.usage_overrides[1].?); + try std.testing.expectEqual(@as(f64, 22), reg.accounts.items[0].last_usage.?.primary.?.used_percent); +} + test "Scenario: Given list with missing team names when running foreground account-name refresh then it waits and saves the updated names" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{});