diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index f64d8b1a..01cf6630 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -50,9 +50,6 @@ jobs: run: | mkdir -p "artifacts/${{ matrix.id }}/binary" cp "zig-out/bin/${{ matrix.binary_name }}" "artifacts/${{ matrix.id }}/binary/${{ matrix.binary_name }}" - if [[ "${{ matrix.id }}" == win32-* ]]; then - cp "zig-out/bin/codex-auth-auto.exe" "artifacts/${{ matrix.id }}/binary/codex-auth-auto.exe" - fi - name: Upload artifact bundle uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f43c487b..433edbff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,9 +172,6 @@ jobs: run: | mkdir -p "artifacts/${{ matrix.id }}/binary" cp "zig-out/bin/${{ matrix.binary_name }}" "artifacts/${{ matrix.id }}/binary/${{ matrix.binary_name }}" - if [[ "${{ matrix.id }}" == win32-* ]]; then - cp "zig-out/bin/codex-auth-auto.exe" "artifacts/${{ matrix.id }}/binary/codex-auth-auto.exe" - fi if [[ "${{ matrix.archive_name }}" == *.zip ]]; then ( cd "artifacts/${{ matrix.id }}/binary" diff --git a/README.md b/README.md index 78804bc2..7a1e7888 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | [`codex-auth remove [--live] [--api\|--skip-api]`](./docs/commands/remove.md) | Remove accounts interactively | | [`codex-auth remove [...]`](./docs/commands/remove.md) | Remove accounts by selector | | [`codex-auth remove --all`](./docs/commands/remove.md) | Remove all stored accounts | -| [`codex-auth status`](./docs/commands/status.md) | Show auto-switch, service, and usage status | ### Import and Maintenance @@ -83,8 +82,6 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| -| [`codex-auth config auto enable\|disable`](./docs/commands/config.md) | Enable or disable background auto-switching | -| [`codex-auth config auto --5h [--weekly ]`](./docs/commands/config.md) | Configure background auto-switch thresholds | | [`codex-auth config live --interval `](./docs/commands/config.md) | Configure live TUI refresh interval | ## Quick Examples @@ -97,7 +94,6 @@ codex-auth switch 02 codex-auth remove work codex-auth import /path/to/auth.json --alias personal codex-auth list --skip-api -codex-auth status ``` ## Q&A diff --git a/build.zig b/build.zig index 3dbabb14..5447ea21 100644 --- a/build.zig +++ b/build.zig @@ -1,23 +1,14 @@ const std = @import("std"); -fn linkWindowsTaskSchedulerLibraries(module: *std.Build.Module) void { - module.linkSystemLibrary("ole32", .{}); - module.linkSystemLibrary("oleaut32", .{}); -} - pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const is_windows = target.result.os.tag == .windows; const package_module = b.createModule(.{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, .link_libc = true, }); - if (is_windows) { - linkWindowsTaskSchedulerLibraries(package_module); - } const main_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), @@ -25,31 +16,12 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - if (is_windows) { - linkWindowsTaskSchedulerLibraries(main_module); - } const exe = b.addExecutable(.{ .name = "codex-auth", .root_module = main_module, }); b.installArtifact(exe); - if (is_windows) { - const auto_module = b.createModule(.{ - .root_source_file = b.path("src/auto_main.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }); - linkWindowsTaskSchedulerLibraries(auto_module); - const auto_exe = b.addExecutable(.{ - .name = "codex-auth-auto", - .root_module = auto_module, - }); - auto_exe.subsystem = .Windows; - b.installArtifact(auto_exe); - } - const fake_node_module = b.createModule(.{ .root_source_file = b.path("tests/fake_node.zig"), .target = target, @@ -73,17 +45,13 @@ pub fn build(b: *std.Build) void { "tests/api_http_test.zig", "tests/api_me_test.zig", "tests/api_usage_test.zig", - "tests/auth_account_test.zig", "tests/auth_test.zig", - "tests/auto_candidate_test.zig", - "tests/auto_daemon_test.zig", "tests/cli_behavior_test.zig", "tests/cli_picker_test.zig", "tests/compat_fs_test.zig", "tests/cli_integration_test.zig", "tests/lib_compile_test.zig", "tests/registry_import_test.zig", - "tests/registry_purge_import_test.zig", "tests/registry_test.zig", "tests/session_test.zig", "tests/terminal_color_test.zig", @@ -107,9 +75,6 @@ pub fn build(b: *std.Build) void { .{ .name = "codex_auth", .module = package_module }, }, }); - if (is_windows) { - linkWindowsTaskSchedulerLibraries(test_module); - } const test_artifact = b.addTest(.{ .root_module = test_module, }); diff --git a/docs/api.md b/docs/api.md index e63ba383..cdc31c8a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,20 +47,14 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - with `--skip-api`, foreground refresh still uses only the active local rollout data because local session files do not identify the other stored accounts - `list` and interactive `switch` use the API-backed path by default; `--api` is accepted as an explicit equivalent - `list --skip-api` and interactive `switch --skip-api` disable the foreground usage API path for that command -- in `switch --live`, the initial live display and later refreshed displays trigger a foreground auto-switch when the active account shows `0%` on the 5h window, `0%` on the weekly window, or a numeric non-`200` usage API status overlay for the active row - `switch --live` still excludes errored rows from candidate selection, and it also skips candidates whose current displayed 5h or weekly value is already `0%` -- with `--skip-api`, `list` and `switch --live` can still refresh only the active account from local rollout data; non-active `switch` rows and non-active foreground auto-switch candidates still come from stored registry data - single-shot `switch --skip-api` skips the pre-render refresh round entirely and shows the stored registry picker directly - `switch ` always resolves selectors locally from stored data and does not accept `--live`, `--api`, or `--skip-api` - interactive `remove`, including `remove --live`, always stays local-only and never makes foreground usage API requests - `remove ` and `remove --all` always resolve selectors from stored local data and do not accept `--live` - single-shot `switch` does not perform another foreground usage refresh after the new account is activated - in `switch --live`, a successful selection patches the current picker state in memory instead of rebuilding it from disk; the active account and `Switched to ...` message both come from the persisted registry state after the local switch succeeds, while the current display keeps its existing usage/account overlays, including any overlay already shown on the newly active row, until the next scheduled live refresh reapplies fresh data asynchronously -- in `switch --live`, a successful manual selection immediately re-runs the foreground auto-switch check on that patched current display instead of waiting for the next scheduled refresh; if the newly active row still shows `0%` or a numeric non-`200` usage overlay in the current display, the auto-switch loop may switch away again right away - in `remove --live`, a successful delete also patches the current picker state in memory; removed rows disappear immediately, surviving overlays stay in place until the next scheduled refresh, and the surviving active account plus the `Removed ...` summary come from the persisted registry state after removal succeeds -- 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 ## Account Name Refresh Rules @@ -73,11 +67,7 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - `switch ` always stays local-only and does not accept `--live`, `--api`, or `--skip-api`. - `remove ` and `remove --all` always stay local-only and do not accept `--live`. - `list` and interactive `switch` load the request auth context from the current active `auth.json` when they do refresh. -- the auto-switch daemon still uses a grouped-scope scan during each cycle when `auto_switch.enabled = true`. -- daemon refreshes load the request auth context from stored account snapshots under `accounts/` and do not depend on the current `auth.json` belonging to the scope being refreshed. -- when multiple stored ChatGPT snapshots exist for one grouped scope, daemon refreshes pick the snapshot with the newest `last_refresh`. - stored snapshots without a usable `access_token` or `chatgpt_account_id` are skipped. -- daemon refreshes do not backfill missing `plan` or `auth_mode` from stored snapshots before deciding whether a grouped Team scope qualifies. At most one `accounts/check` request is attempted per grouped user scope in a given refresh pass. Request failures and unparseable responses are non-fatal and leave stored `account_name` values unchanged. @@ -88,7 +78,6 @@ Grouped account-name refresh always operates on one `chatgpt_user_id` scope at a - `login` and single-file `import` start from the just-parsed auth info - `list` and interactive `switch` start from the current active auth info when foreground refresh is enabled -- the auto-switch daemon scans registry-backed grouped scopes and refreshes each qualifying scope independently That scope includes: @@ -143,4 +132,3 @@ Then: - `Team #1` is filled with `Prod Workspace` - `Team #2` is overwritten from `Old Workspace` to `Sandbox Workspace` -The same grouped-scope rule also applies to synchronous `list` / interactive `switch` refreshes and to the auto-switch daemon. diff --git a/docs/auto-switch.md b/docs/auto-switch.md deleted file mode 100644 index 976735a3..00000000 --- a/docs/auto-switch.md +++ /dev/null @@ -1,114 +0,0 @@ -# Auto-Switch Implementation - -This document is the single source of truth for `codex-auth` background auto-switch behavior. - -It does not describe the foreground `codex-auth switch --live` picker mode. That live picker mode has its own immediate display-driven switching implementation. It reads the same threshold config fields, but it does not share the background watcher candidate scoring or switching path. - -## Commands and Stored Config - -User-facing commands: - -- `codex-auth config auto enable` -- `codex-auth config auto disable` -- `codex-auth config auto [--5h ] [--weekly ]` - -Stored registry fields: - -- `auto_switch.enabled` -- `auto_switch.threshold_5h_percent` -- `auto_switch.threshold_weekly_percent` - -The feature is off by default. - -## Runtime Model - -When enabled, managed services run the long-lived watcher mode: - -- `codex-auth daemon --watch` - -The watcher keeps a single process alive and runs roughly once per second. -Each cycle: - -1. keeps an in-memory candidate index for all non-active accounts, keyed by the same candidate score used for switching -2. reloads `registry.json` only when the on-disk file changed, then rebuilds that in-memory index -3. syncs the currently active `auth.json` into the in-memory registry when the active auth snapshot changed -4. tries to refresh usage from the newest local rollout event first -5. if no new local rollout event is available, or the newest event has no usable rate-limit windows, falls back to the ChatGPT usage API at most once per minute for the current active account -6. keeps the candidate index warm with a bounded candidate upkeep pass instead of batch-refreshing every candidate -7. if the active account should switch, revalidates only the top few stale candidates before making the final switch decision -8. writes `registry.json` only when state changed - -The watcher also emits English-only service logs for debugging: - -- logs use compact `[local]`, `[api]`, and `[switch]` tags -- local rollout captures show the parsed window labels first, then the local-time event timestamp, then the real rollout basename; when the newest local event has no usable usage windows the same `[local]` line also marks `fallback-to-api` -- API refresh logs are reduced to `refresh usage | status=...`, where `status` is the HTTP status when available, `MissingAuth` when the active auth cannot call the ChatGPT usage API, or the direct transport error name such as `TimedOut` / `RequestFailed` - -`daemon --once` still exists for tests and one-shot validation, but the managed service path uses `daemon --watch`. - -## Data Source Priority - -The background watcher is intentionally not API-only. - -- Local rollout events are preferred because they arrive much faster than periodic usage API polling. -- API refresh remains useful as a slower fallback and calibration path when rollout data is missing or stale. -- When a new rollout event arrives but its `rate_limits` payload is `null`, `{}`, or otherwise lacks usable 5h/weekly windows, the watcher keeps the previous `last_usage` snapshot and relies on the API fallback path instead of overwriting usage with empty data. -- The watcher resets the active-account API fallback cooldown when `active_account_key` changes, so a newly active account is not forced to wait behind the previous account's cooldown window. -- API timeout and request-failure logs come from the same 5-second limit used by the underlying request path. - -Local rollout attribution rules are unchanged: - -- only the newest `~/.codex/sessions/**/rollout-*.jsonl` file is considered -- in watcher mode, the newest rollout file is cached in memory and rechecked cheaply between bounded full rescans, so large session trees are not fully walked every second -- the last usable `token_count` event in that file is used -- a newer `token_count` event with unusable `rate_limits` is still treated as a fresh signal for API fallback, but it does not overwrite the stored usage snapshot -- the event is applied only when `event_timestamp_ms >= active_account_activated_at_ms` -- each account remembers its own last consumed local rollout signature `(path, event_timestamp_ms)` so the same local event is not reapplied - -## Switching Rules - -The watcher switches without foreground CLI output when the active account drops below either threshold: - -- `5h remaining < auto_switch.threshold_5h_percent` -- `weekly remaining < auto_switch.threshold_weekly_percent` - -There is one extra near-real-time safety rule for free plans: - -- when the 5h trigger comes from an actual 300-minute window or an unlabeled primary window, the effective 5h threshold for `free` accounts is `max(configured_5h_threshold, 35%)` - -This higher floor exists because free accounts can burn through the last visible quota much faster than once-per-minute checks can react. - -Candidate scoring is reset-aware: - -- if `resets_at <= now`, that window is treated as `100%` -- if both 5h and weekly are known, the candidate score is the lower remaining percentage -- if only one window is known, that window becomes the score -- free accounts that expose only a single `10080`-minute weekly window remain eligible auto-switch candidates and use that weekly remaining percentage as their score -- the watcher keeps that candidate score ordering in a daemon-local in-memory index; it is rebuilt on daemon start or whenever `registry.json` changes externally -- watcher upkeep refreshes at most one stale top candidate per cycle while the current account is still healthy -- when auto-switch is about to leave the current account, the watcher revalidates only the current heap top and then the next top candidates as needed, up to a small bounded budget, instead of refreshing every candidate -- candidate freshness bookkeeping is daemon-local runtime state and is not persisted to `registry.json` -- if no usage snapshot exists after that refresh step, the account is treated as fresh with score `100` -- switching happens only when the best candidate scores strictly better than the current account - -## Service Model - -Platform bootstrap: - -- Linux/WSL: `systemd --user` persistent service -- macOS: `LaunchAgent` with `KeepAlive` -- Windows: user scheduled task with an `ONLOGON` trigger, restart-on-failure settings, and an unlimited execution time for `codex-auth-auto.exe`, plus an immediate `schtasks /Run` during enablement - -Service definition files stay in the platform-standard per-user locations. The managed watcher process uses the current `codex_home` root, so when `CODEX_HOME` is set during enablement the watcher keeps reading and writing that override after it starts in the background. -Foreground commands other than `help`, `version`, `status`, and `daemon` still reconcile the managed service definition after they complete. -`config auto enable` prints a short confirmation after the service is installed. -When migrating from older Linux/WSL timer-based installs, enable/reconcile also removes the legacy `codex-auth-autoswitch.timer` unit file instead of leaving the old minute timer behind. - -## Limits - -The watcher can react within about one polling interval after a new rollout event lands, but it still cannot rescue a request that has already failed because the quota was exhausted inside that same request. - -In other words: - -- this design materially reduces the gap between usage changes and switching -- it does not provide request-level retry/failover by itself diff --git a/docs/commands/README.md b/docs/commands/README.md index df7854e7..63e31005 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -14,8 +14,6 @@ This directory documents command behavior by command. Use `codex-auth | `remove` | [docs/commands/remove.md](./remove.md) | | `clean` | [docs/commands/clean.md](./clean.md) | | `config` | [docs/commands/config.md](./config.md) | -| `status` | [docs/commands/status.md](./status.md) | -| `daemon` | [docs/commands/daemon.md](./daemon.md) | ## Shared Behavior diff --git a/docs/commands/clean.md b/docs/commands/clean.md index 95719ad0..54a089e0 100644 --- a/docs/commands/clean.md +++ b/docs/commands/clean.md @@ -4,6 +4,7 @@ ```shell codex-auth clean +codex-auth clean background ``` ## Behavior @@ -15,6 +16,14 @@ codex-auth clean If `accounts/registry.json` is missing, `clean` still prunes backup files but skips stale snapshot deletion so recovery snapshots remain available for `import --purge` or manual repair. +`clean background` removes legacy background registrations created by older versions: + +- Linux user units named `codex-auth-autoswitch.service` and `codex-auth-autoswitch.timer`. +- macOS LaunchAgent `com.loongphy.codex-auth.auto`. +- Windows scheduled task `CodexAuthAutoSwitch`. + +This is a one-time migration cleanup command. It does not install, start, or run background refresh. + ## Related Docs - Backup behavior: [docs/implement.md](../implement.md) diff --git a/docs/commands/config.md b/docs/commands/config.md index 3efd78e8..6d65f041 100644 --- a/docs/commands/config.md +++ b/docs/commands/config.md @@ -3,33 +3,18 @@ ## Usage ```shell -codex-auth config auto enable -codex-auth config auto disable -codex-auth config auto --5h [--weekly ] -codex-auth config auto --weekly codex-auth config live --interval ``` -## Auto-Switch Config - -`config auto enable` installs or reconciles the managed background watcher. - -- Linux/WSL uses a persistent `systemd --user` service. -- macOS uses a `LaunchAgent`. -- Windows uses a scheduled task that starts the long-running helper at logon and restarts it after failures. - -`config auto disable` removes the managed watcher. - -Threshold flags update the stored background auto-switch thresholds. Auto-switch behavior and platform integration details live in [docs/auto-switch.md](../auto-switch.md). - ## Live Refresh Config `config live --interval ` sets the live TUI refresh interval. - Allowed range: `5` to `3600`. +- Stored in `registry.json` as top-level `interval_seconds`. ## API Refresh -API-backed refresh is the default for supported foreground and background paths. Use per-command `--skip-api` to run a foreground command with local data only. Older `registry.json` files may contain an `api` object; current builds ignore it and omit it on the next registry save. +API-backed refresh is the default for supported foreground paths. Use per-command `--skip-api` to run a foreground command with local data only. Older `registry.json` files may contain an `api` object; current builds ignore it and omit it on the next registry save. API behavior and endpoint details live in [docs/api.md](../api.md). diff --git a/docs/commands/daemon.md b/docs/commands/daemon.md deleted file mode 100644 index 063d90dc..00000000 --- a/docs/commands/daemon.md +++ /dev/null @@ -1,18 +0,0 @@ -# `codex-auth daemon` - -## Usage - -```shell -codex-auth daemon --watch -codex-auth daemon --once -``` - -## Behavior - -`daemon` is the background worker entrypoint used by managed auto-switch services. - -- `--watch` runs the long-lived watcher loop. -- `--once` runs one reconciliation pass and exits. -- Normal users should prefer `codex-auth config auto enable` and `codex-auth config auto disable`. - -Runtime rules for the watcher live in [docs/auto-switch.md](../auto-switch.md). diff --git a/docs/commands/import.md b/docs/commands/import.md index a49f3280..08681f48 100644 --- a/docs/commands/import.md +++ b/docs/commands/import.md @@ -33,7 +33,6 @@ codex-auth import --purge [] - Without a path, it scans `~/.codex/accounts/`. - With a path, it scans auth files from that directory. - It also tries to import the current `~/.codex/auth.json` last. -- It preserves stored `auto_switch` and live refresh configuration. - It clears and rebuilds account records, stored usage, active-account activation time, and local rollout dedupe state. - It does not delete old snapshot files or backups. diff --git a/docs/commands/status.md b/docs/commands/status.md deleted file mode 100644 index 8823f711..00000000 --- a/docs/commands/status.md +++ /dev/null @@ -1,17 +0,0 @@ -# `codex-auth status` - -## Usage - -```shell -codex-auth status -``` - -## Behavior - -Shows local operational state for: - -- auto-switch configuration, -- managed service or scheduled-task status, and -- live refresh interval. - -`status` is read-only and does not reconcile the managed service definition. diff --git a/docs/commands/switch.md b/docs/commands/switch.md index cbddbb34..cf2eb532 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -25,21 +25,6 @@ codex-auth switch - A successful switch patches the current display immediately. - In-flight refresh results are discarded after a manual switch. - Existing usage overlays stay visible until the next scheduled refresh. -- The foreground auto-switch loop is enabled automatically in live mode. - -## Foreground Auto Switch - -`codex-auth switch --live` includes an auto-switch loop in the live picker. -The loop runs once on the initial live display and then runs again after later -live refreshes. - -It switches away from the active account when the current live display shows: - -- `0%` remaining on the 5h window, -- `0%` remaining on the weekly window, or -- a numeric non-`200` usage API status overlay. - -Candidate rows follow the live picker rules. Rows that already show `0%` on 5h or weekly are skipped. ## Query Switch diff --git a/docs/implement.md b/docs/implement.md index d223f610..a2008234 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -6,7 +6,6 @@ This document is the implementation index for `codex-auth`. Command-specific beh - Command behavior: [docs/commands/README.md](./commands/README.md) - API refresh and endpoint rules: [docs/api.md](./api.md) -- Background auto-switching: [docs/auto-switch.md](./auto-switch.md) - File permissions: [docs/permissions.md](./permissions.md) - Schema migration: [docs/schema-migration.md](./schema-migration.md) - Test organization: [docs/tests.md](./tests.md) @@ -33,7 +32,6 @@ Managed files: ## Registry Compatibility - `registry.json.schema_version` is the on-disk migration gate. -- `schema_version = 4` is the current layout with record-keyed snapshots, active-account activation timestamps, per-account local rollout dedupe, and default auto-switch thresholds reset to `1`. - `version = 2` registries using `active_email` and email-keyed snapshots are migrated to the current schema. - Current-layout files that still use the top-level `version = 3` key are rewritten to `schema_version = 4`. - Loading a supported older schema performs the migration in memory and rewrites `registry.json` in the current format. @@ -74,7 +72,7 @@ If `OPENAI_API_KEY` is present, the account is treated as API-key auth. Otherwis - JWT `https://api.openai.com/auth.chatgpt_account_id` - JWT user identity from `chatgpt_user_id` or `user_id` -If account identity fields are missing or mismatched, import/login fails. Existing-registry foreground and background sync skip unsyncable auth files and continue with registry state already on disk. +If account identity fields are missing or mismatched, import/login fails. Existing-registry foreground sync skips unsyncable auth files and continues with registry state already on disk. ## Active Auth Sync @@ -113,7 +111,6 @@ When API usage refresh is disabled, local usage refresh reads Codex rollout file - Rate limits map `window_minutes = 300` to 5h and `window_minutes = 10080` to weekly. - Past reset timestamps render as `100%`. -API-backed refresh details live in [docs/api.md](./api.md). Background watcher refresh details live in [docs/auto-switch.md](./auto-switch.md). ## Display Model diff --git a/docs/release.md b/docs/release.md index 5813d43b..6116aedf 100644 --- a/docs/release.md +++ b/docs/release.md @@ -63,7 +63,6 @@ This document describes the repository's CI, preview package publishing, and tag - The root package exposes the `codex-auth` command and depends on the platform packages through `optionalDependencies`. - Each platform package declares `os` and `cpu`, so npm installs only the matching binary package for the current host platform. - GitHub Release assets and npm packages currently target Linux x64, Linux ARM64, macOS x64, macOS ARM64, Windows x64, and Windows ARM64. -- Windows builds include both `codex-auth.exe` and `codex-auth-auto.exe`; the helper is used only by the managed auto-switch task. - Platform package metadata, target triples, binary names, and GitHub Release archive names are defined in `scripts/npm/metadata.mjs`. - `scripts/npm/check-release-metadata.mjs` verifies that `scripts/npm/metadata.mjs`, `package.json`, `bin/codex-auth.js`, and the release/preview workflow matrices stay aligned. - GitHub Release archives use `.tar.gz` for Linux/macOS targets and `.zip` for Windows targets. diff --git a/docs/schema-migration.md b/docs/schema-migration.md index e48e5438..70cadae4 100644 --- a/docs/schema-migration.md +++ b/docs/schema-migration.md @@ -10,7 +10,6 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r ## Current Policy -- `codex-auth` keeps a single `registry.json`; persisted feature state such as `auto_switch` and `live` stays in that file. - The latest binary supports every released schema. Right now that means: - legacy `version = 2` - current `schema_version = 4` @@ -35,16 +34,18 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - `active_account_key` - `active_account_activated_at_ms` - Per-account `last_local_rollout` - - Current `auto_switch` block - Legacy top-level `api` block, now ignored and omitted on rewrite - Per-account `account_key` - Each account also stores `chatgpt_account_id` and `chatgpt_user_id` - `schema_version = 4` - - Same layout as schema `3` - - Migrates existing `auto_switch.threshold_5h_percent` and `auto_switch.threshold_weekly_percent` values back to the new default `1` + - Same account layout as schema `3` + - Live refresh interval stored as top-level `interval_seconds` + - Older `live.interval_seconds` and removed `auto_switch` blocks are omitted on rewrite ## When To Bump `schema_version` +Before bumping `schema_version`, ask the user whether this change should bump the schema. Do not bump it silently. + Bump the schema version whenever the persisted `registry.json` shape or semantics change. That includes: - Adding, removing, or renaming a persisted field diff --git a/fallback.md b/fallback.md deleted file mode 100644 index c2c94cee..00000000 --- a/fallback.md +++ /dev/null @@ -1,13 +0,0 @@ -# Fallbacks - -## Live refresh falls back to stored registry data - -- Reason: `list --live` and `switch --live` still need a usable selector when a best-effort foreground refresh pass cannot produce an updated display; `switch --live` then reloads the stored view immediately after a successful selection and lets the next scheduled refresh refill any API-backed overlays. -- Protected callers or data: interactive live-mode CLI users and the persisted registry snapshots under the active Codex home. -- Removal conditions: remove this fallback only if live mode is intentionally changed to fail closed. - -## Windows CLI falls back to ASCII-only and ANSI-free status output - -- Reason: Windows PowerShell and ConHost sessions can still decode console bytes with a legacy code page such as CP936, which garbles UTF-8 arrows and checkmarks into text like `鈫?`, and some sessions also leak ANSI reset sequences as visible text such as `0m`; Windows builds therefore use ASCII-only navigation hints and import-status markers and disable ANSI colors until console writes are Unicode-safe end-to-end. -- Protected callers or data: Windows interactive selector users and Windows `import` CLI users reading status output in PowerShell 7, Windows Terminal, or classic console hosts. -- Removal conditions: remove this fallback only after Windows CLI output is emitted through a Unicode-safe path and verified in localized PowerShell/console environments. diff --git a/review.md b/review.md deleted file mode 100644 index 23091140..00000000 --- a/review.md +++ /dev/null @@ -1,62 +0,0 @@ -## Review Notes - -## Account Model - -`chatgpt_user_id` is the primary user identity for account-name sync. - -- One `chatgpt_user_id` represents one user. -- A user can have multiple workspace `chatgpt_account_id` values. -- Those workspace records can cover personal and Team workspaces under the same email. -- We do not treat "same email but different `chatgpt_user_id`" as the grouping rule for this flow. - -### P2 - -Rejected for the reported downgrade scenario. - -`account_id` is a workspace identifier. Personal plans (`free`, `plus`, `pro`) can upgrade or downgrade while keeping the same personal workspace, so their `account_id` stays stable. Team workspaces are different: each team has its own `account_id`, and one user may belong to multiple teams. - -The registry identity is `chatgpt_user_id::chatgpt_account_id`, so a team workspace and a personal workspace are different records by construction. Because of that, the reported "Team account downgraded into plus/pro/free and reused the old Team record" path does not match the account model here. - -In practice: - -- Personal account transitions such as `free -> plus -> pro` keep the same personal `account_id`. -- Team membership is represented by separate workspace `account_id` values. -- A Team workspace record does not become a personal workspace record in place just because the user's personal plan changed. -- Personal accounts do not receive synced workspace `account_name` values in this flow, so the claimed stale Team workspace name does not transfer through the personal upgrade/downgrade path described in the review. - -### P3 - -Accepted. - -The race is not that names are written onto the wrong record by `account_id` matching. The problem is earlier: the detached background refresh is scheduled by one `switch`, but when the child process starts it re-reads the latest `auth.json`, so it may refresh the later active workspace instead of the workspace that triggered the job. - -Current effect: - -- `switch` to workspace A schedules a refresh for A -- before the child starts, another `switch` updates `auth.json` to workspace B -- the first child reads B and refreshes B -- workspace A is left without the expected name backfill - -Simpler direction: - -- let both `list` and `switch` trigger the same detached background refresh -- make that background refresh scan registry snapshots instead of re-reading the current `auth.json` -- for each `chatgpt_user_id` scope that still has grouped Team accounts missing `account_name`, load a stored ChatGPT snapshot token and call the account API once -- apply returned names by `account_id` against the latest registry state - -Multiple workspace records under the same `chatgpt_user_id` are allowed to resolve to the same `account_name`. In that case, duplicate child labels are acceptable, and we do not need to preserve the old grouped fallback labels such as `team #1` and `team #2` once a synced `account_name` is available. - -Example: - -- `user@example.com` / plan=`team` / `account_name="Acme"` -- `user@example.com` / plan=`team` / `account_name="Acme"` - -Rendered output: - -```text -user@example.com - Acme - Acme -``` - -This is acceptable for the new behavior. diff --git a/scripts/npm/check-release-metadata.mjs b/scripts/npm/check-release-metadata.mjs index c1f081e7..b78e8d5f 100644 --- a/scripts/npm/check-release-metadata.mjs +++ b/scripts/npm/check-release-metadata.mjs @@ -71,9 +71,7 @@ function checkPackageMetadata() { } if (pkg.os === "win32") { requireEqual(`${pkg.id}.binaryName`, pkg.binaryName, "codex-auth.exe"); - if (!pkg.binaryFiles.includes("codex-auth.exe") || !pkg.binaryFiles.includes("codex-auth-auto.exe")) { - fail(`${pkg.id}: Windows packages must include codex-auth.exe and codex-auth-auto.exe`); - } + requireEqual(`${pkg.id}.binaryFiles`, pkg.binaryFiles.join(","), "codex-auth.exe"); } else { requireEqual(`${pkg.id}.binaryName`, pkg.binaryName, "codex-auth"); requireEqual(`${pkg.id}.binaryFiles`, pkg.binaryFiles.join(","), "codex-auth"); diff --git a/scripts/npm/metadata.mjs b/scripts/npm/metadata.mjs index b7b74074..e81a8fc0 100644 --- a/scripts/npm/metadata.mjs +++ b/scripts/npm/metadata.mjs @@ -66,7 +66,7 @@ export const platformPackages = [ os: "win32", cpu: "x64", binaryName: "codex-auth.exe", - binaryFiles: ["codex-auth.exe", "codex-auth-auto.exe"], + binaryFiles: ["codex-auth.exe"], archiveName: "codex-auth-Windows-X64.zip", zigTarget: "x86_64-windows-gnu" }, @@ -77,7 +77,7 @@ export const platformPackages = [ os: "win32", cpu: "arm64", binaryName: "codex-auth.exe", - binaryFiles: ["codex-auth.exe", "codex-auth-auto.exe"], + binaryFiles: ["codex-auth.exe"], archiveName: "codex-auth-Windows-ARM64.zip", zigTarget: "aarch64-windows-gnu" } diff --git a/src/auth/account.zig b/src/auth/account.zig deleted file mode 100644 index 8724967b..00000000 --- a/src/auth/account.zig +++ /dev/null @@ -1,143 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const auth = @import("auth.zig"); -const registry = @import("../registry/root.zig"); - -pub const BackgroundRefreshLock = struct { - file: std.Io.File, - - pub fn acquire(allocator: std.mem.Allocator, codex_home: []const u8) !?BackgroundRefreshLock { - try registry.ensureAccountsDir(allocator, codex_home); - const path = try std.fs.path.join(allocator, &[_][]const u8{ - codex_home, - "accounts", - registry.account_name_refresh_lock_file_name, - }); - defer allocator.free(path); - - var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), path, .{ .read = true, .truncate = false }); - errdefer file.close(app_runtime.io()); - if (!(try tryExclusiveLock(file))) { - file.close(app_runtime.io()); - return null; - } - return .{ .file = file }; - } - - pub fn release(self: *BackgroundRefreshLock) void { - self.file.unlock(app_runtime.io()); - self.file.close(app_runtime.io()); - } -}; - -pub const Candidate = struct { - chatgpt_user_id: []u8, - - pub fn deinit(self: *const Candidate, allocator: std.mem.Allocator) void { - allocator.free(self.chatgpt_user_id); - } -}; - -fn hasCandidate(candidates: []const Candidate, chatgpt_user_id: []const u8) bool { - for (candidates) |candidate| { - if (std.mem.eql(u8, candidate.chatgpt_user_id, chatgpt_user_id)) return true; - } - return false; -} - -fn candidateIsNewer(candidate: *const auth.AuthInfo, best: *const auth.AuthInfo) bool { - const candidate_refresh = candidate.last_refresh orelse return false; - const best_refresh = best.last_refresh orelse return true; - return std.mem.order(u8, candidate_refresh, best_refresh) == .gt; -} - -fn tryExclusiveLock(file: std.Io.File) !bool { - return try file.tryLock(app_runtime.io(), .exclusive); -} - -fn storedAuthInfoSupportsAccountNameRefresh(info: *const auth.AuthInfo) bool { - return info.access_token != null and info.chatgpt_account_id != null; -} - -pub fn considerStoredAuthInfoForRefresh( - allocator: std.mem.Allocator, - best_info: *?auth.AuthInfo, - info: auth.AuthInfo, -) void { - if (!storedAuthInfoSupportsAccountNameRefresh(&info)) { - var skipped = info; - skipped.deinit(allocator); - return; - } - - if (best_info.* == null) { - best_info.* = info; - return; - } - - if (candidateIsNewer(&info, &best_info.*.?)) { - var previous = best_info.*.?; - previous.deinit(allocator); - best_info.* = info; - } else { - var rejected = info; - rejected.deinit(allocator); - } -} - -pub fn collectCandidates( - allocator: std.mem.Allocator, - reg: *registry.Registry, -) !std.ArrayList(Candidate) { - var candidates = std.ArrayList(Candidate).empty; - errdefer { - for (candidates.items) |*candidate| candidate.deinit(allocator); - candidates.deinit(allocator); - } - - if (!reg.api.account) return candidates; - - for (reg.accounts.items) |rec| { - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; - if (hasCandidate(candidates.items, rec.chatgpt_user_id)) continue; - if (!registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) continue; - - const duped_id = try allocator.dupe(u8, rec.chatgpt_user_id); - errdefer allocator.free(duped_id); - try candidates.append(allocator, .{ - .chatgpt_user_id = duped_id, - }); - } - - return candidates; -} - -pub fn loadStoredAuthInfoForUser( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - chatgpt_user_id: []const u8, -) !?auth.AuthInfo { - var best_info: ?auth.AuthInfo = null; - errdefer if (best_info) |*info| info.deinit(allocator); - - for (reg.accounts.items) |rec| { - if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; - - const auth_path = try registry.accountAuthPath(allocator, codex_home, rec.account_key); - defer allocator.free(auth_path); - - const info = auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { - error.OutOfMemory => return err, - error.FileNotFound => continue, - else => { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - continue; - }, - }; - considerStoredAuthInfoForRefresh(allocator, &best_info, info); - } - - return best_info; -} diff --git a/src/auto/candidate.zig b/src/auto/candidate.zig deleted file mode 100644 index 628b548c..00000000 --- a/src/auto/candidate.zig +++ /dev/null @@ -1,232 +0,0 @@ -const std = @import("std"); -const registry = @import("../registry/root.zig"); - -pub const CandidateScore = struct { - value: i64, - last_usage_at: i64, - created_at: i64, -}; - -pub const candidate_upkeep_refresh_limit: usize = 1; -pub const candidate_switch_validation_limit: usize = 3; - -pub const CandidateEntry = struct { - account_key: []const u8, - score: CandidateScore, -}; - -pub const CandidateIndex = struct { - heap: std.ArrayListUnmanaged(CandidateEntry) = .empty, - positions: std.StringHashMapUnmanaged(usize) = .empty, - next_score_change_at: ?i64 = null, - - pub fn deinit(self: *CandidateIndex, allocator: std.mem.Allocator) void { - self.heap.deinit(allocator); - self.positions.deinit(allocator); - self.* = .{}; - } - - pub fn rebuild(self: *CandidateIndex, allocator: std.mem.Allocator, reg: *const registry.Registry, now: i64) !void { - self.deinit(allocator); - 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 self.insert(allocator, .{ - .account_key = rec.account_key, - .score = candidateScore(rec, now), - }); - } - self.refreshNextScoreChangeAt(reg, now); - } - - pub fn rebuildIfScoreExpired( - self: *CandidateIndex, - allocator: std.mem.Allocator, - reg: *const registry.Registry, - now: i64, - ) !void { - if (self.next_score_change_at) |deadline| { - if (deadline <= now) { - try self.rebuild(allocator, reg, now); - } - } - } - - pub fn best(self: *const CandidateIndex) ?CandidateEntry { - if (self.heap.items.len == 0) return null; - return self.heap.items[0]; - } - - fn insert(self: *CandidateIndex, allocator: std.mem.Allocator, entry: CandidateEntry) !void { - try self.heap.append(allocator, entry); - const idx = self.heap.items.len - 1; - try self.positions.put(allocator, entry.account_key, idx); - _ = self.siftUp(idx); - } - - fn remove(self: *CandidateIndex, account_key: []const u8) void { - const idx = self.positions.get(account_key) orelse return; - _ = self.positions.remove(account_key); - const last_idx = self.heap.items.len - 1; - if (idx != last_idx) { - self.heap.items[idx] = self.heap.items[last_idx]; - if (self.positions.getPtr(self.heap.items[idx].account_key)) |ptr| { - ptr.* = idx; - } - } - self.heap.items.len = last_idx; - if (idx < self.heap.items.len) { - self.restore(idx); - } - } - - pub fn upsertFromRegistry(self: *CandidateIndex, allocator: std.mem.Allocator, reg: *registry.Registry, account_key: []const u8, now: i64) !void { - if (reg.active_account_key) |active| { - if (std.mem.eql(u8, active, account_key)) { - self.remove(account_key); - self.refreshNextScoreChangeAt(reg, now); - return; - } - } - - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse { - self.remove(account_key); - self.refreshNextScoreChangeAt(reg, now); - return; - }; - const entry: CandidateEntry = .{ - .account_key = reg.accounts.items[idx].account_key, - .score = candidateScore(®.accounts.items[idx], now), - }; - if (self.positions.get(entry.account_key)) |heap_idx| { - self.heap.items[heap_idx] = entry; - self.restore(heap_idx); - self.refreshNextScoreChangeAt(reg, now); - return; - } - try self.insert(allocator, entry); - self.refreshNextScoreChangeAt(reg, now); - } - - pub fn handleActiveSwitch( - self: *CandidateIndex, - allocator: std.mem.Allocator, - reg: *registry.Registry, - old_active_account_key: []const u8, - new_active_account_key: []const u8, - now: i64, - ) !void { - self.remove(new_active_account_key); - try self.upsertFromRegistry(allocator, reg, old_active_account_key, now); - } - - fn refreshNextScoreChangeAt(self: *CandidateIndex, reg: *const registry.Registry, now: i64) void { - const active = reg.active_account_key; - var next_score_change_at: ?i64 = null; - for (reg.accounts.items) |*rec| { - if (active) |account_key| { - if (std.mem.eql(u8, rec.account_key, account_key)) continue; - } - next_score_change_at = earlierFutureTimestamp( - next_score_change_at, - candidateScoreChangeAt(rec.last_usage, now), - now, - ); - } - self.next_score_change_at = next_score_change_at; - } - - pub fn orderedKeys(self: *const CandidateIndex, allocator: std.mem.Allocator) !std.ArrayList([]const u8) { - var ordered = try std.ArrayList([]const u8).initCapacity(allocator, self.heap.items.len); - for (self.heap.items) |entry| { - try ordered.append(allocator, entry.account_key); - } - std.sort.block([]const u8, ordered.items, self, candidateEntryLessThan); - return ordered; - } - - fn candidateEntryLessThan(self: *const CandidateIndex, lhs: []const u8, rhs: []const u8) bool { - const left_idx = self.positions.get(lhs) orelse return false; - const right_idx = self.positions.get(rhs) orelse return false; - const left = self.heap.items[left_idx].score; - const right = self.heap.items[right_idx].score; - return candidateBetter(left, right); - } - - fn restore(self: *CandidateIndex, idx: usize) void { - if (!self.siftUp(idx)) { - self.siftDown(idx); - } - } - - fn siftUp(self: *CandidateIndex, start_idx: usize) bool { - var idx = start_idx; - var moved = false; - while (idx > 0) { - const parent_idx = (idx - 1) / 2; - if (!candidateBetter(self.heap.items[idx].score, self.heap.items[parent_idx].score)) break; - self.swap(idx, parent_idx); - idx = parent_idx; - moved = true; - } - return moved; - } - - fn siftDown(self: *CandidateIndex, start_idx: usize) void { - var idx = start_idx; - while (true) { - const left = idx * 2 + 1; - if (left >= self.heap.items.len) break; - const right = left + 1; - var best_idx = left; - if (right < self.heap.items.len and candidateBetter(self.heap.items[right].score, self.heap.items[left].score)) { - best_idx = right; - } - if (!candidateBetter(self.heap.items[best_idx].score, self.heap.items[idx].score)) break; - self.swap(idx, best_idx); - idx = best_idx; - } - } - - fn swap(self: *CandidateIndex, a: usize, b: usize) void { - if (a == b) return; - std.mem.swap(CandidateEntry, &self.heap.items[a], &self.heap.items[b]); - if (self.positions.getPtr(self.heap.items[a].account_key)) |ptr| ptr.* = a; - if (self.positions.getPtr(self.heap.items[b].account_key)) |ptr| ptr.* = b; - } -}; - -pub fn candidateScore(rec: *const registry.AccountRecord, now: i64) CandidateScore { - const usage_score = registry.usageScoreAt(rec.last_usage, now) orelse 100; - return .{ - .value = usage_score, - .last_usage_at = rec.last_usage_at orelse -1, - .created_at = rec.created_at, - }; -} - -pub fn candidateBetter(a: CandidateScore, b: CandidateScore) bool { - if (a.value != b.value) return a.value > b.value; - 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; -} - -pub fn candidateScoreChangeAt(usage: ?registry.RateLimitSnapshot, now: i64) ?i64 { - if (usage == null) return null; - var next_change_at: ?i64 = null; - if (usage.?.primary) |window| { - next_change_at = earlierFutureTimestamp(next_change_at, window.resets_at, now); - } - if (usage.?.secondary) |window| { - next_change_at = earlierFutureTimestamp(next_change_at, window.resets_at, now); - } - return next_change_at; -} - -pub fn earlierFutureTimestamp(current: ?i64, candidate: ?i64, now: i64) ?i64 { - if (candidate == null or candidate.? <= now) return current; - if (current == null) return candidate.?; - return @min(current.?, candidate.?); -} diff --git a/src/auto/files.zig b/src/auto/files.zig deleted file mode 100644 index 69e65a94..00000000 --- a/src/auto/files.zig +++ /dev/null @@ -1,10 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); - -pub fn fileMtimeNsIfExists(path: []const u8) !?i128 { - const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), path, .{}) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return stat.mtime.nanoseconds; -} diff --git a/src/auto/logging.zig b/src/auto/logging.zig deleted file mode 100644 index 4af86ddc..00000000 --- a/src/auto/logging.zig +++ /dev/null @@ -1,172 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const builtin = @import("builtin"); -const registry = @import("../registry/root.zig"); -const c_time = @cImport({ - @cInclude("time.h"); -}); - -pub fn writeAutoSwitchLogLine( - out: *std.Io.Writer, - from: *const registry.AccountRecord, - to: *const registry.AccountRecord, -) !void { - try out.print("[switch] {s} -> {s}\n", .{ from.email, to.email }); - try out.flush(); -} - -pub fn emitAutoSwitchLog(from: *const registry.AccountRecord, to: *const registry.AccountRecord) void { - var stderr_buffer: [256]u8 = undefined; - var writer = std.Io.File.stderr().writer(app_runtime.io(), &stderr_buffer); - writeAutoSwitchLogLine(&writer.interface, from, to) catch {}; -} - -pub const DaemonLogPriority = enum { - err, - warning, - notice, - info, - debug, -}; - -pub fn emitDaemonLog(priority: DaemonLogPriority, comptime fmt: []const u8, args: anytype) void { - _ = priority; - - var stderr_buffer: [512]u8 = undefined; - var writer = std.Io.File.stderr().writer(app_runtime.io(), &stderr_buffer); - writer.interface.print(fmt ++ "\n", args) catch {}; - writer.interface.flush() catch {}; -} - -pub fn emitTaggedDaemonLog( - priority: DaemonLogPriority, - tag: []const u8, - comptime fmt: []const u8, - args: anytype, -) void { - _ = priority; - - var stderr_buffer: [1024]u8 = undefined; - var writer = std.Io.File.stderr().writer(app_runtime.io(), &stderr_buffer); - writer.interface.print("[{s}] ", .{tag}) catch {}; - writer.interface.print(fmt ++ "\n", args) catch {}; - writer.interface.flush() catch {}; -} - -fn percentLabel(buf: *[5]u8, value: ?i64) []const u8 { - const percent = value orelse return "-"; - const clamped = @min(@max(percent, 0), 100); - return std.fmt.bufPrint(buf, "{d}%", .{clamped}) catch "-"; -} - -pub fn localDateTimeLabel(buf: *[19]u8, timestamp_ms: i64) []const u8 { - const seconds = @divTrunc(timestamp_ms, std.time.ms_per_s); - var tm: c_time.struct_tm = undefined; - if (!localtimeCompat(seconds, &tm)) return "-"; - const year: u32 = @intCast(tm.tm_year + 1900); - const month: u32 = @intCast(tm.tm_mon + 1); - const day: u32 = @intCast(tm.tm_mday); - const hour: u32 = @intCast(tm.tm_hour); - const minute: u32 = @intCast(tm.tm_min); - const second: u32 = @intCast(tm.tm_sec); - return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{ - year, - month, - day, - hour, - minute, - second, - }) catch "-"; -} - -pub fn rolloutFileLabel(buf: *[96]u8, path: []const u8) []const u8 { - const basename = std.fs.path.basename(path); - return std.fmt.bufPrint(buf, "{s}", .{basename}) catch basename; -} - -fn localtimeCompat(ts: i64, out_tm: *c_time.struct_tm) bool { - if (comptime builtin.os.tag == .windows) { - if (comptime @hasDecl(c_time, "_localtime64_s") and @hasDecl(c_time, "__time64_t")) { - var t64 = std.math.cast(c_time.__time64_t, ts) orelse return false; - return c_time._localtime64_s(out_tm, &t64) == 0; - } - return false; - } - - var t = std.math.cast(c_time.time_t, ts) orelse return false; - if (comptime @hasDecl(c_time, "localtime_r")) { - return c_time.localtime_r(&t, out_tm) != null; - } - - if (comptime @hasDecl(c_time, "localtime")) { - const tm_ptr = c_time.localtime(&t); - if (tm_ptr == null) return false; - out_tm.* = tm_ptr.*; - return true; - } - - return false; -} - -fn windowDurationLabel(buf: *[16]u8, window_minutes: ?i64) []const u8 { - const minutes = window_minutes orelse return "unlabeled"; - if (minutes <= 0) return "unlabeled"; - if (@mod(minutes, 24 * 60) == 0) { - return std.fmt.bufPrint(buf, "{d}d", .{@divExact(minutes, 24 * 60)}) catch "unlabeled"; - } - if (@mod(minutes, 60) == 0) { - return std.fmt.bufPrint(buf, "{d}h", .{@divExact(minutes, 60)}) catch "unlabeled"; - } - return std.fmt.bufPrint(buf, "{d}m", .{minutes}) catch "unlabeled"; -} - -fn windowSnapshotLabel(buf: *[32]u8, window: ?registry.RateLimitWindow, now: i64) []const u8 { - const resolved = window orelse return "-"; - var percent_buf: [5]u8 = undefined; - var duration_buf: [16]u8 = undefined; - return std.fmt.bufPrint(buf, "{s}@{s}", .{ - percentLabel(&percent_buf, registry.remainingPercentAt(resolved, now)), - windowDurationLabel(&duration_buf, resolved.window_minutes), - }) catch "-"; -} - -fn windowUsageEntryLabel(buf: *[24]u8, window: ?registry.RateLimitWindow, now: i64) []const u8 { - const resolved = window orelse return ""; - var percent_buf: [5]u8 = undefined; - var duration_buf: [16]u8 = undefined; - return std.fmt.bufPrint(buf, "{s}={s}", .{ - windowDurationLabel(&duration_buf, resolved.window_minutes), - percentLabel(&percent_buf, registry.remainingPercentAt(resolved, now)), - }) catch ""; -} - -pub fn rolloutWindowsLabel(buf: *[64]u8, snapshot: registry.RateLimitSnapshot, now: i64) []const u8 { - var primary_buf: [24]u8 = undefined; - var secondary_buf: [24]u8 = undefined; - const primary = windowUsageEntryLabel(&primary_buf, snapshot.primary, now); - const secondary = windowUsageEntryLabel(&secondary_buf, snapshot.secondary, now); - - if (primary.len != 0 and secondary.len != 0) { - return std.fmt.bufPrint(buf, "{s} {s}", .{ primary, secondary }) catch primary; - } - if (primary.len != 0) { - return std.fmt.bufPrint(buf, "{s}", .{primary}) catch "no-usage-limits-window"; - } - if (secondary.len != 0) { - return std.fmt.bufPrint(buf, "{s}", .{secondary}) catch "no-usage-limits-window"; - } - return "no-usage-limits-window"; -} - -pub fn apiStatusLabel(buf: *[24]u8, status_code: ?u16, has_usage_windows: bool, missing_auth: bool) []const u8 { - if (missing_auth) return "MissingAuth"; - if (status_code) |status| { - if (status == 200 and !has_usage_windows) return "NoUsageLimitsWindow"; - return std.fmt.bufPrint(buf, "{d}", .{status}) catch "-"; - } - return if (has_usage_windows) "-" else "NoUsageLimitsWindow"; -} - -pub fn fieldSeparator() []const u8 { - return " | "; -} diff --git a/src/auto/root.zig b/src/auto/root.zig deleted file mode 100644 index 70d31e61..00000000 --- a/src/auto/root.zig +++ /dev/null @@ -1,343 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const account_api = @import("../api/account.zig"); -const account_name_refresh = @import("../auth/account.zig"); -const auth = @import("../auth/auth.zig"); -const builtin = @import("builtin"); -const c_time = @cImport({ - @cInclude("time.h"); -}); -const cli = @import("../cli/root.zig"); -const io_util = @import("../core/io_util.zig"); -const registry = @import("../registry/root.zig"); -const sessions = @import("../session.zig"); -const terminal_color = @import("../terminal/color.zig"); -const usage_api = @import("../api/usage.zig"); -const service = @import("service.zig"); - -pub const RuntimeState = service.RuntimeState; -const queryRuntimeState = service.queryRuntimeState; -const installService = service.installService; -const uninstallService = service.uninstallService; -const linuxUserSystemdAvailable = service.linuxUserSystemdAvailable; -pub const managedServiceSelfExePath = service.managedServiceSelfExePath; -const currentServiceDefinitionMatches = service.currentServiceDefinitionMatches; -pub const deleteAbsoluteFileIfExists = service.deleteAbsoluteFileIfExists; -pub const linuxUnitText = service.linuxUnitText; -pub const macPlistText = service.macPlistText; -pub const windowsTaskAction = service.windowsTaskAction; -pub const windowsRegisterTaskScript = service.windowsRegisterTaskScript; -pub const windowsTaskMatchScript = service.windowsTaskMatchScript; -pub const windowsEndTaskScript = service.windowsEndTaskScript; -pub const windowsDeleteTaskScript = service.windowsDeleteTaskScript; -pub const windowsTaskStateScript = service.windowsTaskStateScript; -pub const parseWindowsTaskStateOutput = service.parseWindowsTaskStateOutput; -pub const managedServiceSelfExePathFromDir = service.managedServiceSelfExePathFromDir; - -const candidate_mod = @import("candidate.zig"); -const status_mod = @import("status.zig"); -const logging = @import("logging.zig"); -const files = @import("files.zig"); -const state = @import("state.zig"); -const usage_refresh = @import("usage_refresh.zig"); -const switching = @import("switching.zig"); - -pub const Status = status_mod.Status; -pub const CandidateScore = candidate_mod.CandidateScore; -pub const CandidateEntry = candidate_mod.CandidateEntry; -pub const CandidateIndex = candidate_mod.CandidateIndex; -pub const DaemonRefreshState = state.DaemonRefreshState; -const api_refresh_interval_ns = state.api_refresh_interval_ns; -const candidate_switch_validation_limit = candidate_mod.candidate_switch_validation_limit; -const candidateScore = candidate_mod.candidateScore; -const candidateBetter = candidate_mod.candidateBetter; - -pub const helpStateLabel = status_mod.helpStateLabel; -pub const printStatus = status_mod.printStatus; -pub const getStatus = status_mod.getStatus; -pub const writeStatus = status_mod.writeStatus; - -pub const writeAutoSwitchLogLine = logging.writeAutoSwitchLogLine; -const emitAutoSwitchLog = logging.emitAutoSwitchLog; -const emitDaemonLog = logging.emitDaemonLog; -const emitTaggedDaemonLog = logging.emitTaggedDaemonLog; -const localDateTimeLabel = logging.localDateTimeLabel; -const rolloutFileLabel = logging.rolloutFileLabel; -const rolloutWindowsLabel = logging.rolloutWindowsLabel; -const apiStatusLabel = logging.apiStatusLabel; -const fieldSeparator = logging.fieldSeparator; -const fileMtimeNsIfExists = files.fileMtimeNsIfExists; -pub const refreshActiveUsage = usage_refresh.refreshActiveUsage; -const fetchActiveAccountNames = usage_refresh.fetchActiveAccountNames; -pub const refreshActiveAccountNamesForDaemonWithFetcher = usage_refresh.refreshActiveAccountNamesForDaemonWithFetcher; -const refreshActiveUsageForDaemon = usage_refresh.refreshActiveUsageForDaemon; -pub const refreshActiveUsageForDaemonWithApiFetcher = usage_refresh.refreshActiveUsageForDaemonWithApiFetcher; -pub const refreshActiveUsageWithApiFetcher = usage_refresh.refreshActiveUsageWithApiFetcher; - -pub const AutoSwitchAttempt = switching.AutoSwitchAttempt; -pub const bestAutoSwitchCandidateIndex = switching.bestAutoSwitchCandidateIndex; -pub const shouldSwitchCurrent = switching.shouldSwitchCurrent; -pub const maybeAutoSwitch = switching.maybeAutoSwitch; -pub const maybeAutoSwitchWithUsageFetcher = switching.maybeAutoSwitchWithUsageFetcher; -pub const maybeAutoSwitchForDaemonWithUsageFetcher = switching.maybeAutoSwitchForDaemonWithUsageFetcher; - -const lock_file_name = "auto-switch.lock"; -const watch_poll_interval_ns = 1 * std.time.ns_per_s; -const DaemonLock = struct { - file: std.Io.File, - - fn acquire(allocator: std.mem.Allocator, codex_home: []const u8) !?DaemonLock { - const path = try std.fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts", lock_file_name }); - defer allocator.free(path); - var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), path, .{ .read = true, .truncate = false }); - errdefer file.close(app_runtime.io()); - if (!(try tryExclusiveLock(file))) { - file.close(app_runtime.io()); - return null; - } - return .{ .file = file }; - } - - fn release(self: *DaemonLock) void { - self.file.unlock(app_runtime.io()); - self.file.close(app_runtime.io()); - } -}; - -fn tryExclusiveLock(file: std.Io.File) !bool { - return try file.tryLock(app_runtime.io(), .exclusive); -} - -pub fn handleAutoCommand(allocator: std.mem.Allocator, codex_home: []const u8, cmd: cli.types.AutoOptions) !void { - switch (cmd) { - .action => |action| switch (action) { - .enable => try enable(allocator, codex_home), - .disable => try disable(allocator, codex_home), - }, - .configure => |opts| try configureThresholds(allocator, codex_home, opts), - } -} - -pub fn shouldEnsureManagedService(enabled: bool, runtime: RuntimeState, definition_matches: bool) bool { - if (!enabled) return false; - return runtime != .running or !definition_matches; -} - -pub fn supportsManagedServiceOnPlatform(os_tag: std.Target.Os.Tag) bool { - return switch (os_tag) { - .linux, .macos, .windows => true, - else => false, - }; -} - -pub fn reconcileManagedService(allocator: std.mem.Allocator, codex_home: []const u8) !void { - if (!supportsManagedServiceOnPlatform(builtin.os.tag)) return; - - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - - if (!reg.auto_switch.enabled) { - try uninstallService(allocator, codex_home); - return; - } - - if (builtin.os.tag == .linux and !linuxUserSystemdAvailable(allocator)) return; - - const runtime = queryRuntimeState(allocator); - const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); - defer allocator.free(self_exe); - const managed_self_exe = try managedServiceSelfExePath(allocator, self_exe); - defer allocator.free(managed_self_exe); - const definition_matches = try currentServiceDefinitionMatches(allocator, codex_home, managed_self_exe); - if (!shouldEnsureManagedService(reg.auto_switch.enabled, runtime, definition_matches)) return; - - try installService(allocator, codex_home, managed_self_exe); -} - -pub fn runDaemon(allocator: std.mem.Allocator, codex_home: []const u8) !void { - try registry.ensureAccountsDir(allocator, codex_home); - var daemon_lock = (try DaemonLock.acquire(allocator, codex_home)) orelse return; - defer daemon_lock.release(); - var refresh_state = DaemonRefreshState{}; - defer refresh_state.deinit(allocator); - - while (true) { - const keep_running = daemonCycle(allocator, codex_home, &refresh_state) catch |err| blk: { - std.log.err("auto daemon cycle failed: {s}", .{@errorName(err)}); - break :blk true; - }; - if (!keep_running) return; - try std.Io.sleep(app_runtime.io(), .fromNanoseconds(watch_poll_interval_ns), .awake); - } -} - -pub fn runDaemonOnce(allocator: std.mem.Allocator, codex_home: []const u8) !void { - try registry.ensureAccountsDir(allocator, codex_home); - var daemon_lock = (try DaemonLock.acquire(allocator, codex_home)) orelse return; - defer daemon_lock.release(); - - var refresh_state = DaemonRefreshState{}; - defer refresh_state.deinit(allocator); - _ = try daemonCycle(allocator, codex_home, &refresh_state); -} - -fn daemonCycleWithAccountNameFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - refresh_state: *DaemonRefreshState, - account_name_fetcher: anytype, -) !bool { - var reg = try refresh_state.ensureRegistryLoaded(allocator, codex_home); - if (!reg.auto_switch.enabled) return false; - - var changed = false; - if (try refresh_state.syncActiveAuthIfChanged(allocator, codex_home)) { - changed = true; - } - - if (changed) { - try registry.saveRegistry(allocator, codex_home, reg); - try refresh_state.refreshTrackedFileMtims(allocator, codex_home); - changed = false; - } - - if (try refreshActiveAccountNamesForDaemonWithFetcher(allocator, codex_home, reg, refresh_state, account_name_fetcher)) { - changed = true; - } - try refresh_state.reloadRegistryStateIfChanged(allocator, codex_home); - reg = refresh_state.currentRegistry(); - if (!reg.auto_switch.enabled) return true; - - if (try refreshActiveUsageForDaemon(allocator, codex_home, reg, refresh_state)) { - changed = true; - } - const active_idx_before = if (reg.active_account_key) |account_key| - registry.findAccountIndexByAccountKey(reg, account_key) - else - null; - const auto_switch_attempt = try maybeAutoSwitchForDaemonWithUsageFetcher(allocator, codex_home, reg, refresh_state, usage_api.fetchUsageForAuthPathDetailed); - if (auto_switch_attempt.state_changed or auto_switch_attempt.switched) { - changed = true; - } - if (auto_switch_attempt.switched) { - if (active_idx_before) |from_idx| { - if (reg.active_account_key) |account_key| { - if (registry.findAccountIndexByAccountKey(reg, account_key)) |to_idx| { - emitAutoSwitchLog(®.accounts.items[from_idx], ®.accounts.items[to_idx]); - } - } - } - } - - if (changed) { - try registry.saveRegistry(allocator, codex_home, reg); - try refresh_state.refreshTrackedFileMtims(allocator, codex_home); - } - return true; -} - -fn daemonCycle(allocator: std.mem.Allocator, codex_home: []const u8, refresh_state: *DaemonRefreshState) !bool { - return daemonCycleWithAccountNameFetcher(allocator, codex_home, refresh_state, fetchActiveAccountNames); -} - -pub fn daemonCycleWithAccountNameFetcherForTest( - allocator: std.mem.Allocator, - codex_home: []const u8, - refresh_state: *DaemonRefreshState, - account_name_fetcher: anytype, -) !bool { - return daemonCycleWithAccountNameFetcher(allocator, codex_home, refresh_state, account_name_fetcher); -} - -fn enable(allocator: std.mem.Allocator, codex_home: []const u8) !void { - const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); - defer allocator.free(self_exe); - const managed_self_exe = try managedServiceSelfExePath(allocator, self_exe); - defer allocator.free(managed_self_exe); - try enableWithServiceHooks(allocator, codex_home, managed_self_exe, installService, uninstallService); -} - -fn ensureAutoSwitchCanEnable(allocator: std.mem.Allocator) !void { - if (builtin.os.tag == .linux and !linuxUserSystemdAvailable(allocator)) { - std.log.err("cannot enable auto-switch: systemd --user is unavailable", .{}); - return error.CommandFailed; - } -} - -pub fn enableWithServiceHooks( - allocator: std.mem.Allocator, - codex_home: []const u8, - self_exe: []const u8, - installer: anytype, - uninstaller: anytype, -) !void { - try enableWithServiceHooksAndPreflight( - allocator, - codex_home, - self_exe, - installer, - uninstaller, - ensureAutoSwitchCanEnable, - ); -} - -pub fn enableWithServiceHooksAndPreflight( - allocator: std.mem.Allocator, - codex_home: []const u8, - self_exe: []const u8, - installer: anytype, - uninstaller: anytype, - preflight: anytype, -) !void { - try preflight(allocator); - - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - - reg.auto_switch.enabled = true; - try registry.saveRegistry(allocator, codex_home, ®); - errdefer { - reg.auto_switch.enabled = false; - registry.saveRegistry(allocator, codex_home, ®) catch {}; - } - // Service installation can partially succeed on some platforms, so clean up - // any managed artifacts before persisting the disabled rollback state. - errdefer uninstaller(allocator, codex_home) catch {}; - try installer(allocator, codex_home, self_exe); - printAutoEnableUsageNote() catch |err| { - std.log.warn("failed to print auto-enable usage note: {}", .{err}); - }; -} - -fn printAutoEnableUsageNote() !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - try out.writeAll("auto-switch enabled\n"); - try out.flush(); -} - -fn disable(allocator: std.mem.Allocator, codex_home: []const u8) !void { - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - reg.auto_switch.enabled = false; - try registry.saveRegistry(allocator, codex_home, ®); - try uninstallService(allocator, codex_home); -} - -pub fn applyThresholdConfig(cfg: *registry.AutoSwitchConfig, opts: cli.types.AutoThresholdOptions) void { - if (opts.threshold_5h_percent) |value| { - cfg.threshold_5h_percent = value; - } - if (opts.threshold_weekly_percent) |value| { - cfg.threshold_weekly_percent = value; - } -} - -fn configureThresholds(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.AutoThresholdOptions) !void { - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - applyThresholdConfig(®.auto_switch, opts); - try registry.saveRegistry(allocator, codex_home, ®); - try printStatus(allocator, codex_home); -} diff --git a/src/auto/service.zig b/src/auto/service.zig deleted file mode 100644 index 06da91a1..00000000 --- a/src/auto/service.zig +++ /dev/null @@ -1,350 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const builtin = @import("builtin"); -const registry = @import("../registry/root.zig"); -const service_defs = @import("service_defs.zig"); -const service_windows = @import("service_windows.zig"); -const windows_task_scheduler = @import("../platform/windows.zig"); - -const linux_service_name = service_defs.linux_service_name; -const linux_timer_name = service_defs.linux_timer_name; -const mac_label = service_defs.mac_label; -const windows_task_name = service_defs.windows_task_name; -const windows_helper_name = service_defs.windows_helper_name; -const windows_task_trigger_kind = service_defs.windows_task_trigger_kind; -const windows_task_restart_count = service_defs.windows_task_restart_count; -const windows_task_restart_count_value = service_defs.windows_task_restart_count_value; -const windows_task_restart_interval_xml = service_defs.windows_task_restart_interval_xml; -const windows_task_execution_time_limit_xml = service_defs.windows_task_execution_time_limit_xml; - -pub const RuntimeState = service_defs.RuntimeState; -pub const linuxUnitText = service_defs.linuxUnitText; -pub const macPlistText = service_defs.macPlistText; -pub const windowsTaskAction = service_defs.windowsTaskAction; -pub const windowsRegisterTaskScript = service_defs.windowsRegisterTaskScript; -pub const windowsTaskMatchScript = service_defs.windowsTaskMatchScript; -pub const windowsEndTaskScript = service_defs.windowsEndTaskScript; -pub const windowsDeleteTaskScript = service_defs.windowsDeleteTaskScript; -pub const windowsTaskStateScript = service_defs.windowsTaskStateScript; -pub const parseWindowsTaskStateOutput = service_defs.parseWindowsTaskStateOutput; - -const windowsTaskArguments = service_defs.windowsTaskArguments; - -pub fn queryRuntimeState(allocator: std.mem.Allocator) RuntimeState { - return switch (builtin.os.tag) { - .linux => queryLinuxRuntimeState(allocator), - .macos => queryMacRuntimeState(allocator), - .windows => queryWindowsRuntimeState(allocator), - else => .unknown, - }; -} - -pub fn installService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { - switch (builtin.os.tag) { - .linux => try installLinuxService(allocator, codex_home, self_exe), - .macos => try installMacService(allocator, codex_home, self_exe), - .windows => try installWindowsService(allocator, codex_home, self_exe), - else => return error.UnsupportedPlatform, - } -} - -pub fn uninstallService(allocator: std.mem.Allocator, codex_home: []const u8) !void { - switch (builtin.os.tag) { - .linux => try uninstallLinuxService(allocator, codex_home), - .macos => try uninstallMacService(allocator, codex_home), - .windows => try uninstallWindowsService(allocator), - else => return error.UnsupportedPlatform, - } -} - -fn installLinuxService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { - const unit_path = try linuxUnitPath(allocator, linux_service_name); - defer allocator.free(unit_path); - const unit_text = try linuxUnitText(allocator, self_exe, codex_home); - defer allocator.free(unit_text); - - const unit_dir = std.fs.path.dirname(unit_path).?; - try std.Io.Dir.cwd().createDirPath(app_runtime.io(), unit_dir); - try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = unit_path, .data = unit_text }); - try removeLinuxUnit(allocator, linux_timer_name); - try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "daemon-reload" }); - try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "enable", linux_service_name }); - switch (queryLinuxRuntimeState(allocator)) { - .running => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "restart", linux_service_name }), - else => try runChecked(allocator, &[_][]const u8{ "systemctl", "--user", "start", linux_service_name }), - } -} - -fn uninstallLinuxService(allocator: std.mem.Allocator, codex_home: []const u8) !void { - _ = codex_home; - try removeLinuxUnit(allocator, linux_timer_name); - try removeLinuxUnit(allocator, linux_service_name); -} - -fn removeLinuxUnit(allocator: std.mem.Allocator, service_name: []const u8) !void { - const unit_path = try linuxUnitPath(allocator, service_name); - defer allocator.free(unit_path); - runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "stop", service_name }); - runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "disable", service_name }); - runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "reset-failed", service_name }); - deleteAbsoluteFileIfExists(unit_path); - runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "daemon-reload" }); -} - -pub fn linuxUserSystemdAvailable(allocator: std.mem.Allocator) bool { - const result = runCapture(allocator, &[_][]const u8{ "systemctl", "--user", "show-environment" }) catch return false; - defer { - allocator.free(result.stdout); - allocator.free(result.stderr); - } - return switch (result.term) { - .exited => |code| code == 0, - else => false, - }; -} - -fn installMacService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { - const plist_path = try macPlistPath(allocator); - defer allocator.free(plist_path); - const plist = try macPlistText(allocator, self_exe, codex_home); - defer allocator.free(plist); - - const dir = std.fs.path.dirname(plist_path).?; - try std.Io.Dir.cwd().createDirPath(app_runtime.io(), dir); - try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = plist_path, .data = plist }); - _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }) catch {}; - try runChecked(allocator, &[_][]const u8{ "launchctl", "load", plist_path }); -} - -fn uninstallMacService(allocator: std.mem.Allocator, codex_home: []const u8) !void { - _ = codex_home; - const plist_path = try macPlistPath(allocator); - defer allocator.free(plist_path); - _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }) catch {}; - deleteAbsoluteFileIfExists(plist_path); -} - -pub fn deleteAbsoluteFileIfExists(path: []const u8) void { - std.Io.Dir.deleteFileAbsolute(app_runtime.io(), path) catch |err| switch (err) { - error.FileNotFound => {}, - else => {}, - }; -} - -fn installWindowsService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void { - const helper_path = try windowsHelperPath(allocator, self_exe); - defer allocator.free(helper_path); - try std.Io.Dir.cwd().access(app_runtime.io(), helper_path, .{}); - - const arguments = try windowsTaskArguments(allocator, codex_home); - defer allocator.free(arguments); - - try windows_task_scheduler.installTask(allocator, .{ - .task_name = windows_task_name, - .executable_path = helper_path, - .arguments = arguments, - .restart_count = windows_task_restart_count_value, - .restart_interval = windows_task_restart_interval_xml, - .execution_time_limit = windows_task_execution_time_limit_xml, - }); -} - -fn uninstallWindowsService(allocator: std.mem.Allocator) !void { - try windows_task_scheduler.uninstallTask(allocator, windows_task_name); -} - -fn queryLinuxRuntimeState(allocator: std.mem.Allocator) RuntimeState { - const result = runCapture(allocator, &[_][]const u8{ "systemctl", "--user", "is-active", linux_service_name }) catch return .unknown; - defer { - allocator.free(result.stdout); - allocator.free(result.stderr); - } - return switch (result.term) { - .exited => |code| if (code == 0 and std.mem.startsWith(u8, std.mem.trim(u8, result.stdout, " \n\r\t"), "active")) .running else .stopped, - else => .unknown, - }; -} - -fn queryMacRuntimeState(allocator: std.mem.Allocator) RuntimeState { - const plist_path = macPlistPath(allocator) catch return .unknown; - defer allocator.free(plist_path); - const result = runCapture(allocator, &[_][]const u8{ "launchctl", "list", mac_label }) catch return .unknown; - defer { - allocator.free(result.stdout); - allocator.free(result.stderr); - } - return switch (result.term) { - .exited => |code| if (code == 0) .running else .stopped, - else => .unknown, - }; -} - -fn queryWindowsRuntimeState(allocator: std.mem.Allocator) RuntimeState { - return switch (windows_task_scheduler.queryTaskRuntimeState(allocator, windows_task_name)) { - .running => .running, - .stopped => .stopped, - .unknown => .unknown, - }; -} - -fn linuxUnitPath(allocator: std.mem.Allocator, service_name: []const u8) ![]u8 { - const home = try registry.resolveUserHome(allocator); - defer allocator.free(home); - return try std.fs.path.join(allocator, &[_][]const u8{ home, ".config", "systemd", "user", service_name }); -} - -pub fn managedServiceSelfExePath(allocator: std.mem.Allocator, self_exe: []const u8) ![]u8 { - return managedServiceSelfExePathFromDir(allocator, std.Io.Dir.cwd(), self_exe); -} - -pub fn managedServiceSelfExePathFromDir(allocator: std.mem.Allocator, cwd: std.Io.Dir, self_exe: []const u8) ![]u8 { - if (std.mem.indexOf(u8, self_exe, "/.zig-cache/") != null or std.mem.indexOf(u8, self_exe, "\\.zig-cache\\") != null) { - const candidate_rel = try std.fs.path.join(allocator, &[_][]const u8{ "zig-out", "bin", std.fs.path.basename(self_exe) }); - defer allocator.free(candidate_rel); - cwd.access(app_runtime.io(), candidate_rel, .{}) catch return try allocator.dupe(u8, self_exe); - return try app_runtime.realPathFileAlloc(allocator, cwd, candidate_rel); - } - return try allocator.dupe(u8, self_exe); -} - -pub fn currentServiceDefinitionMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { - return switch (builtin.os.tag) { - .linux => try linuxUnitMatches(allocator, codex_home, self_exe), - .macos => try macPlistMatches(allocator, codex_home, self_exe), - .windows => try windowsTaskMatches(allocator, codex_home, self_exe), - else => true, - }; -} - -fn linuxUnitMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { - const unit_path = try linuxUnitPath(allocator, linux_service_name); - defer allocator.free(unit_path); - const expected = try linuxUnitText(allocator, self_exe, codex_home); - defer allocator.free(expected); - if (!(try fileEqualsBytes(allocator, unit_path, expected))) return false; - return !(try linuxUnitHasLegacyResidue(allocator, linux_timer_name)); -} - -fn linuxUnitHasLegacyResidue(allocator: std.mem.Allocator, service_name: []const u8) !bool { - const unit_path = try linuxUnitPath(allocator, service_name); - defer allocator.free(unit_path); - const legacy_unit = try readFileIfExists(allocator, unit_path); - defer if (legacy_unit) |bytes| allocator.free(bytes); - if (legacy_unit != null) return true; - - const result = runCapture(allocator, &[_][]const u8{ - "systemctl", - "--user", - "show", - service_name, - "--property=LoadState,ActiveState,UnitFileState", - }) catch return false; - defer { - allocator.free(result.stdout); - allocator.free(result.stderr); - } - return switch (result.term) { - .exited => |code| code == 0 and linuxShowUnitHasResidue(result.stdout), - else => false, - }; -} - -fn linuxShowUnitHasResidue(output: []const u8) bool { - const load_state = linuxShowProperty(output, "LoadState") orelse return false; - const active_state = linuxShowProperty(output, "ActiveState") orelse return false; - const unit_file_state = linuxShowProperty(output, "UnitFileState") orelse return false; - - if (!std.mem.eql(u8, load_state, "not-found")) return true; - if (!std.mem.eql(u8, active_state, "inactive")) return true; - if (unit_file_state.len != 0 and !std.mem.eql(u8, unit_file_state, "not-found") and !std.mem.eql(u8, unit_file_state, "disabled")) { - return true; - } - return false; -} - -fn linuxShowProperty(output: []const u8, key: []const u8) ?[]const u8 { - var lines = std.mem.splitScalar(u8, output, '\n'); - while (lines.next()) |raw_line| { - const line = std.mem.trim(u8, raw_line, " \r\t"); - if (line.len == 0) continue; - if (!std.mem.startsWith(u8, line, key)) continue; - if (line.len <= key.len or line[key.len] != '=') continue; - return std.mem.trim(u8, line[key.len + 1 ..], " \r\t"); - } - return null; -} - -fn macPlistMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { - const plist_path = try macPlistPath(allocator); - defer allocator.free(plist_path); - const expected = try macPlistText(allocator, self_exe, codex_home); - defer allocator.free(expected); - return try fileEqualsBytes(allocator, plist_path, expected); -} - -fn windowsTaskMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool { - const helper_path = try windowsHelperPath(allocator, self_exe); - defer allocator.free(helper_path); - return try service_windows.taskMatches(allocator, codex_home, helper_path); -} - -fn windowsHelperPath(allocator: std.mem.Allocator, self_exe: []const u8) ![]u8 { - const dir = std.fs.path.dirname(self_exe) orelse return error.FileNotFound; - return try std.fs.path.join(allocator, &[_][]const u8{ dir, windows_helper_name }); -} - -fn macPlistPath(allocator: std.mem.Allocator) ![]u8 { - const home = try registry.resolveUserHome(allocator); - defer allocator.free(home); - return try std.fs.path.join(allocator, &[_][]const u8{ home, "Library", "LaunchAgents", mac_label ++ ".plist" }); -} - -fn runChecked(allocator: std.mem.Allocator, argv: []const []const u8) !void { - const result = try runCapture(allocator, argv); - defer { - allocator.free(result.stdout); - allocator.free(result.stderr); - } - switch (result.term) { - .exited => |code| { - if (code == 0) return; - }, - else => {}, - } - if (result.stderr.len > 0) { - std.log.err("{s}", .{std.mem.trim(u8, result.stderr, " \n\r\t")}); - } - return error.CommandFailed; -} - -fn readFileIfExists(allocator: std.mem.Allocator, path: []const u8) !?[]u8 { - var file = std.Io.Dir.cwd().openFile(app_runtime.io(), path, .{}) catch |err| { - if (err == error.FileNotFound) return null; - return err; - }; - defer file.close(app_runtime.io()); - var read_buffer: [4096]u8 = undefined; - var file_reader = file.reader(app_runtime.io(), &read_buffer); - return try file_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)); -} - -fn fileEqualsBytes(allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !bool { - const data = try readFileIfExists(allocator, path); - defer if (data) |buf| allocator.free(buf); - if (data == null) return false; - return std.mem.eql(u8, data.?, bytes); -} - -fn runCapture(allocator: std.mem.Allocator, argv: []const []const u8) !std.process.RunResult { - return try std.process.run(allocator, app_runtime.io(), .{ - .argv = argv, - .stdout_limit = .limited(1024 * 1024), - .stderr_limit = .limited(1024 * 1024), - }); -} - -fn runIgnoringFailure(allocator: std.mem.Allocator, argv: []const []const u8) void { - const result = runCapture(allocator, argv) catch return; - allocator.free(result.stdout); - allocator.free(result.stderr); -} diff --git a/src/auto/service_defs.zig b/src/auto/service_defs.zig deleted file mode 100644 index be0971f1..00000000 --- a/src/auto/service_defs.zig +++ /dev/null @@ -1,192 +0,0 @@ -const std = @import("std"); -const version = @import("../version.zig"); - -pub const linux_service_name = "codex-auth-autoswitch.service"; -pub const linux_timer_name = "codex-auth-autoswitch.timer"; -pub const mac_label = "com.loongphy.codex-auth.auto"; -pub const windows_task_name = "CodexAuthAutoSwitch"; -pub const windows_helper_name = "codex-auth-auto.exe"; -pub const windows_task_trigger_kind = "LogonTrigger"; -pub const windows_task_restart_count = "999"; -pub const windows_task_restart_count_value: i32 = 999; -pub const windows_task_restart_interval_xml = "PT1M"; -pub const windows_task_execution_time_limit_xml = "PT0S"; -pub const service_version_env_name = "CODEX_AUTH_VERSION"; -pub const codex_home_env_name = "CODEX_HOME"; - -pub const RuntimeState = enum { running, stopped, unknown }; - -pub fn linuxUnitText(allocator: std.mem.Allocator, self_exe: []const u8, codex_home: []const u8) ![]u8 { - const exec = try std.fmt.allocPrint(allocator, "\"{s}\" daemon --watch", .{self_exe}); - defer allocator.free(exec); - const escaped_version = try escapeSystemdValue(allocator, version.app_version); - defer allocator.free(escaped_version); - const escaped_codex_home = try escapeSystemdValue(allocator, codex_home); - defer allocator.free(escaped_codex_home); - return try std.fmt.allocPrint( - allocator, - "[Unit]\nDescription=codex-auth auto-switch watcher\n\n[Service]\nType=simple\nRestart=always\nRestartSec=1\nEnvironment=\"{s}={s}\"\nEnvironment=\"{s}={s}\"\nExecStart={s}\n\n[Install]\nWantedBy=default.target\n", - .{ - service_version_env_name, - escaped_version, - codex_home_env_name, - escaped_codex_home, - exec, - }, - ); -} - -pub fn macPlistText(allocator: std.mem.Allocator, self_exe: []const u8, codex_home: []const u8) ![]u8 { - const exe = try escapeXml(allocator, self_exe); - defer allocator.free(exe); - const current_version = try escapeXml(allocator, version.app_version); - defer allocator.free(current_version); - const escaped_codex_home = try escapeXml(allocator, codex_home); - defer allocator.free(escaped_codex_home); - return try std.fmt.allocPrint( - allocator, - "\n\n\n\n Label\n {s}\n ProgramArguments\n \n {s}\n daemon\n --watch\n \n EnvironmentVariables\n \n {s}\n {s}\n {s}\n {s}\n \n RunAtLoad\n \n KeepAlive\n \n\n\n", - .{ mac_label, exe, service_version_env_name, current_version, codex_home_env_name, escaped_codex_home }, - ); -} - -pub fn windowsTaskAction(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 { - const args = try windowsTaskArguments(allocator, codex_home); - defer allocator.free(args); - return try std.fmt.allocPrint( - allocator, - "\"{s}\" {s}", - .{ helper_path, args }, - ); -} - -pub fn windowsRegisterTaskScript(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 { - const escaped_helper_path = try escapePowerShellSingleQuoted(allocator, helper_path); - defer allocator.free(escaped_helper_path); - const args = try windowsTaskArguments(allocator, codex_home); - defer allocator.free(args); - const escaped_args = try escapePowerShellSingleQuoted(allocator, args); - defer allocator.free(escaped_args); - return try std.fmt.allocPrint( - allocator, - "$action = New-ScheduledTaskAction -Execute '{s}' -Argument '{s}'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -RestartCount {s} -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Register-ScheduledTask -TaskName '{s}' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null", - .{ escaped_helper_path, escaped_args, windows_task_restart_count, windows_task_name }, - ); -} - -pub fn windowsTaskMatchScript(allocator: std.mem.Allocator) ![]u8 { - return try std.fmt.allocPrint( - allocator, - "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 1 }}; $action = $task.Actions | Select-Object -First 1; if ($null -eq $action) {{ exit 2 }}; $xml = [xml](Export-ScheduledTask -TaskName '{s}'); $triggers = @($xml.Task.Triggers.ChildNodes | Where-Object {{ $_.NodeType -eq [System.Xml.XmlNodeType]::Element }}); if ($triggers.Count -ne 1) {{ exit 3 }}; $triggerKind = [string]$triggers[0].LocalName; if ([string]::IsNullOrWhiteSpace($triggerKind)) {{ exit 4 }}; $restartNode = $xml.Task.Settings.RestartOnFailure; if ($null -eq $restartNode) {{ exit 5 }}; $restartCount = [string]$restartNode.Count; $restartInterval = [string]$restartNode.Interval; if ([string]::IsNullOrWhiteSpace($restartCount) -or [string]::IsNullOrWhiteSpace($restartInterval)) {{ exit 6 }}; $executionLimit = [string]$xml.Task.Settings.ExecutionTimeLimit; if ([string]::IsNullOrWhiteSpace($executionLimit)) {{ exit 7 }}; $args = if ([string]::IsNullOrWhiteSpace($action.Arguments)) {{ '' }} else {{ ' ' + $action.Arguments }}; Write-Output ($action.Execute + $args + '|TRIGGER:' + $triggerKind + '|RESTART:' + $restartCount + ',' + $restartInterval + '|LIMIT:' + $executionLimit)", - .{ windows_task_name, windows_task_name }, - ); -} - -pub fn windowsEndTaskScript(allocator: std.mem.Allocator) ![]u8 { - return try std.fmt.allocPrint( - allocator, - "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 0 }}; if ($task.State -eq 4) {{ Stop-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue }}", - .{ windows_task_name, windows_task_name }, - ); -} - -pub fn windowsDeleteTaskScript(allocator: std.mem.Allocator) ![]u8 { - return try std.fmt.allocPrint( - allocator, - "$task = Get-ScheduledTask -TaskName '{s}' -ErrorAction SilentlyContinue; if ($null -eq $task) {{ exit 0 }}; Unregister-ScheduledTask -TaskName '{s}' -Confirm:$false", - .{ windows_task_name, windows_task_name }, - ); -} - -pub fn windowsTaskStateScript() []const u8 { - return "$task = Get-ScheduledTask -TaskName '" ++ windows_task_name ++ "' -ErrorAction SilentlyContinue; if ($null -eq $task) { exit 1 }; Write-Output ([int]$task.State)"; -} - -pub fn parseWindowsTaskStateOutput(output: []const u8) RuntimeState { - const trimmed = std.mem.trim(u8, output, " \n\r\t"); - if (trimmed.len == 0) return .unknown; - const value = std.fmt.parseInt(u8, trimmed, 10) catch return .unknown; - return switch (value) { - 4 => .running, - 0, 1, 2, 3 => .stopped, - else => .unknown, - }; -} - -pub fn escapeXml(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { - var out = std.ArrayList(u8).empty; - defer out.deinit(allocator); - for (raw) |ch| { - switch (ch) { - '&' => try out.appendSlice(allocator, "&"), - '<' => try out.appendSlice(allocator, "<"), - '>' => try out.appendSlice(allocator, ">"), - '"' => try out.appendSlice(allocator, """), - '\'' => try out.appendSlice(allocator, "'"), - else => try out.append(allocator, ch), - } - } - return try out.toOwnedSlice(allocator); -} - -fn escapeSystemdValue(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { - var out = std.ArrayList(u8).empty; - defer out.deinit(allocator); - for (raw) |ch| { - switch (ch) { - '\\' => try out.appendSlice(allocator, "\\\\"), - '"' => try out.appendSlice(allocator, "\\\""), - else => try out.append(allocator, ch), - } - } - return try out.toOwnedSlice(allocator); -} - -fn escapePowerShellSingleQuoted(allocator: std.mem.Allocator, input: []const u8) ![]u8 { - return std.mem.replaceOwned(u8, allocator, input, "'", "''"); -} - -pub fn windowsTaskArguments(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 { - const quoted_codex_home = try quoteWindowsCommandArg(allocator, codex_home); - defer allocator.free(quoted_codex_home); - return try std.fmt.allocPrint( - allocator, - "--service-version {s} --codex-home {s}", - .{ version.app_version, quoted_codex_home }, - ); -} - -fn quoteWindowsCommandArg(allocator: std.mem.Allocator, arg: []const u8) ![]u8 { - const needs_quotes = blk: { - if (arg.len == 0) break :blk true; - for (arg) |ch| { - if (ch <= ' ' or ch == '"') break :blk true; - } - break :blk false; - }; - if (!needs_quotes) return try allocator.dupe(u8, arg); - - var out = std.ArrayList(u8).empty; - defer out.deinit(allocator); - try out.append(allocator, '"'); - - var backslash_count: usize = 0; - for (arg) |byte| { - switch (byte) { - '\\' => backslash_count += 1, - '"' => { - try out.appendNTimes(allocator, '\\', backslash_count * 2 + 1); - try out.append(allocator, '"'); - backslash_count = 0; - }, - else => { - try out.appendNTimes(allocator, '\\', backslash_count); - try out.append(allocator, byte); - backslash_count = 0; - }, - } - } - try out.appendNTimes(allocator, '\\', backslash_count * 2); - try out.append(allocator, '"'); - return try out.toOwnedSlice(allocator); -} diff --git a/src/auto/service_windows.zig b/src/auto/service_windows.zig deleted file mode 100644 index e11a7ef0..00000000 --- a/src/auto/service_windows.zig +++ /dev/null @@ -1,204 +0,0 @@ -const std = @import("std"); -const service_defs = @import("service_defs.zig"); -const windows_task_scheduler = @import("../platform/windows.zig"); - -pub fn taskMatches(allocator: std.mem.Allocator, codex_home: []const u8, helper_path: []const u8) !bool { - const expected_action = try expectedTaskFingerprint(allocator, helper_path, codex_home); - defer allocator.free(expected_action); - const expected_fingerprint = try expectedTaskDefinitionFingerprint(allocator, expected_action); - defer allocator.free(expected_fingerprint); - const task_xml = windows_task_scheduler.readTaskXmlAlloc(allocator, service_defs.windows_task_name) catch return false; - const xml = task_xml orelse return false; - defer allocator.free(xml); - - const actual_fingerprint = (try taskDefinitionFingerprintFromXml(allocator, xml)) orelse return false; - defer allocator.free(actual_fingerprint); - return std.mem.eql(u8, actual_fingerprint, expected_fingerprint); -} - -fn expectedTaskFingerprint(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 { - const args = try service_defs.windowsTaskArguments(allocator, codex_home); - defer allocator.free(args); - return try std.fmt.allocPrint(allocator, "{s} {s}", .{ helper_path, args }); -} - -fn expectedTaskDefinitionFingerprint(allocator: std.mem.Allocator, action: []const u8) ![]u8 { - return taskDefinitionFingerprint( - allocator, - action, - service_defs.windows_task_trigger_kind, - service_defs.windows_task_restart_count, - service_defs.windows_task_restart_interval_xml, - service_defs.windows_task_execution_time_limit_xml, - ); -} - -fn taskDefinitionFingerprint( - allocator: std.mem.Allocator, - action: []const u8, - trigger_kind: []const u8, - restart_count: []const u8, - restart_interval: []const u8, - execution_time_limit: []const u8, -) ![]u8 { - return try std.fmt.allocPrint( - allocator, - "{s}|TRIGGER:{s}|RESTART:{s},{s}|LIMIT:{s}", - .{ action, trigger_kind, restart_count, restart_interval, execution_time_limit }, - ); -} - -fn taskDefinitionFingerprintFromXml(allocator: std.mem.Allocator, xml: []const u8) !?[]u8 { - const command = (try xmlElementTextAlloc(allocator, xml, "Command")) orelse return null; - defer allocator.free(command); - - const maybe_arguments = try xmlElementTextAlloc(allocator, xml, "Arguments"); - defer if (maybe_arguments) |arguments| allocator.free(arguments); - - const triggers = xmlElementContents(xml, "Triggers") orelse return null; - const trigger_kind = xmlSingleChildElementName(triggers) orelse return null; - - const restart_on_failure = xmlElementContents(xml, "RestartOnFailure") orelse return null; - const restart_count = (try xmlElementTextAlloc(allocator, restart_on_failure, "Count")) orelse return null; - defer allocator.free(restart_count); - const restart_interval = (try xmlElementTextAlloc(allocator, restart_on_failure, "Interval")) orelse return null; - defer allocator.free(restart_interval); - const execution_time_limit = (try xmlElementTextAlloc(allocator, xml, "ExecutionTimeLimit")) orelse return null; - defer allocator.free(execution_time_limit); - - const action = if (maybe_arguments) |arguments| - if (arguments.len == 0) - try allocator.dupe(u8, command) - else - try std.fmt.allocPrint(allocator, "{s} {s}", .{ command, arguments }) - else - try allocator.dupe(u8, command); - defer allocator.free(action); - - return try taskDefinitionFingerprint( - allocator, - action, - trigger_kind, - restart_count, - restart_interval, - execution_time_limit, - ); -} - -fn xmlElementContents(xml: []const u8, tag: []const u8) ?[]const u8 { - var open_buf: [64]u8 = undefined; - var close_buf: [64]u8 = undefined; - const open_tag = std.fmt.bufPrint(&open_buf, "<{s}>", .{tag}) catch return null; - const close_tag = std.fmt.bufPrint(&close_buf, "", .{tag}) catch return null; - const open_idx = std.mem.indexOf(u8, xml, open_tag) orelse return null; - const content_start = open_idx + open_tag.len; - const close_idx = std.mem.indexOfPos(u8, xml, content_start, close_tag) orelse return null; - return xml[content_start..close_idx]; -} - -fn xmlElementTextAlloc(allocator: std.mem.Allocator, xml: []const u8, tag: []const u8) !?[]u8 { - const contents = xmlElementContents(xml, tag) orelse return null; - return try xmlDecodeEntitiesAlloc(allocator, std.mem.trim(u8, contents, " \n\r\t")); -} - -fn xmlDecodeEntitiesAlloc(allocator: std.mem.Allocator, text: []const u8) ![]u8 { - var out = std.ArrayList(u8).empty; - defer out.deinit(allocator); - - var idx: usize = 0; - while (idx < text.len) { - if (text[idx] != '&') { - try out.append(allocator, text[idx]); - idx += 1; - continue; - } - if (std.mem.startsWith(u8, text[idx..], "&")) { - try out.append(allocator, '&'); - idx += "&".len; - continue; - } - if (std.mem.startsWith(u8, text[idx..], "<")) { - try out.append(allocator, '<'); - idx += "<".len; - continue; - } - if (std.mem.startsWith(u8, text[idx..], ">")) { - try out.append(allocator, '>'); - idx += ">".len; - continue; - } - if (std.mem.startsWith(u8, text[idx..], """)) { - try out.append(allocator, '"'); - idx += """.len; - continue; - } - if (std.mem.startsWith(u8, text[idx..], "'")) { - try out.append(allocator, '\''); - idx += "'".len; - continue; - } - try out.append(allocator, text[idx]); - idx += 1; - } - - return try out.toOwnedSlice(allocator); -} - -fn xmlSingleChildElementName(xml: []const u8) ?[]const u8 { - var idx: usize = 0; - var depth: usize = 0; - var direct_child_name: ?[]const u8 = null; - - while (findBytePos(xml, idx, '<')) |lt_idx| { - if (lt_idx + 1 >= xml.len) return null; - const next = xml[lt_idx + 1]; - - if (next == '/') { - const close_idx = findBytePos(xml, lt_idx + 2, '>') orelse return null; - if (depth == 0) return null; - depth -= 1; - idx = close_idx + 1; - continue; - } - - if (next == '!' or next == '?') { - const close_idx = findBytePos(xml, lt_idx + 2, '>') orelse return null; - idx = close_idx + 1; - continue; - } - - var name_end = lt_idx + 1; - while (name_end < xml.len and isXmlTagNameChar(xml[name_end])) : (name_end += 1) {} - if (name_end == lt_idx + 1) return null; - - const close_idx = findBytePos(xml, name_end, '>') orelse return null; - const self_closing = tagIsSelfClosing(xml, name_end, close_idx); - - if (depth == 0) { - if (direct_child_name != null) return null; - direct_child_name = xml[lt_idx + 1 .. name_end]; - } - - if (!self_closing) depth += 1; - idx = close_idx + 1; - } - - if (depth != 0) return null; - return direct_child_name; -} - -fn findBytePos(haystack: []const u8, start: usize, needle: u8) ?usize { - if (start >= haystack.len) return null; - const rel = std.mem.indexOfScalar(u8, haystack[start..], needle) orelse return null; - return start + rel; -} - -fn tagIsSelfClosing(xml: []const u8, tag_name_end: usize, tag_close_idx: usize) bool { - var idx = tag_close_idx; - while (idx > tag_name_end and std.ascii.isWhitespace(xml[idx - 1])) : (idx -= 1) {} - return idx > tag_name_end and xml[idx - 1] == '/'; -} - -fn isXmlTagNameChar(ch: u8) bool { - return std.ascii.isAlphabetic(ch) or std.ascii.isDigit(ch) or ch == ':' or ch == '_' or ch == '-' or ch == '.'; -} diff --git a/src/auto/state.zig b/src/auto/state.zig deleted file mode 100644 index eb433fe5..00000000 --- a/src/auto/state.zig +++ /dev/null @@ -1,239 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const registry = @import("../registry/root.zig"); -const sessions = @import("../session.zig"); -const candidate = @import("candidate.zig"); -const files = @import("files.zig"); - -const fileMtimeNsIfExists = files.fileMtimeNsIfExists; - -pub const api_refresh_interval_ns = 60 * std.time.ns_per_s; - -pub const DaemonRefreshState = struct { - last_api_refresh_at_ns: i128 = 0, - last_api_refresh_account_key: ?[]u8 = null, - last_account_name_refresh_at_ns: i128 = 0, - last_account_name_refresh_account_key: ?[]u8 = null, - pending_bad_account_key: ?[]u8 = null, - pending_bad_rollout: ?registry.RolloutSignature = null, - current_reg: ?registry.Registry = null, - registry_mtime_ns: i128 = 0, - auth_mtime_ns: i128 = 0, - candidate_index: candidate.CandidateIndex = .{}, - candidate_check_times: std.StringHashMapUnmanaged(i128) = .empty, - candidate_rejections: std.StringHashMapUnmanaged(bool) = .empty, - rollout_scan_cache: sessions.RolloutScanCache = .{}, - - pub fn deinit(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { - self.clearApiRefresh(allocator); - self.clearAccountNameRefresh(allocator); - self.clearPending(allocator); - if (self.current_reg) |*reg| { - self.candidate_index.deinit(allocator); - self.candidate_check_times.deinit(allocator); - self.candidate_rejections.deinit(allocator); - reg.deinit(allocator); - self.current_reg = null; - } else { - self.candidate_index.deinit(allocator); - self.candidate_check_times.deinit(allocator); - self.candidate_rejections.deinit(allocator); - } - self.rollout_scan_cache.deinit(allocator); - } - - pub fn clearApiRefresh(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { - if (self.last_api_refresh_account_key) |account_key| { - allocator.free(account_key); - } - self.last_api_refresh_account_key = null; - self.last_api_refresh_at_ns = 0; - } - - pub fn clearAccountNameRefresh(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { - if (self.last_account_name_refresh_account_key) |account_key| { - allocator.free(account_key); - } - self.last_account_name_refresh_account_key = null; - self.last_account_name_refresh_at_ns = 0; - } - - pub fn clearPending(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { - if (self.pending_bad_account_key) |account_key| { - allocator.free(account_key); - } - if (self.pending_bad_rollout) |*signature| { - registry.freeRolloutSignature(allocator, signature); - } - self.pending_bad_account_key = null; - self.pending_bad_rollout = null; - } - - pub fn clearPendingIfAccountChanged( - self: *DaemonRefreshState, - allocator: std.mem.Allocator, - active_account_key: ?[]const u8, - ) void { - if (self.pending_bad_account_key == null) return; - if (active_account_key) |account_key| { - if (std.mem.eql(u8, self.pending_bad_account_key.?, account_key)) return; - } - self.clearPending(allocator); - } - - pub fn pendingMatches(self: *const DaemonRefreshState, account_key: []const u8, signature: registry.RolloutSignature) bool { - if (self.pending_bad_account_key == null or self.pending_bad_rollout == null) return false; - return std.mem.eql(u8, self.pending_bad_account_key.?, account_key) and - registry.rolloutSignaturesEqual(self.pending_bad_rollout, signature); - } - - pub fn setPending( - self: *DaemonRefreshState, - allocator: std.mem.Allocator, - account_key: []const u8, - signature: registry.RolloutSignature, - ) !void { - if (self.pendingMatches(account_key, signature)) return; - self.clearPending(allocator); - self.pending_bad_account_key = try allocator.dupe(u8, account_key); - errdefer { - allocator.free(self.pending_bad_account_key.?); - self.pending_bad_account_key = null; - } - self.pending_bad_rollout = try registry.cloneRolloutSignature(allocator, signature); - } - - pub fn resetApiCooldownIfAccountChanged( - self: *DaemonRefreshState, - allocator: std.mem.Allocator, - active_account_key: []const u8, - ) !void { - if (self.last_api_refresh_account_key) |account_key| { - if (std.mem.eql(u8, account_key, active_account_key)) return; - } - self.clearApiRefresh(allocator); - self.last_api_refresh_account_key = try allocator.dupe(u8, active_account_key); - } - - pub fn resetAccountNameCooldownIfAccountChanged( - self: *DaemonRefreshState, - allocator: std.mem.Allocator, - active_account_key: []const u8, - ) !void { - if (self.last_account_name_refresh_account_key) |account_key| { - if (std.mem.eql(u8, account_key, active_account_key)) return; - } - self.clearAccountNameRefresh(allocator); - self.last_account_name_refresh_account_key = try allocator.dupe(u8, active_account_key); - } - - pub fn currentRegistry(self: *DaemonRefreshState) *registry.Registry { - return &self.current_reg.?; - } - - pub fn ensureRegistryLoaded(self: *DaemonRefreshState, allocator: std.mem.Allocator, codex_home: []const u8) !*registry.Registry { - if (self.current_reg == null) { - try self.reloadRegistryState(allocator, codex_home); - // Force the first daemon cycle to sync auth.json into accounts/ snapshots - // before grouped account-name refresh looks for stored auth contexts. - self.auth_mtime_ns = -1; - } else { - try self.reloadRegistryStateIfChanged(allocator, codex_home); - } - return self.currentRegistry(); - } - - pub fn reloadRegistryStateIfChanged(self: *DaemonRefreshState, allocator: std.mem.Allocator, codex_home: []const u8) !void { - const registry_path = try registry.registryPath(allocator, codex_home); - defer allocator.free(registry_path); - const current_mtime = (try fileMtimeNsIfExists(registry_path)) orelse 0; - if (self.current_reg == null or current_mtime != self.registry_mtime_ns) { - try self.reloadRegistryState(allocator, codex_home); - } - } - - pub fn reloadRegistryState(self: *DaemonRefreshState, allocator: std.mem.Allocator, codex_home: []const u8) !void { - var loaded = try registry.loadRegistry(allocator, codex_home); - errdefer loaded.deinit(allocator); - - self.candidate_index.deinit(allocator); - self.candidate_check_times.deinit(allocator); - self.candidate_check_times = .empty; - self.candidate_rejections.deinit(allocator); - self.candidate_rejections = .empty; - if (self.current_reg) |*reg| { - reg.deinit(allocator); - } - self.current_reg = loaded; - try self.candidate_index.rebuild(allocator, &self.current_reg.?, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()); - try self.refreshTrackedFileMtims(allocator, codex_home); - } - - pub fn rebuildCandidateState(self: *DaemonRefreshState, allocator: std.mem.Allocator) !void { - if (self.current_reg == null) return; - self.candidate_index.deinit(allocator); - self.candidate_check_times.deinit(allocator); - self.candidate_check_times = .empty; - self.candidate_rejections.deinit(allocator); - self.candidate_rejections = .empty; - try self.candidate_index.rebuild(allocator, &self.current_reg.?, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()); - } - - pub fn refreshTrackedFileMtims(self: *DaemonRefreshState, allocator: std.mem.Allocator, codex_home: []const u8) !void { - const registry_path = try registry.registryPath(allocator, codex_home); - defer allocator.free(registry_path); - self.registry_mtime_ns = (try fileMtimeNsIfExists(registry_path)) orelse 0; - - const auth_path = try registry.activeAuthPath(allocator, codex_home); - defer allocator.free(auth_path); - self.auth_mtime_ns = (try fileMtimeNsIfExists(auth_path)) orelse 0; - } - - pub fn syncActiveAuthIfChanged(self: *DaemonRefreshState, allocator: std.mem.Allocator, codex_home: []const u8) !bool { - const auth_path = try registry.activeAuthPath(allocator, codex_home); - defer allocator.free(auth_path); - const current_auth_mtime = (try fileMtimeNsIfExists(auth_path)) orelse 0; - if (self.current_reg != null and current_auth_mtime == self.auth_mtime_ns) return false; - self.auth_mtime_ns = current_auth_mtime; - if (self.current_reg == null) return false; - if (try registry.syncActiveAccountFromAuth(allocator, codex_home, &self.current_reg.?)) { - try self.rebuildCandidateState(allocator); - return true; - } - return false; - } - - pub fn markCandidateChecked(self: *DaemonRefreshState, allocator: std.mem.Allocator, account_key: []const u8, now_ns: i128) !void { - try self.candidate_check_times.put(allocator, account_key, now_ns); - } - - pub fn candidateCheckedAt(self: *const DaemonRefreshState, account_key: []const u8) ?i128 { - return self.candidate_check_times.get(account_key); - } - - pub fn clearCandidateChecked(self: *DaemonRefreshState, account_key: []const u8) void { - _ = self.candidate_check_times.remove(account_key); - } - - pub fn markCandidateRejected(self: *DaemonRefreshState, allocator: std.mem.Allocator, account_key: []const u8) !void { - try self.candidate_rejections.put(allocator, account_key, true); - } - - pub fn clearCandidateRejected(self: *DaemonRefreshState, account_key: []const u8) void { - _ = self.candidate_rejections.remove(account_key); - } - - pub fn candidateIsRejected(self: *DaemonRefreshState, account_key: []const u8, now_ns: i128) bool { - if (!self.candidate_rejections.contains(account_key)) return false; - if (self.candidateIsStale(account_key, now_ns)) { - self.clearCandidateRejected(account_key); - return false; - } - return true; - } - - pub fn candidateIsStale(self: *const DaemonRefreshState, account_key: []const u8, now_ns: i128) bool { - const checked_at = self.candidateCheckedAt(account_key) orelse return true; - return (now_ns - checked_at) >= api_refresh_interval_ns; - } -}; diff --git a/src/auto/status.zig b/src/auto/status.zig deleted file mode 100644 index b8a1c4d9..00000000 --- a/src/auto/status.zig +++ /dev/null @@ -1,71 +0,0 @@ -const std = @import("std"); -const io_util = @import("../core/io_util.zig"); -const registry = @import("../registry/root.zig"); -const terminal_color = @import("../terminal/color.zig"); -const service = @import("service.zig"); - -const RuntimeState = service.RuntimeState; -const queryRuntimeState = service.queryRuntimeState; - -pub const Status = struct { - enabled: bool, - runtime: RuntimeState, - threshold_5h_percent: u8, - threshold_weekly_percent: u8, - live_interval_seconds: u16, -}; - -pub fn helpStateLabel(enabled: bool) []const u8 { - return if (enabled) "ON" else "OFF"; -} - -fn colorEnabled() bool { - return terminal_color.stdoutColorEnabled(); -} - -pub fn printStatus(allocator: std.mem.Allocator, codex_home: []const u8) !void { - const status = try getStatus(allocator, codex_home); - var stdout: io_util.Stdout = undefined; - stdout.init(); - try writeStatusWithColor(stdout.out(), status, colorEnabled()); -} - -pub fn getStatus(allocator: std.mem.Allocator, codex_home: []const u8) !Status { - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - return .{ - .enabled = reg.auto_switch.enabled, - .runtime = queryRuntimeState(allocator), - .threshold_5h_percent = reg.auto_switch.threshold_5h_percent, - .threshold_weekly_percent = reg.auto_switch.threshold_weekly_percent, - .live_interval_seconds = reg.live.interval_seconds, - }; -} - -pub fn writeStatus(out: *std.Io.Writer, status: Status) !void { - try writeStatusWithColor(out, status, false); -} - -fn writeStatusWithColor(out: *std.Io.Writer, status: Status, use_color: bool) !void { - _ = use_color; - try out.writeAll("auto-switch: "); - try out.writeAll(helpStateLabel(status.enabled)); - try out.writeAll("\n"); - - try out.writeAll("service: "); - try out.writeAll(@tagName(status.runtime)); - try out.writeAll("\n"); - - try out.writeAll("thresholds: "); - try out.print( - "5h<{d}%, weekly<{d}%", - .{ status.threshold_5h_percent, status.threshold_weekly_percent }, - ); - try out.writeAll("\n"); - - try out.writeAll("live refresh: "); - try out.print("{d}s", .{status.live_interval_seconds}); - try out.writeAll("\n"); - - try out.flush(); -} diff --git a/src/auto/switching.zig b/src/auto/switching.zig deleted file mode 100644 index 8d5831e2..00000000 --- a/src/auto/switching.zig +++ /dev/null @@ -1,497 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const registry = @import("../registry/root.zig"); -const usage_api = @import("../api/usage.zig"); -const candidate_mod = @import("candidate.zig"); -const state = @import("state.zig"); -const logging = @import("logging.zig"); - -const DaemonRefreshState = state.DaemonRefreshState; -const CandidateScore = candidate_mod.CandidateScore; -const candidate_switch_validation_limit = candidate_mod.candidate_switch_validation_limit; -const candidateScore = candidate_mod.candidateScore; -const candidateBetter = candidate_mod.candidateBetter; -const emitAutoSwitchLog = logging.emitAutoSwitchLog; - -const free_plan_realtime_guard_5h_percent: i64 = 35; - -pub const AutoSwitchAttempt = struct { - refreshed_candidates: bool, - state_changed: bool = false, - switched: bool, -}; - -pub fn bestAutoSwitchCandidateIndex(reg: *registry.Registry, now: i64) ?usize { - const active = reg.active_account_key orelse return null; - var best_idx: ?usize = null; - var best: ?CandidateScore = null; - for (reg.accounts.items, 0..) |*rec, idx| { - if (std.mem.eql(u8, rec.account_key, active)) continue; - const score = candidateScore(rec, now); - if (best == null or candidateBetter(score, best.?)) { - best = score; - best_idx = idx; - } - } - return best_idx; -} - -pub fn shouldSwitchCurrent(reg: *registry.Registry, now: i64) bool { - const account_key = reg.active_account_key orelse return false; - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; - const rec = ®.accounts.items[idx]; - const resolved_5h = resolve5hTriggerWindow(rec.last_usage); - const threshold_5h_percent = effective5hThresholdPercent(reg, rec, resolved_5h.allow_free_guard); - const rem_5h = registry.remainingPercentAt(resolved_5h.window, now); - const rem_week = registry.remainingPercentAt(registry.resolveRateWindow(rec.last_usage, 10080, false), now); - return (rem_5h != null and rem_5h.? < threshold_5h_percent) or - (rem_week != null and rem_week.? < @as(i64, reg.auto_switch.threshold_weekly_percent)); -} - -pub fn effective5hThresholdPercent(reg: *registry.Registry, rec: *const registry.AccountRecord, allow_free_guard: bool) i64 { - var threshold = @as(i64, reg.auto_switch.threshold_5h_percent); - if (allow_free_guard and registry.resolvePlan(rec) == .free) { - threshold = @max(threshold, free_plan_realtime_guard_5h_percent); - } - return threshold; -} - -pub fn maybeAutoSwitch(allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry) !bool { - const attempt = try maybeAutoSwitchWithUsageFetcher(allocator, codex_home, reg, usage_api.fetchUsageForAuthPath); - return attempt.switched; -} - -pub fn maybeAutoSwitchWithUsageFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - usage_fetcher: anytype, -) !AutoSwitchAttempt { - return maybeAutoSwitchWithUsageFetcherAndRefreshState(allocator, codex_home, reg, null, usage_fetcher); -} - -pub fn maybeAutoSwitchForDaemonWithUsageFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, - usage_fetcher: anytype, -) !AutoSwitchAttempt { - if (!reg.auto_switch.enabled) return .{ .refreshed_candidates = false, .switched = false }; - const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - 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 = @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds()); - const active_idx = registry.findAccountIndexByAccountKey(reg, active) orelse return .{ - .refreshed_candidates = false, - .switched = false, - }; - const current = candidateScore(®.accounts.items[active_idx], now); - const should_switch_current = shouldSwitchCurrent(reg, now); - - 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, - ); - refreshed_candidates = upkeep.attempted != 0; - changed = upkeep.updated != 0; - } - - if (!should_switch_current) { - return .{ - .refreshed_candidates = refreshed_candidates, - .state_changed = changed, - .switched = false, - }; - } - - 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, - ); - 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 .{ - .refreshed_candidates = refreshed_candidates, - .state_changed = changed, - .switched = false, - }; - const candidate_idx = registry.findAccountIndexByAccountKey(reg, best_candidate_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) { - 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); - try refresh_state.candidate_index.handleActiveSwitch( - allocator, - reg, - previous_active_key, - next_active_key, - std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), - ); - 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, - }; - } - - 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) { - 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); - try refresh_state.candidate_index.handleActiveSwitch( - allocator, - reg, - previous_active_key, - next_active_key, - std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), - ); - 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, - }; -} - -pub fn maybeAutoSwitchWithUsageFetcherAndRefreshState( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: ?*DaemonRefreshState, - usage_fetcher: anytype, -) !AutoSwitchAttempt { - 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.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - if (!shouldSwitchCurrent(reg, now)) return .{ .refreshed_candidates = false, .switched = false }; - - _ = refresh_state; - const should_refresh_candidates = reg.api.usage; - - const refreshed_candidates = if (should_refresh_candidates) - try refreshAutoSwitchCandidatesWithUsageFetcher(allocator, codex_home, reg, usage_fetcher) - else - false; - - const active_idx = registry.findAccountIndexByAccountKey(reg, active) orelse return .{ - .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, - }; - } - - try registry.activateAccountByKey(allocator, codex_home, reg, reg.accounts.items[candidate_idx].account_key); - return .{ .refreshed_candidates = refreshed_candidates, .state_changed = true, .switched = true }; -} - -pub fn refreshAutoSwitchCandidatesWithUsageFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - usage_fetcher: anytype, -) !bool { - const active = reg.active_account_key orelse return false; - var changed = false; - var attempted: usize = 0; - var updated: usize = 0; - - for (reg.accounts.items) |rec| { - if (std.mem.eql(u8, rec.account_key, active)) continue; - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; - attempted += 1; - - const auth_path = registry.accountAuthPath(allocator, codex_home, rec.account_key) catch continue; - defer allocator.free(auth_path); - - const latest_usage = usage_fetcher(allocator, auth_path) catch continue; - if (latest_usage == null) continue; - - var latest = latest_usage.?; - var snapshot_consumed = false; - defer if (!snapshot_consumed) registry.freeRateLimitSnapshot(allocator, &latest); - - if (registry.rateLimitSnapshotsEqual(rec.last_usage, latest)) continue; - registry.updateUsage(allocator, reg, rec.account_key, latest); - snapshot_consumed = true; - changed = true; - updated += 1; - } - - return changed; -} - -pub const CandidateRefreshSummary = struct { - attempted: usize = 0, - updated: usize = 0, -}; - -pub fn keyIsSkipped(skipped_keys: []const []const u8, account_key: []const u8) bool { - for (skipped_keys) |skipped| { - if (std.mem.eql(u8, skipped, account_key)) return true; - } - return false; -} - -pub fn bestDaemonCandidateForSwitch( - allocator: std.mem.Allocator, - refresh_state: *DaemonRefreshState, - skipped_keys: []const []const u8, - now_ns: i128, -) !?[]const u8 { - var ordered = try refresh_state.candidate_index.orderedKeys(allocator); - defer ordered.deinit(allocator); - - for (ordered.items) |account_key| { - if (refresh_state.candidateIsRejected(account_key, now_ns)) continue; - if (!keyIsSkipped(skipped_keys, account_key)) return account_key; - } - return null; -} - -pub fn refreshDaemonCandidateUpkeepWithUsageFetcher( - 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 refresh_state.candidate_index.orderedKeys(allocator); - 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; - } - - _ = now; - return summary; -} - -pub fn refreshDaemonSwitchCandidatesWithUsageFetcher( - 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) { - const best_account_key = (try bestDaemonCandidateForSwitch(allocator, refresh_state, skipped_keys.items, now_ns)) 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; - } - - _ = now; - return summary; -} - -pub const SingleCandidateRefreshResult = struct { - visited: bool = false, - attempted: usize = 0, - updated: usize = 0, - disqualify_for_switch: bool = false, -}; - -pub fn refreshDaemonCandidateUsageByKeyWithFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, - account_key: []const u8, - usage_fetcher: anytype, - now_ns: i128, -) !SingleCandidateRefreshResult { - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return .{}; - const rec = ®.accounts.items[idx]; - - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) { - try refresh_state.markCandidateChecked(allocator, account_key, now_ns); - refresh_state.clearCandidateRejected(account_key); - return .{ .visited = true }; - } - - const auth_path = registry.accountAuthPath(allocator, codex_home, account_key) catch { - try refresh_state.markCandidateChecked(allocator, account_key, now_ns); - return .{ .visited = true }; - }; - defer allocator.free(auth_path); - - try refresh_state.markCandidateChecked(allocator, account_key, now_ns); - const fetch_result = usage_fetcher(allocator, auth_path) catch { - return .{ - .visited = true, - .attempted = 1, - }; - }; - if (fetch_result.missing_auth) { - try refresh_state.markCandidateRejected(allocator, account_key); - return .{ - .visited = true, - .attempted = 1, - .disqualify_for_switch = true, - }; - } - if (fetch_result.status_code) |status_code| { - if (status_code != 200) { - try refresh_state.markCandidateRejected(allocator, account_key); - return .{ - .visited = true, - .attempted = 1, - .disqualify_for_switch = true, - }; - } - } - - const latest_usage = fetch_result.snapshot; - if (latest_usage == null) { - if (fetch_result.status_code != null) { - try refresh_state.markCandidateRejected(allocator, account_key); - } - return .{ - .visited = true, - .attempted = 1, - .disqualify_for_switch = fetch_result.status_code != null, - }; - } - - var latest = latest_usage.?; - var snapshot_consumed = false; - defer if (!snapshot_consumed) registry.freeRateLimitSnapshot(allocator, &latest); - - refresh_state.clearCandidateRejected(account_key); - - if (registry.rateLimitSnapshotsEqual(rec.last_usage, latest)) { - return .{ .visited = true, .attempted = 1 }; - } - - registry.updateUsage(allocator, reg, account_key, latest); - snapshot_consumed = true; - try refresh_state.candidate_index.upsertFromRegistry(allocator, reg, account_key, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()); - return .{ .visited = true, .attempted = 1, .updated = 1 }; -} - -pub const Resolved5hWindow = struct { - window: ?registry.RateLimitWindow, - allow_free_guard: bool, -}; - -pub fn resolve5hTriggerWindow(usage: ?registry.RateLimitSnapshot) Resolved5hWindow { - if (usage == null) return .{ .window = null, .allow_free_guard = false }; - if (usage.?.primary) |primary| { - if (primary.window_minutes == null) { - return .{ .window = primary, .allow_free_guard = true }; - } - if (primary.window_minutes.? == 300) { - return .{ .window = primary, .allow_free_guard = true }; - } - } - if (usage.?.secondary) |secondary| { - if (secondary.window_minutes != null and secondary.window_minutes.? == 300) { - return .{ .window = secondary, .allow_free_guard = true }; - } - } - if (usage.?.primary) |primary| { - return .{ .window = primary, .allow_free_guard = false }; - } - return .{ .window = null, .allow_free_guard = false }; -} diff --git a/src/auto/usage_refresh.zig b/src/auto/usage_refresh.zig deleted file mode 100644 index 0b1b3811..00000000 --- a/src/auto/usage_refresh.zig +++ /dev/null @@ -1,449 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); -const account_api = @import("../api/account.zig"); -const account_name_refresh = @import("../auth/account.zig"); -const registry = @import("../registry/root.zig"); -const sessions = @import("../session.zig"); -const usage_api = @import("../api/usage.zig"); -const files = @import("files.zig"); -const logging = @import("logging.zig"); -const state = @import("state.zig"); - -const DaemonRefreshState = state.DaemonRefreshState; -const api_refresh_interval_ns = state.api_refresh_interval_ns; -const fileMtimeNsIfExists = files.fileMtimeNsIfExists; -const emitDaemonLog = logging.emitDaemonLog; -const emitTaggedDaemonLog = logging.emitTaggedDaemonLog; -const localDateTimeLabel = logging.localDateTimeLabel; -const rolloutFileLabel = logging.rolloutFileLabel; -const rolloutWindowsLabel = logging.rolloutWindowsLabel; -const apiStatusLabel = logging.apiStatusLabel; -const fieldSeparator = logging.fieldSeparator; - -pub fn refreshActiveUsage(allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry) !bool { - return refreshActiveUsageWithApiFetcher(allocator, codex_home, reg, usage_api.fetchActiveUsage); -} - -pub fn fetchActiveAccountNames( - allocator: std.mem.Allocator, - access_token: []const u8, - account_id: []const u8, -) !account_api.FetchResult { - return try account_api.fetchAccountsForTokenDetailed( - allocator, - account_api.default_account_endpoint, - access_token, - account_id, - ); -} - -fn applyDaemonAccountNameEntriesToLatestRegistry( - allocator: std.mem.Allocator, - codex_home: []const u8, - chatgpt_user_id: []const u8, - entries: []const account_api.AccountEntry, -) !bool { - var latest = try registry.loadRegistry(allocator, codex_home); - defer latest.deinit(allocator); - - if (!latest.auto_switch.enabled or !latest.api.account) return false; - if (!registry.shouldFetchTeamAccountNamesForUser(&latest, chatgpt_user_id)) return false; - if (!try registry.applyAccountNamesForUser(allocator, &latest, chatgpt_user_id, entries)) return false; - - try registry.saveRegistry(allocator, codex_home, &latest); - return true; -} - -pub fn refreshActiveAccountNamesForDaemon( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, -) !bool { - return refreshActiveAccountNamesForDaemonWithFetcher( - allocator, - codex_home, - reg, - refresh_state, - fetchActiveAccountNames, - ); -} - -pub fn refreshActiveAccountNamesForDaemonWithFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, - fetcher: anytype, -) !bool { - if (!reg.auto_switch.enabled) return false; - if (!reg.api.account) return false; - const account_key = reg.active_account_key orelse return false; - try refresh_state.resetAccountNameCooldownIfAccountChanged(allocator, account_key); - - const now_ns = @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds()); - if (refresh_state.last_account_name_refresh_at_ns != 0 and - (now_ns - refresh_state.last_account_name_refresh_at_ns) < api_refresh_interval_ns) - { - return false; - } - - var candidates = try account_name_refresh.collectCandidates(allocator, reg); - defer { - for (candidates.items) |*candidate| candidate.deinit(allocator); - candidates.deinit(allocator); - } - if (candidates.items.len == 0) return false; - - var attempted = false; - var changed = false; - - for (candidates.items) |candidate| { - var latest = try registry.loadRegistry(allocator, codex_home); - defer latest.deinit(allocator); - - if (!latest.auto_switch.enabled or !latest.api.account) continue; - if (!registry.shouldFetchTeamAccountNamesForUser(&latest, candidate.chatgpt_user_id)) continue; - - var info = (try account_name_refresh.loadStoredAuthInfoForUser( - allocator, - codex_home, - &latest, - candidate.chatgpt_user_id, - )) orelse continue; - defer info.deinit(allocator); - - const access_token = info.access_token orelse continue; - const chatgpt_account_id = info.chatgpt_account_id orelse continue; - if (!attempted) { - refresh_state.last_account_name_refresh_at_ns = now_ns; - attempted = true; - } - - const result = fetcher(allocator, access_token, chatgpt_account_id) catch |err| { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - continue; - }; - defer result.deinit(allocator); - - const entries = result.entries orelse continue; - if (try applyDaemonAccountNameEntriesToLatestRegistry(allocator, codex_home, candidate.chatgpt_user_id, entries)) { - changed = true; - } - } - - return changed; -} - -pub fn refreshActiveUsageWithApiFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - api_fetcher: anytype, -) !bool { - if (reg.api.usage) { - return switch (try refreshActiveUsageFromApi(allocator, codex_home, reg, api_fetcher)) { - .updated => true, - .unchanged, .unavailable => false, - }; - } - return refreshActiveUsageFromSessions(allocator, codex_home, reg); -} - -pub const ApiRefreshResult = enum { unavailable, unchanged, updated }; - -fn refreshActiveUsageFromApi( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - api_fetcher: anytype, -) !ApiRefreshResult { - const latest_usage = api_fetcher(allocator, codex_home) catch return .unavailable; - if (latest_usage == null) return .unavailable; - - var latest = latest_usage.?; - var snapshot_consumed = false; - defer if (!snapshot_consumed) registry.freeRateLimitSnapshot(allocator, &latest); - - const account_key = reg.active_account_key orelse return .unchanged; - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return .unchanged; - if (registry.rateLimitSnapshotsEqual(reg.accounts.items[idx].last_usage, latest)) return .unchanged; - - registry.updateUsage(allocator, reg, account_key, latest); - snapshot_consumed = true; - return .updated; -} - -fn refreshActiveUsageFromSessions( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, -) !bool { - const latest_usage = sessions.scanLatestUsageWithSource(allocator, codex_home) catch |err| switch (err) { - error.FileNotFound => return false, - else => return err, - }; - if (latest_usage == null) return false; - var latest = latest_usage.?; - var snapshot_consumed = false; - defer { - allocator.free(latest.path); - if (!snapshot_consumed) { - registry.freeRateLimitSnapshot(allocator, &latest.snapshot); - } - } - const signature: registry.RolloutSignature = .{ - .path = latest.path, - .event_timestamp_ms = latest.event_timestamp_ms, - }; - const account_key = reg.active_account_key orelse return false; - const activated_at_ms = reg.active_account_activated_at_ms orelse 0; - if (latest.event_timestamp_ms < activated_at_ms) return false; - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; - if (registry.rolloutSignaturesEqual(reg.accounts.items[idx].last_local_rollout, signature)) return false; - registry.updateUsage(allocator, reg, account_key, latest.snapshot); - snapshot_consumed = true; - try registry.setAccountLastLocalRollout(allocator, ®.accounts.items[idx], latest.path, latest.event_timestamp_ms); - return true; -} - -pub fn refreshActiveUsageForDaemon( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, -) !bool { - return refreshActiveUsageForDaemonWithDetailedApiFetcher( - allocator, - codex_home, - reg, - refresh_state, - usage_api.fetchActiveUsageDetailed, - ); -} - -pub fn refreshActiveUsageForDaemonWithDetailedApiFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, - api_fetcher: anytype, -) !bool { - const account_key = reg.active_account_key orelse return false; - refresh_state.clearPendingIfAccountChanged(allocator, account_key); - try refresh_state.resetApiCooldownIfAccountChanged(allocator, account_key); - const active_idx = registry.findAccountIndexByAccountKey(reg, account_key); - - if (try refreshActiveUsageFromSessionsForDaemon(allocator, codex_home, reg, refresh_state)) { - return true; - } - if (!reg.api.usage) return false; - - const now_ns = @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds()); - if (refresh_state.last_api_refresh_at_ns != 0 and (now_ns - refresh_state.last_api_refresh_at_ns) < api_refresh_interval_ns) { - return false; - } - refresh_state.last_api_refresh_at_ns = now_ns; - - const fetch_result = api_fetcher(allocator, codex_home) catch |err| { - emitTaggedDaemonLog(.warning, "api", "refresh usage{s}status={s}", .{ - fieldSeparator(), - @errorName(err), - }); - return false; - }; - - const latest_usage = fetch_result.snapshot; - const status_code = fetch_result.status_code; - const missing_auth = fetch_result.missing_auth; - var status_buf: [24]u8 = undefined; - if (latest_usage == null) { - emitTaggedDaemonLog(.warning, "api", "refresh usage{s}status={s}", .{ - fieldSeparator(), - apiStatusLabel(&status_buf, status_code, false, missing_auth), - }); - return false; - } - - var latest = latest_usage.?; - var snapshot_consumed = false; - defer if (!snapshot_consumed) registry.freeRateLimitSnapshot(allocator, &latest); - - if (active_idx == null) { - emitTaggedDaemonLog(.debug, "api", "refresh usage{s}status={s}", .{ - fieldSeparator(), - apiStatusLabel(&status_buf, status_code, true, missing_auth), - }); - return false; - } - if (registry.rateLimitSnapshotsEqual(reg.accounts.items[active_idx.?].last_usage, latest)) { - emitTaggedDaemonLog(.debug, "api", "refresh usage{s}status={s}", .{ - fieldSeparator(), - apiStatusLabel(&status_buf, status_code, true, missing_auth), - }); - refresh_state.clearPending(allocator); - return false; - } - - registry.updateUsage(allocator, reg, account_key, latest); - snapshot_consumed = true; - emitTaggedDaemonLog(.info, "api", "refresh usage{s}status={s}", .{ - fieldSeparator(), - apiStatusLabel(&status_buf, status_code, true, missing_auth), - }); - refresh_state.clearPending(allocator); - return true; -} - -pub fn refreshActiveUsageForDaemonWithApiFetcher( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, - api_fetcher: anytype, -) !bool { - const account_key = reg.active_account_key orelse return false; - refresh_state.clearPendingIfAccountChanged(allocator, account_key); - try refresh_state.resetApiCooldownIfAccountChanged(allocator, account_key); - if (try refreshActiveUsageFromSessionsForDaemon(allocator, codex_home, reg, refresh_state)) { - return true; - } - if (!reg.api.usage) return false; - - const now_ns = @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds()); - if (refresh_state.last_api_refresh_at_ns != 0 and (now_ns - refresh_state.last_api_refresh_at_ns) < api_refresh_interval_ns) { - return false; - } - refresh_state.last_api_refresh_at_ns = now_ns; - - return switch (try refreshActiveUsageFromApi(allocator, codex_home, reg, api_fetcher)) { - .updated => blk: { - emitTaggedDaemonLog(.info, "api", "refresh usage{s}status=200", .{fieldSeparator()}); - refresh_state.clearPending(allocator); - break :blk true; - }, - .unchanged => blk: { - emitTaggedDaemonLog(.debug, "api", "refresh usage{s}status=200", .{fieldSeparator()}); - refresh_state.clearPending(allocator); - break :blk false; - }, - .unavailable => blk: { - emitTaggedDaemonLog(.warning, "api", "refresh usage{s}status=NoUsageLimitsWindow", .{fieldSeparator()}); - break :blk false; - }, - }; -} - -fn refreshActiveUsageFromSessionsForDaemon( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - refresh_state: *DaemonRefreshState, -) !bool { - var latest_event = (sessions.scanLatestRolloutEventWithCache(allocator, codex_home, &refresh_state.rollout_scan_cache) catch |err| switch (err) { - error.FileNotFound => return false, - else => return err, - }) orelse return false; - defer latest_event.deinit(allocator); - - const account_key = reg.active_account_key orelse return false; - const activated_at_ms = reg.active_account_activated_at_ms orelse 0; - if (latest_event.event_timestamp_ms < activated_at_ms) return false; - - const signature: registry.RolloutSignature = .{ - .path = latest_event.path, - .event_timestamp_ms = latest_event.event_timestamp_ms, - }; - const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; - if (registry.rolloutSignaturesEqual(reg.accounts.items[idx].last_local_rollout, signature)) { - refresh_state.clearPending(allocator); - return false; - } - - var event_time_buf: [19]u8 = undefined; - const event_time = localDateTimeLabel(&event_time_buf, latest_event.event_timestamp_ms); - var file_buf: [96]u8 = undefined; - const file_label = rolloutFileLabel(&file_buf, latest_event.path); - - if (!latest_event.hasUsableWindows()) { - if (try applyLatestUsableSnapshotFromRolloutFile( - allocator, - reg, - account_key, - idx, - latest_event.path, - latest_event.mtime, - activated_at_ms, - )) { - refresh_state.clearPending(allocator); - return true; - } - if (refresh_state.pendingMatches(account_key, signature)) { - return false; - } - emitTaggedDaemonLog(.warning, "local", "no usage limits window{s}fallback-to-api{s}event={s}{s}file={s}", .{ - fieldSeparator(), - fieldSeparator(), - event_time, - fieldSeparator(), - file_label, - }); - try refresh_state.setPending(allocator, account_key, signature); - return false; - } - - const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - var windows_buf: [64]u8 = undefined; - emitTaggedDaemonLog(.notice, "local", "{s}{s}event={s}{s}file={s}", .{ - rolloutWindowsLabel(&windows_buf, latest_event.snapshot.?, now), - fieldSeparator(), - event_time, - fieldSeparator(), - file_label, - }); - registry.updateUsage(allocator, reg, account_key, latest_event.snapshot.?); - latest_event.snapshot = null; - try registry.setAccountLastLocalRollout(allocator, ®.accounts.items[idx], latest_event.path, latest_event.event_timestamp_ms); - refresh_state.clearPending(allocator); - return true; -} - -fn applyLatestUsableSnapshotFromRolloutFile( - allocator: std.mem.Allocator, - reg: *registry.Registry, - account_key: []const u8, - idx: usize, - rollout_path: []const u8, - rollout_mtime: i64, - activated_at_ms: i64, -) !bool { - const latest_usage = sessions.scanLatestUsableUsageInFile(allocator, rollout_path, rollout_mtime) catch |err| switch (err) { - error.FileNotFound => return false, - else => return err, - }; - if (latest_usage == null) return false; - - var usable = latest_usage.?; - var snapshot_consumed = false; - defer { - allocator.free(usable.path); - if (!snapshot_consumed) { - registry.freeRateLimitSnapshot(allocator, &usable.snapshot); - } - } - - if (usable.event_timestamp_ms < activated_at_ms) return false; - - const usable_signature: registry.RolloutSignature = .{ - .path = usable.path, - .event_timestamp_ms = usable.event_timestamp_ms, - }; - if (registry.rolloutSignaturesEqual(reg.accounts.items[idx].last_local_rollout, usable_signature)) { - return false; - } - - registry.updateUsage(allocator, reg, account_key, usable.snapshot); - snapshot_consumed = true; - try registry.setAccountLastLocalRollout(allocator, ®.accounts.items[idx], usable.path, usable.event_timestamp_ms); - return true; -} diff --git a/src/auto_main.zig b/src/auto_main.zig deleted file mode 100644 index b43cf70b..00000000 --- a/src/auto_main.zig +++ /dev/null @@ -1,46 +0,0 @@ -const std = @import("std"); -const auto = @import("auto/root.zig"); -const registry = @import("registry/root.zig"); - -fn resolveDaemonCodexHome(allocator: std.mem.Allocator, init: std.process.Init.Minimal) ![]u8 { - var arena_state = std.heap.ArenaAllocator.init(allocator); - defer arena_state.deinit(); - const args = try init.args.toSlice(arena_state.allocator()); - - var codex_home_override: ?[]u8 = null; - defer if (codex_home_override) |path| allocator.free(path); - - var i: usize = 1; - while (i < args.len) : (i += 1) { - const arg = args[i]; - if (std.mem.eql(u8, arg, "--service-version")) { - if (i + 1 >= args.len) return error.InvalidCliUsage; - i += 1; - continue; - } - if (std.mem.eql(u8, arg, "--codex-home")) { - if (i + 1 >= args.len) return error.InvalidCliUsage; - if (codex_home_override != null) return error.InvalidCliUsage; - codex_home_override = try allocator.dupe(u8, args[i + 1]); - i += 1; - continue; - } - return error.InvalidCliUsage; - } - - if (codex_home_override) |path| { - return try registry.resolveCodexHomeFromEnv(allocator, path, null, null); - } - return try registry.resolveCodexHome(allocator); -} - -pub fn main(init: std.process.Init.Minimal) !void { - var gpa: std.heap.DebugAllocator(.{}) = .init; - defer std.debug.assert(gpa.deinit() == .ok); - const allocator = gpa.allocator(); - - const codex_home = try resolveDaemonCodexHome(allocator, init); - defer allocator.free(codex_home); - - try auto.runDaemon(allocator, codex_home); -} diff --git a/src/cli/commands/clean.zig b/src/cli/commands/clean.zig index 0c335f31..a87cd7de 100644 --- a/src/cli/commands/clean.zig +++ b/src/cli/commands/clean.zig @@ -3,5 +3,16 @@ const types = @import("../types.zig"); const common = @import("common.zig"); pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { - return common.parseSimpleCommandArgs(allocator, "clean", .clean, .{ .clean = .{} }, args); + if (args.len == 0) return .{ .command = .{ .clean = .{} } }; + const first = std.mem.sliceTo(args[0], 0); + if (args.len == 1 and common.isHelpFlag(first)) return .{ .command = .{ .help = .clean } }; + if (args.len == 1 and std.mem.eql(u8, first, "background")) { + return .{ .command = .{ .clean = .{ .target = .background } } }; + } + if (args.len > 1) { + return common.usageErrorResult(allocator, .clean, "unexpected argument after `clean`: `{s}`.", .{ + std.mem.sliceTo(args[1], 0), + }); + } + return common.usageErrorResult(allocator, .clean, "unknown clean target `{s}`.", .{first}); } diff --git a/src/cli/commands/config.zig b/src/cli/commands/config.zig index afac35cd..14a61555 100644 --- a/src/cli/commands/config.zig +++ b/src/cli/commands/config.zig @@ -9,60 +9,12 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa if (args.len < 1) return common.usageErrorResult(allocator, .config, "`config` requires a section.", .{}); const scope = std.mem.sliceTo(args[0], 0); - if (std.mem.eql(u8, scope, "auto")) { - return parseAuto(allocator, args[1..]); - } if (std.mem.eql(u8, scope, "live")) { return parseLive(allocator, args[1..]); } return common.usageErrorResult(allocator, .config, "unknown config section `{s}`.", .{scope}); } -fn parseAuto(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { - if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { - return .{ .command = .{ .help = .config } }; - } - if (args.len == 1) { - const action = std.mem.sliceTo(args[0], 0); - if (std.mem.eql(u8, action, "enable")) return .{ .command = .{ .config = .{ .auto_switch = .{ .action = .enable } } } }; - if (std.mem.eql(u8, action, "disable")) return .{ .command = .{ .config = .{ .auto_switch = .{ .action = .disable } } } }; - } - - var threshold_5h_percent: ?u8 = null; - var threshold_weekly_percent: ?u8 = null; - var i: usize = 0; - while (i < args.len) : (i += 1) { - const arg = std.mem.sliceTo(args[i], 0); - if (std.mem.eql(u8, arg, "--5h")) { - if (i + 1 >= args.len) return common.usageErrorResult(allocator, .config, "missing value for `--5h`.", .{}); - if (threshold_5h_percent != null) return common.usageErrorResult(allocator, .config, "duplicate `--5h` for `config auto`.", .{}); - threshold_5h_percent = common.parsePercentArg(std.mem.sliceTo(args[i + 1], 0)) orelse - return common.usageErrorResult(allocator, .config, "`--5h` must be an integer from 1 to 100.", .{}); - i += 1; - continue; - } - if (std.mem.eql(u8, arg, "--weekly")) { - if (i + 1 >= args.len) return common.usageErrorResult(allocator, .config, "missing value for `--weekly`.", .{}); - if (threshold_weekly_percent != null) return common.usageErrorResult(allocator, .config, "duplicate `--weekly` for `config auto`.", .{}); - threshold_weekly_percent = common.parsePercentArg(std.mem.sliceTo(args[i + 1], 0)) orelse - return common.usageErrorResult(allocator, .config, "`--weekly` must be an integer from 1 to 100.", .{}); - i += 1; - continue; - } - if (std.mem.eql(u8, arg, "enable") or std.mem.eql(u8, arg, "disable")) { - return common.usageErrorResult(allocator, .config, "`config auto` cannot mix actions with threshold flags.", .{}); - } - return common.usageErrorResult(allocator, .config, "unknown argument `{s}` for `config auto`.", .{arg}); - } - if (threshold_5h_percent == null and threshold_weekly_percent == null) { - return common.usageErrorResult(allocator, .config, "`config auto` requires an action or threshold flags.", .{}); - } - return .{ .command = .{ .config = .{ .auto_switch = .{ .configure = .{ - .threshold_5h_percent = threshold_5h_percent, - .threshold_weekly_percent = threshold_weekly_percent, - } } } } }; -} - fn parseLive(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { return .{ .command = .{ .help = .config } }; diff --git a/src/cli/commands/daemon.zig b/src/cli/commands/daemon.zig deleted file mode 100644 index b195c75c..00000000 --- a/src/cli/commands/daemon.zig +++ /dev/null @@ -1,15 +0,0 @@ -const std = @import("std"); -const types = @import("../types.zig"); -const common = @import("common.zig"); - -pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { - if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { - return .{ .command = .{ .help = .daemon } }; - } - if (args.len == 1) { - const mode = std.mem.sliceTo(args[0], 0); - if (std.mem.eql(u8, mode, "--watch")) return .{ .command = .{ .daemon = .{ .mode = .watch } } }; - if (std.mem.eql(u8, mode, "--once")) return .{ .command = .{ .daemon = .{ .mode = .once } } }; - } - return common.usageErrorResult(allocator, .daemon, "`daemon` requires `--watch` or `--once`.", .{}); -} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index 28e16b8b..341cc2d8 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -4,13 +4,11 @@ const common = @import("common.zig"); const clean = @import("clean.zig"); const config = @import("config.zig"); -const daemon = @import("daemon.zig"); const export_auth = @import("export.zig"); const import_auth = @import("import.zig"); const list = @import("list.zig"); const login = @import("login.zig"); const remove = @import("remove.zig"); -const status = @import("status.zig"); const switch_account = @import("switch.zig"); pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { @@ -46,9 +44,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type if (std.mem.eql(u8, cmd, "switch")) return switch_account.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "remove")) return remove.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]); - if (std.mem.eql(u8, cmd, "status")) return status.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]); - if (std.mem.eql(u8, cmd, "daemon")) return daemon.parse(allocator, args[2..]); return common.usageErrorResult(allocator, .top_level, "unknown command `{s}`.", .{cmd}); } @@ -94,7 +90,6 @@ fn parseHelpArgs(allocator: std.mem.Allocator, rest: []const [:0]const u8) !type fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "list")) return .list; - if (std.mem.eql(u8, name, "status")) return .status; if (std.mem.eql(u8, name, "login")) return .login; if (std.mem.eql(u8, name, "import")) return .import_auth; if (std.mem.eql(u8, name, "export")) return .export_auth; @@ -102,6 +97,5 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "remove")) return .remove_account; if (std.mem.eql(u8, name, "clean")) return .clean; if (std.mem.eql(u8, name, "config")) return .config; - if (std.mem.eql(u8, name, "daemon")) return .daemon; return null; } diff --git a/src/cli/commands/status.zig b/src/cli/commands/status.zig deleted file mode 100644 index ef776a4d..00000000 --- a/src/cli/commands/status.zig +++ /dev/null @@ -1,7 +0,0 @@ -const std = @import("std"); -const types = @import("../types.zig"); -const common = @import("common.zig"); - -pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { - return common.parseSimpleCommandArgs(allocator, "status", .status, .{ .status = {} }, args); -} diff --git a/src/cli/help.zig b/src/cli/help.zig index 53f6e1e3..950b5cf6 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const registry = @import("../registry/root.zig"); const io_util = @import("../core/io_util.zig"); const version = @import("../version.zig"); const types = @import("types.zig"); @@ -7,19 +6,18 @@ const style = @import("style.zig"); const HelpTopic = types.HelpTopic; -pub fn printHelp(auto_cfg: *const registry.AutoSwitchConfig) !void { +pub fn printHelp() !void { var stdout: io_util.Stdout = undefined; stdout.init(); const out = stdout.out(); const use_color = style.stdoutColorEnabled(); - try writeHelp(out, use_color, auto_cfg); + try writeHelp(out, use_color); try out.flush(); } pub fn writeHelp( out: *std.Io.Writer, use_color: bool, - auto_cfg: *const registry.AutoSwitchConfig, ) !void { if (use_color) try out.writeAll(style.ansi.cyan); try out.writeAll("codex-auth"); @@ -30,16 +28,6 @@ pub fn writeHelp( if (use_color) try out.writeAll(style.ansi.reset); try out.writeAll("\n\n"); - if (use_color) try out.writeAll(style.ansi.cyan); - try out.writeAll("Auto Switch:"); - if (use_color) try out.writeAll(style.ansi.reset); - try out.print( - " {s} (5h<{d}%, weekly<{d}%)\n", - .{ if (auto_cfg.enabled) "ON" else "OFF", auto_cfg.threshold_5h_percent, auto_cfg.threshold_weekly_percent }, - ); - - try out.writeAll("\n"); - if (use_color) try out.writeAll(style.ansi.cyan); try out.writeAll("Commands:"); if (use_color) try out.writeAll(style.ansi.reset); @@ -49,7 +37,6 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "help ", "Show command-specific help"); try writeCommandSummary(out, use_color, "--version, -V", "Show version"); try writeCommandSummary(out, use_color, "list [--live] [--active] [--api|--skip-api]", "List available accounts"); - try writeCommandSummary(out, use_color, "status", "Show auto-switch and service status"); try writeCommandSummary(out, use_color, "login [--device-auth]", "Login and add the current account"); try writeCommandSummary(out, use_color, "import", "Import auth files or rebuild registry"); try writeCommandDetail(out, use_color, "import [--alias ]"); @@ -64,13 +51,9 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "remove ..."); try writeCommandDetail(out, use_color, "remove --all"); try writeCommandSummary(out, use_color, "clean", "Delete backup and stale files under accounts/"); + try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); - try writeCommandDetail(out, use_color, "config auto enable"); - try writeCommandDetail(out, use_color, "config auto disable"); - try writeCommandDetail(out, use_color, "config auto --5h [--weekly ]"); - try writeCommandDetail(out, use_color, "config auto --weekly "); try writeCommandDetail(out, use_color, "config live --interval "); - try writeCommandSummary(out, use_color, "daemon --watch|--once", "Run the background auto-switch daemon"); try out.writeAll("\n"); if (use_color) try out.writeAll(style.ansi.cyan); @@ -137,7 +120,6 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { return switch (topic) { .top_level => "", .list => "list", - .status => "status", .login => "login", .import_auth => "import", .export_auth => "export", @@ -145,7 +127,6 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .remove_account => "remove", .clean => "clean", .config => "config", - .daemon => "daemon", }; } @@ -153,28 +134,26 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { return switch (topic) { .top_level => "Command-line account management for Codex.", .list => "List available accounts.", - .status => "Show auto-switch and service status.", .login => "Run `codex login` or `codex login --device-auth`, then add the current account.", .import_auth => "Import auth files or rebuild the registry.", .export_auth => "Export stored account auth files.", .switch_account => "Switch the active account by alias, email, display number, or partial query.", .remove_account => "Remove one or more accounts by alias, email, display number, or partial query.", .clean => "Delete backup and stale files under accounts/.", - .config => "Manage auto-switch and live refresh configuration.", - .daemon => "Run the background auto-switch daemon.", + .config => "Manage live refresh configuration.", }; } fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .export_auth, .switch_account, .remove_account, .config, .daemon => true, + .import_auth, .export_auth, .switch_account, .remove_account, .config => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .config, .daemon => true, + .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .config => true, else => false, }; } @@ -203,7 +182,6 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth help \n"); }, .list => try out.writeAll(" codex-auth list [--live] [--active] [--api|--skip-api]\n"), - .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); try out.writeAll(" codex-auth login --device-auth\n"); @@ -226,18 +204,13 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove ...\n"); try out.writeAll(" codex-auth remove --all\n"); }, - .clean => try out.writeAll(" codex-auth clean\n"), + .clean => { + try out.writeAll(" codex-auth clean\n"); + try out.writeAll(" codex-auth clean background\n"); + }, .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 --5h [--weekly ]\n"); - try out.writeAll(" codex-auth config auto --weekly \n"); try out.writeAll(" codex-auth config live --interval \n"); }, - .daemon => { - try out.writeAll(" codex-auth daemon --watch\n"); - try out.writeAll(" codex-auth daemon --once\n"); - }, } } @@ -245,7 +218,6 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { return switch (topic) { .top_level => "codex-auth --help", .list => "codex-auth list --help", - .status => "codex-auth status --help", .login => "codex-auth login --help", .import_auth => "codex-auth import --help", .export_auth => "codex-auth export --help", @@ -253,7 +225,6 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .remove_account => "codex-auth remove --help", .clean => "codex-auth clean --help", .config => "codex-auth config --help", - .daemon => "codex-auth daemon --help", }; } @@ -300,18 +271,9 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" Remove one or more matching accounts.\n"); }, .config => { - try out.writeAll(" auto enable Enable background auto-switching.\n"); - try out.writeAll(" auto disable Disable background auto-switching.\n"); - try out.writeAll(" --5h Set the 5-hour usage threshold from 1 to 100.\n"); - try out.writeAll(" --weekly \n"); - try out.writeAll(" Set the weekly usage threshold from 1 to 100.\n"); try out.writeAll(" live --interval \n"); try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); }, - .daemon => { - try out.writeAll(" --watch Run continuously and switch accounts when thresholds are reached.\n"); - try out.writeAll(" --once Run one auto-switch check, then exit.\n"); - }, else => {}, } } @@ -327,7 +289,7 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { .top_level => { try out.writeAll(" codex-auth list\n"); try out.writeAll(" codex-auth import /path/to/auth.json --alias personal\n"); - try out.writeAll(" codex-auth config auto enable\n"); + try out.writeAll(" codex-auth switch --live\n"); }, .list => { try out.writeAll(" codex-auth list\n"); @@ -336,7 +298,6 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth list --api\n"); try out.writeAll(" codex-auth list --skip-api\n"); }, - .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); try out.writeAll(" codex-auth login --device-auth\n"); @@ -371,15 +332,13 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove john@example.com jane@example.com\n"); try out.writeAll(" codex-auth remove --all\n"); }, - .clean => try out.writeAll(" codex-auth clean\n"), + .clean => { + try out.writeAll(" codex-auth clean\n"); + try out.writeAll(" codex-auth clean background\n"); + }, .config => { - try out.writeAll(" codex-auth config auto --5h 12 --weekly 8\n"); try out.writeAll(" codex-auth config live --interval 60\n"); }, - .daemon => { - try out.writeAll(" codex-auth daemon --watch\n"); - try out.writeAll(" codex-auth daemon --once\n"); - }, } } diff --git a/src/cli/live_tui.zig b/src/cli/live_tui.zig index 41aa7524..5409704b 100644 --- a/src/cli/live_tui.zig +++ b/src/cli/live_tui.zig @@ -49,6 +49,10 @@ test "list viewport keys keep paging and accept alternate-scroll wheel arrows" { try std.testing.expectEqual(@as(usize, 85), viewport_start); } +pub fn nowSecond() i64 { + return std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); +} + pub const LiveAutoSwitchState = struct { enabled: bool, pending: bool, @@ -72,10 +76,6 @@ pub const LiveAutoSwitchState = struct { } }; -pub fn nowSecond() i64 { - return std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); -} - pub fn switchFixedLines(status_line: []const u8, action_line: []const u8) usize { var lines: usize = 3; if (status_line.len != 0) lines += 1; diff --git a/src/cli/picker.zig b/src/cli/picker.zig index 7f138f56..1d6228db 100644 --- a/src/cli/picker.zig +++ b/src/cli/picker.zig @@ -38,5 +38,4 @@ pub const removeOwnedAccountKey = nav.removeOwnedAccountKey; pub const containsOwnedAccountKey = nav.containsOwnedAccountKey; pub const toggleOwnedAccountKey = nav.toggleOwnedAccountKey; pub const clearOwnedAccountKeys = nav.clearOwnedAccountKeys; - pub const maybeAutoSwitchTargetKeyAlloc = auto.maybeAutoSwitchTargetKeyAlloc; diff --git a/src/cli/picker_auto.zig b/src/cli/picker_auto.zig index f5f57520..5cb4697b 100644 --- a/src/cli/picker_auto.zig +++ b/src/cli/picker_auto.zig @@ -5,35 +5,24 @@ const selection = @import("selection.zig"); const row_data = @import("rows.zig"); const nav = @import("picker_nav.zig"); -const SwitchSelectionDisplay = selection.SwitchSelectionDisplay; const SwitchRows = row_data.SwitchRows; -const resolveRateWindow = row_data.resolveRateWindow; -const usageOverrideForAccount = row_data.usageOverrideForAccount; +const SwitchSelectionDisplay = selection.SwitchSelectionDisplay; const accountKeyForSelectableAlloc = nav.accountKeyForSelectableAlloc; +const resolveRateWindow = row_data.resolveRateWindow; -fn numericUsageOverrideStatus(usage_override: ?[]const u8) ?u16 { - const value = usage_override orelse return null; - return std.fmt.parseInt(u16, value, 10) catch null; -} - -fn accountCrossesSwitchThreshold(reg: *const registry.Registry, rec: *const registry.AccountRecord, now: i64) bool { +fn accountHasNoRemaining(rec: *const registry.AccountRecord, now: i64) bool { const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const rem_5h = registry.remainingPercentAt(rate_5h, now); const rem_week = registry.remainingPercentAt(rate_week, now); - return (rem_5h != null and rem_5h.? <= @as(i64, reg.auto_switch.threshold_5h_percent)) or - (rem_week != null and rem_week.? <= @as(i64, reg.auto_switch.threshold_weekly_percent)); + return (rem_5h != null and rem_5h.? == 0) or + (rem_week != null and rem_week.? == 0); } fn shouldAutoSwitchActiveAccount(display: SwitchSelectionDisplay, now: i64) bool { const active_account_key = display.reg.active_account_key orelse return false; const active_idx = registry.findAccountIndexByAccountKey(display.reg, active_account_key) orelse return false; - - if (numericUsageOverrideStatus(usageOverrideForAccount(display.usage_overrides, active_idx))) |status_code| { - return status_code != 200; - } - - return accountCrossesSwitchThreshold(display.reg, &display.reg.accounts.items[active_idx], now); + return accountHasNoRemaining(&display.reg.accounts.items[active_idx], now); } fn resetDistanceSeconds(window: ?registry.RateLimitWindow, now: i64) i64 { @@ -69,7 +58,7 @@ fn bestAutoSwitchCandidateSelectableIndex( const account_idx = rows.items[row_idx].account_index orelse continue; const rec = ®.accounts.items[account_idx]; if (std.mem.eql(u8, rec.account_key, active_account_key)) continue; - if (accountCrossesSwitchThreshold(reg, rec, now)) continue; + if (accountHasNoRemaining(rec, now)) continue; if (best_account_idx == null or autoSwitchCandidateIsBetter(rec, ®.accounts.items[best_account_idx.?], now)) { best_selectable_idx = selectable_idx; diff --git a/src/cli/types.zig b/src/cli/types.zig index b51cf4fc..35c9c6de 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -35,29 +35,17 @@ pub const RemoveOptions = struct { live: bool = false, api_mode: ApiMode = .default, }; -pub const CleanOptions = struct {}; -pub const AutoAction = enum { enable, disable }; -pub const AutoThresholdOptions = struct { - threshold_5h_percent: ?u8, - threshold_weekly_percent: ?u8, -}; -pub const AutoOptions = union(enum) { - action: AutoAction, - configure: AutoThresholdOptions, +pub const CleanTarget = enum { accounts, background }; +pub const CleanOptions = struct { + target: CleanTarget = .accounts, }; pub const LiveOptions = struct { interval_seconds: u16, }; -pub const ConfigOptions = union(enum) { - auto_switch: AutoOptions, - live: LiveOptions, -}; -pub const DaemonMode = enum { watch, once }; -pub const DaemonOptions = struct { mode: DaemonMode }; +pub const ConfigOptions = union(enum) { live: LiveOptions }; pub const HelpTopic = enum { top_level, list, - status, login, import_auth, export_auth, @@ -65,7 +53,6 @@ pub const HelpTopic = enum { remove_account, clean, config, - daemon, }; pub const Command = union(enum) { @@ -77,8 +64,6 @@ pub const Command = union(enum) { remove_account: RemoveOptions, clean: CleanOptions, config: ConfigOptions, - status: void, - daemon: DaemonOptions, version: void, help: HelpTopic, }; diff --git a/src/platform/windows.zig b/src/platform/windows.zig deleted file mode 100644 index 56ec4dda..00000000 --- a/src/platform/windows.zig +++ /dev/null @@ -1,317 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); - -pub const RuntimeState = enum { running, stopped, unknown }; - -pub const TaskSpec = struct { - task_name: []const u8, - executable_path: []const u8, - arguments: []const u8, - restart_count: i32, - restart_interval: []const u8, - execution_time_limit: []const u8, -}; - -const impl = if (builtin.os.tag == .windows) struct { - const c = @cImport({ - @cDefine("_FORTIFY_SOURCE", "0"); - @cInclude("windows.h"); - @cInclude("taskschd.h"); - @cInclude("combaseapi.h"); - @cInclude("oleauto.h"); - }); - - fn guid(d1: u32, d2: u16, d3: u16, d4: [8]u8) c.GUID { - return .{ .Data1 = d1, .Data2 = d2, .Data3 = d3, .Data4 = d4 }; - } - - const clsid_task_scheduler = - guid(0x0f87369f, 0xa4e5, 0x4cfc, .{ 0xbd, 0x3e, 0x73, 0xe6, 0x15, 0x45, 0x72, 0xdd }); - const iid_itask_service = - guid(0x2faba4c7, 0x4da9, 0x4013, .{ 0x96, 0x97, 0x20, 0xcc, 0x3f, 0xd4, 0x0f, 0x85 }); - const iid_iexec_action = - guid(0x4c3d624d, 0xfd6b, 0x49a3, .{ 0xb9, 0xb7, 0x09, 0xcb, 0x3c, 0xd3, 0xf0, 0x47 }); - - fn hresultBits(hr: c.HRESULT) u32 { - return @bitCast(@as(i32, @intCast(hr))); - } - - fn mapHResult(hr: c.HRESULT) anyerror { - return switch (hresultBits(hr)) { - 0x80070002, 0x80070003 => error.TaskNotFound, - 0x80070005 => error.AccessDenied, - else => error.TaskSchedulerCallFailed, - }; - } - - fn checkHResult(hr: c.HRESULT) !void { - if (hr >= 0) return; - return mapHResult(hr); - } - - fn releaseCom(ptr: anytype) void { - const value = switch (@typeInfo(@TypeOf(ptr))) { - .optional => ptr orelse return, - else => ptr, - }; - _ = value.lpVtbl.*.Release.?(value); - } - - fn queryInterface(source: anytype, iid: *const c.GUID, out: anytype) !void { - try checkHResult(source.lpVtbl.*.QueryInterface.?(source, iid, @ptrCast(out))); - } - - const OwnedBstr = struct { - value: c.BSTR = null, - - fn init(allocator: std.mem.Allocator, utf8: []const u8) !OwnedBstr { - const utf16 = try std.unicode.utf8ToUtf16LeAlloc(allocator, utf8); - defer allocator.free(utf16); - return .{ .value = c.SysAllocStringLen(utf16.ptr, @intCast(utf16.len)) orelse return error.OutOfMemory }; - } - - fn deinit(self: *OwnedBstr) void { - if (self.value != null) c.SysFreeString(self.value); - self.value = null; - } - }; - - fn bstrToUtf8Alloc(allocator: std.mem.Allocator, value: c.BSTR) ![]u8 { - const len: usize = c.SysStringLen(value); - const utf16 = @as([*]const u16, @ptrCast(value))[0..len]; - return try std.unicode.utf16LeToUtf8Alloc(allocator, utf16); - } - - fn initEmptyVariant() c.VARIANT { - var value = std.mem.zeroes(c.VARIANT); - c.VariantInit(&value); - return value; - } - - fn clearVariant(value: *c.VARIANT) void { - _ = c.VariantClear(value); - } - - const Session = struct { - com_initialized: bool = false, - service: ?*c.ITaskService = null, - root: ?*c.ITaskFolder = null, - - fn init(allocator: std.mem.Allocator) !Session { - var self = Session{}; - const init_hr = c.CoInitializeEx(null, c.COINIT_MULTITHREADED); - if (init_hr == c.RPC_E_CHANGED_MODE) { - self.com_initialized = false; - } else { - try checkHResult(init_hr); - self.com_initialized = true; - } - errdefer if (self.com_initialized) c.CoUninitialize(); - - var service: ?*c.ITaskService = null; - try checkHResult(c.CoCreateInstance( - &clsid_task_scheduler, - null, - c.CLSCTX_INPROC_SERVER, - &iid_itask_service, - @ptrCast(&service), - )); - self.service = service; - errdefer releaseCom(self.service); - - var empty_server = initEmptyVariant(); - defer clearVariant(&empty_server); - var empty_user = initEmptyVariant(); - defer clearVariant(&empty_user); - var empty_domain = initEmptyVariant(); - defer clearVariant(&empty_domain); - var empty_password = initEmptyVariant(); - defer clearVariant(&empty_password); - try checkHResult(service.?.lpVtbl.*.Connect.?(service.?, empty_server, empty_user, empty_domain, empty_password)); - - var root_path = try OwnedBstr.init(allocator, "\\"); - defer root_path.deinit(); - var root: ?*c.ITaskFolder = null; - try checkHResult(service.?.lpVtbl.*.GetFolder.?(service.?, root_path.value, @ptrCast(&root))); - self.root = root; - return self; - } - - fn deinit(self: *Session) void { - releaseCom(self.root); - releaseCom(self.service); - if (self.com_initialized) c.CoUninitialize(); - } - - fn getTask(self: *Session, allocator: std.mem.Allocator, task_name: []const u8) !?*c.IRegisteredTask { - var name = try OwnedBstr.init(allocator, task_name); - defer name.deinit(); - - var task: ?*c.IRegisteredTask = null; - const hr = self.root.?.lpVtbl.*.GetTask.?(self.root.?, name.value, @ptrCast(&task)); - if (hr < 0) { - const err = mapHResult(hr); - if (err == error.TaskNotFound) return null; - return err; - } - return task; - } - }; - - pub fn queryTaskRuntimeState(allocator: std.mem.Allocator, task_name: []const u8) RuntimeState { - var session = Session.init(allocator) catch return .unknown; - defer session.deinit(); - - const task = session.getTask(allocator, task_name) catch return .unknown; - if (task == null) return .stopped; - defer releaseCom(task); - - var state: c.TASK_STATE = c.TASK_STATE_UNKNOWN; - const hr = task.?.lpVtbl.*.get_State.?(task.?, &state); - if (hr < 0) return .unknown; - - return switch (@as(c_int, @intCast(state))) { - c.TASK_STATE_RUNNING => .running, - c.TASK_STATE_UNKNOWN => .unknown, - else => .stopped, - }; - } - - pub fn installTask(allocator: std.mem.Allocator, spec: TaskSpec) !void { - var session = try Session.init(allocator); - defer session.deinit(); - - if (try session.getTask(allocator, spec.task_name)) |existing| { - defer releaseCom(existing); - _ = existing.lpVtbl.*.Stop.?(existing, 0); - } - - var definition: ?*c.ITaskDefinition = null; - try checkHResult(session.service.?.lpVtbl.*.NewTask.?(session.service.?, 0, @ptrCast(&definition))); - defer releaseCom(definition); - - var principal: ?*c.IPrincipal = null; - try checkHResult(definition.?.lpVtbl.*.get_Principal.?(definition.?, @ptrCast(&principal))); - defer releaseCom(principal); - try checkHResult(principal.?.lpVtbl.*.put_LogonType.?(principal.?, c.TASK_LOGON_INTERACTIVE_TOKEN)); - try checkHResult(principal.?.lpVtbl.*.put_RunLevel.?(principal.?, c.TASK_RUNLEVEL_LUA)); - - var settings: ?*c.ITaskSettings = null; - try checkHResult(definition.?.lpVtbl.*.get_Settings.?(definition.?, @ptrCast(&settings))); - defer releaseCom(settings); - try checkHResult(settings.?.lpVtbl.*.put_Enabled.?(settings.?, c.VARIANT_TRUE)); - try checkHResult(settings.?.lpVtbl.*.put_AllowDemandStart.?(settings.?, c.VARIANT_TRUE)); - - var restart_interval = try OwnedBstr.init(allocator, spec.restart_interval); - defer restart_interval.deinit(); - try checkHResult(settings.?.lpVtbl.*.put_RestartInterval.?(settings.?, restart_interval.value)); - try checkHResult(settings.?.lpVtbl.*.put_RestartCount.?(settings.?, @intCast(spec.restart_count))); - - var execution_limit = try OwnedBstr.init(allocator, spec.execution_time_limit); - defer execution_limit.deinit(); - try checkHResult(settings.?.lpVtbl.*.put_ExecutionTimeLimit.?(settings.?, execution_limit.value)); - - var triggers: ?*c.ITriggerCollection = null; - try checkHResult(definition.?.lpVtbl.*.get_Triggers.?(definition.?, @ptrCast(&triggers))); - defer releaseCom(triggers); - - var trigger: ?*c.ITrigger = null; - try checkHResult(triggers.?.lpVtbl.*.Create.?(triggers.?, c.TASK_TRIGGER_LOGON, @ptrCast(&trigger))); - defer releaseCom(trigger); - try checkHResult(trigger.?.lpVtbl.*.put_Enabled.?(trigger.?, c.VARIANT_TRUE)); - - var actions: ?*c.IActionCollection = null; - try checkHResult(definition.?.lpVtbl.*.get_Actions.?(definition.?, @ptrCast(&actions))); - defer releaseCom(actions); - - var action: ?*c.IAction = null; - try checkHResult(actions.?.lpVtbl.*.Create.?(actions.?, c.TASK_ACTION_EXEC, @ptrCast(&action))); - defer releaseCom(action); - - var exec_action: ?*c.IExecAction = null; - try queryInterface(action.?, &iid_iexec_action, &exec_action); - defer releaseCom(exec_action); - - var executable_path = try OwnedBstr.init(allocator, spec.executable_path); - defer executable_path.deinit(); - try checkHResult(exec_action.?.lpVtbl.*.put_Path.?(exec_action.?, executable_path.value)); - - var arguments = try OwnedBstr.init(allocator, spec.arguments); - defer arguments.deinit(); - try checkHResult(exec_action.?.lpVtbl.*.put_Arguments.?(exec_action.?, arguments.value)); - - var task_name = try OwnedBstr.init(allocator, spec.task_name); - defer task_name.deinit(); - - var empty_user = initEmptyVariant(); - defer clearVariant(&empty_user); - var empty_password = initEmptyVariant(); - defer clearVariant(&empty_password); - var empty_sddl = initEmptyVariant(); - defer clearVariant(&empty_sddl); - - var registered: ?*c.IRegisteredTask = null; - try checkHResult(session.root.?.lpVtbl.*.RegisterTaskDefinition.?(session.root.?, task_name.value, definition.?, c.TASK_CREATE_OR_UPDATE, empty_user, empty_password, c.TASK_LOGON_INTERACTIVE_TOKEN, empty_sddl, @ptrCast(®istered))); - defer releaseCom(registered); - - var run_params = initEmptyVariant(); - defer clearVariant(&run_params); - var running_task: ?*c.IRunningTask = null; - const run_hr = registered.?.lpVtbl.*.Run.?(registered.?, run_params, @ptrCast(&running_task)); - if (running_task) |value| { - _ = value.lpVtbl.*.Release.?(value); - } - try checkHResult(run_hr); - } - - pub fn uninstallTask(allocator: std.mem.Allocator, task_name: []const u8) !void { - var session = try Session.init(allocator); - defer session.deinit(); - - if (try session.getTask(allocator, task_name)) |task| { - defer releaseCom(task); - _ = task.lpVtbl.*.Stop.?(task, 0); - } else { - return; - } - - var name = try OwnedBstr.init(allocator, task_name); - defer name.deinit(); - try checkHResult(session.root.?.lpVtbl.*.DeleteTask.?(session.root.?, name.value, 0)); - } - - pub fn readTaskXmlAlloc(allocator: std.mem.Allocator, task_name: []const u8) !?[]u8 { - var session = try Session.init(allocator); - defer session.deinit(); - - const task = try session.getTask(allocator, task_name); - if (task == null) return null; - defer releaseCom(task); - - var xml: c.BSTR = null; - try checkHResult(task.?.lpVtbl.*.get_Xml.?(task.?, &xml)); - defer if (xml != null) c.SysFreeString(xml); - return try bstrToUtf8Alloc(allocator, xml); - } -} else struct { - pub fn queryTaskRuntimeState(_: std.mem.Allocator, _: []const u8) RuntimeState { - return .unknown; - } - - pub fn installTask(_: std.mem.Allocator, _: TaskSpec) !void { - return error.UnsupportedPlatform; - } - - pub fn uninstallTask(_: std.mem.Allocator, _: []const u8) !void { - return error.UnsupportedPlatform; - } - - pub fn readTaskXmlAlloc(_: std.mem.Allocator, _: []const u8) !?[]u8 { - return error.UnsupportedPlatform; - } -}; - -pub const queryTaskRuntimeState = impl.queryTaskRuntimeState; -pub const installTask = impl.installTask; -pub const uninstallTask = impl.uninstallTask; -pub const readTaskXmlAlloc = impl.readTaskXmlAlloc; diff --git a/src/registry/clean.zig b/src/registry/clean.zig index 272452ef..1b16d23c 100644 --- a/src/registry/clean.zig +++ b/src/registry/clean.zig @@ -7,7 +7,6 @@ const c_time = @cImport({ }); const Registry = common.Registry; -const account_name_refresh_lock_file_name = common.account_name_refresh_lock_file_name; const accountSnapshotFileName = common.accountSnapshotFileName; const accountAuthPath = common.accountAuthPath; const readFileAlloc = common.readFileAlloc; @@ -216,8 +215,6 @@ pub fn isAllowedCurrentSnapshot(reg: *const Registry, entry_name: []const u8) bo pub fn isAllowedAccountsEntry(reg: *const Registry, entry_name: []const u8) bool { if (std.mem.eql(u8, entry_name, "registry.json")) return true; - if (std.mem.eql(u8, entry_name, "auto-switch.lock")) return true; - if (std.mem.eql(u8, entry_name, account_name_refresh_lock_file_name)) return true; if (std.mem.eql(u8, entry_name, "backups")) return true; return isAllowedCurrentSnapshot(reg, entry_name); } diff --git a/src/registry/common.zig b/src/registry/common.zig index 70f4201a..abdd5153 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -10,9 +10,6 @@ pub const PlanType = enum { free, plus, prolite, pro, team, business, enterprise pub const AuthMode = enum { chatgpt, apikey }; pub const current_schema_version: u32 = 4; pub const min_supported_schema_version: u32 = 2; -pub const default_auto_switch_threshold_5h_percent: u8 = 1; -pub const default_auto_switch_threshold_weekly_percent: u8 = 1; -pub const account_name_refresh_lock_file_name = "account-name-refresh.lock"; pub const private_file_permissions: std.Io.File.Permissions = switch (builtin.os.tag) { .windows => .default_file, else => .fromMode(0o600), @@ -79,12 +76,6 @@ pub const RolloutSignature = struct { event_timestamp_ms: i64, }; -pub const AutoSwitchConfig = struct { - enabled: bool = false, - threshold_5h_percent: u8 = default_auto_switch_threshold_5h_percent, - threshold_weekly_percent: u8 = default_auto_switch_threshold_weekly_percent, -}; - pub const ApiConfig = struct { usage: bool = true, account: bool = true, @@ -145,7 +136,6 @@ pub const Registry = struct { schema_version: u32, active_account_key: ?[]u8, active_account_activated_at_ms: ?i64, - auto_switch: AutoSwitchConfig, api: ApiConfig, live: LiveConfig = defaultLiveConfig(), accounts: std.ArrayList(AccountRecord), @@ -159,10 +149,6 @@ pub const Registry = struct { } }; -pub fn defaultAutoSwitchConfig() AutoSwitchConfig { - return .{}; -} - pub fn defaultApiConfig() ApiConfig { return .{}; } diff --git a/src/registry/import.zig b/src/registry/import.zig index b415cb5a..7894b3c5 100644 --- a/src/registry/import.zig +++ b/src/registry/import.zig @@ -8,10 +8,8 @@ const parse = @import("parse.zig"); const account_ops = @import("account_ops.zig"); const PlanType = common.PlanType; -const AutoSwitchConfig = common.AutoSwitchConfig; const AccountRecord = common.AccountRecord; const Registry = common.Registry; -const defaultAutoSwitchConfig = common.defaultAutoSwitchConfig; const freeAccountRecord = common.freeAccountRecord; const normalizeEmailAlloc = common.normalizeEmailAlloc; const activeAuthPath = common.activeAuthPath; @@ -28,7 +26,6 @@ const getNonEmptyEnvVarOwned = common.getNonEmptyEnvVarOwned; const registryPath = common.registryPath; const backupAuthIfChanged = clean.backupAuthIfChanged; const backupDir = clean.backupDir; -const parseAutoSwitch = parse.parseAutoSwitch; const findAccountIndexByAccountKey = account_ops.findAccountIndexByAccountKey; const setActiveAccountKey = account_ops.setActiveAccountKey; const activateAccountByKey = account_ops.activateAccountByKey; @@ -41,7 +38,6 @@ fn defaultRegistry() Registry { .schema_version = common.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = defaultAutoSwitchConfig(), .api = common.defaultApiConfig(), .accounts = std.ArrayList(AccountRecord).empty, }; @@ -81,7 +77,6 @@ pub fn purgeRegistryFromImportSourceWithSaver( const carry_forward = try loadPurgeCarryForwardConfig(allocator, codex_home); var reg = defaultRegistry(); - reg.auto_switch = carry_forward.auto_switch; reg.live = carry_forward.live; defer reg.deinit(allocator); diff --git a/src/registry/import_carry.zig b/src/registry/import_carry.zig index b090a12a..764baf7f 100644 --- a/src/registry/import_carry.zig +++ b/src/registry/import_carry.zig @@ -1,19 +1,16 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); const common = @import("common.zig"); -const parse = @import("parse.zig"); -const AutoSwitchConfig = common.AutoSwitchConfig; const LiveConfig = common.LiveConfig; -const defaultAutoSwitchConfig = common.defaultAutoSwitchConfig; const defaultLiveConfig = common.defaultLiveConfig; const registryPath = common.registryPath; const readFileAlloc = common.readFileAlloc; -const parseAutoSwitch = parse.parseAutoSwitch; +const parse = @import("parse.zig"); const parseLiveConfig = parse.parseLiveConfig; +const parseLiveIntervalSeconds = parse.parseLiveIntervalSeconds; const PurgeCarryForwardConfig = struct { - auto_switch: AutoSwitchConfig = defaultAutoSwitchConfig(), live: LiveConfig = defaultLiveConfig(), }; @@ -38,24 +35,30 @@ fn parsePurgeCarryForwardConfig(allocator: std.mem.Allocator, data: []const u8) var cfg = PurgeCarryForwardConfig{}; var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch { - applyCarryForwardObjectSlice(allocator, data, "auto_switch", &cfg.auto_switch, parseCarryForwardAutoSwitch); applyCarryForwardObjectSlice(allocator, data, "live", &cfg.live, parseCarryForwardLiveConfig); + applyCarryForwardScalarSlice(data, "interval_seconds", &cfg.live); return cfg; }; defer parsed.deinit(); switch (parsed.value) { .object => |obj| { - if (obj.get("auto_switch")) |v| parseAutoSwitch(allocator, &cfg.auto_switch, v); if (obj.get("live")) |v| parseLiveConfig(&cfg.live, v); + if (obj.get("interval_seconds")) |v| { + if (parseLiveIntervalSeconds(v)) |value| cfg.live.interval_seconds = value; + } }, else => {}, } return cfg; } -fn parseCarryForwardAutoSwitch(allocator: std.mem.Allocator, value: std.json.Value, target: *AutoSwitchConfig) void { - parseAutoSwitch(allocator, target, value); +fn applyCarryForwardScalarSlice(data: []const u8, field_name: []const u8, target: *LiveConfig) void { + const slice = findJsonScalarFieldSlice(data, field_name) orelse return; + const value = std.fmt.parseInt(i64, std.mem.trim(u8, slice, " \r\n\t,"), 10) catch return; + if (parseLiveIntervalSeconds(.{ .integer = value })) |interval| { + target.interval_seconds = interval; + } } fn parseCarryForwardLiveConfig(_: std.mem.Allocator, value: std.json.Value, target: *LiveConfig) void { @@ -96,6 +99,23 @@ fn findJsonObjectFieldSlice(data: []const u8, field_name: []const u8) ?[]const u return null; } +fn findJsonScalarFieldSlice(data: []const u8, field_name: []const u8) ?[]const u8 { + var pattern_buffer: [64]u8 = undefined; + if (field_name.len + 2 > pattern_buffer.len) return null; + pattern_buffer[0] = '"'; + @memcpy(pattern_buffer[1 .. 1 + field_name.len], field_name); + pattern_buffer[1 + field_name.len] = '"'; + const pattern = pattern_buffer[0 .. field_name.len + 2]; + + const name_idx = std.mem.indexOf(u8, data, pattern) orelse return null; + var idx = skipJsonWhitespace(data, name_idx + pattern.len); + if (idx >= data.len or data[idx] != ':') return null; + idx = skipJsonWhitespace(data, idx + 1); + const start = idx; + while (idx < data.len and data[idx] != ',' and data[idx] != '\n' and data[idx] != '\r' and data[idx] != '}') : (idx += 1) {} + return data[start..idx]; +} + fn skipJsonWhitespace(data: []const u8, start: usize) usize { var idx = start; while (idx < data.len and std.ascii.isWhitespace(data[idx])) : (idx += 1) {} diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 86d78b81..079808cc 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -3,7 +3,6 @@ const common = @import("common.zig"); const PlanType = common.PlanType; const AuthMode = common.AuthMode; -const AutoSwitchConfig = common.AutoSwitchConfig; const LiveConfig = common.LiveConfig; const RateLimitSnapshot = common.RateLimitSnapshot; const RateLimitWindow = common.RateLimitWindow; @@ -47,30 +46,6 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSna return snap; } -pub fn parseAutoSwitch(allocator: std.mem.Allocator, cfg: *AutoSwitchConfig, v: std.json.Value) void { - _ = allocator; - const obj = switch (v) { - .object => |o| o, - else => return, - }; - if (obj.get("enabled")) |enabled| { - switch (enabled) { - .bool => |flag| cfg.enabled = flag, - else => {}, - } - } - if (obj.get("threshold_5h_percent")) |threshold| { - if (parseThresholdPercent(threshold)) |value| { - cfg.threshold_5h_percent = value; - } - } - if (obj.get("threshold_weekly_percent")) |threshold| { - if (parseThresholdPercent(threshold)) |value| { - cfg.threshold_weekly_percent = value; - } - } -} - pub fn parseLiveConfig(cfg: *LiveConfig, v: std.json.Value) void { const obj = switch (v) { .object => |o| o, diff --git a/src/registry/root.zig b/src/registry/root.zig index 758bc566..b7b7a285 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -14,9 +14,6 @@ pub const PlanType = common.PlanType; pub const AuthMode = common.AuthMode; pub const current_schema_version = common.current_schema_version; pub const min_supported_schema_version = common.min_supported_schema_version; -pub const default_auto_switch_threshold_5h_percent = common.default_auto_switch_threshold_5h_percent; -pub const default_auto_switch_threshold_weekly_percent = common.default_auto_switch_threshold_weekly_percent; -pub const account_name_refresh_lock_file_name = common.account_name_refresh_lock_file_name; pub const private_file_permissions = common.private_file_permissions; pub const private_dir_permissions = common.private_dir_permissions; pub const getEnvMap = common.getEnvMap; @@ -28,7 +25,6 @@ pub const RateLimitWindow = common.RateLimitWindow; pub const CreditsSnapshot = common.CreditsSnapshot; pub const RateLimitSnapshot = common.RateLimitSnapshot; pub const RolloutSignature = common.RolloutSignature; -pub const AutoSwitchConfig = common.AutoSwitchConfig; pub const ApiConfig = common.ApiConfig; pub const default_live_refresh_interval_seconds = common.default_live_refresh_interval_seconds; pub const min_live_refresh_interval_seconds = common.min_live_refresh_interval_seconds; @@ -39,7 +35,6 @@ pub const resolvePlan = common.resolvePlan; pub const resolveDisplayPlan = common.resolveDisplayPlan; pub const planLabel = common.planLabel; pub const Registry = common.Registry; -pub const defaultAutoSwitchConfig = common.defaultAutoSwitchConfig; pub const defaultApiConfig = common.defaultApiConfig; pub const defaultLiveConfig = common.defaultLiveConfig; pub const freeAccountRecord = common.freeAccountRecord; diff --git a/src/registry/storage.zig b/src/registry/storage.zig index 3449a759..19abb10e 100644 --- a/src/registry/storage.zig +++ b/src/registry/storage.zig @@ -14,14 +14,12 @@ const PlanType = common.PlanType; const AuthMode = common.AuthMode; const RateLimitSnapshot = common.RateLimitSnapshot; const RolloutSignature = common.RolloutSignature; -const AutoSwitchConfig = common.AutoSwitchConfig; const LiveConfig = common.LiveConfig; const AccountRecord = common.AccountRecord; const Registry = common.Registry; const current_schema_version = common.current_schema_version; const min_supported_schema_version = common.min_supported_schema_version; const private_file_permissions = common.private_file_permissions; -const defaultAutoSwitchConfig = common.defaultAutoSwitchConfig; const defaultApiConfig = common.defaultApiConfig; const defaultLiveConfig = common.defaultLiveConfig; const freeAccountRecord = common.freeAccountRecord; @@ -40,12 +38,10 @@ const normalizeEmailAlloc = common.normalizeEmailAlloc; const parsePlanType = parse.parsePlanType; const parseAuthMode = parse.parseAuthMode; const parseUsage = parse.parseUsage; -const parseAutoSwitch = parse.parseAutoSwitch; const parseLiveConfig = parse.parseLiveConfig; -const liveConfigNeedsRewrite = parse.liveConfigNeedsRewrite; +const parseLiveIntervalSeconds = parse.parseLiveIntervalSeconds; const parseRolloutSignature = parse.parseRolloutSignature; const readInt = parse.readInt; -const parseThresholdPercent = parse.parseThresholdPercent; const parseAccountRecord = storage_parse.parseAccountRecord; const accountFromAuth = account_ops.accountFromAuth; const upsertAccount = account_ops.upsertAccount; @@ -79,7 +75,6 @@ pub fn defaultRegistry() Registry { .schema_version = current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = defaultAutoSwitchConfig(), .api = defaultApiConfig(), .live = defaultLiveConfig(), .accounts = std.ArrayList(AccountRecord).empty, @@ -300,12 +295,7 @@ fn loadLegacyRegistryV2( } } - if (root_obj.get("auto_switch")) |v| { - parseAutoSwitch(allocator, ®.auto_switch, v); - } - if (root_obj.get("live")) |v| { - parseLiveConfig(®.live, v); - } + parseRegistryLiveConfig(®.live, root_obj); for (legacy_accounts.items) |*legacy| { try migrateLegacyRecord(allocator, codex_home, ®, legacy_active_email, legacy); @@ -347,12 +337,7 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa } } - if (root_obj.get("auto_switch")) |v| { - parseAutoSwitch(allocator, ®.auto_switch, v); - } - if (root_obj.get("live")) |v| { - parseLiveConfig(®.live, v); - } + parseRegistryLiveConfig(®.live, root_obj); return reg; } @@ -376,23 +361,35 @@ fn usesLegacyVersionField(root_obj: std.json.ObjectMap) bool { fn currentLayoutNeedsRewrite(root_obj: std.json.ObjectMap) bool { if (root_obj.get("last_attributed_rollout") != null) return true; if (root_obj.get("api") != null) return true; - if (root_obj.get("live")) |v| { - if (liveConfigNeedsRewrite(v)) return true; + if (root_obj.get("auto_switch") != null) return true; + if (root_obj.get("live") != null) return true; + if (root_obj.get("interval_seconds")) |v| { + if (parseLiveIntervalSeconds(v) == null) return true; } else { return true; } return root_obj.get("active_account_key") != null and root_obj.get("active_account_activated_at_ms") == null; } +fn parseRegistryLiveConfig(live: *LiveConfig, root_obj: std.json.ObjectMap) void { + if (root_obj.get("interval_seconds")) |v| { + if (parseLiveIntervalSeconds(v)) |value| { + live.interval_seconds = value; + return; + } + } + if (root_obj.get("live")) |v| { + parseLiveConfig(live, v); + } +} + fn detectSchemaVersion(root_obj: std.json.ObjectMap) u32 { return schemaVersionFieldValue(root_obj) orelse if (root_obj.get("active_email") != null) 2 else current_schema_version; } fn applySchemaMigrations(reg: *Registry, loaded_schema_version: u32) void { - if (loaded_schema_version < 4) { - reg.auto_switch.threshold_5h_percent = common.default_auto_switch_threshold_5h_percent; - reg.auto_switch.threshold_weekly_percent = common.default_auto_switch_threshold_weekly_percent; - } + _ = reg; + _ = loaded_schema_version; } fn logUnsupportedRegistryVersion(version_value: u32) void { diff --git a/src/registry/storage_write.zig b/src/registry/storage_write.zig index c06cae06..a5202a86 100644 --- a/src/registry/storage_write.zig +++ b/src/registry/storage_write.zig @@ -5,8 +5,6 @@ const clean = @import("clean.zig"); const common = @import("common.zig"); const AccountRecord = common.AccountRecord; -const AutoSwitchConfig = common.AutoSwitchConfig; -const LiveConfig = common.LiveConfig; const Registry = common.Registry; const current_schema_version = common.current_schema_version; const ensureAccountsDir = common.ensureAccountsDir; @@ -26,8 +24,7 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: * .schema_version = current_schema_version, .active_account_key = reg.active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, - .auto_switch = reg.auto_switch, - .live = reg.live, + .interval_seconds = reg.live.interval_seconds, .accounts = reg.accounts.items, }; var aw: std.Io.Writer.Allocating = .init(allocator); @@ -106,7 +103,6 @@ const RegistryOut = struct { schema_version: u32, active_account_key: ?[]const u8, active_account_activated_at_ms: ?i64, - auto_switch: AutoSwitchConfig, - live: LiveConfig, + interval_seconds: u16, accounts: []const AccountRecord, }; diff --git a/src/root.zig b/src/root.zig index 3a15ceab..4f7c321a 100644 --- a/src/root.zig +++ b/src/root.zig @@ -7,10 +7,8 @@ pub const api = struct { pub const auth = struct { pub const core = @import("auth/auth.zig"); - pub const account = @import("auth/account.zig"); }; -pub const auto = @import("auto/root.zig"); pub const cli = @import("cli/root.zig"); pub const workflows = @import("workflows/root.zig"); @@ -20,10 +18,6 @@ pub const core = struct { pub const runtime = @import("core/runtime.zig"); }; -pub const platform = struct { - pub const windows = @import("platform/windows.zig"); -}; - pub const registry = @import("registry/root.zig"); pub const session = @import("session.zig"); diff --git a/src/workflows/account_names.zig b/src/workflows/account_names.zig index 156f2d46..794a1cd0 100644 --- a/src/workflows/account_names.zig +++ b/src/workflows/account_names.zig @@ -1,30 +1,18 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); const account_api = @import("../api/account.zig"); -const account_name_refresh = @import("../auth/account.zig"); const auth = @import("../auth/auth.zig"); const cli = @import("../cli/root.zig"); const registry = @import("../registry/root.zig"); const targets = @import("targets.zig"); -const workflow_env = @import("env.zig"); const ForegroundUsageRefreshTarget = targets.ForegroundUsageRefreshTarget; -const getEnvMap = workflow_env.getEnvMap; -const account_name_refresh_only_env = workflow_env.account_name_refresh_only_env; -const disable_background_account_name_refresh_env = workflow_env.disable_background_account_name_refresh_env; -const skip_service_reconcile_env = workflow_env.skip_service_reconcile_env; -const isBackgroundAccountNameRefreshDisabled = workflow_env.isBackgroundAccountNameRefreshDisabled; pub const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, access_token: []const u8, account_id: []const u8, ) anyerror!account_api.FetchResult; -pub const BackgroundRefreshLockAcquirer = *const fn ( - allocator: std.mem.Allocator, - codex_home: []const u8, -) anyerror!?account_name_refresh.BackgroundRefreshLock; - pub fn maybeRefreshForegroundAccountNames( allocator: std.mem.Allocator, codex_home: []const u8, @@ -275,126 +263,6 @@ pub fn shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled( return registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id); } -pub fn shouldScheduleBackgroundAccountNameRefresh(reg: *registry.Registry) bool { - if (!reg.api.account) return false; - - for (reg.accounts.items) |rec| { - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; - if (registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) return true; - } - - return false; -} - -pub fn applyAccountNameRefreshEntriesToLatestRegistry( - allocator: std.mem.Allocator, - codex_home: []const u8, - chatgpt_user_id: []const u8, - entries: []const account_api.AccountEntry, -) !bool { - var latest = try registry.loadRegistry(allocator, codex_home); - defer latest.deinit(allocator); - - if (!shouldRefreshTeamAccountNamesForUserScope(&latest, chatgpt_user_id)) return false; - if (!try registry.applyAccountNamesForUser(allocator, &latest, chatgpt_user_id, entries)) return false; - - try registry.saveRegistry(allocator, codex_home, &latest); - return true; -} - -pub fn runBackgroundAccountNameRefresh( - allocator: std.mem.Allocator, - codex_home: []const u8, - fetcher: AccountFetchFn, -) !void { - return try runBackgroundAccountNameRefreshWithLockAcquirer( - allocator, - codex_home, - fetcher, - account_name_refresh.BackgroundRefreshLock.acquire, - ); -} - -pub fn runBackgroundAccountNameRefreshWithLockAcquirer( - allocator: std.mem.Allocator, - codex_home: []const u8, - fetcher: AccountFetchFn, - lock_acquirer: BackgroundRefreshLockAcquirer, -) !void { - var refresh_lock = (try lock_acquirer(allocator, codex_home)) orelse return; - defer refresh_lock.release(); - - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - var candidates = try account_name_refresh.collectCandidates(allocator, ®); - defer { - for (candidates.items) |*candidate| candidate.deinit(allocator); - candidates.deinit(allocator); - } - - for (candidates.items) |candidate| { - var latest = try registry.loadRegistry(allocator, codex_home); - defer latest.deinit(allocator); - - if (!shouldRefreshTeamAccountNamesForUserScope(&latest, candidate.chatgpt_user_id)) continue; - - var info = (try account_name_refresh.loadStoredAuthInfoForUser( - allocator, - codex_home, - &latest, - candidate.chatgpt_user_id, - )) orelse continue; - defer info.deinit(allocator); - - const access_token = info.access_token orelse continue; - const chatgpt_account_id = info.chatgpt_account_id orelse continue; - const result = fetcher(allocator, access_token, chatgpt_account_id) catch |err| { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - continue; - }; - defer result.deinit(allocator); - - const entries = result.entries orelse continue; - _ = try applyAccountNameRefreshEntriesToLatestRegistry(allocator, codex_home, candidate.chatgpt_user_id, entries); - } -} - -pub fn spawnBackgroundAccountNameRefresh(allocator: std.mem.Allocator) !void { - var env_map = getEnvMap(allocator) catch |err| { - std.log.warn("background account metadata refresh skipped: {s}", .{@errorName(err)}); - return; - }; - defer env_map.deinit(); - - try env_map.put(account_name_refresh_only_env, "1"); - try env_map.put(disable_background_account_name_refresh_env, "1"); - try env_map.put(skip_service_reconcile_env, "1"); - - const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); - defer allocator.free(self_exe); - - _ = try std.process.spawn(app_runtime.io(), .{ - .argv = &[_][]const u8{ self_exe, "list" }, - .environ_map = &env_map, - .stdin = .ignore, - .stdout = .ignore, - .stderr = .ignore, - .create_no_window = true, - }); -} - -pub fn maybeSpawnBackgroundAccountNameRefresh( - allocator: std.mem.Allocator, - reg: *registry.Registry, -) void { - if (isBackgroundAccountNameRefreshDisabled()) return; - if (!shouldScheduleBackgroundAccountNameRefresh(reg)) return; - - spawnBackgroundAccountNameRefresh(allocator) catch |err| { - std.log.warn("background account metadata refresh skipped: {s}", .{@errorName(err)}); - }; -} - pub fn refreshAccountNamesAfterImport( allocator: std.mem.Allocator, reg: *registry.Registry, diff --git a/src/workflows/clean.zig b/src/workflows/clean.zig index d2ba8e10..e44629d7 100644 --- a/src/workflows/clean.zig +++ b/src/workflows/clean.zig @@ -1,19 +1,32 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); +const cli = @import("../cli/root.zig"); const registry = @import("../registry/root.zig"); +const legacy_background = @import("legacy_background.zig"); -pub fn handleClean(allocator: std.mem.Allocator, codex_home: []const u8) !void { - const summary = try registry.cleanAccountsBackups(allocator, codex_home); +pub fn handleClean(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.CleanOptions) !void { var stdout: [256]u8 = undefined; var writer = std.Io.File.stdout().writer(app_runtime.io(), &stdout); const out = &writer.interface; - try out.print( - "cleaned accounts: auth_backups={d}, registry_backups={d}, stale_entries={d}\n", - .{ - summary.auth_backups_removed, - summary.registry_backups_removed, - summary.stale_snapshot_files_removed, + switch (opts.target) { + .accounts => { + const summary = try registry.cleanAccountsBackups(allocator, codex_home); + try out.print( + "cleaned accounts: auth_backups={d}, registry_backups={d}, stale_entries={d}\n", + .{ + summary.auth_backups_removed, + summary.registry_backups_removed, + summary.stale_snapshot_files_removed, + }, + ); }, - ); + .background => { + const summary = try legacy_background.clean(allocator); + try out.print( + "cleaned legacy background registrations: platform={s}, files_removed={d}\n", + .{ summary.platform, summary.files_removed }, + ); + }, + } try out.flush(); } diff --git a/src/workflows/config.zig b/src/workflows/config.zig index a3cfe2cb..216635d5 100644 --- a/src/workflows/config.zig +++ b/src/workflows/config.zig @@ -1,12 +1,10 @@ const std = @import("std"); const cli = @import("../cli/root.zig"); -const auto = @import("../auto/root.zig"); const io_util = @import("../core/io_util.zig"); const registry = @import("../registry/root.zig"); pub fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ConfigOptions) !void { switch (opts) { - .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), .live => |live_opts| try handleLiveCommand(allocator, codex_home, live_opts), } } diff --git a/src/workflows/env.zig b/src/workflows/env.zig index de6d291a..fd587385 100644 --- a/src/workflows/env.zig +++ b/src/workflows/env.zig @@ -1,22 +1,6 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); -pub const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; -pub const account_name_refresh_only_env = "CODEX_AUTH_REFRESH_ACCOUNT_NAMES_ONLY"; -pub const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH"; - -pub fn getEnvMap(allocator: std.mem.Allocator) !std.process.Environ.Map { - return try app_runtime.currentEnviron().createMap(allocator); -} - -pub fn getEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) ![]u8 { - var env_map = try getEnvMap(allocator); - defer env_map.deinit(); - - const value = env_map.get(name) orelse return error.EnvironmentVariableNotFound; - return try allocator.dupe(u8, value); -} - pub fn nowMilliseconds() i64 { return std.Io.Timestamp.now(app_runtime.io(), .real).toMilliseconds(); } @@ -24,20 +8,3 @@ pub fn nowMilliseconds() i64 { pub fn nowSeconds() i64 { return std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); } - -pub fn hasNonEmptyEnvVar(name: []const u8) bool { - const value = getEnvVarOwned(std.heap.page_allocator, name) catch |err| switch (err) { - error.EnvironmentVariableNotFound => return false, - else => return false, - }; - defer std.heap.page_allocator.free(value); - return value.len != 0; -} - -pub fn isAccountNameRefreshOnlyMode() bool { - return hasNonEmptyEnvVar(account_name_refresh_only_env); -} - -pub fn isBackgroundAccountNameRefreshDisabled() bool { - return hasNonEmptyEnvVar(disable_background_account_name_refresh_env); -} diff --git a/src/workflows/help.zig b/src/workflows/help.zig index 3a370611..4978c299 100644 --- a/src/workflows/help.zig +++ b/src/workflows/help.zig @@ -1,24 +1,5 @@ -const std = @import("std"); const cli = @import("../cli/root.zig"); -const registry = @import("../registry/root.zig"); -pub const HelpConfig = struct { - auto_switch: registry.AutoSwitchConfig, -}; - -pub fn loadHelpConfig(allocator: std.mem.Allocator, codex_home: []const u8) HelpConfig { - var reg = registry.loadRegistry(allocator, codex_home) catch { - return .{ - .auto_switch = registry.defaultAutoSwitchConfig(), - }; - }; - defer reg.deinit(allocator); - return .{ - .auto_switch = reg.auto_switch, - }; -} - -pub fn handleTopLevelHelp(allocator: std.mem.Allocator, codex_home: []const u8) !void { - const help_cfg = loadHelpConfig(allocator, codex_home); - try cli.help.printHelp(&help_cfg.auto_switch); +pub fn handleTopLevelHelp() !void { + try cli.help.printHelp(); } diff --git a/src/workflows/legacy_background.zig b/src/workflows/legacy_background.zig new file mode 100644 index 00000000..725205c8 --- /dev/null +++ b/src/workflows/legacy_background.zig @@ -0,0 +1,86 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const app_runtime = @import("../core/runtime.zig"); +const registry = @import("../registry/root.zig"); + +const linux_service_name = "codex-auth-autoswitch.service"; +const linux_timer_name = "codex-auth-autoswitch.timer"; +const mac_label = "com.loongphy.codex-auth.auto"; +const windows_task_name = "CodexAuthAutoSwitch"; + +pub const CleanSummary = struct { + platform: []const u8, + files_removed: usize = 0, +}; + +pub fn clean(allocator: std.mem.Allocator) !CleanSummary { + return switch (builtin.os.tag) { + .linux => cleanLinux(allocator), + .macos => cleanMac(allocator), + .windows => cleanWindows(allocator), + else => .{ .platform = @tagName(builtin.os.tag) }, + }; +} + +fn cleanLinux(allocator: std.mem.Allocator) !CleanSummary { + var summary = CleanSummary{ .platform = "linux" }; + cleanupLinuxUnit(allocator, linux_timer_name, &summary); + cleanupLinuxUnit(allocator, linux_service_name, &summary); + runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "daemon-reload" }); + return summary; +} + +fn cleanupLinuxUnit(allocator: std.mem.Allocator, unit_name: []const u8, summary: *CleanSummary) void { + runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "stop", unit_name }); + runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "disable", unit_name }); + runIgnoringFailure(allocator, &[_][]const u8{ "systemctl", "--user", "reset-failed", unit_name }); + + const path = linuxUnitPath(allocator, unit_name) catch return; + defer allocator.free(path); + if (deleteAbsoluteFileIfExists(path)) summary.files_removed += 1; +} + +fn linuxUnitPath(allocator: std.mem.Allocator, unit_name: []const u8) ![]u8 { + const home = try registry.resolveUserHome(allocator); + defer allocator.free(home); + return try std.fs.path.join(allocator, &[_][]const u8{ home, ".config", "systemd", "user", unit_name }); +} + +fn cleanMac(allocator: std.mem.Allocator) !CleanSummary { + var summary = CleanSummary{ .platform = "macos" }; + const plist_path = try macPlistPath(allocator); + defer allocator.free(plist_path); + runIgnoringFailure(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }); + if (deleteAbsoluteFileIfExists(plist_path)) summary.files_removed += 1; + return summary; +} + +fn macPlistPath(allocator: std.mem.Allocator) ![]u8 { + const home = try registry.resolveUserHome(allocator); + defer allocator.free(home); + return try std.fs.path.join(allocator, &[_][]const u8{ home, "Library", "LaunchAgents", mac_label ++ ".plist" }); +} + +fn cleanWindows(allocator: std.mem.Allocator) !CleanSummary { + runIgnoringFailure(allocator, &[_][]const u8{ "schtasks.exe", "/End", "/TN", windows_task_name }); + runIgnoringFailure(allocator, &[_][]const u8{ "schtasks.exe", "/Delete", "/TN", windows_task_name, "/F" }); + return .{ .platform = "windows" }; +} + +fn deleteAbsoluteFileIfExists(path: []const u8) bool { + std.Io.Dir.deleteFileAbsolute(app_runtime.io(), path) catch |err| switch (err) { + error.FileNotFound => return false, + else => return false, + }; + return true; +} + +fn runIgnoringFailure(allocator: std.mem.Allocator, argv: []const []const u8) void { + const result = std.process.run(allocator, app_runtime.io(), .{ + .argv = argv, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + }) catch return; + allocator.free(result.stdout); + allocator.free(result.stderr); +} diff --git a/src/workflows/list.zig b/src/workflows/list.zig index dc66dd09..b8ee8645 100644 --- a/src/workflows/list.zig +++ b/src/workflows/list.zig @@ -6,10 +6,7 @@ const account_names = @import("account_names.zig"); const live_flow = @import("live.zig"); const preflight = @import("preflight.zig"); const usage_refresh = @import("usage.zig"); -const workflow_env = @import("env.zig"); -const isAccountNameRefreshOnlyMode = workflow_env.isAccountNameRefreshOnlyMode; -const runBackgroundAccountNameRefresh = account_names.runBackgroundAccountNameRefresh; const defaultAccountFetcher = account_names.defaultAccountFetcher; const maybeRefreshForegroundAccountNamesWithAccountApiEnabled = account_names.maybeRefreshForegroundAccountNamesWithAccountApiEnabled; const ensureLiveTty = preflight.ensureLiveTty; @@ -23,8 +20,6 @@ const switchLiveRuntimeMaybeTakeUpdatedDisplay = live_flow.switchLiveRuntimeMayb const switchLiveRuntimeBuildStatusLine = live_flow.switchLiveRuntimeBuildStatusLine; pub fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ListOptions) !void { - if (isAccountNameRefreshOnlyMode()) return try runBackgroundAccountNameRefresh(allocator, codex_home, defaultAccountFetcher); - if (opts.live) { try ensureLiveTty(.list); const live_allocator = std.heap.smp_allocator; diff --git a/src/workflows/live_display.zig b/src/workflows/live_display.zig index daa75b02..91d1473c 100644 --- a/src/workflows/live_display.zig +++ b/src/workflows/live_display.zig @@ -241,8 +241,8 @@ pub fn cloneRegistryAlloc(allocator: std.mem.Allocator, reg: *const registry.Reg .schema_version = reg.schema_version, .active_account_key = active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, - .auto_switch = reg.auto_switch, .api = reg.api, + .live = reg.live, .accounts = std.ArrayList(registry.AccountRecord).empty, }; errdefer cloned.deinit(allocator); @@ -337,8 +337,8 @@ pub fn buildRemoveLiveActionDisplay( .schema_version = current_display.reg.schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = current_display.reg.auto_switch, .api = current_display.reg.api, + .live = current_display.reg.live, .accounts = std.ArrayList(registry.AccountRecord).empty, }; errdefer reg.deinit(allocator); diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index ba53245c..a3d64c37 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -5,10 +5,7 @@ const cli = @import("../cli/root.zig"); const registry = @import("../registry/root.zig"); const account_names = @import("account_names.zig"); const targets = @import("targets.zig"); -const workflow_env = @import("env.zig"); -const skip_service_reconcile_env = workflow_env.skip_service_reconcile_env; -const hasNonEmptyEnvVar = workflow_env.hasNonEmptyEnvVar; const ForegroundUsageRefreshTarget = targets.ForegroundUsageRefreshTarget; const LiveTtyTarget = targets.LiveTtyTarget; const liveTtyPreflightError = targets.liveTtyPreflightError; @@ -28,14 +25,6 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.InvalidRemoveSelectionInput; } -pub fn shouldReconcileManagedService(cmd: cli.types.Command) bool { - if (hasNonEmptyEnvVar(skip_service_reconcile_env)) return false; - return switch (cmd) { - .help, .version, .status, .daemon => false, - else => true, - }; -} - pub fn ensureLiveTty(target: LiveTtyTarget) !void { const err = liveTtyPreflightError( target, diff --git a/src/workflows/root.zig b/src/workflows/root.zig index b5ec0a3b..1bb7b8e4 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -1,13 +1,11 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); const account_api = @import("../api/account.zig"); -const account_name_refresh = @import("../auth/account.zig"); const cli = @import("../cli/root.zig"); const chatgpt_http = @import("../api/http.zig"); const display_rows = @import("../tui/display.zig"); const registry = @import("../registry/root.zig"); const auth = @import("../auth/auth.zig"); -const auto = @import("../auto/root.zig"); const format = @import("../tui/table.zig"); const usage_api = @import("../api/usage.zig"); const account_names = @import("account_names.zig"); @@ -28,7 +26,6 @@ const workflow_env = @import("env.zig"); const targets = @import("targets.zig"); const usage_refresh = @import("usage.zig"); -const isAccountNameRefreshOnlyMode = workflow_env.isAccountNameRefreshOnlyMode; pub const nowMilliseconds = workflow_env.nowMilliseconds; pub const nowSeconds = workflow_env.nowSeconds; pub const ForegroundUsageRefreshTarget = targets.ForegroundUsageRefreshTarget; @@ -56,10 +53,6 @@ pub const refreshAccountNamesAfterLogin = account_names.refreshAccountNamesAfter pub const refreshAccountNamesAfterSwitch = account_names.refreshAccountNamesAfterSwitch; pub const refreshAccountNamesForList = account_names.refreshAccountNamesForList; const shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled = account_names.shouldRefreshTeamAccountNamesForUserScopeWithAccountApiEnabled; -pub const shouldScheduleBackgroundAccountNameRefresh = account_names.shouldScheduleBackgroundAccountNameRefresh; -pub const runBackgroundAccountNameRefresh = account_names.runBackgroundAccountNameRefresh; -pub const runBackgroundAccountNameRefreshWithLockAcquirer = account_names.runBackgroundAccountNameRefreshWithLockAcquirer; -const maybeSpawnBackgroundAccountNameRefresh = account_names.maybeSpawnBackgroundAccountNameRefresh; pub const refreshAccountNamesAfterImport = account_names.refreshAccountNamesAfterImport; const loadSingleFileImportAuthInfo = account_names.loadSingleFileImportAuthInfo; pub const reconcileActiveAuthAfterRemove = active_auth.reconcileActiveAuthAfterRemove; @@ -73,7 +66,6 @@ pub const findMatchingAccounts = query_mod.findMatchingAccounts; const findMatchingAccountsForRemove = query_mod.findMatchingAccountsForRemove; const findAccountIndexByDisplayNumber = query_mod.findAccountIndexByDisplayNumber; pub const isHandledCliError = preflight.isHandledCliError; -pub const shouldReconcileManagedService = preflight.shouldReconcileManagedService; const ensureLiveTty = preflight.ensureLiveTty; const apiModeUsesApi = preflight.apiModeUsesApi; const ensureForegroundNodeAvailableWithApiEnabled = preflight.ensureForegroundNodeAvailableWithApiEnabled; @@ -96,8 +88,6 @@ const loadSwitchSelectionDisplay = live_flow.loadSwitchSelectionDisplay; const removeSelectedAccountsAndPersist = live_flow.removeSelectedAccountsAndPersist; pub const switchLiveRuntimeApplySelection = live_flow.switchLiveRuntimeApplySelection; pub const removeLiveRuntimeApplySelection = live_flow.removeLiveRuntimeApplySelection; -pub const HelpConfig = help_workflow.HelpConfig; -pub const loadHelpConfig = help_workflow.loadHelpConfig; pub fn main(init: std.process.Init.Minimal) !void { var exit_code: u8 = 0; @@ -135,7 +125,7 @@ fn runMain(init: std.process.Init.Minimal) !void { const needs_codex_home = switch (cmd) { .version => false, - .help => |topic| topic == .top_level, + .help => false, else => true, }; const codex_home = if (needs_codex_home) try registry.resolveCodexHome(allocator) else null; @@ -144,14 +134,9 @@ fn runMain(init: std.process.Init.Minimal) !void { switch (cmd) { .version => try cli.output.printVersion(), .help => |topic| switch (topic) { - .top_level => try help_workflow.handleTopLevelHelp(allocator, codex_home.?), + .top_level => try help_workflow.handleTopLevelHelp(), else => try cli.help.printCommandHelp(topic), }, - .status => try auto.printStatus(allocator, codex_home.?), - .daemon => |opts| switch (opts.mode) { - .watch => try auto.runDaemon(allocator, codex_home.?), - .once => try auto.runDaemonOnce(allocator, codex_home.?), - }, .config => |opts| try config_workflow.handleConfig(allocator, codex_home.?, opts), .list => |opts| try list_workflow.handleList(allocator, codex_home.?, opts), .login => |opts| try login_workflow.handleLogin(allocator, codex_home.?, opts), @@ -159,11 +144,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .export_auth => |opts| try export_workflow.handleExport(allocator, codex_home.?, opts), .switch_account => |opts| try switch_workflow.handleSwitch(allocator, codex_home.?, opts), .remove_account => |opts| try remove_workflow.handleRemove(allocator, codex_home.?, opts), - .clean => try clean_workflow.handleClean(allocator, codex_home.?), - } - - if (shouldReconcileManagedService(cmd)) { - try auto.reconcileManagedService(allocator, codex_home.?); + .clean => |opts| try clean_workflow.handleClean(allocator, codex_home.?, opts), } } diff --git a/src/workflows/usage.zig b/src/workflows/usage.zig index 02b13637..b32b9c9d 100644 --- a/src/workflows/usage.zig +++ b/src/workflows/usage.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const auto = @import("../auto/root.zig"); const registry = @import("../registry/root.zig"); +const sessions = @import("../session.zig"); const usage_api = @import("../api/usage.zig"); const foreground_usage_refresh_concurrency: usize = 5; @@ -295,7 +295,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable if (!usage_api_enabled) { state.local_only_mode = true; - if (try auto.refreshActiveUsage(allocator, codex_home, reg)) { + if (try refreshActiveUsageFromLocalSessions(allocator, codex_home, reg)) { if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); } return state; @@ -440,6 +440,43 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable return state; } +fn refreshActiveUsageFromLocalSessions( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, +) !bool { + const latest_usage = sessions.scanLatestUsageWithSource(allocator, codex_home) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + if (latest_usage == null) return false; + + var latest = latest_usage.?; + var snapshot_consumed = false; + defer { + allocator.free(latest.path); + if (!snapshot_consumed) { + registry.freeRateLimitSnapshot(allocator, &latest.snapshot); + } + } + + const account_key = reg.active_account_key orelse return false; + const activated_at_ms = reg.active_account_activated_at_ms orelse 0; + if (latest.event_timestamp_ms < activated_at_ms) return false; + const idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return false; + + const signature: registry.RolloutSignature = .{ + .path = latest.path, + .event_timestamp_ms = latest.event_timestamp_ms, + }; + if (registry.rolloutSignaturesEqual(reg.accounts.items[idx].last_local_rollout, signature)) return false; + + registry.updateUsage(allocator, reg, account_key, latest.snapshot); + snapshot_consumed = true; + try registry.setAccountLastLocalRollout(allocator, ®.accounts.items[idx], latest.path, latest.event_timestamp_ms); + return true; +} + pub fn initForegroundUsagePool( allocator: std.mem.Allocator, n_jobs: usize, diff --git a/tests/auto_candidate_test.zig b/tests/auto_candidate_test.zig deleted file mode 100644 index e846b609..00000000 --- a/tests/auto_candidate_test.zig +++ /dev/null @@ -1,57 +0,0 @@ -const std = @import("std"); -const codex_auth = @import("codex_auth"); - -const auto = codex_auth.auto; -const fixtures = @import("support/fixtures.zig"); -const test_fixtures = fixtures; -const registry = codex_auth.registry; -const CandidateIndex = auto.CandidateIndex; - -test "candidate index refreshes cached ranking after a reset window expires" { - const gpa = std.testing.allocator; - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "reset@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "steady@example.com", "", null); - - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - const reset_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "reset@example.com"); - defer gpa.free(reset_account_key); - const steady_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "steady@example.com"); - defer gpa.free(steady_account_key); - - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - const reset_idx = registry.findAccountIndexByAccountKey(®, reset_account_key) orelse return error.TestExpectedEqual; - reg.accounts.items[reset_idx].last_usage = .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = 1010 }, - .secondary = null, - .credits = null, - .plan_type = .pro, - }; - reg.accounts.items[reset_idx].last_usage_at = 100; - - const steady_idx = registry.findAccountIndexByAccountKey(®, steady_account_key) orelse return error.TestExpectedEqual; - reg.accounts.items[steady_idx].last_usage = .{ - .primary = .{ .used_percent = 60.0, .window_minutes = 300, .resets_at = null }, - .secondary = null, - .credits = null, - .plan_type = .pro, - }; - reg.accounts.items[steady_idx].last_usage_at = 50; - - var index = CandidateIndex{}; - defer index.deinit(gpa); - - try index.rebuild(gpa, ®, 1000); - try std.testing.expect(index.best() != null); - try std.testing.expect(std.mem.eql(u8, index.best().?.account_key, steady_account_key)); - - try index.rebuildIfScoreExpired(gpa, ®, 1011); - try std.testing.expect(index.best() != null); - try std.testing.expect(std.mem.eql(u8, index.best().?.account_key, reset_account_key)); -} diff --git a/tests/auto_daemon_test.zig b/tests/auto_daemon_test.zig deleted file mode 100644 index ada70bcf..00000000 --- a/tests/auto_daemon_test.zig +++ /dev/null @@ -1,2153 +0,0 @@ -const std = @import("std"); -const app_runtime = @import("codex_auth").core.runtime; -const fs = @import("codex_auth").core.compat_fs; -const account_api = @import("codex_auth").api.account; -const auto = @import("codex_auth").auto; -const registry = @import("codex_auth").registry; -const usage_api = @import("codex_auth").api.usage; -const fixtures = @import("support/fixtures.zig"); - -const rollout_line = "{" ++ - "\"timestamp\":\"2025-01-01T00:00:00Z\"," ++ - "\"type\":\"event_msg\"," ++ - "\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"used_percent\":92.0,\"window_minutes\":300,\"resets_at\":123},\"secondary\":{\"used_percent\":49.0,\"window_minutes\":10080,\"resets_at\":456},\"plan_type\":\"pro\"}}}"; -const null_rate_limits_rollout_line = "{" ++ - "\"timestamp\":\"2025-01-01T00:00:01Z\"," ++ - "\"type\":\"event_msg\"," ++ - "\"payload\":{\"type\":\"token_count\",\"rate_limits\":null}}"; -const empty_rate_limits_rollout_line = "{" ++ - "\"timestamp\":\"2025-01-01T00:00:02Z\"," ++ - "\"type\":\"event_msg\"," ++ - "\"payload\":{\"type\":\"token_count\",\"rate_limits\":{}}}"; -var daemon_api_fetch_count: usize = 0; -var candidate_api_fetch_count: usize = 0; -var daemon_account_name_fetch_count: usize = 0; -var daemon_account_name_fetch_mutex: std.Io.Mutex = .init; -var daemon_account_name_fetch_registry_rewrite_codex_home: ?[]const u8 = null; -var candidate_high_auth_path: ?[]const u8 = null; -var candidate_low_auth_path: ?[]const u8 = null; -var candidate_reject_auth_path: ?[]const u8 = null; -const daemon_grouped_user_id = "user-auto-grouped"; -const daemon_primary_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; -const daemon_secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; - -fn appendGroupedAccount( - allocator: std.mem.Allocator, - reg: *registry.Registry, - chatgpt_user_id: []const u8, - chatgpt_account_id: []const u8, - email: []const u8, - plan: registry.PlanType, -) !void { - const record_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); - errdefer allocator.free(record_key); - - try reg.accounts.append(allocator, .{ - .account_key = record_key, - .chatgpt_account_id = try allocator.dupe(u8, chatgpt_account_id), - .chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id), - .email = try allocator.dupe(u8, email), - .alias = try allocator.dupe(u8, ""), - .account_name = null, - .plan = plan, - .auth_mode = .chatgpt, - .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), - .last_used_at = null, - .last_usage = null, - .last_usage_at = null, - .last_local_rollout = null, - }); -} - -fn authJsonWithIds( - allocator: std.mem.Allocator, - email: []const u8, - plan: []const u8, - chatgpt_user_id: []const u8, - chatgpt_account_id: []const u8, -) ![]u8 { - const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; - const payload = try std.fmt.allocPrint( - allocator, - "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", - .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, - ); - defer allocator.free(payload); - - const header_b64 = try fixtures.b64url(allocator, header); - defer allocator.free(header_b64); - const payload_b64 = try fixtures.b64url(allocator, payload); - defer allocator.free(payload_b64); - const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ header_b64, ".", payload_b64, ".sig" }); - defer allocator.free(jwt); - - return try std.fmt.allocPrint( - allocator, - "{{\"tokens\":{{\"access_token\":\"access-{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", - .{ email, chatgpt_account_id, jwt }, - ); -} - -fn writeActiveAuthWithIds( - allocator: std.mem.Allocator, - codex_home: []const u8, - email: []const u8, - plan: []const u8, - chatgpt_user_id: []const u8, - chatgpt_account_id: []const u8, -) !void { - const auth_path = try registry.activeAuthPath(allocator, codex_home); - defer allocator.free(auth_path); - - const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); - defer allocator.free(auth_json); - try fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); -} - -fn writeAccountSnapshotWithIds( - allocator: std.mem.Allocator, - codex_home: []const u8, - email: []const u8, - plan: []const u8, - chatgpt_user_id: []const u8, - chatgpt_account_id: []const u8, -) !void { - const account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); - defer allocator.free(account_key); - - const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); - defer allocator.free(auth_path); - - const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); - defer allocator.free(auth_json); - try fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); -} - -fn resetDaemonAccountNameFetcher() void { - daemon_account_name_fetch_count = 0; - daemon_account_name_fetch_registry_rewrite_codex_home = null; -} - -fn buildGroupedAccountNamesFetchResult(allocator: std.mem.Allocator) !account_api.FetchResult { - const entries = try allocator.alloc(account_api.AccountEntry, 2); - errdefer allocator.free(entries); - - entries[0] = .{ - .account_id = try allocator.dupe(u8, daemon_primary_account_id), - .account_name = try allocator.dupe(u8, "Primary Workspace"), - }; - errdefer entries[0].deinit(allocator); - entries[1] = .{ - .account_id = try allocator.dupe(u8, daemon_secondary_account_id), - .account_name = try allocator.dupe(u8, "Backup Workspace"), - }; - errdefer entries[1].deinit(allocator); - - return .{ - .entries = entries, - .status_code = 200, - }; -} - -fn fetchGroupedAccountNames( - allocator: std.mem.Allocator, - access_token: []const u8, - account_id: []const u8, -) !account_api.FetchResult { - _ = access_token; - _ = account_id; - daemon_account_name_fetch_count += 1; - - return buildGroupedAccountNamesFetchResult(allocator); -} - -fn fetchGroupedAccountNamesAfterConcurrentRegistryRewrite( - allocator: std.mem.Allocator, - access_token: []const u8, - account_id: []const u8, -) !account_api.FetchResult { - _ = access_token; - _ = account_id; - daemon_account_name_fetch_count += 1; - - const codex_home = daemon_account_name_fetch_registry_rewrite_codex_home orelse return error.TestMissingCodexHome; - var latest = try registry.loadRegistry(allocator, codex_home); - defer latest.deinit(allocator); - latest.live.interval_seconds = 45; - try registry.saveRegistry(allocator, codex_home, &latest); - - return buildGroupedAccountNamesFetchResult(allocator); -} - -test "Scenario: Given auto-switch daemon with missing grouped account names when it detects the active scope then it refreshes and saves them" { - daemon_account_name_fetch_mutex.lockUncancelable(fs.io()); - defer daemon_account_name_fetch_mutex.unlock(fs.io()); - - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.api.usage = false; - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); - try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); - try registry.saveRegistry(gpa, codex_home, ®); - try writeActiveAuthWithIds(gpa, codex_home, "group@example.com", "team", daemon_grouped_user_id, daemon_primary_account_id); - - resetDaemonAccountNameFetcher(); - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( - gpa, - codex_home, - &refresh_state, - fetchGroupedAccountNames, - )); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); - try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[0].account_name.?); - try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); - - try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( - gpa, - codex_home, - &refresh_state, - fetchGroupedAccountNames, - )); - try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); -} - -test "Scenario: Given auto-switch disabled when account names are missing then the daemon skips grouped name refresh" { - daemon_account_name_fetch_mutex.lockUncancelable(fs.io()); - defer daemon_account_name_fetch_mutex.unlock(fs.io()); - - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = false; - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); - try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); - - resetDaemonAccountNameFetcher(); - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - try std.testing.expect(!(try auto.refreshActiveAccountNamesForDaemonWithFetcher( - gpa, - codex_home, - ®, - &refresh_state, - fetchGroupedAccountNames, - ))); - try std.testing.expectEqual(@as(usize, 0), daemon_account_name_fetch_count); -} - -test "Scenario: Given daemon account-name refresh when registry changes during fetch then it merges onto the latest registry" { - daemon_account_name_fetch_mutex.lockUncancelable(fs.io()); - defer daemon_account_name_fetch_mutex.unlock(fs.io()); - - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.api.usage = true; - reg.api.account = true; - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); - try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); - try registry.saveRegistry(gpa, codex_home, ®); - try writeActiveAuthWithIds(gpa, codex_home, "group@example.com", "team", daemon_grouped_user_id, daemon_primary_account_id); - - const rewrite_codex_home = try gpa.dupe(u8, codex_home); - defer gpa.free(rewrite_codex_home); - resetDaemonAccountNameFetcher(); - daemon_account_name_fetch_registry_rewrite_codex_home = rewrite_codex_home; - defer daemon_account_name_fetch_registry_rewrite_codex_home = null; - - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( - gpa, - codex_home, - &refresh_state, - fetchGroupedAccountNamesAfterConcurrentRegistryRewrite, - )); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); - try std.testing.expectEqual(@as(u16, 45), loaded.live.interval_seconds); - try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[0].account_name.?); - try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); -} - -test "Scenario: Given auto-switch daemon with only another user missing grouped account names when it runs then it refreshes that stored scope too" { - daemon_account_name_fetch_mutex.lockUncancelable(fs.io()); - defer daemon_account_name_fetch_mutex.unlock(fs.io()); - - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.api.usage = false; - reg.api.account = true; - try appendGroupedAccount(gpa, ®, "user-active", "acct-active-a", "active@example.com", .team); - reg.accounts.items[0].account_name = try gpa.dupe(u8, "Active Workspace"); - try appendGroupedAccount(gpa, ®, "user-active", "acct-active-b", "active@example.com", .team); - reg.accounts.items[1].account_name = try gpa.dupe(u8, "Active Backup"); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); - try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); - try registry.saveRegistry(gpa, codex_home, ®); - try writeActiveAuthWithIds(gpa, codex_home, "active@example.com", "team", "user-active", "acct-active-a"); - try writeAccountSnapshotWithIds(gpa, codex_home, "group@example.com", "team", daemon_grouped_user_id, daemon_primary_account_id); - - resetDaemonAccountNameFetcher(); - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( - gpa, - codex_home, - &refresh_state, - fetchGroupedAccountNames, - )); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); - try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[2].account_name.?); - try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[3].account_name.?); -} - -test "Scenario: Given auto-switch daemon with grouped team names and only a stored plus snapshot for the same user when it runs then it updates the team records" { - daemon_account_name_fetch_mutex.lockUncancelable(fs.io()); - defer daemon_account_name_fetch_mutex.unlock(fs.io()); - - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.api.usage = false; - reg.api.account = true; - try appendGroupedAccount(gpa, ®, "user-active", "acct-active-a", "active@example.com", .team); - reg.accounts.items[0].account_name = try gpa.dupe(u8, "Active Workspace"); - try appendGroupedAccount(gpa, ®, "user-active", "acct-active-b", "active@example.com", .team); - reg.accounts.items[1].account_name = try gpa.dupe(u8, "Active Backup"); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); - reg.accounts.items[3].account_name = try gpa.dupe(u8, "Old Backup Workspace"); - try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, "acct-plus", "group@example.com", .plus); - try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); - try registry.saveRegistry(gpa, codex_home, ®); - try writeActiveAuthWithIds(gpa, codex_home, "active@example.com", "team", "user-active", "acct-active-a"); - try writeAccountSnapshotWithIds(gpa, codex_home, "group@example.com", "plus", daemon_grouped_user_id, "acct-plus"); - - resetDaemonAccountNameFetcher(); - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( - gpa, - codex_home, - &refresh_state, - fetchGroupedAccountNames, - )); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); - try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[2].account_name.?); - try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[3].account_name.?); - try std.testing.expect(loaded.accounts.items[4].account_name == null); -} - -fn appendAccountWithUsage( - allocator: std.mem.Allocator, - reg: *registry.Registry, - email: []const u8, - usage: ?registry.RateLimitSnapshot, - last_usage_at: ?i64, -) !void { - try fixtures.appendAccount(allocator, reg, email, "", null); - const idx = reg.accounts.items.len - 1; - reg.accounts.items[idx].last_usage = usage; - reg.accounts.items[idx].last_usage_at = last_usage_at; -} - -fn apiSnapshot() registry.RateLimitSnapshot { - return .{ - .primary = .{ .used_percent = 15.0, .window_minutes = 300, .resets_at = 1000 }, - .secondary = .{ .used_percent = 4.0, .window_minutes = 10080, .resets_at = 2000 }, - .credits = null, - .plan_type = .pro, - }; -} - -fn fetchApiSnapshot(_: std.mem.Allocator, _: []const u8) !?registry.RateLimitSnapshot { - return apiSnapshot(); -} - -fn fetchApiError(_: std.mem.Allocator, _: []const u8) !?registry.RateLimitSnapshot { - return error.TestApiUnavailable; -} - -fn fetchCountingApiSnapshot(_: std.mem.Allocator, _: []const u8) !?registry.RateLimitSnapshot { - daemon_api_fetch_count += 1; - return apiSnapshot(); -} - -fn fetchCountingApiError(_: std.mem.Allocator, _: []const u8) !?registry.RateLimitSnapshot { - daemon_api_fetch_count += 1; - return error.TestApiUnavailable; -} - -fn fetchCandidateUsageByAuthPathDetailed(_: std.mem.Allocator, auth_path: []const u8) !usage_api.UsageFetchResult { - if (candidate_high_auth_path) |path| { - if (std.mem.eql(u8, auth_path, path)) { - return .{ - .snapshot = .{ - .primary = .{ .used_percent = 12.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 7.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, - .status_code = 200, - }; - } - } - if (candidate_low_auth_path) |path| { - if (std.mem.eql(u8, auth_path, path)) { - return .{ - .snapshot = .{ - .primary = .{ .used_percent = 96.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 60.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, - .status_code = 200, - }; - } - } - if (candidate_reject_auth_path) |path| { - if (std.mem.eql(u8, auth_path, path)) { - return .{ .snapshot = null, .status_code = 403 }; - } - } - return .{ .snapshot = null, .status_code = null }; -} - -fn fetchCandidateUsageByAuthPath(allocator: std.mem.Allocator, auth_path: []const u8) !?registry.RateLimitSnapshot { - const result = try fetchCandidateUsageByAuthPathDetailed(allocator, auth_path); - return result.snapshot; -} - -fn fetchCountingCandidateUsageByAuthPathDetailed(allocator: std.mem.Allocator, auth_path: []const u8) !usage_api.UsageFetchResult { - candidate_api_fetch_count += 1; - return fetchCandidateUsageByAuthPathDetailed(allocator, auth_path); -} - -fn fetchCandidateUsageHttp403(_: std.mem.Allocator, _: []const u8) !usage_api.UsageFetchResult { - candidate_api_fetch_count += 1; - return .{ .snapshot = null, .status_code = 403 }; -} - -fn fetchCandidateUsageNoWindow200(_: std.mem.Allocator, _: []const u8) !usage_api.UsageFetchResult { - candidate_api_fetch_count += 1; - return .{ .snapshot = null, .status_code = 200 }; -} - -fn fetchCandidateUsageUnavailable(_: std.mem.Allocator, _: []const u8) !usage_api.UsageFetchResult { - candidate_api_fetch_count += 1; - return error.TestApiUnavailable; -} - -fn fetchCandidateUsageMissingAuth(_: std.mem.Allocator, _: []const u8) !usage_api.UsageFetchResult { - candidate_api_fetch_count += 1; - return .{ .snapshot = null, .status_code = null, .missing_auth = true }; -} - -fn partialServiceArtifactPath(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 { - return try fs.path.join(allocator, &[_][]const u8{ codex_home, "accounts", "partial-service-artifact" }); -} - -fn installServiceWithPartialArtifact( - allocator: std.mem.Allocator, - codex_home: []const u8, - _: []const u8, -) !void { - const artifact_path = try partialServiceArtifactPath(allocator, codex_home); - defer allocator.free(artifact_path); - try fs.cwd().writeFile(.{ .sub_path = artifact_path, .data = "partial" }); - return error.TestInstallFailed; -} - -fn uninstallPartialServiceArtifact(allocator: std.mem.Allocator, codex_home: []const u8) !void { - const artifact_path = try partialServiceArtifactPath(allocator, codex_home); - defer allocator.free(artifact_path); - fs.cwd().deleteFile(artifact_path) catch |err| switch (err) { - error.FileNotFound => {}, - else => return err, - }; -} - -fn preflightFailure(_: std.mem.Allocator) !void { - return error.TestPreflightFailed; -} - -fn preflightSuccess(_: std.mem.Allocator) !void {} - -test "Scenario: Given no-snapshot account when selecting auto candidate then it is treated as fresh quota" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "known@example.com", .{ - .primary = .{ .used_percent = 40.0, .window_minutes = 300, .resets_at = null }, - .secondary = null, - .credits = null, - .plan_type = null, - }, 200); - try appendAccountWithUsage(gpa, ®, "fresh@example.com", null, null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - const idx = auto.bestAutoSwitchCandidateIndex(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()) orelse return error.TestExpectedEqual; - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[idx].email, "fresh@example.com")); -} - -test "Scenario: Given free candidate with only a primary weekly window when selecting auto candidate then it remains eligible" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 30.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "pro@example.com", .{ - .primary = .{ .used_percent = 80.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 70.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 200); - try appendAccountWithUsage(gpa, ®, "free@example.com", .{ - .primary = .{ .used_percent = 40.0, .window_minutes = 10080, .resets_at = null }, - .secondary = null, - .credits = null, - .plan_type = .free, - }, 300); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - const idx = auto.bestAutoSwitchCandidateIndex(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()) orelse return error.TestExpectedEqual; - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[idx].email, "free@example.com")); -} - -test "Scenario: Given free candidate with only a secondary weekly window when selecting auto candidate then it remains eligible" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 30.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "pro@example.com", .{ - .primary = .{ .used_percent = 85.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 80.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 200); - try appendAccountWithUsage(gpa, ®, "free@example.com", .{ - .primary = null, - .secondary = .{ .used_percent = 45.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .free, - }, 300); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - const idx = auto.bestAutoSwitchCandidateIndex(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()) orelse return error.TestExpectedEqual; - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[idx].email, "free@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 = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - - try appendAccountWithUsage(gpa, ®, "free@example.com", .{ - .primary = .{ .used_percent = 66.0, .window_minutes = 10080, .resets_at = null }, - .secondary = null, - .credits = null, - .plan_type = .free, - }, 100); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "free@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(!auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); -} - -test "Scenario: Given weekly remaining below threshold when checking current then auto switch is required" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.threshold_weekly_percent = 5; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 20.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 97.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); -} - -test "Scenario: Given custom 5h threshold when checking current then it uses configured value" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.threshold_5h_percent = 15; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 88.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 40.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); -} - -test "Scenario: Given missing window_minutes in the primary slot when checking current then 5h fallback still triggers auto switch" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.threshold_5h_percent = 10; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = null, .resets_at = null }, - .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); -} - -test "Scenario: Given free account near exhaustion when checking current then realtime guard switches earlier than the configured threshold" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - - try appendAccountWithUsage(gpa, ®, "free@example.com", .{ - .primary = .{ .used_percent = 70.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 20.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .free, - }, 100); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "free@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); -} - -test "Scenario: Given stricter weekly threshold when checking current then default trigger can be suppressed" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.threshold_weekly_percent = 3; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 20.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 96.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(!auto.shouldSwitchCurrent(®, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds())); -} - -test "Scenario: Given threshold overrides when applying config then unspecified values stay unchanged" { - var cfg = registry.defaultAutoSwitchConfig(); - cfg.threshold_5h_percent = 11; - cfg.threshold_weekly_percent = 7; - - auto.applyThresholdConfig(&cfg, .{ - .threshold_5h_percent = 13, - .threshold_weekly_percent = null, - }); - - try std.testing.expect(cfg.threshold_5h_percent == 13); - try std.testing.expect(cfg.threshold_weekly_percent == 7); -} - -test "Scenario: Given better candidate when auto switch runs then auth and active account move silently" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 10; - - try appendAccountWithUsage(gpa, ®, "low@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 10.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "fresh@example.com", null, null); - const low_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "low@example.com"); - defer gpa.free(low_account_id); - try registry.setActiveAccountKey(gpa, ®, low_account_id); - - const low_auth = try fixtures.authJsonWithEmailPlan(gpa, "low@example.com", "pro"); - defer gpa.free(low_auth); - const fresh_auth = try fixtures.authJsonWithEmailPlan(gpa, "fresh@example.com", "pro"); - defer gpa.free(fresh_auth); - - const low_path = try registry.accountAuthPath(gpa, codex_home, low_account_id); - defer gpa.free(low_path); - const fresh_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "fresh@example.com"); - defer gpa.free(fresh_account_id); - const fresh_path = try registry.accountAuthPath(gpa, codex_home, fresh_account_id); - defer gpa.free(fresh_path); - const active_path = try registry.activeAuthPath(gpa, codex_home); - defer gpa.free(active_path); - - try fs.cwd().writeFile(.{ .sub_path = low_path, .data = low_auth }); - try fs.cwd().writeFile(.{ .sub_path = fresh_path, .data = fresh_auth }); - try fs.cwd().writeFile(.{ .sub_path = active_path, .data = low_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.?, fresh_account_id)); - - const active_data = try fixtures.readFileAlloc(gpa, active_path); - defer gpa.free(active_data); - try std.testing.expect(std.mem.eql(u8, active_data, fresh_auth)); -} - -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 = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 10; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "low@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 10.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "fresh@example.com", null, null); - const low_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "low@example.com"); - defer gpa.free(low_account_id); - try registry.setActiveAccountKey(gpa, ®, low_account_id); - - const fresh_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "fresh@example.com"); - defer gpa.free(fresh_account_id); - const low_auth = try fixtures.authJsonWithEmailPlan(gpa, "low@example.com", "pro"); - defer gpa.free(low_auth); - const fresh_auth = try fixtures.authJsonWithEmailPlan(gpa, "fresh@example.com", "pro"); - defer gpa.free(fresh_auth); - const low_path = try registry.accountAuthPath(gpa, codex_home, low_account_id); - defer gpa.free(low_path); - const fresh_path = try registry.accountAuthPath(gpa, codex_home, fresh_account_id); - defer gpa.free(fresh_path); - const active_path = try registry.activeAuthPath(gpa, codex_home); - defer gpa.free(active_path); - try fs.cwd().writeFile(.{ .sub_path = low_path, .data = low_auth }); - try fs.cwd().writeFile(.{ .sub_path = fresh_path, .data = fresh_auth }); - try fs.cwd().writeFile(.{ .sub_path = active_path, .data = low_auth }); - - candidate_high_auth_path = try gpa.dupe(u8, fresh_path); - defer { - gpa.free(candidate_high_auth_path.?); - candidate_high_auth_path = null; - } - - const attempt = try auto.maybeAutoSwitchWithUsageFetcher(gpa, codex_home, ®, fetchCandidateUsageByAuthPath); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, fresh_account_id)); -} - -test "Scenario: Given API mode and poor refreshed candidate when auto switching then it stays on the current account" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 10; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "low@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 10.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "fresh@example.com", null, null); - const low_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "low@example.com"); - defer gpa.free(low_account_id); - try registry.setActiveAccountKey(gpa, ®, low_account_id); - - const fresh_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "fresh@example.com"); - defer gpa.free(fresh_account_id); - const fresh_auth = try fixtures.authJsonWithEmailPlan(gpa, "fresh@example.com", "pro"); - defer gpa.free(fresh_auth); - const fresh_path = try registry.accountAuthPath(gpa, codex_home, fresh_account_id); - defer gpa.free(fresh_path); - try fs.cwd().writeFile(.{ .sub_path = fresh_path, .data = fresh_auth }); - - candidate_low_auth_path = try gpa.dupe(u8, fresh_path); - defer { - gpa.free(candidate_low_auth_path.?); - candidate_low_auth_path = null; - } - - const attempt = try auto.maybeAutoSwitchWithUsageFetcher(gpa, codex_home, ®, fetchCandidateUsageByAuthPath); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(!attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, low_account_id)); -} - -test "Scenario: Given repeated daemon candidate refresh attempts within cooldown when auto switching then candidate API refresh is rate-limited" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 2; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "low@example.com", .{ - .primary = .{ .used_percent = 95.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 10.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = null, - }, 100); - try appendAccountWithUsage(gpa, ®, "fresh@example.com", null, null); - const low_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "low@example.com"); - defer gpa.free(low_account_id); - try registry.setActiveAccountKey(gpa, ®, low_account_id); - - const fresh_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "fresh@example.com"); - defer gpa.free(fresh_account_id); - const fresh_auth = try fixtures.authJsonWithEmailPlan(gpa, "fresh@example.com", "pro"); - defer gpa.free(fresh_auth); - const fresh_path = try registry.accountAuthPath(gpa, codex_home, fresh_account_id); - defer gpa.free(fresh_path); - try fs.cwd().writeFile(.{ .sub_path = fresh_path, .data = fresh_auth }); - - candidate_low_auth_path = try gpa.dupe(u8, fresh_path); - defer { - gpa.free(candidate_low_auth_path.?); - candidate_low_auth_path = null; - } - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const first_attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); - const second_attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); - - try std.testing.expect(first_attempt.refreshed_candidates); - try std.testing.expect(!first_attempt.switched); - try std.testing.expect(!second_attempt.refreshed_candidates); - try std.testing.expect(!second_attempt.switched); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); -} - -test "Scenario: Given switch-time candidate validation returns non-200 then that candidate is disqualified" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 2; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "candidate@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const candidate_auth = try fixtures.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); - defer gpa.free(candidate_auth); - const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_path); - try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCandidateUsageHttp403); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(!attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, active_account_id)); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); -} - -test "Scenario: Given switch-time candidate validation returns 200 without windows then that candidate is disqualified" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 2; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "candidate@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const candidate_auth = try fixtures.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); - defer gpa.free(candidate_auth); - const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_path); - try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCandidateUsageNoWindow200); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(!attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, active_account_id)); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); -} - -test "Scenario: Given a candidate is rejected by API validation then it stays rejected across the next daemon cycle" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 10; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "candidate@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const candidate_auth = try fixtures.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); - defer gpa.free(candidate_auth); - const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_path); - try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const first_attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCandidateUsageHttp403); - const second_attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCandidateUsageHttp403); - - try std.testing.expect(first_attempt.refreshed_candidates); - try std.testing.expect(!first_attempt.switched); - try std.testing.expect(!second_attempt.refreshed_candidates); - try std.testing.expect(!second_attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, active_account_id)); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); -} - -test "Scenario: Given switch-time candidate validation reports missing auth then that candidate is disqualified" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 2; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "candidate@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const candidate_auth = try fixtures.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); - defer gpa.free(candidate_auth); - const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_path); - try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCandidateUsageMissingAuth); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(!attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, active_account_id)); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); -} - -test "Scenario: Given switch-time candidate validation gets no response then the candidate remains eligible" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 2; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "candidate@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const candidate_auth = try fixtures.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); - defer gpa.free(candidate_auth); - const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_path); - try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCandidateUsageUnavailable); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, candidate_account_id)); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); -} - -test "Scenario: Given daemon api mode and an api-key candidate when auto switching then the candidate stays eligible without usage refresh" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 2; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 99.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 90.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "apikey@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "apikey@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const active_auth = try fixtures.authJsonWithEmailPlan(gpa, "active@example.com", "pro"); - defer gpa.free(active_auth); - const active_account_path = try registry.accountAuthPath(gpa, codex_home, active_account_id); - defer gpa.free(active_account_path); - const candidate_account_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_account_path); - const active_path = try registry.activeAuthPath(gpa, codex_home); - defer gpa.free(active_path); - - try fs.cwd().writeFile(.{ .sub_path = active_account_path, .data = active_auth }); - try fs.cwd().writeFile(.{ .sub_path = candidate_account_path, .data = "{\"OPENAI_API_KEY\":\"sk-test\"}" }); - try fs.cwd().writeFile(.{ .sub_path = active_path, .data = active_auth }); - - const candidate_idx = fixtures.findAccountIndexByEmail(®, "apikey@example.com") orelse return error.TestExpectedEqual; - reg.accounts.items[candidate_idx].auth_mode = .apikey; - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); - try std.testing.expect(!attempt.refreshed_candidates); - try std.testing.expect(attempt.switched); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, candidate_account_id)); - try std.testing.expectEqual(@as(usize, 0), candidate_api_fetch_count); - - const active_auth_data = try fixtures.readFileAlloc(gpa, active_path); - defer gpa.free(active_auth_data); - try std.testing.expectEqualStrings("{\"OPENAI_API_KEY\":\"sk-test\"}", active_auth_data); -} - -test "Scenario: Given healthy active usage when daemon runs then it performs only bounded candidate upkeep" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 10.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 10.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "candidate@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const candidate_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "candidate@example.com"); - defer gpa.free(candidate_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const candidate_auth = try fixtures.authJsonWithEmailPlan(gpa, "candidate@example.com", "pro"); - defer gpa.free(candidate_auth); - const candidate_path = try registry.accountAuthPath(gpa, codex_home, candidate_account_id); - defer gpa.free(candidate_path); - try fs.cwd().writeFile(.{ .sub_path = candidate_path, .data = candidate_auth }); - - candidate_high_auth_path = try gpa.dupe(u8, candidate_path); - defer { - gpa.free(candidate_high_auth_path.?); - candidate_high_auth_path = null; - } - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(attempt.state_changed); - try std.testing.expect(!attempt.switched); - try std.testing.expectEqual(@as(usize, 1), candidate_api_fetch_count); - - const candidate_idx = fixtures.findAccountIndexByEmail(®, "candidate@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[candidate_idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 12.0), reg.accounts.items[candidate_idx].last_usage.?.primary.?.used_percent); -} - -test "Scenario: Given stale top candidates when daemon switches then it validates them in priority order instead of refreshing all candidates" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.auto_switch.enabled = true; - reg.auto_switch.threshold_5h_percent = 10; - reg.api.usage = true; - - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 96.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 70.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .pro, - }, 100); - try appendAccountWithUsage(gpa, ®, "first@example.com", null, null); - try appendAccountWithUsage(gpa, ®, "second@example.com", null, null); - try appendAccountWithUsage(gpa, ®, "third@example.com", null, null); - - const active_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_id); - const first_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "first@example.com"); - defer gpa.free(first_account_id); - const second_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "second@example.com"); - defer gpa.free(second_account_id); - const third_account_id = try fixtures.accountKeyForEmailAlloc(gpa, "third@example.com"); - defer gpa.free(third_account_id); - try registry.setActiveAccountKey(gpa, ®, active_account_id); - - const first_auth = try fixtures.authJsonWithEmailPlan(gpa, "first@example.com", "pro"); - defer gpa.free(first_auth); - const second_auth = try fixtures.authJsonWithEmailPlan(gpa, "second@example.com", "pro"); - defer gpa.free(second_auth); - const third_auth = try fixtures.authJsonWithEmailPlan(gpa, "third@example.com", "pro"); - defer gpa.free(third_auth); - const first_path = try registry.accountAuthPath(gpa, codex_home, first_account_id); - defer gpa.free(first_path); - const second_path = try registry.accountAuthPath(gpa, codex_home, second_account_id); - defer gpa.free(second_path); - const third_path = try registry.accountAuthPath(gpa, codex_home, third_account_id); - defer gpa.free(third_path); - const active_path = try registry.activeAuthPath(gpa, codex_home); - defer gpa.free(active_path); - try fs.cwd().writeFile(.{ .sub_path = first_path, .data = first_auth }); - try fs.cwd().writeFile(.{ .sub_path = second_path, .data = second_auth }); - try fs.cwd().writeFile(.{ .sub_path = third_path, .data = third_auth }); - try fs.cwd().writeFile(.{ .sub_path = active_path, .data = first_auth }); - - candidate_low_auth_path = try gpa.dupe(u8, first_path); - candidate_high_auth_path = try gpa.dupe(u8, second_path); - candidate_reject_auth_path = try gpa.dupe(u8, third_path); - defer { - gpa.free(candidate_low_auth_path.?); - candidate_low_auth_path = null; - gpa.free(candidate_high_auth_path.?); - candidate_high_auth_path = null; - gpa.free(candidate_reject_auth_path.?); - candidate_reject_auth_path = null; - } - - candidate_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - const attempt = try auto.maybeAutoSwitchForDaemonWithUsageFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingCandidateUsageByAuthPathDetailed); - try std.testing.expect(attempt.refreshed_candidates); - try std.testing.expect(attempt.switched); - try std.testing.expectEqual(@as(usize, 3), candidate_api_fetch_count); - try std.testing.expect(reg.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, reg.active_account_key.?, second_account_id)); -} - -test "Scenario: Given linux service unit when rendering then it keeps a persistent daemon watcher alive" { - const gpa = std.testing.allocator; - const unit = try auto.linuxUnitText(gpa, "/tmp/codex-auth", "/tmp/custom-codex-home"); - defer gpa.free(unit); - - try std.testing.expect(std.mem.indexOf(u8, unit, "Description=codex-auth auto-switch watcher") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "Type=simple") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "Restart=always") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "Environment=\"CODEX_AUTH_VERSION=") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "Environment=\"CODEX_HOME=/tmp/custom-codex-home\"") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "ExecStart=\"/tmp/codex-auth\" daemon --watch") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "[Install]") != null); - try std.testing.expect(std.mem.indexOf(u8, unit, "WantedBy=default.target") != null); -} - -test "Scenario: Given a zig build run executable path when resolving the managed service binary then it prefers zig-out" { - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.createDirPath(app_runtime.io(), "zig-out/bin"); - try tmp.dir.writeFile(app_runtime.io(), .{ .sub_path = "zig-out/bin/codex-auth", .data = "" }); - - const path = try auto.managedServiceSelfExePathFromDir( - gpa, - tmp.dir, - "/tmp/codex-auth/.zig-cache/o/abcd1234/codex-auth", - ); - defer gpa.free(path); - - const expected = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "zig-out/bin/codex-auth"); - defer gpa.free(expected); - try std.testing.expectEqualStrings(expected, path); -} - -test "Scenario: Given a stable executable path when resolving the managed service binary then it keeps the original path" { - const gpa = std.testing.allocator; - const path = try auto.managedServiceSelfExePath(gpa, "/usr/local/bin/codex-auth"); - defer gpa.free(path); - - try std.testing.expectEqualStrings("/usr/local/bin/codex-auth", path); -} - -test "Scenario: Given mac plist when rendering then it includes version metadata and daemon args" { - const gpa = std.testing.allocator; - const plist = try auto.macPlistText(gpa, "/tmp/codex-auth", "/tmp/custom-codex-home"); - defer gpa.free(plist); - - try std.testing.expect(std.mem.indexOf(u8, plist, "CODEX_AUTH_VERSION") != null); - try std.testing.expect(std.mem.indexOf(u8, plist, "CODEX_HOME") != null); - try std.testing.expect(std.mem.indexOf(u8, plist, "/tmp/custom-codex-home") != null); - try std.testing.expect(std.mem.indexOf(u8, plist, "daemon") != null); -} - -test "Scenario: Given windows task action when rendering then it launches the helper directly without cmd" { - const gpa = std.testing.allocator; - const action = try auto.windowsTaskAction(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe", "C:\\Users\\demo\\Codex Home\\"); - defer gpa.free(action); - - try std.testing.expect(std.mem.indexOf(u8, action, "cmd.exe /D /C") == null); - try std.testing.expect(std.mem.indexOf(u8, action, "\"C:\\Program Files\\codex-auth\\codex-auth-auto.exe\"") != null); - try std.testing.expect(std.mem.indexOf(u8, action, "--service-version ") != null); - try std.testing.expect(std.mem.indexOf(u8, action, "--codex-home \"C:\\Users\\demo\\Codex Home\\\\\"") != null); - try std.testing.expect(std.mem.indexOf(u8, action, "powershell.exe") == null); - try std.testing.expect(action.len < 262); -} - -test "Scenario: Given windows task register script when rendering then it configures restart-on-failure" { - const gpa = std.testing.allocator; - const script = try auto.windowsRegisterTaskScript(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe", "C:\\Users\\demo\\Codex Home\\"); - defer gpa.free(script); - - try std.testing.expect(std.mem.indexOf(u8, script, "New-ScheduledTaskAction") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "--codex-home \"C:\\Users\\demo\\Codex Home\\\\\"") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "New-ScheduledTaskTrigger -AtLogOn") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1)") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "Register-ScheduledTask -TaskName 'CodexAuthAutoSwitch'") != null); -} - -test "Scenario: Given windows task match script when rendering then it validates both action and the logon trigger" { - const gpa = std.testing.allocator; - const script = try auto.windowsTaskMatchScript(gpa); - defer gpa.free(script); - - try std.testing.expect(std.mem.indexOf(u8, script, "Get-ScheduledTask -TaskName 'CodexAuthAutoSwitch' -ErrorAction SilentlyContinue") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "Export-ScheduledTask -TaskName 'CodexAuthAutoSwitch'") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "RestartOnFailure") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "ExecutionTimeLimit") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "LocalName") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "$action.Execute + $args + '|TRIGGER:' + $triggerKind + '|RESTART:' + $restartCount + ',' + $restartInterval + '|LIMIT:' + $executionLimit") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "|TRIGGER:") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "|RESTART:") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "|LIMIT:") != null); -} - -test "Scenario: Given auto-switch disabled when reconciling managed service then it stays off" { - try std.testing.expect(!auto.shouldEnsureManagedService(false, .stopped, false)); - try std.testing.expect(!auto.shouldEnsureManagedService(false, .running, true)); -} - -test "Scenario: Given auto-switch enabled with stopped or stale service when reconciling then it is refreshed" { - try std.testing.expect(auto.shouldEnsureManagedService(true, .stopped, true)); - try std.testing.expect(auto.shouldEnsureManagedService(true, .running, false)); - try std.testing.expect(!auto.shouldEnsureManagedService(true, .running, true)); -} - -test "Scenario: Given partial service install failure when enabling auto-switch then registry and artifacts roll back" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - try registry.saveRegistry(gpa, codex_home, ®); - - try std.testing.expectError( - error.TestInstallFailed, - auto.enableWithServiceHooksAndPreflight( - gpa, - codex_home, - "/tmp/codex-auth", - installServiceWithPartialArtifact, - uninstallPartialServiceArtifact, - preflightSuccess, - ), - ); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(!loaded.auto_switch.enabled); - - const artifact_path = try partialServiceArtifactPath(gpa, codex_home); - defer gpa.free(artifact_path); - try std.testing.expectError(error.FileNotFound, fs.cwd().access(artifact_path, .{})); -} - -test "Scenario: Given preflight failure when enabling auto-switch then registry is unchanged and installer is skipped" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try registry.ensureAccountsDir(gpa, codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - try registry.saveRegistry(gpa, codex_home, ®); - - try std.testing.expectError( - error.TestPreflightFailed, - auto.enableWithServiceHooksAndPreflight( - gpa, - codex_home, - "/tmp/codex-auth", - installServiceWithPartialArtifact, - uninstallPartialServiceArtifact, - preflightFailure, - ), - ); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(!loaded.auto_switch.enabled); - - const artifact_path = try partialServiceArtifactPath(gpa, codex_home); - defer gpa.free(artifact_path); - try std.testing.expectError(error.FileNotFound, fs.cwd().access(artifact_path, .{})); -} - -test "Scenario: Given supported and unsupported OS tags when checking service support then only managed-service platforms reconcile" { - try std.testing.expect(auto.supportsManagedServiceOnPlatform(.linux)); - try std.testing.expect(auto.supportsManagedServiceOnPlatform(.macos)); - try std.testing.expect(auto.supportsManagedServiceOnPlatform(.windows)); - try std.testing.expect(!auto.supportsManagedServiceOnPlatform(.freebsd)); -} - -test "Scenario: Given automatic switch when writing daemon log then it records source and destination emails" { - const gpa = std.testing.allocator; - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - try fixtures.appendAccount(gpa, ®, "from@example.com", "work", null); - try fixtures.appendAccount(gpa, ®, "to@example.com", "personal", null); - - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try auto.writeAutoSwitchLogLine(&aw.writer, ®.accounts.items[0], ®.accounts.items[1]); - - const output = aw.written(); - try std.testing.expect(std.mem.eql(u8, output, "[switch] from@example.com -> to@example.com\n")); -} - -test "Scenario: Given an absolute managed unit path when deleting it then the file is removed" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.writeFile(.{ .sub_path = "codex-auth-autoswitch.timer", .data = "[Timer]\n" }); - const timer_path = try tmp.dir.realpathAlloc(gpa, "codex-auth-autoswitch.timer"); - defer gpa.free(timer_path); - - auto.deleteAbsoluteFileIfExists(timer_path); - - try std.testing.expectError(error.FileNotFound, fs.openFileAbsolute(timer_path, .{})); -} - -test "Scenario: Given windows delete task script when rendering then missing tasks are treated as success" { - const gpa = std.testing.allocator; - const script = try auto.windowsDeleteTaskScript(gpa); - defer gpa.free(script); - - try std.testing.expect(std.mem.indexOf(u8, script, "Get-ScheduledTask -TaskName 'CodexAuthAutoSwitch' -ErrorAction SilentlyContinue") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "if ($null -eq $task) { exit 0 }") != null); - try std.testing.expect(std.mem.indexOf(u8, script, "Unregister-ScheduledTask -TaskName 'CodexAuthAutoSwitch' -Confirm:$false") != null); -} - -test "Scenario: Given windows task state output when parsing then localized text is no longer required" { - try std.testing.expect(auto.parseWindowsTaskStateOutput("4\r\n") == .running); - try std.testing.expect(auto.parseWindowsTaskStateOutput("3\r\n") == .stopped); - try std.testing.expect(auto.parseWindowsTaskStateOutput("2\r\n") == .stopped); - try std.testing.expect(auto.parseWindowsTaskStateOutput("1\r\n") == .stopped); - try std.testing.expect(auto.parseWindowsTaskStateOutput("garbled\r\n") == .unknown); -} - -test "Scenario: Given status when rendering then auto settings are shown" { - const gpa = std.testing.allocator; - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try auto.writeStatus(&aw.writer, .{ - .enabled = true, - .runtime = .running, - .threshold_5h_percent = 12, - .threshold_weekly_percent = 8, - .live_interval_seconds = 60, - }); - - 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, "thresholds: 5h<12%, weekly<8%") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "live refresh: 60s") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "usage:") == null); - try std.testing.expect(std.mem.indexOf(u8, output, "account:") == null); - try std.testing.expect(std.mem.indexOf(u8, output, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); -} - -test "Scenario: Given status with custom live interval when rendering status body then risk warning stays off stdout" { - const gpa = std.testing.allocator; - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try auto.writeStatus(&aw.writer, .{ - .enabled = true, - .runtime = .running, - .threshold_5h_percent = 12, - .threshold_weekly_percent = 8, - .live_interval_seconds = 45, - }); - - const output = aw.written(); - try std.testing.expect(std.mem.indexOf(u8, output, "live refresh: 45s") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "usage:") == null); - try std.testing.expect(std.mem.indexOf(u8, output, "account:") == null); - try std.testing.expect(std.mem.indexOf(u8, output, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); -} - -test "Scenario: Given missing sessions dir when refreshing active usage then it is skipped without error" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(!(try auto.refreshActiveUsage(gpa, codex_home, ®))); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage == null); -} - -test "Scenario: Given local-only mode when refreshing usage then api fetcher is never used" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(!(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiError))); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage == null); -} - -test "Scenario: Given local-only daemon mode and newest null-limits event when refreshing usage then it keeps the last usable local snapshot" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = false; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - reg.active_account_activated_at_ms = 0; - - const usable_then_null = - "{\"timestamp\":\"2025-01-01T00:00:00Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"used_percent\":55.0,\"window_minutes\":300,\"resets_at\":123},\"secondary\":{\"used_percent\":10.0,\"window_minutes\":10080,\"resets_at\":456},\"plan_type\":\"pro\"}}}\n" ++ - "{\"timestamp\":\"2025-01-01T00:00:01Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":null}}\n"; - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = usable_then_null }); - - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchApiError)); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 55.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); -} - -test "Scenario: Given api-backed daemon mode and newest null-limits event with earlier usable local data when api is unavailable then it still applies the local snapshot" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - reg.active_account_activated_at_ms = 0; - - const usable_then_null = - "{\"timestamp\":\"2025-01-01T00:00:00Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"used_percent\":55.0,\"window_minutes\":300,\"resets_at\":123},\"secondary\":{\"used_percent\":10.0,\"window_minutes\":10080,\"resets_at\":456},\"plan_type\":\"pro\"}}}\n" ++ - "{\"timestamp\":\"2025-01-01T00:00:01Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":null}}\n"; - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = usable_then_null }); - - daemon_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiError)); - try std.testing.expectEqual(@as(usize, 0), daemon_api_fetch_count); - - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 55.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); - try std.testing.expect(reg.accounts.items[idx].last_local_rollout != null); - try std.testing.expectEqual(@as(i64, 1735689600000), reg.accounts.items[idx].last_local_rollout.?.event_timestamp_ms); -} - -test "Scenario: Given api usage for active account when refreshing usage then it updates without rollout files" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try std.testing.expect(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiSnapshot)); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expectEqual(@as(f64, 15.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); - try std.testing.expectEqual(registry.PlanType.pro, reg.accounts.items[idx].last_usage.?.plan_type.?); - try std.testing.expect(reg.accounts.items[idx].last_usage_at != null); -} - -test "Scenario: Given unchanged api usage when refreshing usage then rollout fallback is skipped" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try appendAccountWithUsage(gpa, ®, "active@example.com", apiSnapshot(), 777); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = rollout_line ++ "\n" }); - - try std.testing.expect(!(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiSnapshot))); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expectEqual(@as(f64, 15.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); - try std.testing.expectEqual(@as(i64, 777), reg.accounts.items[idx].last_usage_at.?); -} - -test "Scenario: Given api-backed switch with stale rollout when api later fails then the stale rollout is not assigned to the new active account" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "a@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "b@example.com", "", null); - const account_id_a = try fixtures.accountKeyForEmailAlloc(gpa, "a@example.com"); - defer gpa.free(account_id_a); - try registry.setActiveAccountKey(gpa, ®, account_id_a); - reg.active_account_activated_at_ms = 0; - reg.active_account_activated_at_ms = 0; - - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = rollout_line ++ "\n" }); - try std.testing.expect(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiSnapshot)); - - const account_id_b = try fixtures.accountKeyForEmailAlloc(gpa, "b@example.com"); - defer gpa.free(account_id_b); - try registry.setActiveAccountKey(gpa, ®, account_id_b); - - try std.testing.expect(!(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiError))); - const b_idx = fixtures.findAccountIndexByEmail(®, "b@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[b_idx].last_usage == null); -} - -test "Scenario: Given unchanged rollout after switching accounts when refreshing usage then it is not reassigned to the new active account" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = false; - try fixtures.appendAccount(gpa, ®, "a@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "b@example.com", "", null); - const account_id_a = try fixtures.accountKeyForEmailAlloc(gpa, "a@example.com"); - defer gpa.free(account_id_a); - try registry.setActiveAccountKey(gpa, ®, account_id_a); - reg.active_account_activated_at_ms = 0; - - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = rollout_line ++ "\n" }); - - try std.testing.expect(try auto.refreshActiveUsage(gpa, codex_home, ®)); - const a_idx = fixtures.findAccountIndexByEmail(®, "a@example.com") orelse return error.TestExpectedEqual; - const b_idx = fixtures.findAccountIndexByEmail(®, "b@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[a_idx].last_usage != null); - - const account_id_b = try fixtures.accountKeyForEmailAlloc(gpa, "b@example.com"); - defer gpa.free(account_id_b); - try registry.setActiveAccountKey(gpa, ®, account_id_b); - reg.active_account_activated_at_ms = 1735689600001; - reg.active_account_activated_at_ms = 1735689630000; - try std.testing.expect(!(try auto.refreshActiveUsage(gpa, codex_home, ®))); - try std.testing.expect(reg.accounts.items[b_idx].last_usage == null); -} - -test "Scenario: Given new rollout event in the same file after switching accounts when refreshing usage then it is assigned to the new active account" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = false; - try fixtures.appendAccount(gpa, ®, "a@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "b@example.com", "", null); - const account_id_a = try fixtures.accountKeyForEmailAlloc(gpa, "a@example.com"); - defer gpa.free(account_id_a); - try registry.setActiveAccountKey(gpa, ®, account_id_a); - reg.active_account_activated_at_ms = 0; - - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = rollout_line ++ "\n" }); - try std.testing.expect(try auto.refreshActiveUsage(gpa, codex_home, ®)); - - const account_id_b = try fixtures.accountKeyForEmailAlloc(gpa, "b@example.com"); - defer gpa.free(account_id_b); - try registry.setActiveAccountKey(gpa, ®, account_id_b); - reg.active_account_activated_at_ms = 1735689630000; - - const next_rollout_line = "{" ++ - "\"timestamp\":\"2025-01-01T00:01:00Z\"," ++ - "\"type\":\"event_msg\"," ++ - "\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"used_percent\":48.0,\"window_minutes\":300,\"resets_at\":123},\"secondary\":{\"used_percent\":12.0,\"window_minutes\":10080,\"resets_at\":456},\"plan_type\":\"pro\"}}}"; - try tmp.dir.writeFile(.{ - .sub_path = "sessions/run-1/rollout-a.jsonl", - .data = rollout_line ++ "\n" ++ next_rollout_line ++ "\n", - }); - - try std.testing.expect(try auto.refreshActiveUsage(gpa, codex_home, ®)); - const b_idx = fixtures.findAccountIndexByEmail(®, "b@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[b_idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 48.0), reg.accounts.items[b_idx].last_usage.?.primary.?.used_percent); -} - -test "Scenario: Given API-enabled mode and API failure when refreshing usage then local usage stays untouched and local rollout state is unchanged" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = rollout_line ++ "\n" }); - - try std.testing.expect(!(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiError))); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage == null); - try std.testing.expect(reg.accounts.items[idx].last_local_rollout == null); -} - -test "Scenario: Given daemon sees a null-rate-limits rollout then it falls back to the API without overwriting local rollout state" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = null_rate_limits_rollout_line ++ "\n" }); - - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchApiSnapshot)); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 15.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); - try std.testing.expect(reg.accounts.items[idx].last_local_rollout == null); -} - -test "Scenario: Given daemon sees an empty-rate-limits rollout then it also falls back to the API" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = empty_rate_limits_rollout_line ++ "\n" }); - - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchApiSnapshot)); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 15.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); - try std.testing.expect(reg.accounts.items[idx].last_local_rollout == null); -} - -test "Scenario: Given repeated bad rollout events within the daemon cooldown then API fallback is rate-limited" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "active@example.com", "", null); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = null_rate_limits_rollout_line ++ "\n" }); - - daemon_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiSnapshot)); - try std.testing.expect(!(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiSnapshot))); - try std.testing.expectEqual(@as(usize, 1), daemon_api_fetch_count); -} - -test "Scenario: Given the active daemon account changes during API cooldown then the new account refreshes immediately" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "a@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "b@example.com", "", null); - - const account_id_a = try fixtures.accountKeyForEmailAlloc(gpa, "a@example.com"); - defer gpa.free(account_id_a); - const account_id_b = try fixtures.accountKeyForEmailAlloc(gpa, "b@example.com"); - defer gpa.free(account_id_b); - - try registry.setActiveAccountKey(gpa, ®, account_id_a); - - daemon_api_fetch_count = 0; - var refresh_state = auto.DaemonRefreshState{}; - defer refresh_state.deinit(gpa); - - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiSnapshot)); - try std.testing.expectEqual(@as(usize, 1), daemon_api_fetch_count); - - try registry.setActiveAccountKey(gpa, ®, account_id_b); - try std.testing.expect(try auto.refreshActiveUsageForDaemonWithApiFetcher(gpa, codex_home, ®, &refresh_state, fetchCountingApiSnapshot)); - try std.testing.expectEqual(@as(usize, 2), daemon_api_fetch_count); -} - -test "Scenario: Given api failure when returning to local refresh after switching accounts then the pre-switch rollout is not assigned to the new active account" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - reg.api.usage = true; - try fixtures.appendAccount(gpa, ®, "a@example.com", "", null); - try fixtures.appendAccount(gpa, ®, "b@example.com", "", null); - const account_id_a = try fixtures.accountKeyForEmailAlloc(gpa, "a@example.com"); - defer gpa.free(account_id_a); - try registry.setActiveAccountKey(gpa, ®, account_id_a); - - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = rollout_line ++ "\n" }); - - try std.testing.expect(!(try auto.refreshActiveUsageWithApiFetcher(gpa, codex_home, ®, fetchApiError))); - - const account_id_b = try fixtures.accountKeyForEmailAlloc(gpa, "b@example.com"); - defer gpa.free(account_id_b); - try registry.setActiveAccountKey(gpa, ®, account_id_b); - reg.api.usage = false; - - try std.testing.expect(!(try auto.refreshActiveUsage(gpa, codex_home, ®))); - const b_idx = fixtures.findAccountIndexByEmail(®, "b@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[b_idx].last_usage == null); -} - -test "Scenario: Given latest rollout file without usable rate limits when refreshing usage then stored usage is preserved" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("sessions/run-1"); - - var reg = fixtures.makeEmptyRegistry(); - defer reg.deinit(gpa); - try appendAccountWithUsage(gpa, ®, "active@example.com", .{ - .primary = .{ .used_percent = 41.0, .window_minutes = 300, .resets_at = null }, - .secondary = .{ .used_percent = 12.0, .window_minutes = 10080, .resets_at = null }, - .credits = null, - .plan_type = .team, - }, 777); - const active_account_key = try fixtures.accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try registry.setActiveAccountKey(gpa, ®, active_account_key); - - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-a.jsonl", .data = "{\"timestamp\":\"2025-01-01T00:00:00Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":null}}\n" }); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-b.jsonl", .data = "{\"timestamp\":\"2025-01-01T00:00:01Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":null}}\n" }); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-c.jsonl", .data = "{\"timestamp\":\"2025-01-01T00:00:02Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":null}}\n" }); - try tmp.dir.writeFile(.{ .sub_path = "sessions/run-1/rollout-d.jsonl", .data = rollout_line ++ "\n" }); - - const base_time = @as(i128, @as(i128, std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds())); - { - var file = try tmp.dir.openFile("sessions/run-1/rollout-d.jsonl", .{ .mode = .read_write }); - defer file.close(); - try file.updateTimes(base_time, base_time); - } - { - var file = try tmp.dir.openFile("sessions/run-1/rollout-c.jsonl", .{ .mode = .read_write }); - defer file.close(); - try file.updateTimes(base_time + std.time.ns_per_s, base_time + std.time.ns_per_s); - } - { - var file = try tmp.dir.openFile("sessions/run-1/rollout-b.jsonl", .{ .mode = .read_write }); - defer file.close(); - try file.updateTimes(base_time + (2 * std.time.ns_per_s), base_time + (2 * std.time.ns_per_s)); - } - { - var file = try tmp.dir.openFile("sessions/run-1/rollout-a.jsonl", .{ .mode = .read_write }); - defer file.close(); - try file.updateTimes(base_time + (3 * std.time.ns_per_s), base_time + (3 * std.time.ns_per_s)); - } - - try std.testing.expect(!(try auto.refreshActiveUsage(gpa, codex_home, ®))); - const idx = fixtures.findAccountIndexByEmail(®, "active@example.com") orelse return error.TestExpectedEqual; - try std.testing.expect(reg.accounts.items[idx].last_usage != null); - try std.testing.expectEqual(@as(f64, 41.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); - try std.testing.expectEqual(@as(i64, 777), reg.accounts.items[idx].last_usage_at.?); -} diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 13489210..f690a66e 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -13,7 +13,6 @@ fn makeRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -332,47 +331,15 @@ test "Scenario: Given help when rendering then login and command help notes are const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); defer aw.deinit(); - var auto_cfg = registry.defaultAutoSwitchConfig(); - auto_cfg.enabled = true; - auto_cfg.threshold_5h_percent = 12; - auto_cfg.threshold_weekly_percent = 8; - try cli.help.writeHelp(&aw.writer, false, &auto_cfg); + try cli.help.writeHelp(&aw.writer, false); 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, "Usage API:") == null); - try std.testing.expect(std.mem.indexOf(u8, help, "Account API:") == null); - try std.testing.expect(std.mem.indexOf(u8, help, "Auto Switch: ON (5h<12%, weekly<8%)\n\nCommands:\n --help, -h") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "help ") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "--version, -V") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Commands:") != null); try std.testing.expect(std.mem.indexOf(u8, help, "list [--live] [--active] [--api|--skip-api]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "login [--device-auth]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "import [--alias ]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "import --cpa [] [--alias ]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "import --alias \n") == null); - try std.testing.expect(std.mem.indexOf(u8, help, "Run `codex-auth --help` for command-specific usage details.") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "API-backed refresh is the default") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "login") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "clean") != null); try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "switch ") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "remove [--live] [--api|--skip-api]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "remove ...") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "remove --all") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Delete backup and stale files under accounts/") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "status") != null); - 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 --5h [--weekly ]") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "auto --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); - try std.testing.expect(std.mem.indexOf(u8, help, "live --interval ") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "daemon --watch|--once") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "auto ...") == null); - try std.testing.expect(std.mem.indexOf(u8, help, "migrate") == null); + try std.testing.expect(std.mem.indexOf(u8, help, "config live --interval ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "auto enable") == null); } test "Scenario: Given simple command help when rendering then examples are omitted" { @@ -452,27 +419,18 @@ test "Scenario: Given remove command help when rendering then options explain li try std.testing.expect(std.mem.indexOf(u8, help, "Remove one or more matching accounts.") != null); } -test "Scenario: Given config and daemon help when rendering then special modes are explained" { +test "Scenario: Given config help when rendering then live mode is explained" { const gpa = std.testing.allocator; var config_aw: std.Io.Writer.Allocating = .init(gpa); defer config_aw.deinit(); - var daemon_aw: std.Io.Writer.Allocating = .init(gpa); - defer daemon_aw.deinit(); try cli.help.writeCommandHelp(&config_aw.writer, false, .config); - try cli.help.writeCommandHelp(&daemon_aw.writer, false, .daemon); const config_help = config_aw.written(); - try std.testing.expect(std.mem.indexOf(u8, config_help, "auto enable Enable background auto-switching.") != null); - try std.testing.expect(std.mem.indexOf(u8, config_help, "--5h Set the 5-hour usage threshold from 1 to 100.") != null); - try std.testing.expect(std.mem.indexOf(u8, config_help, "--weekly \n Set the weekly usage threshold from 1 to 100.") != null); try std.testing.expect(std.mem.indexOf(u8, config_help, "codex-auth config live --interval ") != null); try std.testing.expect(std.mem.indexOf(u8, config_help, "live --interval \n Set the live TUI refresh interval from 5 to 3600 seconds.") != null); try std.testing.expect(std.mem.indexOf(u8, config_help, "codex-auth config live --interval 60") != null); - - const daemon_help = daemon_aw.written(); - try std.testing.expect(std.mem.indexOf(u8, daemon_help, "--watch Run continuously and switch accounts when thresholds are reached.") != null); - try std.testing.expect(std.mem.indexOf(u8, daemon_help, "--once Run one auto-switch check, then exit.") != null); + try std.testing.expect(std.mem.indexOf(u8, config_help, "auto") == null); } test "Scenario: Given scanned import report when rendering then stdout and stderr match the import format" { @@ -537,163 +495,6 @@ test "Scenario: Given single-file skipped import report when rendering then summ try std.testing.expectEqualStrings(expected_stderr, stderr_aw.written()); } -test "Scenario: Given status when parsing then status command is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "status" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - switch (result) { - .command => |cmd| switch (cmd) { - .status => {}, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } -} - -test "Scenario: Given config auto 5h threshold when parsing then threshold configuration is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--5h", "12" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.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.threshold_5h_percent != null); - try std.testing.expect(cfg.threshold_5h_percent.? == 12); - 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 thresholds together when parsing then both window thresholds are preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--5h", "12", "--weekly", "8" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.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.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 enable when parsing then auto action is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "enable" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - switch (result) { - .command => |cmd| switch (cmd) { - .config => |opts| switch (opts) { - .auto_switch => |auto_opts| switch (auto_opts) { - .action => |action| try std.testing.expectEqual(cli.types.AutoAction.enable, action), - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } -} - -test "Scenario: Given config auto action mixed with threshold flags when parsing then usage error is returned" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "enable", "--5h", "12" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - try expectUsageError(result, .config, "cannot mix actions"); -} - -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" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - try expectUsageError(result, .config, "`--weekly` must be an integer from 1 to 100."); -} - -test "Scenario: Given config auto repeated threshold flag when parsing then usage error is returned" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--5h", "12", "--5h", "15" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - try expectUsageError(result, .config, "duplicate `--5h`"); -} - -test "Scenario: Given config auto threshold without value when parsing then usage error is returned" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--weekly" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - try expectUsageError(result, .config, "missing value for `--weekly`"); -} - -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.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - try expectUsageError(result, .config, "requires an action or threshold flags"); -} - -test "Scenario: Given config auto threshold with weekly only when parsing then single-window config is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "config", "auto", "--weekly", "9" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.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.threshold_5h_percent == null); - try std.testing.expect(cfg.threshold_weekly_percent != null); - try std.testing.expect(cfg.threshold_weekly_percent.? == 9); - }, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } -} - test "Scenario: Given removed top-level auto command when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "auto", "enable" }; @@ -722,7 +523,6 @@ test "Scenario: Given config live interval when parsing then interval is preserv .command => |cmd| switch (cmd) { .config => |opts| switch (opts) { .live => |live_opts| try std.testing.expectEqual(@as(u16, 30), live_opts.interval_seconds), - else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, }, @@ -748,15 +548,6 @@ test "Scenario: Given config live unknown flag when parsing then usage error is try expectUsageError(result, .config, "unknown flag `--refresh` for `config live`."); } -test "Scenario: Given status with extra args when parsing then usage error is returned" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "status", "extra" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - try expectUsageError(result, .status, "unexpected argument"); -} - test "Scenario: Given migrate when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "migrate" }; @@ -781,30 +572,15 @@ test "Scenario: Given clean when parsing then clean command is preserved" { } } -test "Scenario: Given daemon watch when parsing then daemon command is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "daemon", "--watch" }; - var result = try cli.commands.parseArgs(gpa, &args); - defer cli.commands.freeParseResult(gpa, &result); - - switch (result) { - .command => |cmd| switch (cmd) { - .daemon => |opts| try std.testing.expectEqual(cli.types.DaemonMode.watch, opts.mode), - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } -} - -test "Scenario: Given daemon once when parsing then one-shot daemon command is preserved" { +test "Scenario: Given clean background when parsing then background target is preserved" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "daemon", "--once" }; + const args = [_][:0]const u8{ "codex-auth", "clean", "background" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); switch (result) { .command => |cmd| switch (cmd) { - .daemon => |opts| try std.testing.expectEqual(cli.types.DaemonMode.once, opts.mode), + .clean => |opts| try std.testing.expectEqual(cli.types.CleanTarget.background, opts.target), else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 72111f0f..473943f4 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -296,7 +296,6 @@ fn runCliWithIsolatedHomeAndPathAndApiKeyNode( _ = env_map.swapRemove("CODEX_HOME"); try env_map.put("PATH", path_override); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); try env_map.put("CODEX_FAKE_NODE_RESPONSE_DIR", fake_node_response_dir); // On Windows, point to the compiled fake-node.exe via a relative path so @@ -328,7 +327,6 @@ fn runCliWithIsolatedHome( try env_map.put("USERPROFILE", home_root); _ = env_map.swapRemove("CODEX_HOME"); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); return try runCapture(allocator, project_root, &env_map, argv.items); } @@ -354,7 +352,6 @@ fn runCliWithIsolatedHomeAndCodexHome( try env_map.put("USERPROFILE", home_root); try env_map.put("CODEX_HOME", codex_home); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); return try runCapture(allocator, project_root, &env_map, argv.items); } @@ -382,7 +379,6 @@ fn runCliWithIsolatedHomeAndCodexHomeAndPath( try env_map.put("CODEX_HOME", codex_home); try env_map.put("PATH", path_override); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); return try runCapture(allocator, project_root, &env_map, argv.items); } @@ -409,7 +405,6 @@ fn runCliWithIsolatedHomeAndPath( _ = env_map.swapRemove("CODEX_HOME"); try env_map.put("PATH", path_override); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); return try runCapture(allocator, project_root, &env_map, argv.items); } @@ -437,7 +432,6 @@ fn runCliWithIsolatedHomeAndPathAndStdin( _ = env_map.swapRemove("CODEX_HOME"); try env_map.put("PATH", path_override); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); var child = std.process.spawn(fs.io(), .{ .argv = argv.items, @@ -505,7 +499,6 @@ fn runCliWithIsolatedHomeAndStdin( try env_map.put("USERPROFILE", home_root); _ = env_map.swapRemove("CODEX_HOME"); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); var child = std.process.spawn(fs.io(), .{ .argv = argv.items, @@ -3304,30 +3297,7 @@ test "Scenario: Given parseable auth without email for the active account when r try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, backup_key)); } -test "Scenario: Given default api usage when rendering status then no warning is printed" { - const gpa = std.testing.allocator; - const project_root = try projectRootAlloc(gpa); - defer gpa.free(project_root); - try buildCliBinary(gpa, project_root); - - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const home_root = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(home_root); - - const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{"status"}); - defer gpa.free(result.stdout); - defer gpa.free(result.stderr); - - try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "auto-switch: OFF") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "usage:") == null); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "account:") == null); - try std.testing.expectEqualStrings("", result.stderr); -} - -test "Scenario: Given config live interval when running command then registry and status show the interval" { +test "Scenario: Given config live interval when running command then registry stores the interval" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); defer gpa.free(project_root); @@ -3352,11 +3322,12 @@ test "Scenario: Given config live interval when running command then registry an defer loaded.deinit(gpa); try std.testing.expectEqual(@as(u16, 45), loaded.live.interval_seconds); - const status_result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{"status"}); - defer gpa.free(status_result.stdout); - defer gpa.free(status_result.stderr); - try expectSuccess(status_result); - try std.testing.expect(std.mem.indexOf(u8, status_result.stdout, "live refresh: 45s") != null); + const registry_path = try registry.registryPath(gpa, codex_home); + defer gpa.free(registry_path); + const data = try fixtures.readFileAlloc(gpa, registry_path); + defer gpa.free(data); + try std.testing.expect(std.mem.indexOf(u8, data, "\"interval_seconds\": 45") != null); + try std.testing.expect(std.mem.indexOf(u8, data, "\"live\"") == null); } test "Scenario: Given default api usage when listing accounts then no warning is printed" { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 7439ecba..f6745051 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -8,12 +8,12 @@ const registry = codex_auth.registry; const isQuitInput = cli.picker.isQuitInput; const isQuitKey = cli.picker.isQuitKey; const activeSelectableIndex = cli.picker.activeSelectableIndex; +const maybeAutoSwitchTargetKeyAlloc = cli.picker.maybeAutoSwitchTargetKeyAlloc; const accountRowCount = cli.picker.accountRowCount; const displayedIndexForSelectable = cli.picker.displayedIndexForSelectable; const selectableIndexForAccountKey = cli.picker.selectableIndexForAccountKey; const accountIdForSelectable = cli.picker.accountIdForSelectable; const filterErroredRowsFromSelectableIndices = cli.rows.filterErroredRowsFromSelectableIndices; -const maybeAutoSwitchTargetKeyAlloc = cli.picker.maybeAutoSwitchTargetKeyAlloc; const renderSwitchScreen = cli.render.renderSwitchScreen; const renderRemoveScreen = cli.render.renderRemoveScreen; const renderListScreenViewport = cli.render.renderListScreenViewport; @@ -60,7 +60,6 @@ fn makeTestRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -615,24 +614,6 @@ test "Scenario: Given queued live list scroll keys before rendering then viewpor try std.testing.expectEqual(@as(usize, 45), viewport_start); } -test "Scenario: Given live auto switch state when starting then the initial display triggers auto-switch once" { - var enabled = live_tui.LiveAutoSwitchState.init(true); - try std.testing.expect(enabled.takePending()); - try std.testing.expect(!enabled.takePending()); - - enabled.noteRefreshedDisplay(); - try std.testing.expect(enabled.takePending()); - try std.testing.expect(!enabled.takePending()); - - enabled.noteRefreshedDisplay(); - enabled.noteActionDisplay(); - try std.testing.expect(!enabled.takePending()); - - var disabled = live_tui.LiveAutoSwitchState.init(false); - disabled.noteRefreshedDisplay(); - try std.testing.expect(!disabled.takePending()); -} - test "Scenario: Given a long selectable live list when paging then selection can reach the first and final accounts" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); @@ -938,36 +919,22 @@ test "Scenario: Given live switch navigation shortcuts when an account is unavai try std.testing.expectEqualStrings("user-1::acc-1", selected_account_key.?); } -test "Scenario: Given active usage at threshold when picking a live auto-switch target then nearest 5h reset wins" { - const gpa = std.testing.allocator; - var reg = makeTestRegistry(); - defer reg.deinit(gpa); - - const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "backup-a@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-3", "backup-b@example.com", "", .team); - reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); - reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 99, 10, 3600, 7 * 24 * 3600); - reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 5, 5, 30 * 60, 60 * 60); - reg.accounts.items[2].last_usage = testUsageSnapshotWithResets(now, 50, 50, 5 * 60 + 1, 2 * 60 * 60); - - var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, null); - defer rows.deinit(gpa); - try filterErroredRowsFromSelectableIndices(gpa, &rows); - - const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{ - .reg = ®, - .usage_overrides = null, - }, &rows); - defer if (target_key) |value| gpa.free(value); +test "Scenario: Given live auto switch state when starting then the initial display triggers auto-switch once" { + var enabled = live_tui.LiveAutoSwitchState.init(true); + try std.testing.expect(enabled.takePending()); + try std.testing.expect(!enabled.takePending()); + enabled.noteRefreshedDisplay(); + try std.testing.expect(enabled.takePending()); + enabled.noteActionDisplay(); + try std.testing.expect(!enabled.takePending()); - try std.testing.expect(target_key != null); - try std.testing.expectEqualStrings("user-1::acc-3", target_key.?); + var disabled = live_tui.LiveAutoSwitchState.init(false); + try std.testing.expect(!disabled.takePending()); + disabled.noteRefreshedDisplay(); + try std.testing.expect(!disabled.takePending()); } -test "Scenario: Given equal 5h resets when picking a live auto-switch target then nearest weekly reset wins" { +test "Scenario: Given active usage at zero when picking a live auto-switch target then healthy candidate wins" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); defer reg.deinit(gpa); @@ -975,12 +942,12 @@ test "Scenario: Given equal 5h resets when picking a live auto-switch target the const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "backup-a@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-3", "backup-b@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "exhausted@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-3", "healthy@example.com", "", .team); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); - reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 99, 10, 3600, 7 * 24 * 3600); - reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 10, 10, 30 * 60, 6 * 60 * 60); - reg.accounts.items[2].last_usage = testUsageSnapshotWithResets(now, 90, 90, 30 * 60, 30 * 60); + reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 100, 20, 3600, 7 * 24 * 3600); + reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 100, 10, 30 * 60, 6 * 60 * 60); + reg.accounts.items[2].last_usage = testUsageSnapshotWithResets(now, 50, 50, 30 * 60, 30 * 60); var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, null); defer rows.deinit(gpa); @@ -996,35 +963,7 @@ test "Scenario: Given equal 5h resets when picking a live auto-switch target the try std.testing.expectEqualStrings("user-1::acc-3", target_key.?); } -test "Scenario: Given an active api status error when picking an auto-switch target then a healthy candidate is chosen" { - const gpa = std.testing.allocator; - var reg = makeTestRegistry(); - defer reg.deinit(gpa); - - const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - - try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "backup@example.com", "", .team); - reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); - reg.accounts.items[0].last_usage = testUsageSnapshot(now, 20, 20); - reg.accounts.items[1].last_usage = testUsageSnapshot(now, 10, 10); - - const usage_overrides = [_]?[]const u8{ "403", null }; - var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, &usage_overrides); - defer rows.deinit(gpa); - try filterErroredRowsFromSelectableIndices(gpa, &rows); - - const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{ - .reg = ®, - .usage_overrides = &usage_overrides, - }, &rows); - defer if (target_key) |value| gpa.free(value); - - try std.testing.expect(target_key != null); - try std.testing.expectEqualStrings("user-1::acc-2", target_key.?); -} - -test "Scenario: Given only exhausted candidates when picking an auto-switch target then no target is returned" { +test "Scenario: Given active usage above zero when picking a live auto-switch target then no target is chosen" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); defer reg.deinit(gpa); @@ -1032,10 +971,10 @@ test "Scenario: Given only exhausted candidates when picking an auto-switch targ const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); - try appendTestAccount(gpa, ®, "user-1::acc-2", "backup@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "healthy@example.com", "", .team); reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); - reg.accounts.items[0].last_usage = testUsageSnapshot(now, 100, 10); - reg.accounts.items[1].last_usage = testUsageSnapshot(now, 100, 100); + reg.accounts.items[0].last_usage = testUsageSnapshotWithResets(now, 99, 20, 3600, 7 * 24 * 3600); + reg.accounts.items[1].last_usage = testUsageSnapshotWithResets(now, 50, 50, 30 * 60, 30 * 60); var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, null); defer rows.deinit(gpa); diff --git a/tests/registry_purge_import_test.zig b/tests/registry_purge_import_test.zig deleted file mode 100644 index 7fbd0995..00000000 --- a/tests/registry_purge_import_test.zig +++ /dev/null @@ -1,833 +0,0 @@ -const std = @import("std"); -const fs = @import("codex_auth").core.compat_fs; -const builtin = @import("builtin"); -const registry = @import("codex_auth").registry; - -fn b64url(allocator: std.mem.Allocator, input: []const u8) ![]u8 { - const encoder = std.base64.url_safe_no_pad.Encoder; - const out_len = encoder.calcSize(input.len); - const buf = try allocator.alloc(u8, out_len); - _ = encoder.encode(buf, input); - return buf; -} - -fn legacySnapshotNameForEmail(allocator: std.mem.Allocator, email: []const u8) ![]u8 { - const key = try b64url(allocator, email); - defer allocator.free(key); - return std.fmt.allocPrint(allocator, "{s}.auth.json", .{key}); -} - -fn accountKeyForEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 { - const chatgpt_user_id = try chatgptUserIdForEmailAlloc(allocator, email); - defer allocator.free(chatgpt_user_id); - const chatgpt_account_id = try chatgptAccountIdForEmailAlloc(allocator, email); - defer allocator.free(chatgpt_account_id); - return std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); -} - -fn hashPart(seed: u64, email: []const u8, modulus: u64) u64 { - return std.hash.Wyhash.hash(seed, email) % modulus; -} - -fn chatgptAccountIdForEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 { - return std.fmt.allocPrint( - allocator, - "{d:0>8}-{d:0>4}-{d:0>4}-{d:0>4}-{d:0>12}", - .{ - hashPart(1, email, 100_000_000), - hashPart(2, email, 10_000), - 4000 + hashPart(3, email, 1000), - 8000 + hashPart(4, email, 1000), - hashPart(5, email, 1_000_000_000_000), - }, - ); -} - -fn chatgptUserIdForEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 { - return std.fmt.allocPrint( - allocator, - "user-{x:0>8}{x:0>8}{x:0>6}", - .{ - hashPart(6, email, 0x100000000), - hashPart(7, email, 0x100000000), - hashPart(8, email, 0x1000000), - }, - ); -} - -fn authJsonWithEmailPlan(allocator: std.mem.Allocator, email: []const u8, plan: []const u8) ![]u8 { - const chatgpt_account_id = try chatgptAccountIdForEmailAlloc(allocator, email); - defer allocator.free(chatgpt_account_id); - const chatgpt_user_id = try chatgptUserIdForEmailAlloc(allocator, email); - defer allocator.free(chatgpt_user_id); - const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; - const payload = try std.fmt.allocPrint( - allocator, - "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", - .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, - ); - defer allocator.free(payload); - - const h64 = try b64url(allocator, header); - defer allocator.free(h64); - const p64 = try b64url(allocator, payload); - defer allocator.free(p64); - const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ h64, ".", p64, ".sig" }); - defer allocator.free(jwt); - - return try std.fmt.allocPrint( - allocator, - "{{\"tokens\":{{\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", - .{ chatgpt_account_id, jwt }, - ); -} - -fn authJsonWithExplicitIds( - allocator: std.mem.Allocator, - email: []const u8, - chatgpt_account_id: []const u8, - chatgpt_user_id: []const u8, - plan: []const u8, -) ![]u8 { - const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; - const payload = try std.fmt.allocPrint( - allocator, - "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", - .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, - ); - defer allocator.free(payload); - - const h64 = try b64url(allocator, header); - defer allocator.free(h64); - const p64 = try b64url(allocator, payload); - defer allocator.free(p64); - const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ h64, ".", p64, ".sig" }); - defer allocator.free(jwt); - - return try std.fmt.allocPrint( - allocator, - "{{\"tokens\":{{\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", - .{ chatgpt_account_id, jwt }, - ); -} - -test "Scenario: Given legacy version key current-layout registry when loading then it rewrites to schema_version" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "version": 3, - \\ "active_account_key": null, - \\ "auto_switch": { - \\ "enabled": true - \\ }, - \\ "accounts": [] - \\} - , - }); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.auto_switch.enabled); - try std.testing.expect(loaded.schema_version == registry.current_schema_version); - - var file = try tmp.dir.openFile("accounts/registry.json", .{}); - defer file.close(); - const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); - defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"version\": 3") == null); -} - -test "Scenario: Given newer schema version when loading then it is rejected" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "schema_version": 999, - \\ "accounts": [] - \\} - , - }); - - try std.testing.expectError(error.UnsupportedRegistryVersion, registry.loadRegistry(gpa, codex_home)); -} - -test "Scenario: Given v2 registry when loading then it migrates to record-key layout and rewrites schema_version" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const email = "legacy@example.com"; - const auth_json = try authJsonWithEmailPlan(gpa, email, "team"); - defer gpa.free(auth_json); - const legacy_name = try legacySnapshotNameForEmail(gpa, email); - defer gpa.free(legacy_name); - const legacy_rel = try fs.path.join(gpa, &[_][]const u8{ "accounts", legacy_name }); - defer gpa.free(legacy_rel); - try tmp.dir.writeFile(.{ .sub_path = legacy_rel, .data = auth_json }); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260312-000000", .data = auth_json }); - - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "version": 2, - \\ "active_email": "legacy@example.com", - \\ "accounts": [ - \\ { - \\ "email": "legacy@example.com", - \\ "alias": "legacy", - \\ "plan": "pro", - \\ "auth_mode": "chatgpt", - \\ "created_at": 1, - \\ "last_used_at": 2, - \\ "last_usage_at": 3, - \\ "last_usage": { - \\ "primary": { - \\ "used_percent": 25, - \\ "window_minutes": 300, - \\ "resets_at": 123 - \\ }, - \\ "plan_type": "team" - \\ } - \\ } - \\ ] - \\} - , - }); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.schema_version == registry.current_schema_version); - try std.testing.expect(loaded.accounts.items.len == 1); - try std.testing.expect(loaded.active_account_key != null); - - const account_id = try accountKeyForEmailAlloc(gpa, email); - defer gpa.free(account_id); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_key, account_id)); - try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, account_id)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].alias, "legacy")); - try std.testing.expect(loaded.accounts.items[0].last_used_at != null); - try std.testing.expect(loaded.accounts.items[0].last_used_at.? >= 2); - try std.testing.expectEqual(@as(i64, 3), loaded.accounts.items[0].last_usage_at.?); - try std.testing.expectEqual(@as(f64, 25.0), loaded.accounts.items[0].last_usage.?.primary.?.used_percent); - try std.testing.expectEqual(registry.PlanType.team, loaded.accounts.items[0].last_usage.?.plan_type.?); - - const migrated_path = try registry.accountAuthPath(gpa, codex_home, account_id); - defer gpa.free(migrated_path); - var migrated = try fs.cwd().openFile(migrated_path, .{}); - migrated.close(); - try std.testing.expectError(error.FileNotFound, tmp.dir.openFile(legacy_rel, .{})); - - var file = try tmp.dir.openFile("accounts/registry.json", .{}); - defer file.close(); - const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); - defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); - const active_expect = try std.fmt.allocPrint(gpa, "\"active_account_key\": \"{s}\"", .{account_id}); - defer gpa.free(active_expect); - try std.testing.expect(std.mem.indexOf(u8, contents, active_expect) != null); -} - -test "Scenario: Given purge import with file when rebuilding then current auth is imported as active and old registry entries are discarded" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.makePath("imports"); - - const imported_auth = try authJsonWithEmailPlan(gpa, "personal@example.com", "plus"); - defer gpa.free(imported_auth); - try tmp.dir.writeFile(.{ .sub_path = "imports/personal.json", .data = imported_auth }); - - const active_auth = try authJsonWithEmailPlan(gpa, "active@example.com", "team"); - defer gpa.free(active_auth); - try tmp.dir.writeFile(.{ .sub_path = "auth.json", .data = active_auth }); - - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "schema_version": 4, - \\ "active_account_key": "user-r4g1strystale000001::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", - \\ "active_account_activated_at_ms": 1735689600000, - \\ "auto_switch": { - \\ "enabled": true, - \\ "threshold_5h_percent": 12, - \\ "threshold_weekly_percent": 7 - \\ }, - \\ "api": { - \\ "usage": true - \\ }, - \\ "accounts": [ - \\ { - \\ "account_key": "user-r4g1strystale000001::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", - \\ "chatgpt_account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", - \\ "chatgpt_user_id": "user-r4g1strystale000001", - \\ "email": "stale@example.com", - \\ "alias": "stale", - \\ "plan": "pro", - \\ "auth_mode": "chatgpt", - \\ "created_at": 1, - \\ "last_used_at": null, - \\ "last_usage_at": 9, - \\ "last_usage": { - \\ "primary": { - \\ "used_percent": 99, - \\ "window_minutes": 300, - \\ "resets_at": 123 - \\ } - \\ } - \\ } - \\ ] - \\} - , - }); - - const import_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "imports", "personal.json" }); - defer gpa.free(import_path); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, import_path, "personal"); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.accounts.items.len == 2); - try std.testing.expect(loaded.auto_switch.enabled); - try std.testing.expectEqual(@as(u8, 12), loaded.auto_switch.threshold_5h_percent); - try std.testing.expectEqual(@as(u8, 7), loaded.auto_switch.threshold_weekly_percent); - try std.testing.expect(loaded.api.usage); - try std.testing.expect(loaded.api.account); - try std.testing.expect(loaded.active_account_activated_at_ms != null); - - const active_account_key = try accountKeyForEmailAlloc(gpa, "active@example.com"); - defer gpa.free(active_account_key); - try std.testing.expect(loaded.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, active_account_key)); - - const stale_idx = registry.findAccountIndexByAccountKey(&loaded, "user-r4g1strystale000001::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"); - try std.testing.expect(stale_idx == null); - - const imported_account_id = try accountKeyForEmailAlloc(gpa, "personal@example.com"); - defer gpa.free(imported_account_id); - const imported_idx = registry.findAccountIndexByAccountKey(&loaded, imported_account_id) orelse return error.TestExpectedEqual; - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[imported_idx].alias, "personal")); - try std.testing.expect(loaded.accounts.items[imported_idx].last_usage == null); - try std.testing.expect(loaded.accounts.items[imported_idx].last_usage_at == null); - try std.testing.expect(loaded.accounts.items[imported_idx].last_local_rollout == null); - - const active_idx = registry.findAccountIndexByAccountKey(&loaded, active_account_key) orelse return error.TestExpectedEqual; - try std.testing.expect(loaded.accounts.items[active_idx].last_usage == null); - try std.testing.expect(loaded.accounts.items[active_idx].last_usage_at == null); - try std.testing.expect(loaded.accounts.items[active_idx].last_local_rollout == null); -} - -test "Scenario: Given purge with newer schema registry when rebuilding then auto and api config are preserved" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.makePath("imports"); - - const imported_auth = try authJsonWithEmailPlan(gpa, "personal@example.com", "plus"); - defer gpa.free(imported_auth); - try tmp.dir.writeFile(.{ .sub_path = "imports/personal.json", .data = imported_auth }); - - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "schema_version": 999, - \\ "auto_switch": { - \\ "enabled": true, - \\ "threshold_5h_percent": 18, - \\ "threshold_weekly_percent": 6 - \\ }, - \\ "api": { - \\ "usage": true - \\ }, - \\ "accounts": [] - \\} - , - }); - - const import_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "imports", "personal.json" }); - defer gpa.free(import_path); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, import_path, "personal"); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.auto_switch.enabled); - try std.testing.expectEqual(@as(u8, 18), loaded.auto_switch.threshold_5h_percent); - try std.testing.expectEqual(@as(u8, 6), loaded.auto_switch.threshold_weekly_percent); - try std.testing.expect(loaded.api.usage); - try std.testing.expect(loaded.api.account); -} - -test "Scenario: Given purge with malformed registry when rebuilding then auto and api config are recovered best effort" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.makePath("imports"); - - const imported_auth = try authJsonWithEmailPlan(gpa, "personal@example.com", "plus"); - defer gpa.free(imported_auth); - try tmp.dir.writeFile(.{ .sub_path = "imports/personal.json", .data = imported_auth }); - - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "auto_switch": { - \\ "enabled": true, - \\ "threshold_5h_percent": 13, - \\ "threshold_weekly_percent": 4 - \\ }, - \\ "api": { - \\ "usage": true - \\ }, - \\ "accounts": [oops] - \\} - , - }); - - const import_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "imports", "personal.json" }); - defer gpa.free(import_path); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, import_path, "personal"); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.auto_switch.enabled); - try std.testing.expectEqual(@as(u8, 13), loaded.auto_switch.threshold_5h_percent); - try std.testing.expectEqual(@as(u8, 4), loaded.auto_switch.threshold_weekly_percent); - try std.testing.expect(loaded.api.usage); - try std.testing.expect(loaded.api.account); -} - -test "Scenario: Given purge without path when rebuilding then it scans account snapshots and ignores registry metadata files" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const snapshot_auth = try authJsonWithEmailPlan(gpa, "snap@example.com", "pro"); - defer gpa.free(snapshot_auth); - const snapshot_account_id = try accountKeyForEmailAlloc(gpa, "snap@example.com"); - defer gpa.free(snapshot_account_id); - const snapshot_path = try registry.accountAuthPath(gpa, codex_home, snapshot_account_id); - defer gpa.free(snapshot_path); - const snapshot_name = fs.path.basename(snapshot_path); - const snapshot_rel = try fs.path.join(gpa, &[_][]const u8{ "accounts", snapshot_name }); - defer gpa.free(snapshot_rel); - try tmp.dir.writeFile(.{ .sub_path = snapshot_rel, .data = snapshot_auth }); - try tmp.dir.writeFile(.{ .sub_path = "accounts/registry.json", .data = "{\"bad\":\"registry\"}" }); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.1", .data = "backup" }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.accounts.items.len == 1); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "snap@example.com")); -} - -test "Scenario: Given purge without path and only auth backups when rebuilding then it imports backup auth files too" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const backup_auth = try authJsonWithEmailPlan(gpa, "backup-only@example.com", "team"); - defer gpa.free(backup_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-010101", .data = backup_auth }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "backup-only@example.com")); - - const record_key = try accountKeyForEmailAlloc(gpa, "backup-only@example.com"); - defer gpa.free(record_key); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_key, record_key)); - - const snapshot_path = try registry.accountAuthPath(gpa, codex_home, record_key); - defer gpa.free(snapshot_path); - var snapshot = try fs.cwd().openFile(snapshot_path, .{}); - snapshot.close(); -} - -test "Scenario: Given purge without a recoverable active auth when rebuilding then it activates the first sorted account and backs up the previous auth" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const zed_auth = try authJsonWithEmailPlan(gpa, "zed@example.com", "team"); - defer gpa.free(zed_auth); - const zed_record_key = try accountKeyForEmailAlloc(gpa, "zed@example.com"); - defer gpa.free(zed_record_key); - const zed_snapshot_path = try registry.accountAuthPath(gpa, codex_home, zed_record_key); - defer gpa.free(zed_snapshot_path); - const zed_snapshot_rel = try std.fs.path.relative(gpa, codex_home, null, codex_home, zed_snapshot_path); - defer gpa.free(zed_snapshot_rel); - try tmp.dir.writeFile(.{ .sub_path = zed_snapshot_rel, .data = zed_auth }); - - const alpha_auth = try authJsonWithEmailPlan(gpa, "alpha@example.com", "plus"); - defer gpa.free(alpha_auth); - const alpha_record_key = try accountKeyForEmailAlloc(gpa, "alpha@example.com"); - defer gpa.free(alpha_record_key); - const alpha_snapshot_path = try registry.accountAuthPath(gpa, codex_home, alpha_record_key); - defer gpa.free(alpha_snapshot_path); - const alpha_snapshot_rel = try std.fs.path.relative(gpa, codex_home, null, codex_home, alpha_snapshot_path); - defer gpa.free(alpha_snapshot_rel); - try tmp.dir.writeFile(.{ .sub_path = alpha_snapshot_rel, .data = alpha_auth }); - - const stale_auth = "{\"broken\":true}"; - try tmp.dir.writeFile(.{ .sub_path = "auth.json", .data = stale_auth }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - try std.testing.expectEqual(@as(usize, 2), report.imported); - try std.testing.expectEqual(@as(usize, 0), report.updated); - try std.testing.expectEqual(@as(usize, 0), report.skipped); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 2), loaded.accounts.items.len); - try std.testing.expect(loaded.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, alpha_record_key)); - - const active_auth_path = try registry.activeAuthPath(gpa, codex_home); - defer gpa.free(active_auth_path); - var active_file = try fs.cwd().openFile(active_auth_path, .{}); - defer active_file.close(); - const active_auth = try active_file.readToEndAlloc(gpa, 10 * 1024 * 1024); - defer gpa.free(active_auth); - try std.testing.expectEqualStrings(alpha_auth, active_auth); - - var backup_name: ?[]u8 = null; - defer if (backup_name) |name| gpa.free(name); - - var accounts = try tmp.dir.openDir("accounts", .{ .iterate = true }); - defer accounts.close(); - var it = accounts.iterate(); - var backup_count: usize = 0; - while (try it.next()) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.startsWith(u8, entry.name, "auth.json.bak.")) continue; - backup_count += 1; - if (backup_name == null) { - backup_name = try gpa.dupe(u8, entry.name); - } - } - try std.testing.expectEqual(@as(usize, 1), backup_count); - try std.testing.expect(backup_name != null); - - const backup_rel = try fs.path.join(gpa, &[_][]const u8{ "accounts", backup_name.? }); - defer gpa.free(backup_rel); - var backup_file = try tmp.dir.openFile(backup_rel, .{}); - defer backup_file.close(); - const backup_contents = try backup_file.readToEndAlloc(gpa, 10 * 1024 * 1024); - defer gpa.free(backup_contents); - try std.testing.expectEqualStrings(stale_auth, backup_contents); -} - -test "Scenario: Given purge without path and an empty snapshot when rebuilding then it reports malformed json and still imports valid backups" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const backup_auth = try authJsonWithEmailPlan(gpa, "backup-valid@example.com", "team"); - defer gpa.free(backup_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-010101", .data = backup_auth }); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-020202", .data = "" }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - try std.testing.expectEqual(@as(usize, 1), report.imported); - try std.testing.expectEqual(@as(usize, 1), report.skipped); - - var found_malformed = false; - for (report.events.items) |event| { - if (event.outcome != .skipped) continue; - if (std.mem.eql(u8, event.label, "auth.json.bak.20260317-020202")) { - found_malformed = std.mem.eql(u8, event.reason.?, "MalformedJson"); - break; - } - } - try std.testing.expect(found_malformed); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "backup-valid@example.com")); -} - -test "Scenario: Given purge without path and a broken snapshot symlink when rebuilding then it skips that entry and still imports valid backups" { - if (builtin.os.tag == .windows) return error.SkipZigTest; - - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const backup_auth = try authJsonWithEmailPlan(gpa, "backup-symlink@example.com", "team"); - defer gpa.free(backup_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-010101", .data = backup_auth }); - try tmp.dir.symLink("missing.auth.json", "accounts/auth.json.bak.20260317-020202", .{}); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - try std.testing.expectEqual(@as(usize, 1), report.imported); - try std.testing.expectEqual(@as(usize, 1), report.skipped); - - var found_missing = false; - for (report.events.items) |event| { - if (event.outcome != .skipped) continue; - if (std.mem.eql(u8, event.label, "auth.json.bak.20260317-020202")) { - found_missing = std.mem.eql(u8, event.reason.?, "FileNotFound"); - break; - } - } - try std.testing.expect(found_missing); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "backup-symlink@example.com")); -} - -test "Scenario: Given purge without path and duplicate snapshots when rebuilding then newest snapshot wins and accounts are sorted by email" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const duplicate_record_key = "1::acct1"; - const old_backup_auth = try authJsonWithExplicitIds(gpa, "zed@example.com", "acct1", "1", "free"); - defer gpa.free(old_backup_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-010101", .data = old_backup_auth }); - - const current_snapshot_auth = try authJsonWithExplicitIds(gpa, "zed@example.com", "acct1", "1", "team"); - defer gpa.free(current_snapshot_auth); - const current_snapshot_path = try registry.accountAuthPath(gpa, codex_home, duplicate_record_key); - defer gpa.free(current_snapshot_path); - const current_snapshot_rel = try std.fs.path.relative(gpa, codex_home, null, codex_home, current_snapshot_path); - defer gpa.free(current_snapshot_rel); - try tmp.dir.writeFile(.{ - .sub_path = current_snapshot_rel, - .data = current_snapshot_auth, - }); - - const alpha_auth = try authJsonWithEmailPlan(gpa, "alpha@example.com", "plus"); - defer gpa.free(alpha_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-020202", .data = alpha_auth }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 2), loaded.accounts.items.len); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "alpha@example.com")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].email, "zed@example.com")); - - const duplicate_idx = registry.findAccountIndexByAccountKey(&loaded, duplicate_record_key) orelse return error.TestExpectedEqual; - try std.testing.expectEqual(registry.PlanType.team, loaded.accounts.items[duplicate_idx].plan.?); - - var snapshot = try fs.cwd().openFile(current_snapshot_path, .{}); - defer snapshot.close(); - const snapshot_contents = try snapshot.readToEndAlloc(gpa, 10 * 1024 * 1024); - defer gpa.free(snapshot_contents); - try std.testing.expect(std.mem.eql(u8, snapshot_contents, current_snapshot_auth)); -} - -test "Scenario: Given same team account id across different users when purging then record key keeps both imported accounts" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const shared_chatgpt_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; - - const first_auth = try authJsonWithExplicitIds( - gpa, - "trade5258@bytebit.ggff.net", - shared_chatgpt_account_id, - "user-VcL6uT0HoEblRE4RSV7NsUDI", - "team", - ); - defer gpa.free(first_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-154910", .data = first_auth }); - - const second_auth = try authJsonWithExplicitIds( - gpa, - "cloning5942@bytebit.ggff.net", - shared_chatgpt_account_id, - "user-ESYgcy2QkOGZc0NoxSlFCeVT", - "team", - ); - defer gpa.free(second_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-171806", .data = second_auth }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 2), loaded.accounts.items.len); - - const first_record_key = "user-VcL6uT0HoEblRE4RSV7NsUDI::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; - const second_record_key = "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; - - const first_idx = registry.findAccountIndexByAccountKey(&loaded, first_record_key) orelse return error.TestExpectedEqual; - const second_idx = registry.findAccountIndexByAccountKey(&loaded, second_record_key) orelse return error.TestExpectedEqual; - - try std.testing.expect(!std.mem.eql(u8, loaded.accounts.items[first_idx].account_key, loaded.accounts.items[second_idx].account_key)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[first_idx].chatgpt_account_id, shared_chatgpt_account_id)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[second_idx].chatgpt_account_id, shared_chatgpt_account_id)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[first_idx].chatgpt_user_id, "user-VcL6uT0HoEblRE4RSV7NsUDI")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[second_idx].chatgpt_user_id, "user-ESYgcy2QkOGZc0NoxSlFCeVT")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[first_idx].email, "trade5258@bytebit.ggff.net")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[second_idx].email, "cloning5942@bytebit.ggff.net")); -} - -test "Scenario: Given same user across team and free workspaces when purging then record key keeps both workspace records" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - const shared_chatgpt_user_id = "user-NuLAf1g5RIAwHDQxfoHfgcPo"; - const shared_email = "flashback6936@8bits.ggff.net"; - - const team_auth = try authJsonWithExplicitIds( - gpa, - shared_email, - "d52355a3-bfa6-4d2b-882e-d4a2927f488c", - shared_chatgpt_user_id, - "team", - ); - defer gpa.free(team_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-135020", .data = team_auth }); - - const free_auth = try authJsonWithExplicitIds( - gpa, - shared_email, - "fe43c186-7b49-4880-8744-e662b796a9d9", - shared_chatgpt_user_id, - "free", - ); - defer gpa.free(free_auth); - try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.20260317-172239", .data = free_auth }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 2), loaded.accounts.items.len); - - const team_record_key = "user-NuLAf1g5RIAwHDQxfoHfgcPo::d52355a3-bfa6-4d2b-882e-d4a2927f488c"; - const free_record_key = "user-NuLAf1g5RIAwHDQxfoHfgcPo::fe43c186-7b49-4880-8744-e662b796a9d9"; - - const team_idx = registry.findAccountIndexByAccountKey(&loaded, team_record_key) orelse return error.TestExpectedEqual; - const free_idx = registry.findAccountIndexByAccountKey(&loaded, free_record_key) orelse return error.TestExpectedEqual; - - try std.testing.expect(!std.mem.eql(u8, loaded.accounts.items[team_idx].account_key, loaded.accounts.items[free_idx].account_key)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[team_idx].chatgpt_user_id, shared_chatgpt_user_id)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[free_idx].chatgpt_user_id, shared_chatgpt_user_id)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[team_idx].chatgpt_account_id, "d52355a3-bfa6-4d2b-882e-d4a2927f488c")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[free_idx].chatgpt_account_id, "fe43c186-7b49-4880-8744-e662b796a9d9")); - try std.testing.expectEqual(registry.PlanType.team, loaded.accounts.items[team_idx].plan.?); - try std.testing.expectEqual(registry.PlanType.free, loaded.accounts.items[free_idx].plan.?); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[team_idx].email, shared_email)); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[free_idx].email, shared_email)); -} - -test "Scenario: Given purge without accounts directory when rebuilding then current auth still restores the active account" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - const active_auth = try authJsonWithEmailPlan(gpa, "active@example.com", "team"); - defer gpa.free(active_auth); - try tmp.dir.writeFile(.{ .sub_path = "auth.json", .data = active_auth }); - - var report = try registry.purgeRegistryFromImportSource(gpa, codex_home, null, null); - defer report.deinit(gpa); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.accounts.items.len == 1); - try std.testing.expect(loaded.active_account_key != null); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "active@example.com")); -} diff --git a/tests/registry_test.zig b/tests/registry_test.zig index 37636f5f..cac02410 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -88,7 +88,6 @@ fn makeEmptyRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -288,8 +287,6 @@ 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.threshold_5h_percent = 12; - reg.auto_switch.threshold_weekly_percent = 8; reg.api.usage = true; try registry.setAccountLastLocalRollout(gpa, ®.accounts.items[0], "/tmp/sessions/run-1/rollout-a.jsonl", 1735689600000); @@ -304,8 +301,6 @@ test "registry save/load" { var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); try std.testing.expect(loaded.accounts.items.len == 1); - 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); try std.testing.expect(loaded.api.account); try std.testing.expect(loaded.active_account_activated_at_ms != null); @@ -549,84 +544,6 @@ test "registry save omits api config" { try std.testing.expect(std.mem.indexOf(u8, saved, "\"api\"") == null); } -test "registry load defaults missing auto threshold fields" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "schema_version": 3, - \\ "active_account_key": null, - \\ "auto_switch": { - \\ "enabled": true - \\ }, - \\ "accounts": [] - \\} - , - }); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expect(loaded.auto_switch.enabled); - 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); - try std.testing.expect(loaded.api.account); - try std.testing.expect(loaded.active_account_activated_at_ms == null); - - const registry_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); - defer gpa.free(registry_path); - const saved = try fixtures.readFileAlloc(gpa, registry_path); - defer gpa.free(saved); - try std.testing.expect(std.mem.indexOf(u8, saved, "\"api\"") == null); -} - -test "registry load migrates old auto thresholds to default one percent" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "schema_version": 3, - \\ "active_account_key": null, - \\ "auto_switch": { - \\ "enabled": true, - \\ "threshold_5h_percent": 12, - \\ "threshold_weekly_percent": 8 - \\ }, - \\ "accounts": [] - \\} - , - }); - - 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.current_schema_version, loaded.schema_version); - try std.testing.expectEqual(@as(u8, 1), loaded.auto_switch.threshold_5h_percent); - try std.testing.expectEqual(@as(u8, 1), loaded.auto_switch.threshold_weekly_percent); - - const registry_path = try fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); - defer gpa.free(registry_path); - const saved = try fixtures.readFileAlloc(gpa, registry_path); - defer gpa.free(saved); - try std.testing.expect(std.mem.indexOf(u8, saved, "\"schema_version\": 4") != null); - try std.testing.expect(std.mem.indexOf(u8, saved, "\"threshold_5h_percent\": 1") != null); - try std.testing.expect(std.mem.indexOf(u8, saved, "\"threshold_weekly_percent\": 1") != null); -} - test "registry load ignores legacy api.usage and rewrites file without api config" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); @@ -759,9 +676,6 @@ test "legacy current-layout registry version field rewrites to schema_version" { \\{ \\ "version": 3, \\ "active_account_key": null, - \\ "auto_switch": { - \\ "enabled": true - \\ }, \\ "accounts": [] \\} , @@ -776,6 +690,8 @@ test "legacy current-layout registry version field rewrites to schema_version" { const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"interval_seconds\": 60") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"live\"") == null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"version\"") == null); } @@ -1274,7 +1190,6 @@ test "clean uses a whitelist and only removes non-current entries under accounts try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.3", .data = "a3" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/registry.json.bak.1", .data = "r1" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/registry.json.bak.2", .data = "r2" }); - try tmp.dir.writeFile(.{ .sub_path = "accounts/" ++ registry.account_name_refresh_lock_file_name, .data = "" }); try tmp.dir.writeFile(.{ .sub_path = keep_rel_path, .data = "keep" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/bGVnYWN5QGV4YW1wbGUuY29t.auth.json", .data = "legacy" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/notes.txt", .data = "junk" }); @@ -1294,8 +1209,6 @@ test "clean uses a whitelist and only removes non-current entries under accounts try std.testing.expect(try countBackups(accounts, "registry.json") == 0); var kept = try tmp.dir.openFile(keep_rel_path, .{}); kept.close(); - var refresh_lock = try tmp.dir.openFile("accounts/" ++ registry.account_name_refresh_lock_file_name, .{}); - refresh_lock.close(); try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("accounts/bGVnYWN5QGV4YW1wbGUuY29t.auth.json", .{})); try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("accounts/notes.txt", .{})); try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("accounts/tmpdir/old.txt", .{})); diff --git a/tests/support/fixtures.zig b/tests/support/fixtures.zig index 3ba209a8..50b287d6 100644 --- a/tests/support/fixtures.zig +++ b/tests/support/fixtures.zig @@ -185,7 +185,6 @@ pub fn makeEmptyRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index bf498459..cf2985a6 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -12,7 +12,6 @@ fn makeTestRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; diff --git a/tests/tui_display_test.zig b/tests/tui_display_test.zig index 5669c97f..8fd63800 100644 --- a/tests/tui_display_test.zig +++ b/tests/tui_display_test.zig @@ -7,7 +7,6 @@ fn makeRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index a9fbd550..8ebec328 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -20,7 +20,6 @@ fn makeTestRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; diff --git a/tests/workflows_core_test.zig b/tests/workflows_core_test.zig index ce22b350..4455b02a 100644 --- a/tests/workflows_core_test.zig +++ b/tests/workflows_core_test.zig @@ -37,7 +37,6 @@ fn makeRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -339,20 +338,6 @@ test "Scenario: Given account_id query when finding matching accounts then it is try std.testing.expect(matches.items.len == 0); } -test "Scenario: Given foreground commands when checking reconcile policy then config commands self-heal services but status does not" { - 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 = .{ - .threshold_5h_percent = 12, - .threshold_weekly_percent = null, - } } } })); - try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .config = .{ .live = .{ .interval_seconds = 30 } } })); - try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .help = .top_level })); - try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .status = {} })); - try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .version = {} })); - try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .daemon = .{ .mode = .once } })); -} - test "Scenario: Given foreground usage refresh targets when checking refresh policy then list, switch, and remove refresh" { try std.testing.expect(main_mod.shouldRefreshForegroundUsage(.list)); try std.testing.expect(main_mod.shouldRefreshForegroundUsage(.switch_account)); @@ -1015,35 +1000,6 @@ test "Scenario: Given grouped team accounts with account api disabled when refre try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } -test "Scenario: Given grouped team accounts with account api disabled when checking switch background refresh then it is skipped" { - const gpa = std.testing.allocator; - var reg = makeRegistry(); - defer reg.deinit(gpa); - reg.api.account = false; - - 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 std.testing.expect(!main_mod.shouldScheduleBackgroundAccountNameRefresh(®)); -} - -test "Scenario: Given only another user has missing grouped team names when checking background refresh then it is still scheduled" { - const gpa = std.testing.allocator; - var reg = makeRegistry(); - defer reg.deinit(gpa); - - try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); - reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); - try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); - reg.accounts.items[1].account_name = try gpa.dupe(u8, "Backup Workspace"); - try appendAccount(gpa, ®, "user-OTHER::acct-OTHER-A", "other@example.com", "", .team); - try appendAccount(gpa, ®, "user-OTHER::acct-OTHER-B", "other@example.com", "", .team); - try registry.setActiveAccountKey(gpa, ®, primary_record_key); - - try std.testing.expect(main_mod.shouldScheduleBackgroundAccountNameRefresh(®)); -} - test "Scenario: Given login with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -1088,134 +1044,6 @@ test "Scenario: Given switched account with missing account names when refreshin try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); } -test "Scenario: Given registry changes while background account-name refresh is in flight when it finishes then latest config is preserved" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - 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 registry.saveRegistry(gpa, codex_home, ®); - try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); - try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); - - resetMockAccountNameFetcher(); - mutate_registry_during_account_fetch = true; - mutate_registry_codex_home = codex_home; - try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcherWithRegistryMutation); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); - try std.testing.expectEqual(@as(u16, 45), loaded.live.interval_seconds); - try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[0].account_name.?); - try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); -} - -test "Scenario: Given grouped stored snapshots without active auth when running background account-name refresh then it updates the missing names" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - 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.saveRegistry(gpa, codex_home, ®); - try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); - - resetMockAccountNameFetcher(); - try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcher); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "Primary Workspace")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); -} - -test "Scenario: Given grouped stored snapshots with multiple tokens when running background account-name refresh then it prefers the newest last_refresh" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - 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.saveRegistry(gpa, codex_home, ®); - try writeAccountSnapshotWithIdsAndLastRefresh( - gpa, - codex_home, - "user@example.com", - "team", - shared_user_id, - primary_account_id, - "stale-token", - "2026-03-20T00:00:00Z", - ); - try writeAccountSnapshotWithIdsAndLastRefresh( - gpa, - codex_home, - "user@example.com", - "team", - shared_user_id, - secondary_account_id, - "fresh-token", - "2026-03-21T00:00:00Z", - ); - - resetMockAccountNameFetcher(); - expected_mock_account_name_fetch_account_id = secondary_account_id; - try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcherRequiringFreshToken); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "Primary Workspace")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); -} - -test "Scenario: Given grouped team names with only a stored plus snapshot for the same user when running background account-name refresh then it updates the team records" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - var reg = makeRegistry(); - defer reg.deinit(gpa); - try appendAccount(gpa, ®, shared_user_id ++ "::" ++ primary_account_id, "same-user@example.com", "", .team); - try appendAccount(gpa, ®, shared_user_id ++ "::" ++ secondary_account_id, "same-user@example.com", "", .team); - reg.accounts.items[1].account_name = try gpa.dupe(u8, "Old Backup Workspace"); - try appendAccount(gpa, ®, shared_user_id ++ "::" ++ tertiary_account_id, "same-user@example.com", "", .plus); - try registry.saveRegistry(gpa, codex_home, ®); - try writeAccountSnapshotWithIds(gpa, codex_home, "same-user@example.com", "plus", shared_user_id, tertiary_account_id); - - resetMockAccountNameFetcher(); - try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcher); - - var loaded = try registry.loadRegistry(gpa, codex_home); - defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "Primary Workspace")); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); - try std.testing.expect(loaded.accounts.items[2].account_name == null); -} - test "Scenario: Given single-file import with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -1464,34 +1292,4 @@ test "Scenario: Given no remaining accounts when reconciling after remove then a const active_auth_path = try registry.activeAuthPath(gpa, codex_home); defer gpa.free(active_auth_path); try std.testing.expectError(error.FileNotFound, fs.cwd().openFile(active_auth_path, .{})); -} - -test "Scenario: Given newer registry schema when loading help config then default help settings are used" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - try tmp.dir.writeFile(.{ - .sub_path = "accounts/registry.json", - .data = - \\{ - \\ "schema_version": 999, - \\ "auto_switch": { - \\ "enabled": true, - \\ "threshold_5h_percent": 1, - \\ "threshold_weekly_percent": 1 - \\ }, - \\ "api": { - \\ "usage": true - \\ }, - \\ "accounts": [] - \\} - , - }); - - const help_cfg = main_mod.loadHelpConfig(gpa, codex_home); - try std.testing.expectEqual(registry.defaultAutoSwitchConfig(), help_cfg.auto_switch); -} +} \ No newline at end of file diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index d34281fc..952f5322 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -2,7 +2,6 @@ const std = @import("std"); const codex_auth = @import("codex_auth"); const account_api = codex_auth.api.account; -const account_name_refresh = codex_auth.auth.account; const app_runtime = codex_auth.core.runtime; const cli = codex_auth.cli; const fixtures = @import("support/fixtures.zig"); @@ -23,7 +22,6 @@ const loadStoredSwitchSelectionDisplayWithRefreshError = main_mod.loadStoredSwit const mergeSwitchLiveRefreshIntoLatest = main_mod.mergeSwitchLiveRefreshIntoLatest; const nowMilliseconds = main_mod.nowMilliseconds; const removeLiveRuntimeApplySelection = main_mod.removeLiveRuntimeApplySelection; -const runBackgroundAccountNameRefreshWithLockAcquirer = main_mod.runBackgroundAccountNameRefreshWithLockAcquirer; const switch_live_default_refresh_interval_ms = main_mod.switch_live_default_refresh_interval_ms; const switchLiveRuntimeApplySelection = main_mod.switchLiveRuntimeApplySelection; const findAccountIndexByAccountKeyConst = main_mod.findAccountIndexByAccountKeyConst; @@ -31,44 +29,6 @@ const nowSeconds = main_mod.nowSeconds; const mapSwitchUsageOverridesToLatest = main_mod.mapSwitchUsageOverridesToLatest; const replaceOptionalOwnedString = main_mod.replaceOptionalOwnedString; -test "background account-name refresh returns early when another refresh holds the lock" { - const TestState = struct { - var fetch_count: usize = 0; - - fn lockUnavailable(_: std.mem.Allocator, _: []const u8) !?account_name_refresh.BackgroundRefreshLock { - return null; - } - - fn unexpectedFetcher( - allocator: std.mem.Allocator, - access_token: []const u8, - account_id: []const u8, - ) !account_api.FetchResult { - _ = allocator; - _ = access_token; - _ = account_id; - fetch_count += 1; - return error.TestUnexpectedFetch; - } - }; - - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "."); - defer gpa.free(codex_home); - - TestState.fetch_count = 0; - try runBackgroundAccountNameRefreshWithLockAcquirer( - gpa, - codex_home, - TestState.unexpectedFetcher, - TestState.lockUnavailable, - ); - try std.testing.expectEqual(@as(usize, 0), TestState.fetch_count); -} - test "handled cli errors include missing node" { try std.testing.expect(isHandledCliError(error.NodeJsRequired)); } @@ -82,7 +42,6 @@ fn saveLivePolicyTestRegistry( .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .live = live_config, .accounts = std.ArrayList(registry.AccountRecord).empty, @@ -241,7 +200,6 @@ test "live refresh merge preserves accounts newly added to the latest registry" .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -252,7 +210,6 @@ test "live refresh merge preserves accounts newly added to the latest registry" .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -264,7 +221,6 @@ test "live refresh merge preserves accounts newly added to the latest registry" .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -321,7 +277,6 @@ test "switch live action patches the current display after switching" { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -426,7 +381,6 @@ test "switch live action does not wait for an in-flight refresh" { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -504,7 +458,6 @@ test "remove live action patches the current display after deleting the active a .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -624,7 +577,6 @@ test "remove live action does not wait for an in-flight refresh" { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -733,7 +685,6 @@ test "live fallback display preserves the refresh error name" { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, }; @@ -774,7 +725,6 @@ test "buildStatusLine releases mutex on allocation failure" { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, };