diff --git a/AGENTS.md b/AGENTS.md index a3fee23..82934b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Conventions: No file the drivers write holds these in practice, and a file that fails to parse is treated as a damaged one. - **HTTP.** `ClientHTTP1::Fetch` ignores `*_proxy` variables. The OpenRouter requests send `User-Agent: crafter-review`; `app_ready` sends `review-gates`. Reason phrases are the standard ones, not the server's. -- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took) (SeatPool). +- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took; the per-seat `ANTHROPIC_API_KEY_GRANT_DAY` cycle days are still unported: one `REVIEW_GATES_API_CYCLE_DAY` for every API seat) (SeatPool). - An account whose 5-hour limit Anthropic has removed reports the weekly window alone. Its burst window (`Seats::BurstWindow`) is the weekly one: it is ranked and admitted by the weekly window with the passes in flight projected on it (0.04 points per pass at xhigh, 0.125 otherwise), under the 99 % cutoff. - A pass's projected share of the windows follows the run's effort: 0.5 / 0.125 points of the 5-hour window and 0.125 / 0.04 of the weekly one at max / xhigh (`SeatPool::LensCostPct`, `LensCost7dPct`), the pooled means of 5438 passes on 141 seat-runs (2026-10-12..15) rounded up. The weekly cutoff is 99 %: the running passes are projected against it, so the last percent is spent a pass at a time. - Every OAuth account and the local login takes at most 128 passes at once, every driver's counted. @@ -119,7 +119,6 @@ Conventions: - A 'concurrents' rejection pauses the seat for 20 s or its retry-after, not until a window's reset. - Board entries and the folded `foreign` record carry `cost7d`, and so does the dump's seat summary. - The `seats` report and the log table say `no limit` in such an account's 5-hour column. -- **The ledger's cycles and credit-outs** (SeatPool, Seats, Drivers): an API seat's cycle starts on its grant day, `ANTHROPIC_API_KEY_GRANT_DAY` (`Seats::ApiGrantDays`; the seat token reader takes the line), else `REVIEW_GATES_API_CYCLE_DAY`, the day every OpenRouter key's starts on; `LoadSeats` puts an API seat's day in the seat and the ledger (`Ledger::SetDay`). A new cycle starts the spend over (`Ledger::Entry`) and carries a credit-out, with the time the key was last seen answering again. `Probe` re-probes a paid key the ledger holds out on credit (`ledgerOut` `credit`; one out on its budget it skips) - an API key with a request, an OpenRouter key through `ProbeOpenRouter` - and `seats` every API key; `Ledger::Reconcile` has the ledger follow an ok or credit-error reading in one locked update on the file's entry: an ok on a key out since before the probe started lifts the mark - an API key's is new credits (`Ledger::GrantEntry`: a cycle from the grant day when that is within the week, else from today), an OpenRouter key keeps its cycle's spend - a credit error on a key the file has as fine, with no lift seen since the probe started, is recorded, and anything else writes nothing. A mark is local time with its UTC offset (`2026-10-25T02:00:05+01:00`) and marks are ordered by instant, across the hour that repeats when DST ends and between drivers in different zones; a stamp without an offset, as older drivers wrote, is read as local time. `seats` stamps what it writes after its probe, not at the start its probes share. An ok reading over a credit error recorded since the probe started leaves the seat out, and a key that answers with its cycle's budget spent is out on that budget. `seats` shows the probe's reading beside the driver's verdict (`LedgerOut`, the one `LoadSeats` holds a seat out by), the grant day and the next credits (`NextGrant`). - **Board identity and liveness, process lifetime, stop:** see "Inside the Flatpak". - **Edge cases:** - Usage errors of `run`, `freeze`, `slots`, `config` and the other commands exit 3, the preflight's code, and so does an INVARIANT failure of `run` and `slots`. The helpers exit 64. @@ -181,7 +180,6 @@ What the binary reads at run time, and how. A variable the driver only sets or s |---|---|---|---|---| | `CLAUDE_CODE_OAUTH_TOKEN1`..`6` (+ legacy `CLAUDE_CODE_OAUTH_TOKEN`) | `:Seats` SeatTokensFromEnv, via `:SeatPool` LoadSeats (run) and `seats` | none | The fixed names 1..6 only; `TOKEN7` does nothing. Stripped; an empty value is no seat. The bare name is seat 1 when `TOKEN1` is empty. The env file's lines override the process environment. Stripped from every child's environment. | OAuth seats `1`..`6`; a token reaches claude only as its seat's `CLAUDE_CONFIG_DIR/.credentials.json` | | `ANTHROPIC_API_KEY1`..`6` | `:Seats` ApiKeysFromEnv (run, seats) | none | The fixed names 1..6, stripped; empty is no seat. The bare `ANTHROPIC_API_KEY` is never a seat (the env-file parser wants the digit too) and is stripped from every child. | API-credit seats `api1`..`api6`, each key in its own process's `ANTHROPIC_API_KEY` only | -| `ANTHROPIC_API_KEY1_GRANT_DAY`..`6` | `:Seats` ApiGrantDays, via LoadSeats (run) and `seats` | `REVIEW_GATES_API_CYCLE_DAY` | Stripped decimal digits of any script, 1..31; anything else is ignored. The env file's lines override the process environment. | The day of the month seat n's ledger cycle starts on | | `OPENROUTER_KEY`, `OPENROUTER_KEY1`..`6` | `:Seats` OpenRouterKeysFromEnv, via LoadSeats (run; `seats` ignores them) | none | As the OAuth tokens; the bare name is `or1` when `KEY1` is empty. Never in `auto`: `--seats openrouter` or `orN` ids; mixed with other seats it is a preflight error (exit 3). | OpenRouter seats `or1`..`or6` | | `SEATS_ENV_FILE` | `:SeatPool` ResolveSeatsFile (run's Gate 0, `seats`) | `/.env` if it is a file holding a seat or a placement setting, each with a value (or no UTF-8 text: its error is the command's), else the machine's `$XDG_CONFIG_HOME/crafter-review/env` (`~/.config/crafter-review/env`) | `--env-file` wins (in run and `seats` even `--env-file ""`, meaning no file); empty counts as unset. Seat lines are taken (`[export ]NAME=value`, one pair of matching quotes stripped), overriding the process environment; the file's placement settings (`SeatPool::PlacementSettings`) go, under the process environment, into the map the placement readers take (`SeatPool::SettingsOf`, from the one read the tokens come from too), so every placement knob below may come from the file, the process environment winning; no other reader sees a line of it, and its other `REVIEW_GATES_*` lines are named (the run's log, `seats` on stderr). A file that cannot be read, or is not UTF-8: run stops at preflight (exit 3), `seats` exits 1 with the read error. | The dotenv file the seat secrets come from | | `XDG_CONFIG_HOME` | `:SeatPool` GlobalEnvFile | `~/.config` | The directory whose `crafter-review/env` is the machine's own seats file, the last place a run or `seats` looks (2026-11-01). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point it (and `HOST_XDG_CONFIG_HOME`) at a path nothing creates, so a global file on the machine stays out of them. | @@ -197,7 +195,7 @@ What the binary reads at run time, and how. A variable the driver only sets or s | `REVIEW_GATES_API_BUDGET_USD` | `:SeatPool` ApiConfig (run, seats) | `200.0` | MAX_UTIL's syntax, kept if >= 0 (`inf` means no cap); empty, garbage, negative, `nan` give the default. `--api-budget` overrides it in run. | USD one API key may spend per cycle; at it the key is out | | `REVIEW_GATES_API_LENS_COST_USD` | `:SeatPool` ApiConfig | `1.0` | As API_BUDGET_USD | USD reserved per running process: a pass fits while spent + (running+1) × this + other drivers' cost ≤ budget | | `REVIEW_GATES_API_CONCURRENCY` | `:SeatPool` ApiConfig | `8` | As API_BUDGET_USD, truncated toward zero; `0` keeps every pass off the API seats; `inf` exits 1 with an OverflowError. | Processes at once on one API seat, every driver's counted | -| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..31; a day past a month's end is its last day. | The grant day of an API seat without its own `_GRANT_DAY`, and the day the driver's spend on every OpenRouter key starts over | +| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..28. | The day of the month the ledger's spend resets | | `REVIEW_GATES_OPENROUTER_BUDGET_USD` | `:SeatPool` ApiConfig | `100.0` | As API_BUDGET_USD | USD per cycle per OpenRouter key | | `REVIEW_GATES_OPENROUTER_LENS_COST_USD` | `:SeatPool` ApiConfig | `0.1` | As API_BUDGET_USD | Reserve per process on an OpenRouter seat | | `REVIEW_GATES_OPENROUTER_CONCURRENCY` | `:SeatPool` ApiConfig | `16` | As API_CONCURRENCY | Processes at once on one OpenRouter seat | diff --git a/README.md b/README.md index 19a12bb..1669236 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The machine provides the rest: | | | |---|---| | **The Claude Code CLI** | Proprietary, not bundled. Found through `CLAUDE_BIN` or `REVIEW_GATES_CLAUDE_BIN`, the PATH, an editor extension's bundled binary, `~/.claude/local` or `~/.local/bin`. | -| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too; `ANTHROPIC_API_KEY_GRANT_DAY` sets the day an API seat's cycle starts. `crafter-review seats` probes them; every driver on the machine shares one seat board. | +| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too. `crafter-review seats` probes them; every driver on the machine shares one seat board. | | **Chromium** | For a lens with the browser: `crafter-review browser install`, once. | | **A local Supabase stack** | For a lens with the database (`supabase start`, or the repository's own bring-up). The `sql` helper speaks the Postgres protocol itself; every call is one transaction, rolled back. | | **gpg** | Only for signed commits: `freeze` reads the signatures through gnupg's keyboxd socket. | @@ -41,7 +41,7 @@ crafter-review config # edit .review-gates/config.json until it prints no `.review-gates/config.json` is committed. It says: -- what `freeze` checks: `authors` (null: not checked), `aiTrailers`, `requireSigned`, and the branch a range is measured against (`base`, `rangeFrom`); +- what `freeze` checks: `authors`, `aiTrailers`, `requireSigned`, and the branch a range is measured against (`base`, `rangeFrom`); - `modes.`: the kind of artifact, the lenses it runs and their effort. Every lens is off until a mode lists it; `run --mode` picks one; - `lenses.`: the environment a lens gets (`none`, `browser`, `supabase` or `browser+supabase`) and, if the repository has one, its `section` - a file of this repository's own appended to the lens's charter; - `functional`, `slots`, `supabase`: how the application is started and reached, one stack or several, and where its database answers; diff --git a/implementations/Crafter.Review-Config.cpp b/implementations/Crafter.Review-Config.cpp index a766e84..f2b539b 100644 --- a/implementations/Crafter.Review-Config.cpp +++ b/implementations/Crafter.Review-Config.cpp @@ -9,6 +9,7 @@ import :Core; import :Plan; import :Tools; import :PgWire; +import :Process; import :Platform; import :Prompt; import std; @@ -150,8 +151,8 @@ namespace Crafter::Review::Config { // when HEAD tracks nothing; base: always merge-base HEAD // ..HEAD, the whole pull request. {"rangeFrom", "upstream"}, - // Author and committer emails the freeze accepts; null: neither - // is checked. + // Author and committer emails the freeze accepts; null: the + // repository's `git config user.email`. {"authors", nullptr}, // forbid | allow: Assisted-by / Co-Authored-By trailers in the // range. @@ -265,8 +266,6 @@ namespace Crafter::Review::Config { return a.is_string() && a.get_ref().contains('@'); }))) { out.emplace_back("authors must be null or a list of email addresses"); - } else if (authors.is_array() && authors.empty()) { - out.emplace_back("authors is an empty list, which no commit passes - null leaves author and committer unchecked"); } for (const std::string_view key : {"allowDirty", "gate0"}) { if (!AllStrings(Dyn::At(config, key))) out.push_back(std::format("{} must be a list of strings", key)); @@ -454,12 +453,21 @@ namespace Crafter::Review::Config { return copy; } - std::optional> Authors(const Json& config) { + std::string GitUserEmail(const fs::path& repo) { + const Process::Captured run = Process::Run({"git", "-C", repo.string(), "config", "user.email"}); + return run.returnCode == 0 ? Dyn::Strip(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))) : std::string(); + } + + std::vector Authors(const Json& config, const fs::path& repo) { const Json& authors = Dyn::Get(config, "authors"); - if (!authors.is_array()) return std::nullopt; - std::vector out; - for (const Json& author : authors) out.push_back(Dyn::Str(author)); - return out; + if (Dyn::Truthy(authors)) { + std::vector out; + for (const Json& author : authors) out.push_back(Dyn::Str(author)); + return out; + } + const std::string email = GitUserEmail(repo); + if (email.empty()) return {}; + return {email}; } std::map LensSectionsOf(const Json& config, const fs::path& repo) { diff --git a/implementations/Crafter.Review-Driver.cpp b/implementations/Crafter.Review-Driver.cpp index d9de552..cddb026 100644 --- a/implementations/Crafter.Review-Driver.cpp +++ b/implementations/Crafter.Review-Driver.cpp @@ -534,8 +534,7 @@ inventory unit, against a frozen commit range.)"; constexpr std::string_view Prog = "crafter-review seats"; const Parsed parsed = ParseOptions(args, SeatsOptions); if (parsed.help) { - std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 and ANTHROPIC_API_KEY1..6 from the environment and/or the dotenv file. Never prints a token. An API key's " - "probe is recorded in the seat ledger: new credits, or the credit error.\n", + std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n", Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions)); return 0; } @@ -604,23 +603,9 @@ inventory unit, against a frozen commit range.)"; Seats::ProbeOptions api = haiku; api.api = true; apiSeats = ProbeAll(todo, api); - // The driver's ledger: what it spent on each key this cycle, - // from each seat's grant day. It follows the probe: a key that - // answers again after the credit error is new credits, the - // credit error on a key it has as fine is recorded. - std::map days; - for (const auto& [n, day] : Seats::ApiGrantDays(env)) days[std::format("api{}", n)] = day; - SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig, days); - for (Json& s : apiSeats) { - const std::string id = std::format("api{}", s.at("seat").get()); - s["grantDay"] = ledger.Day(id); - // `now`, taken before the probes started, is their start; - // what Reconcile writes is stamped when it is written. - s["event"] = ledger.Reconcile(id, s.at("state"), now); - s["ledger"] = ledger.Entry(id, now); - s["ledgerOut"] = SeatPool::LedgerOut(s.at("ledger"), apiConfig.at("budgetUsd").get()); - s["nextCredits"] = SeatPool::NextGrant(now, ledger.Day(id), Dyn::Get(s.at("ledger"), "outSince")); - } + // The driver's ledger: what it spent on each key this cycle. + SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig); + for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get())); } // The board: the drivers running now and what they hold on each // account - read, never written. @@ -646,7 +631,7 @@ inventory unit, against a frozen commit range.)"; for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get())); std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin); if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys)); - if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, now, board, apiKeys)); + if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys)); std::println("\n{}", Seats::RenderDrivers(board, now, zone)); // The pids above are no use from inside the sandbox: the way to // stop a run instead. diff --git a/implementations/Crafter.Review-Freeze.cpp b/implementations/Crafter.Review-Freeze.cpp index a1dc4b5..e111ddc 100644 --- a/implementations/Crafter.Review-Freeze.cpp +++ b/implementations/Crafter.Review-Freeze.cpp @@ -211,18 +211,20 @@ namespace Crafter::Review::Freeze { fail(std::format("{} is not the toplevel of a git checkout", repo)); return report; } - const std::optional>& authors = options.authors; + std::vector authors = options.authors; + if (authors.empty()) authors = Config::Authors({{"authors", nullptr}}, repo); + if (authors.empty()) { + fail("no author identity: set `authors` in .review-gates/config.json or git config user.email"); + return report; + } const Base base = ResolveBase(repo, options.base, options.configBase, options.rangeFrom); if (!base.failure.empty()) { fail(base.failure); return report; } const std::string head = Artifact::TreeHead(repo); - Json authorList; - if (authors) { - authorList = Json::array(); - for (const std::string& author : *authors) authorList.push_back(author); - } + Json authorList = Json::array(); + for (const std::string& author : authors) authorList.push_back(author); report["base"] = base.sha; report["baseFrom"] = base.how; report["upstream"] = base.upstream ? Json(*base.upstream) : Json(); @@ -259,12 +261,11 @@ namespace Crafter::Review::Freeze { commitList.push_back({{"sha", c.sha}, {"subject", c.subject}, {"author", c.author}, {"committer", c.committer}, {"sig", c.sig}}); } report["commits"] = commitList; - if (!authors) warn("no authors in .review-gates/config.json: author and committer not checked"); - const std::string authorText = authors ? Dyn::Join(*authors, ", ") : std::string(); + const std::string authorText = Dyn::Join(authors, ", "); for (const Commit& c : commits) { const std::string who = std::format("{} {}", Head12(c.sha), Dyn::Repr(Dyn::Slice(c.subject, 0, 50))); - if (authors && !std::ranges::contains(*authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText)); - if (authors && !std::ranges::contains(*authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText)); + if (!std::ranges::contains(authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText)); + if (!std::ranges::contains(authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText)); if (const std::optional trailer = AiTrailer(c.body)) { const bool forbid = options.aiTrailers == "forbid"; const std::string message = std::format("{}: AI attribution trailer {}{}", who, Dyn::Repr(Dyn::Strip(*trailer)), @@ -471,7 +472,7 @@ namespace Crafter::Review::Freeze { options.replace = arguments.replace; options.worktree = arguments.worktree; options.requireSigned = arguments.requireSigned || Dyn::Truthy(Dyn::Get(config, "requireSigned")); - options.authors = Config::Authors(config); + options.authors = Config::Authors(config, repo); options.aiTrailers = Dyn::Str(Dyn::Get(config, "aiTrailers")); options.allowDirty = allowDirty; const Json& configBase = Dyn::Get(config, "base"); diff --git a/implementations/Crafter.Review-Run.cpp b/implementations/Crafter.Review-Run.cpp index 62b28c5..410ce5d 100644 --- a/implementations/Crafter.Review-Run.cpp +++ b/implementations/Crafter.Review-Run.cpp @@ -767,7 +767,7 @@ namespace Crafter::Review::Run { std::ranges::sort(sorted); Die(std::format("OpenRouter seats need OpenRouter model ids (vendor/model): --models opus=...,fable=... (now {})", Dyn::Join(sorted, ", "))); } - if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log, {}, ledger.get()); + if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log); if (board) { // Our readings onto the board (a probed-ok seat clears a verdict // another driver left), theirs into our seats. @@ -793,12 +793,7 @@ namespace Crafter::Review::Run { for (const Json& s : seats) names.push_back(std::format("{} ({})", Dyn::Str(s.at("id")), Dyn::Str(s.at("kind")))); std::string line = std::format("seats: {} from {}", Dyn::Join(names, ", "), envFile && !envFile->empty() ? *envFile : std::string("this machine")); if (std::ranges::any_of(seats, [](const Json& s) { return s.at("kind") == "api"; })) { - std::vector days; - for (const Json& s : seats) { - if (s.at("kind") == "api") days.push_back(std::format("{} {}", Dyn::Str(s.at("id")), Dyn::Str(Dyn::Get(s, "grantDay")))); - } - line += std::format("; {}, budget ${:g} per api seat per cycle from its grant day ({}; ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), Dyn::Join(days, ", "), - ledger->Path().string()); + line += std::format("; {}, budget ${:g} per api seat per cycle (ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), ledger->Path().string()); } if (openrouter) { line += std::format("; budget ${:g} per OpenRouter seat per cycle, {} concurrent (ledger {})", Number(apiCfg.at("orBudgetUsd")), Dyn::Str(apiCfg.at("orConcurrency")), ledger->Path().string()); diff --git a/implementations/Crafter.Review-SeatPool.cpp b/implementations/Crafter.Review-SeatPool.cpp index 8c54da6..2f908f3 100644 --- a/implementations/Crafter.Review-SeatPool.cpp +++ b/implementations/Crafter.Review-SeatPool.cpp @@ -206,8 +206,7 @@ namespace Crafter::Review::SeatPool { {"budgetUsd", num("REVIEW_GATES_API_BUDGET_USD", 200.0)}, {"lensCostUsd", num("REVIEW_GATES_API_LENS_COST_USD", 1.0)}, {"concurrency", Int(num("REVIEW_GATES_API_CONCURRENCY", 8))}, - // A day past a month's end is its last day (CycleStart). - {"cycleDay", std::max(1, std::min(31, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))}, + {"cycleDay", std::max(1, std::min(28, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))}, {"order", EnvOf(env, "REVIEW_GATES_SEAT_ORDER") == "oauth-first" ? "oauth-first" : "api-first"}, // OpenRouter seats: a prepaid key, cheap models - a smaller reserve // per running process. @@ -234,153 +233,47 @@ namespace Crafter::Review::SeatPool { return root / "seat-ledger.json"; } - namespace { - std::chrono::year_month_day LocalDate(double now) { - using namespace std::chrono; - const std::string today = Platform::LocalTime("%Y-%m-%d", now); - return {year(std::stoi(today.substr(0, 4))), month(static_cast(std::stoi(today.substr(5, 2)))), day(static_cast(std::stoi(today.substr(8, 2))))}; - } - - std::chrono::year_month_day ParseIso(std::string_view iso) { - using namespace std::chrono; - const std::string text(iso); - return {year(std::stoi(text.substr(0, 4))), month(static_cast(std::stoi(text.substr(5, 2)))), day(static_cast(std::stoi(text.substr(8, 2))))}; - } - - std::string Iso(const std::chrono::year_month_day& d) { - return std::format("{:04d}-{:02d}-{:02d}", static_cast(d.year()), static_cast(d.month()), static_cast(d.day())); - } - - // A ledger stamp: local time with its UTC offset, - // 2026-10-25T02:00:05+01:00, so any driver orders it by instant across - // the hour that repeats when DST ends, or from another zone. - std::string Stamp(double at) { - std::string text = Platform::LocalTime("%Y-%m-%dT%H:%M:%S%z", at); - if (text.size() == 24) text.insert(22, ":"); - return text; - } - - // A stamp's instant, epoch seconds: by its offset, else (a stamp - // written before 1.1.0) as local time; none when it cannot be read. - std::optional Instant(std::string_view stamp) { - auto number = [&](std::size_t from, std::size_t width) -> std::optional { - if (from + width > stamp.size()) return std::nullopt; - std::int32_t value = 0; - for (std::size_t i = from; i < from + width; ++i) { - if (stamp[i] < '0' || stamp[i] > '9') return std::nullopt; - value = value * 10 + (stamp[i] - '0'); - } - return value; - }; - if (stamp.size() < 19 || stamp[4] != '-' || stamp[7] != '-' || stamp[10] != 'T' || stamp[13] != ':' || stamp[16] != ':') return std::nullopt; - const std::optional y = number(0, 4), mo = number(5, 2), d = number(8, 2), h = number(11, 2), mi = number(14, 2), s = number(17, 2); - if (!y || !mo || !d || !h || !mi || !s) return std::nullopt; - const double clock = *h * 3600.0 + *mi * 60.0 + *s; - if (stamp.size() == 25 && (stamp[19] == '+' || stamp[19] == '-') && stamp[22] == ':') { - const std::optional oh = number(20, 2), om = number(23, 2); - const std::chrono::year_month_day date {std::chrono::year(*y), std::chrono::month(static_cast(*mo)), std::chrono::day(static_cast(*d))}; - if (!oh || !om || !date.ok()) return std::nullopt; - const double offset = (stamp[19] == '-' ? -1.0 : 1.0) * (*oh * 3600.0 + *om * 60.0); - return static_cast(std::chrono::sys_days(date).time_since_epoch().count()) * 86400.0 + clock - offset; - } - if (stamp.size() != 19) return std::nullopt; - std::tm local {}; - local.tm_year = *y - 1900; - local.tm_mon = *mo - 1; - local.tm_mday = *d; - local.tm_hour = *h; - local.tm_min = *mi; - local.tm_sec = *s; - local.tm_isdst = -1; - const std::time_t seconds = std::mktime(&local); - if (seconds == static_cast(-1)) return std::nullopt; - return static_cast(seconds); - } - - // That day of the month, a day past its end its last day. - std::chrono::year_month_day OnDay(const std::chrono::year_month& ym, std::int32_t day) { - using namespace std::chrono; - const auto last = static_cast(static_cast(year_month_day_last(ym.year(), month_day_last(ym.month())).day())); - return {ym.year(), ym.month(), std::chrono::day(static_cast(std::clamp(day, 1, last)))}; - } - } - std::string CycleStart(double now, std::int32_t day) { using namespace std::chrono; - const year_month_day date = LocalDate(now); - const year_month ym(date.year(), date.month()); - const year_month_day thisMonth = OnDay(ym, day); - return Iso(date >= thisMonth ? thisMonth : OnDay(ym - months(1), day)); - } - - std::string NextGrant(double now, std::int32_t day, const Json& outSince) { - using namespace std::chrono; - const std::string cycle = CycleStart(now, day); - if (Dyn::Truthy(outSince) && Dyn::Slice(Dyn::Str(outSince), 0, 10) < cycle) return cycle; - const year_month_day start = ParseIso(cycle); - return Iso(OnDay(year_month(start.year(), start.month()) + months(1), day)); - } - - std::pair LedgerVerdict(const Json& entry, double budget) { - const Json& outSince = Dyn::Get(entry, "outSince"); - if (Dyn::Truthy(outSince)) return {true, std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(entry, "outReason"), "out"), Dyn::Slice(Dyn::Str(outSince), 0, 16))}; - const double spent = Num(Dyn::Get(entry, "spentUsd")); - if (spent >= budget) return {true, std::format("ledger: ${:.2f} spent this cycle, budget ${}", spent, G(budget))}; - return {false, std::string()}; - } - - Json LedgerOut(const Json& entry, double budget) { - if (!LedgerVerdict(entry, budget).first) return nullptr; - return Dyn::Truthy(Dyn::Get(entry, "outSince")) ? "credit" : "budget"; + const std::string today = Platform::LocalTime("%Y-%m-%d", now); + const year_month_day date(year(std::stoi(today.substr(0, 4))), month(static_cast(std::stoi(today.substr(5, 2)))), + std::chrono::day(static_cast(std::stoi(today.substr(8, 2))))); + auto iso = [](const year_month_day& d) { + return std::format("{:04d}-{:02d}-{:02d}", static_cast(d.year()), static_cast(d.month()), static_cast(d.day())); + }; + if (static_cast(static_cast(date.day())) >= day) { + return iso(year_month_day(date.year(), date.month(), std::chrono::day(static_cast(day)))); + } + const year_month_day previous(sys_days(year_month_day(date.year(), date.month(), std::chrono::day(1))) - days(1)); + const auto last = static_cast(static_cast(previous.day())); + return iso(year_month_day(previous.year(), previous.month(), std::chrono::day(static_cast(std::min(day, last))))); } // ---- the ledger ------------------------------------------------------------------- - Ledger::Ledger(fs::path path, Json apiConfig, Json data, std::map days) - : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()), days(std::move(days)) {} + Ledger::Ledger(fs::path path, Json apiConfig, Json data) : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()) {} - Ledger Ledger::Load(const fs::path& path, const Json& apiConfig, std::map days) { + Ledger Ledger::Load(const fs::path& path, const Json& apiConfig) { Json data; try { data = Dyn::LoadJsonFile(path); } catch (const std::exception&) { data = Json::object(); } - return Ledger(path, apiConfig, data.is_object() ? data : Json::object(), std::move(days)); - } - - std::int32_t Ledger::Day(const std::string& seatId) const { - const auto found = days.find(seatId); - if (found != days.end() && found->second > 0) return found->second; - return static_cast(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))); - } - - void Ledger::SetDay(const std::string& seatId, std::int32_t day) { - days[seatId] = day; + return Ledger(path, apiConfig, data.is_object() ? data : Json::object()); } Json& Ledger::Entry(const std::string& seatId, std::optional now) { - const std::string cycle = CycleStart(NowOr(now), Day(seatId)); + const std::string cycle = CycleStart(NowOr(now), static_cast(Int(AsDouble(Dyn::At(apiConfig, "cycleDay"))))); const Json& e = Dyn::Get(data, seatId); - if (!Dyn::Truthy(e)) { + // Only a newer cycle starts over: an entry of a later one (another + // driver's cycle day) is not wiped. + if (!Dyn::Truthy(e) || cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) { data[seatId] = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}}; - } else if (cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) { - // Only a newer cycle starts over: an entry of a later one (another - // driver's cycle day, or a grant seen early) is not wiped. A - // credit-out carries: the cycle's day is a configured date, the - // credit error was observed (2026-10-17: the calendar cycle held - // api3 out for the first week of its new credits; an OpenRouter - // key's turn puts nothing on it), and only a probe that sees the - // key answer again lifts it (Reconcile). When the key was last seen - // answering again carries too: Reconcile records no credit error - // read before it. - Json next = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", Dyn::Get(e, "outSince")}, {"outReason", Dyn::Get(e, "outReason")}}; - if (Dyn::Truthy(Dyn::Get(e, "grantSeen"))) next["grantSeen"] = Dyn::Get(e, "grantSeen"); - data[seatId] = std::move(next); } return data[seatId]; } - Json Ledger::Update(const std::string& seatId, const std::function& change, std::optional now) { + Json Ledger::Update(const std::string& seatId, const std::function& change, std::optional now) { // The file is the record: re-read under its lock, this process only // ever adds to it (2026-10-15: each driver saved the copy it had loaded // and the last writer won - api4 reached the credit error with $157.54 @@ -392,10 +285,10 @@ namespace Crafter::Review::SeatPool { for (auto it = file.begin(); it != file.end(); ++it) merged[it.key()] = it.value(); data = std::move(merged); Json& e = Entry(seatId, now); - const bool write = change(e); + change(e); entry = e; - if (write) file = data; - return write; + file = data; + return true; }, data, 0600, {}); return entry; } @@ -404,70 +297,16 @@ namespace Crafter::Review::SeatPool { return Update(seatId, [&](Json& e) { e["spentUsd"] = Dyn::Round(AsDouble(Dyn::At(e, "spentUsd")) + usd, 4); e["procs"] = Dyn::Add(Dyn::At(e, "procs"), Json(1)); - return true; }, now); } Json Ledger::MarkOut(const std::string& seatId, std::string_view reason, std::optional now) { return Update(seatId, [&](Json& e) { - e["outSince"] = Stamp(NowOr(now)); + e["outSince"] = Platform::LocalTime("%Y-%m-%dT%H:%M:%S", NowOr(now)); e["outReason"] = Dyn::Slice(reason, 0, 160); - return true; }, now); } - Json Ledger::GrantEntry(const std::string& seatId, double at) const { - using namespace std::chrono; - const std::string cycle = CycleStart(at, Day(seatId)); - const std::string weekAgo = Iso(year_month_day(sys_days(LocalDate(at)) - std::chrono::days(7))); - const std::string start = cycle >= weekAgo ? cycle : Iso(LocalDate(at)); - return {{"cycle", start}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}, {"grantSeen", Stamp(at)}}; - } - - std::string Ledger::Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional now) { - if (state != "ok" && state != "out") return {}; - const double at = NowOr(now); - // A mark made before the probe started, by instant to the second the - // stamps hold; one made since may postdate what the probe read, and - // one that cannot be read counts as made since. - const double started = std::floor(probedAt); - auto before = [&](const Json& stamp) { - const std::optional instant = Instant(Dyn::Str(stamp)); - return instant && *instant < started; - }; - const Json fresh = GrantEntry(seatId, at); - std::string event; - // Decided on the file's entry under the lock, not on this process's - // copy: another driver may have recorded the grant and spent on it, - // or the credit error, since this copy was read. - // An OpenRouter key (or) is prepaid: its lift is no grant, no new - // cycle - the mark goes, the cycle's spend stays. - const bool openRouter = seatId.starts_with("or"); - Update(seatId, [&](Json& e) { - const Json& outSince = Dyn::Get(e, "outSince"); - if (state == "ok") { - if (!Dyn::Truthy(outSince) || !before(outSince)) return false; - if (openRouter) { - e["outSince"] = nullptr; - e["outReason"] = nullptr; - e["grantSeen"] = Stamp(at); - event = "credit-back"; - } else { - e = fresh; - event = "new-grant"; - } - return true; - } - const Json& seen = Dyn::Get(e, "grantSeen"); - if (Dyn::Truthy(outSince) || (Dyn::Truthy(seen) && !before(seen))) return false; - e["outSince"] = Stamp(at); - e["outReason"] = openRouter ? "key limit reached" : "credit balance too low"; - event = "credit-gone"; - return true; - }, at); - return event; - } - void Ledger::Save() { try { Platform::WriteAtomic(path, DumpFile(data), 0600); @@ -480,10 +319,6 @@ namespace Crafter::Review::SeatPool { return data; } - const Json& Ledger::ApiConfig() const { - return apiConfig; - } - const fs::path& Ledger::Path() const { return path; } @@ -601,7 +436,7 @@ namespace Crafter::Review::SeatPool { return env; } - std::vector LoadSeats(const std::map& env, std::string_view specText, Ledger* ledger, const Json& apiConfig, std::optional now) { + std::vector LoadSeats(const std::map& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) { const std::map& merged = env; std::vector> pool; for (const auto& [n, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key)); @@ -647,31 +482,18 @@ namespace Crafter::Review::SeatPool { if (std::ranges::any_of(seats, IsOpenRouter) && !std::ranges::all_of(seats, IsOpenRouter)) { throw std::invalid_argument("--seats: an OpenRouter seat cannot share a pool with Anthropic seats or the local login - a model name means a different model per endpoint"); } - // An API seat's cycle starts on its grant day (2026-10-17): the day - // its organization receives the plan's credits, the Console's - // Promotional-credits expiry minus three days. - const std::map grantDays = Seats::ApiGrantDays(env); - for (Json& s : seats) { - if (Kind(s) != "api") continue; - const auto found = grantDays.find(static_cast(Dyn::ParseInt(Dyn::Slice(Id(s), 3)).value_or(0))); - const std::int32_t day = found != grantDays.end() ? found->second : (Dyn::Truthy(apiConfig) ? static_cast(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))) : 1); - s["grantDay"] = day; - if (ledger) ledger->SetDay(Id(s), day); - } if (ledger) { - const double at = NowOr(now); + const double now = Platform::Now(); for (Json& s : seats) { if (!IsPaid(s)) continue; - const Json& e = ledger->Entry(Id(s), at); + const Json& e = ledger->Entry(Id(s), now); s["spentUsd"] = AsDouble(Dyn::At(e, "spentUsd")); - const double budget = Dyn::Truthy(apiConfig) ? Budget(s, apiConfig) : std::numeric_limits::infinity(); - const auto [out, why] = LedgerVerdict(e, budget); - if (out) { + if (Dyn::Truthy(Dyn::Get(e, "outSince"))) { s["state"] = "out"; - s["error"] = why; - // Why the ledger holds the seat out: a credit-out is - // probed again, a spent budget is not. - s["ledgerOut"] = LedgerOut(e, budget); + s["error"] = std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(e, "outReason"), "out"), Dyn::Slice(Dyn::Str(e.at("outSince")), 0, 16)); + } else if (Dyn::Truthy(apiConfig) && AsDouble(s.at("spentUsd")) >= Budget(s, apiConfig)) { + s["state"] = "out"; + s["error"] = std::format("ledger: ${:.2f} spent this cycle, budget ${}", AsDouble(s.at("spentUsd")), G(Budget(s, apiConfig))); } } } @@ -719,62 +541,7 @@ namespace Crafter::Review::SeatPool { return std::format("{}: {}{}", Dyn::Str(s.at("state")), Dyn::Join(parts, ", "), Dyn::Truthy(binding) ? std::format(" (binding {})", Dyn::Str(binding)) : std::string()); } - namespace { - // A paid key after its probe (started at probedAt): the seat takes - // the spend the file holds, every driver's; a lift puts a held-out - // seat back (an API key's with its cycle restarted), a credit error - // recorded since the probe started stands over its answer, the credit - // error takes it out (an API key's with the ledger's record and the - // next credits' date in the reason), an unreadable probe leaves a - // held-out seat held out. - void AfterPaidProbe(Json& s, const Json& held, Ledger* ledger, double probedAt, double now, const Log& log) { - const std::string event = ledger ? ledger->Reconcile(Id(s), s.at("state"), probedAt, now) : std::string(); - const Json entry = ledger ? ledger->Entry(Id(s), now) : Json::object(); - if (ledger) s["spentUsd"] = Num(Dyn::Get(entry, "spentUsd")); - if (event == "new-grant") { - s["ledgerOut"] = nullptr; - s["error"] = nullptr; - log(std::format("seat {}: the key answers again - new credits; the ledger cycle restarts (from {})", Id(s), Dyn::Str(entry.at("cycle")))); - } else if (event == "credit-back") { - s["ledgerOut"] = nullptr; - s["error"] = nullptr; - log(std::format("seat {}: the key's limit has room again - back, its cycle's spend kept", Id(s))); - } else if (s.at("state") == "ok" && Dyn::Truthy(Dyn::Get(entry, "outSince"))) { - s["state"] = "out"; - s["ledgerOut"] = "credit"; - s["error"] = LedgerVerdict(entry, std::numeric_limits::infinity()).second; - } else if (s.at("state") == "ok") { - s["ledgerOut"] = nullptr; - } else if (s.at("state") == "out" && IsOpenRouter(s)) { - // The probe's error says why. - s["ledgerOut"] = "credit"; - } else if (s.at("state") == "out") { - const Json e = ledger ? ledger->Entry(Id(s), now) : Json::object(); - const Json& outSince = Dyn::Get(e, "outSince"); - const Json& day = Dyn::Get(s, "grantDay"); - s["ledgerOut"] = "credit"; - s["error"] = std::format("credit balance too low{}; next credits due {}", Dyn::Truthy(outSince) ? std::format(" since {}", Dyn::Slice(Dyn::Str(outSince), 0, 16)) : std::string(), - NextGrant(now, Dyn::Truthy(day) ? static_cast(Int(AsDouble(day))) : 1, outSince)); - } else if (s.at("state") == "unknown" && Dyn::Truthy(held)) { - s["state"] = "out"; - s["error"] = std::format("{} (probe unreadable: {})", Dyn::Str(held), Dyn::Str(Dyn::Get(s, "error"))); - } - // A key that answers with its cycle's budget spent is out on that - // budget, as LoadSeats holds such a seat out: an OpenRouter key's - // lift keeps its spend, and another driver may have spent on a - // grant it saw. Left ok, the seat would be neither placed nor out. - if (ledger && s.at("state") == "ok") { - if (const auto [out, why] = LedgerVerdict(entry, Budget(s, ledger->ApiConfig())); out) { - s["state"] = "out"; - s["ledgerOut"] = "budget"; - s["error"] = why; - } - } - } - } - - void Probe(std::vector& seats, const std::string& claudeBin, double timeout, const std::vector& models, const Log& log, const ProbeFunction& probe, Ledger* ledger, - std::optional now, const OpenRouterProbeFunction& probeKey) { + void Probe(std::vector& seats, const std::string& claudeBin, double timeout, const std::vector& models, const Log& log, const ProbeFunction& probe) { const ProbeFunction run = probe ? probe : ProbeFunction([](std::int32_t seat, const std::string& token, const std::string& model, double t, const std::string& bin, bool local, bool api) { return Seats::ProbeSeat(seat, token, {.model = model, .timeoutS = t, .claudeBin = bin, .local = local, .api = api}); }); @@ -788,7 +555,7 @@ namespace Crafter::Review::SeatPool { break; } } - if (!models.empty() && std::ranges::any_of(seats, IsOpenRouter)) { + if (std::ranges::any_of(seats, IsOpenRouter)) { const Json prices = FetchOpenRouterPrices(models, log); for (auto it = prices.begin(); it != prices.end(); ++it) { const Json& p = it.value(); @@ -802,33 +569,22 @@ namespace Crafter::Review::SeatPool { } const std::string lensModel = fable.value_or(std::string(Seats::ProbeModel)); for (Json& s : seats) { + if (s.at("state") == "out") continue; const std::string kind = Kind(s); - if (s.at("state") == "out" && !(IsPaid(s) && Dyn::Get(s, "ledgerOut") == "credit")) continue; const std::string id = Id(s); const std::string secret = Dyn::StrOr(Dyn::Get(s, "secret"), ""); if (kind == "openrouter") { - // The ledger's verdict before the probe. - const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json(); - const double probedAt = NowOr(now); - const Json r = probeKey ? probeKey(secret, timeout) : ProbeOpenRouter(secret, timeout); + const Json r = ProbeOpenRouter(secret, timeout); ApplyProbe(s, r); s["remainingUsd"] = Dyn::Get(r, "remaining"); - AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log); log(std::format("seat {} ({}): {}", id, kind, Describe(s))); continue; } const std::int32_t n = kind == "api" ? static_cast(Dyn::ParseInt(Dyn::Slice(id, 3)).value_or(0)) : (kind == "local" ? 0 : static_cast(Dyn::ParseInt(id).value_or(0))); Json r; if (kind == "api") { - // The ledger's verdict before the probe. - const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json(); - const double probedAt = NowOr(now); - ApplyProbe(s, run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true)); - AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log); - log(std::format("seat {} ({}): {}", id, kind, Describe(s))); - continue; - } - if (kind == "local") { + r = run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true); + } else if (kind == "local") { r = run(n, "", lensModel, timeout, claudeBin, true, false); } else { r = run(n, secret, lensModel, timeout, claudeBin, false, false); diff --git a/implementations/Crafter.Review-Seats.cpp b/implementations/Crafter.Review-Seats.cpp index da5c470..80a97cb 100644 --- a/implementations/Crafter.Review-Seats.cpp +++ b/implementations/Crafter.Review-Seats.cpp @@ -298,20 +298,6 @@ namespace Crafter::Review::Seats { return out; } - std::map ApiGrantDays(const std::map& env) { - std::map out; - for (std::int32_t i = 1; i <= MaxSeats; ++i) { - // Decimal digits of any script; past 31 the value stays out of - // range. - const std::u32string value = Dyn::Decode(Dyn::Strip(EnvOf(env, std::format("ANTHROPIC_API_KEY{}_GRANT_DAY", i)))); - if (value.empty() || !std::ranges::all_of(value, [](char32_t c) { return Dyn::DecimalValue(c) >= 0; })) continue; - std::int32_t day = 0; - for (const char32_t c : value) day = std::min(day * 10 + Dyn::DecimalValue(c), 32); - if (day >= 1 && day <= 31) out[i] = day; - } - return out; - } - std::vector> OpenRouterKeysFromEnv(const std::map& env) { std::vector> out; for (std::int32_t i = 1; i <= MaxSeats; ++i) { @@ -355,9 +341,7 @@ namespace Crafter::Review::Seats { for (const auto& [name, value] : DotenvLines(text)) { for (const std::string_view stem : {std::string_view("CLAUDE_CODE_OAUTH_TOKEN"), std::string_view("ANTHROPIC_API_KEY"), std::string_view("OPENROUTER_KEY")}) { if (!name.starts_with(stem)) continue; - std::string_view rest = std::string_view(name).substr(stem.size()); - // ANTHROPIC_API_KEY_GRANT_DAY: the seat's grant day. - if (stem == "ANTHROPIC_API_KEY" && rest.size() == 11 && rest.ends_with("_GRANT_DAY")) rest = rest.substr(0, 1); + const std::string_view rest = std::string_view(name).substr(stem.size()); const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6'; if (!rest.empty() && !digit) break; if (stem == "ANTHROPIC_API_KEY" && !digit) break; @@ -996,54 +980,26 @@ namespace Crafter::Review::Seats { return Dyn::Join(lines, "\n"); } - std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, double now, const Json& board, const std::map& keys) { - const std::string today = Platform::LocalTime("%Y-%m-%d", now); - // "17 Sept" - auto date = [](const std::string& iso) { - return std::format("{} {}", std::stoi(iso.substr(8, 2)), Months[static_cast(std::stoi(iso.substr(5, 2)) - 1)]); - }; - std::vector> rows = {{"seat", "probe", "driver", "driver spend this cycle", "procs", "cycle from", "grant day", "next credits", "in flight"}}; - std::vector notes; + std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, const Json& board, const std::map& keys) { + std::vector> rows = {{"seat", "state", "driver spend this cycle", "procs", "cycle from", "ledger: out", "in flight"}}; for (const Json& s : SortedBySeat(apiSeats)) { - const std::string id = std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))); const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict()); const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd")); - const std::string state = Dyn::Str(Dyn::At(s, "state")); - const std::string probe = state + (Dyn::Truthy(Dyn::Get(s, "error")) && state != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string()); - // The driver's verdict, SeatPool::LedgerOut's. - const Json& ledgerOut = Dyn::Get(s, "ledgerOut"); - const bool out = !ledgerOut.is_null(); - const std::string driver = !out ? std::string("ok") - : ledgerOut == "credit" ? std::format("out (credit since {})", Dyn::Replace(Dyn::Slice(Dyn::Str(Dyn::Get(ledger, "outSince")), 0, 16), "T", " ")) - : std::string("out (budget spent)"); - const std::string due = Dyn::StrOr(Dyn::Get(s, "nextCredits"), ""); - const std::string dueText = due.size() < 10 ? std::string("—") : due == today ? std::string("today") : due < today ? std::format("overdue, {}", date(due)) : date(due); + const std::string state = Dyn::Str(Dyn::At(s, "state")) + (Dyn::Truthy(Dyn::Get(s, "error")) && Dyn::At(s, "state") != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string()); + const Json& outSince = Dyn::Get(ledger, "outSince"); const std::int64_t n = Inflight(board, keys, s); - rows.push_back({id, probe, driver, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"), - Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::StrOr(Dyn::Get(s, "grantDay"), "—"), dueText, - n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—")}); - const Json& event = Dyn::Get(s, "event"); - if (event == "new-grant") { - notes.push_back(std::format("{}: the key answers again - new credits; the ledger cycle restarted (from {})", id, Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"))); - } else if (event == "credit-gone") { - notes.push_back(std::format("{}: the credit error, now recorded in the ledger", id)); - } else if (state == "ok" && ledgerOut == "credit") { - notes.push_back(std::format("{}: the key answered, but the credit error was recorded since the probe started - the ledger's verdict stands", id)); - } else if (state == "ok" && out) { - notes.push_back(std::format("{}: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", id)); - } else if (state != "ok" && state != "out" && out) { - notes.push_back(std::format("{}: probe {} - the ledger's verdict stands", id, state)); - } + rows.push_back({ + std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))), state, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"), + Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::Truthy(outSince) ? std::format("since {}", Dyn::Replace(Dyn::Slice(Dyn::Str(outSince), 0, 16), "T", " ")) : std::string("—"), + n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—"), + }); } - std::vector lines = {RenderTable(rows)}; - lines.insert(lines.end(), notes.begin(), notes.end()); - lines.push_back(std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · a cycle starts on the seat's grant day (ANTHROPIC_API_KEY_GRANT_DAY, else " - "REVIEW_GATES_API_CYCLE_DAY={}) · {} processes per seat · placed at +${} each", - Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), - Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd")))); - lines.emplace_back("the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent, and \"overdue\" means the grant day " - "is wrong"); - return Dyn::Join(lines, "\n"); + return Dyn::Join(std::vector{ + RenderTable(rows), + std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each", + Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))), + "the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent", + }, "\n"); } std::string RenderDrivers(const Json& board, double now, const Zone& zone) { diff --git a/interfaces/Crafter.Review-Config.cppm b/interfaces/Crafter.Review-Config.cppm index 461905a..d51bbe2 100644 --- a/interfaces/Crafter.Review-Config.cppm +++ b/interfaces/Crafter.Review-Config.cppm @@ -36,9 +36,10 @@ export namespace Crafter::Review::Config { // The config for a dump: the database URL with its password masked, no // `_` bookkeeping keys. Json Redacted(const Json& config); - // The emails the freeze accepts as author and committer: the configured - // list; none (null, or no `authors` key) - the freeze checks neither. - std::optional> Authors(const Json& config); + // The emails the freeze accepts: the configured list, else the + // repository's git identity. + std::vector Authors(const Json& config, const std::filesystem::path& repo); + std::string GitUserEmail(const std::filesystem::path& repo); // The lens files no mode lists (what runs nowhere: the slots and init). std::vector LensesOff(const Json& config); // {lens file: environment} for every lens of the table, as the config diff --git a/interfaces/Crafter.Review-Freeze.cppm b/interfaces/Crafter.Review-Freeze.cppm index f4f5faa..d95d1eb 100644 --- a/interfaces/Crafter.Review-Freeze.cppm +++ b/interfaces/Crafter.Review-Freeze.cppm @@ -56,8 +56,7 @@ export namespace Crafter::Review::Freeze { bool replace = false; bool worktree = false; bool requireSigned = false; - // The emails author and committer must be among; none: not checked. - std::optional> authors; + std::vector authors; std::string aiTrailers = "forbid"; std::vector allowDirty; std::optional configBase; diff --git a/interfaces/Crafter.Review-SeatPool.cppm b/interfaces/Crafter.Review-SeatPool.cppm index 7d06a16..243dab5 100644 --- a/interfaces/Crafter.Review-SeatPool.cppm +++ b/interfaces/Crafter.Review-SeatPool.cppm @@ -133,66 +133,28 @@ export namespace Crafter::Review::SeatPool { // The other drivers' load on a seat as the last board fold left it. Json Foreign(const Json& seat); std::filesystem::path LedgerPath(const std::optional& scratchRoot = std::nullopt); - // The most recent date with that day of the month, as YYYY-MM-DD; a day - // past a month's end is its last day. + // The most recent date with that day of the month, as YYYY-MM-DD. std::string CycleStart(double now, std::int32_t day); - // When a seat's next credits are due, as YYYY-MM-DD: the grant day after - // the current cycle's, or the current cycle's own when the seat went out - // on credit before it (outSince) and is still out - due today, or overdue: - // the grant day is wrong. - std::string NextGrant(double now, std::int32_t day, const Json& outSince = nullptr); - // The driver's verdict on a paid seat from its ledger entry alone: out - // with the reason, or ok with none. - std::pair LedgerVerdict(const Json& entry, double budget); - // Why that verdict holds a seat out: "credit" (the key answered the - // credit error), "budget", or null when it does not. - Json LedgerOut(const Json& entry, double budget); // What the driver spent on each API seat this cycle, and whether the key // answered the credit error; one file for every driver, each write a delta - // applied under its lock. A seat's cycle starts on its grant day (days, - // else the config's cycleDay). + // applied under its lock. class Ledger { public: - Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object(), std::map days = {}); - static Ledger Load(const std::filesystem::path& path, const Json& apiConfig, std::map days = {}); - std::int32_t Day(const std::string& seatId) const; - void SetDay(const std::string& seatId, std::int32_t day); - // The seat's entry of the current cycle. A new cycle starts the spend - // over; a credit-out carries over it - only a probe that sees the key - // answer again lifts it (Reconcile) - with the time the key was last - // seen answering again. This process's copy: only an Update - // re-reads the file. + Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object()); + static Ledger Load(const std::filesystem::path& path, const Json& apiConfig); Json& Entry(const std::string& seatId, std::optional now = std::nullopt); Json Add(const std::string& seatId, double usd, std::optional now = std::nullopt); Json MarkOut(const std::string& seatId, std::string_view reason, std::optional now = std::nullopt); - // The probe's reading of an API key (probedAt: when the probe - // started), and the ledger follows it, decided under the lock on the - // entry as the file has it: "ok" on a key held out on credit since - // before the probe started lifts the mark - an API key's is new - // credits, GrantEntry's entry, "new-grant"; an OpenRouter key's keeps - // its cycle's spend, "credit-back" -; the credit error on a key the file - // has as fine, no grant seen since the probe started, is recorded, - // "credit-gone". Anything else writes nothing, "": a mark another - // driver made after the probe started may postdate its reading, and - // a grant another driver already recorded keeps its spend. - std::string Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional now = std::nullopt); void Save(); const Json& Data() const; const std::filesystem::path& Path() const; - // The config the ledger was loaded with (ApiConfig's). - const Json& ApiConfig() const; private: std::filesystem::path path; Json apiConfig; Json data; - std::map days; - // change returns whether to write the file. - Json Update(const std::string& seatId, const std::function& change, std::optional now); - // A grant's entry: the cycle from the seat's grant day when that is - // within the week, else from today; no spend, no out mark. - Json GrantEntry(const std::string& seatId, double at) const; + Json Update(const std::string& seatId, const std::function& change, std::optional now); }; Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret); @@ -201,23 +163,12 @@ export namespace Crafter::Review::SeatPool { // local), local | api | oauth | openrouter, // or a list such as api1,1,2,local. std::invalid_argument with a // ValueError message for a seat that is not configured, or OpenRouter - // mixed with the rest. An API seat gets its grantDay - // (ANTHROPIC_API_KEY_GRANT_DAY of env, else the cycleDay) into the - // seat and the ledger; a seat the ledger holds out is out, ledgerOut - // "credit" or "budget". - std::vector LoadSeats(const std::map& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr, - std::optional now = std::nullopt); + // mixed with the rest. + std::vector LoadSeats(const std::map& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr); using ProbeFunction = std::function; - // An OpenRouter key's probe, ProbeOpenRouter's shape. - using OpenRouterProbeFunction = std::function; - // One probe per seat before the run; a seat already out is skipped, - // except a paid key the ledger holds out on credit: probed again - // (Reconcile). A probed key that answers with its cycle's budget spent - // (an OpenRouter key's lift keeps its spend) is out on that budget. The - // OpenRouter price catalogue is fetched when there are models to price. + // One probe per seat before the run; a seat already out is skipped. void Probe(std::vector& seats, const std::string& claudeBin, double timeout, const std::vector& models, - const std::function& log, const ProbeFunction& probe = {}, Ledger* ledger = nullptr, std::optional now = std::nullopt, - const OpenRouterProbeFunction& probeKey = {}); + const std::function& log, const ProbeFunction& probe = {}); void ApplyProbe(Json& seat, const Json& result); std::string Describe(const Json& seat); diff --git a/interfaces/Crafter.Review-Seats.cppm b/interfaces/Crafter.Review-Seats.cppm index da98783..8f59529 100644 --- a/interfaces/Crafter.Review-Seats.cppm +++ b/interfaces/Crafter.Review-Seats.cppm @@ -46,18 +46,13 @@ export namespace Crafter::Review::Seats { Json ConfigFromEnv(const std::map& env); std::vector> SeatTokensFromEnv(const std::map& env); std::vector> ApiKeysFromEnv(const std::map& env); - // {n: day}: ANTHROPIC_API_KEY_GRANT_DAY, the day of the month seat n's - // organization receives its plan's credits (the Console's - // Promotional-credits expiry minus three days), 1..31; anything else is - // ignored. - std::map ApiGrantDays(const std::map& env); std::vector> OpenRouterKeysFromEnv(const std::map& env); // Every `[export ]NAME=value` line of a dotenv text, in order: blanks // around the `=` and the value dropped, one pair of matching quotes // stripped; anything else skipped. std::vector> DotenvLines(std::string_view text); // Only the seat lines of those: the OAuth tokens, the numbered API keys - // with their grant days and the OpenRouter keys; everything else ignored. + // and the OpenRouter keys; everything else ignored. std::map SeatTokensFromEnvFile(std::string_view text); // The REVIEW_GATES_* lines of a dotenv file (quotes and `export` // stripped). SeatPool::SettingsOf lays the placement settings among them @@ -147,11 +142,7 @@ export namespace Crafter::Review::Seats { std::string RenderTable(const std::vector>& rows); std::string RenderStatusReport(const std::vector& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr, const std::map& keys = {}); - // API-credit seats: the probe's verdict beside the driver's (its ledger - // against the budget), the cycle, the grant day and when the next - // credits are due - each seat's ledger, ledgerOut, grantDay, nextCredits - // and the probe's ledger event as the seats command set them. - std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, double now, const Json& board = nullptr, + std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, const Json& board = nullptr, const std::map& keys = {}); std::string RenderDrivers(const Json& board, double now, const Zone& zone); } diff --git a/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml b/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml index d423f8a..8e249d7 100644 --- a/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml +++ b/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml @@ -27,19 +27,6 @@ - - -

New

-
    -
  • Each API seat's credit cycle starts on its own grant day, ANTHROPIC_API_KEY<n>_GRANT_DAY in the seats file. A seat out of credit stays out across the rollover until a probe sees its key answer again, and seats shows each API seat's grant day, when its next credits are due and whether the driver holds it out.
  • -
  • Leaving authors out of the config turns the freeze's author and committer check off, where it used to check git config user.email. An empty list is a config error.
  • -
-

Fixes

-
    -
  • An OpenRouter key that ran out of credit is probed again at every run instead of waiting for the month's turn.
  • -
-
-

Fixes

diff --git a/process/prepush.md b/process/prepush.md index f571fad..5ac75bf 100644 --- a/process/prepush.md +++ b/process/prepush.md @@ -136,7 +136,7 @@ delta `@{upstream}..HEAD`, the rule for a repository that pushes to its own trunk; `base`: always the merge-base with `base`, the whole pull request for a repository whose branches become PRs — their commits must be linear, rebased, never a merge of the integration branch), `authors` (the emails the -freeze accepts; null = neither author nor committer is checked), `aiTrailers` (`forbid` or +freeze accepts; null = `git config user.email`), `aiTrailers` (`forbid` or `allow`), `requireSigned`, `allowDirty` (tracked files the local stack rewrites in place while the lenses run), `gate0` (the commands Gate 0 runs by hand, for the skill), `modes` (`modes.`: the @@ -209,9 +209,8 @@ default), `notes`. output where a commit message's "tested" claim can point at it (`--context-paths`). 2. **Freeze** — `freeze` checks, all of them, before it - exports anything: author AND committer are in `authors` (when it is - set); AI attribution trailers (`Assisted-by:`, `Co-Authored-By:`) by the - `aiTrailers` rule; the + exports anything: author AND committer are in `authors`; AI attribution + trailers (`Assisted-by:`, `Co-Authored-By:`) by the `aiTrailers` rule; the tracked tree is clean (the `allowDirty` paths aside, warned); no merge commit in the range (`git format-patch` drops them silently, so the artifact would not be the push); the range is a fast-forward of diff --git a/process/skill/prepush/SKILL.md b/process/skill/prepush/SKILL.md index 84d28b7..dd4a925 100644 --- a/process/skill/prepush/SKILL.md +++ b/process/skill/prepush/SKILL.md @@ -1,6 +1,6 @@ --- name: prepush -description: Pre-push gates for a repository set up for crafter-review (`crafter-review init`). Runs the pipeline's procedure end to end — Gate 0 build/tests + freeze (identity when `authors` is set, trailers by the config's rule, clean tree, no merges, fast-forward), the application and the database under test brought up from the frozen tree, the driver run of the lenses the config turns on - every lens is off until it does (repo-better-solution → repo-functional with the Playwright MCP → repo-bug-prover and repo-security side by side → repo-facts, the commit truth) via crafter-review, the fix/rerun loop, the owner's approval of the hash, verify, push. Nothing is pushed unreviewed or unapproved. +description: Pre-push gates for a repository set up for crafter-review (`crafter-review init`). Runs the pipeline's procedure end to end — Gate 0 build/tests + freeze (identity, trailers by the config's rule, clean tree, no merges, fast-forward), the application and the database under test brought up from the frozen tree, the driver run of the lenses the config turns on - every lens is off until it does (repo-better-solution → repo-functional with the Playwright MCP → repo-bug-prover and repo-security side by side → repo-facts, the commit truth) via crafter-review, the fix/rerun loop, the owner's approval of the hash, verify, push. Nothing is pushed unreviewed or unapproved. --- Follow the pipeline's procedure, `prepush.md`, EXACTLY — read it now, in diff --git a/tests/Commands/main.cpp b/tests/Commands/main.cpp index b3577a1..c416bb8 100644 --- a/tests/Commands/main.cpp +++ b/tests/Commands/main.cpp @@ -37,12 +37,6 @@ model= prev= for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done cat > /dev/null -# A Console key whose credit is gone, answering after two seconds. -if [ "$ANTHROPIC_API_KEY" = sk-ant-api-spent ]; then - sleep 2 - echo '{"type":"result","subtype":"success","is_error":true,"result":"Credit balance is too low","total_cost_usd":0}' - exit 1 -fi now=$(date +%s) fable= [ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}" @@ -346,7 +340,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total // --env (a prefix of --env-file) names the file for the tokens and // the settings alike, with ./.env in the way (the API seat's section // is where the order shows). - Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=16\nREVIEW_GATES_SEAT_ORDER=oauth-first\n"); + Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nREVIEW_GATES_SEAT_ORDER=oauth-first\n"); const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake); // An application's .env (no seat line, no setting) is passed over for // the machine's own seats file. @@ -371,8 +365,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board; an application's .env " "is passed over for the machine's own file; a FIFO is read once, its settings and its seats alike", report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ") - && report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | ok | $0.00 of $200") && report.out.contains("drivers: none running") - && other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | ok | $0.00 of $200") && other.out.contains("| 16 |") + && report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running") + && other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | $0.00 of $200") && other.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)") && !other.out.contains("tok-two") && !other.out.contains("sk-ant-api-two") && appSeats.returnCode == 0 && appSeats.out.starts_with(std::format("seat pool: 1 seat(s) (3) from {}, probed ", (base / "app-xdg" / "crafter-review" / "env").string())) && fromFifo.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ") @@ -383,29 +377,6 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total && parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z") && !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"), json.out.substr(0, 600) + json.err); - // The credit error a probe reads is recorded in the ledger stamped - // when it is written, not at the start the probes share (1.1.0 round - // 3): the spent key answers two seconds after the command began. - Write(base / "spent" / ".env", "ANTHROPIC_API_KEY3=sk-ant-api-spent\n"); - const double spentStart = Platform::Now(); - const Process::Captured spent = Self({"seats", "--json"}, base / "spent", withFake); - Json spentParsed; - try { - spentParsed = Json::parse(spent.out); - } catch (const std::exception&) { - } - // The child's TZ is UTC (Env): the stamp is compared in UTC. - const auto utc = [](double epoch) { - return std::format("{:%Y-%m-%dT%H:%M:%S}", std::chrono::floor(std::chrono::sys_seconds(std::chrono::seconds(static_cast(epoch))))); - }; - Json spentEntry; - if (spentParsed.is_object() && spentParsed.contains("apiSeats") && !spentParsed.at("apiSeats").empty()) { - spentEntry = Dyn::Get(Dyn::Get(spentParsed.at("apiSeats").at(0), "ledger"), "outSince"); - } - Check("seats: a credit error the probe read is recorded in the shared ledger stamped when written, after the probe, never at the probes' common start", - spent.returnCode == 0 && spentParsed.is_object() && spentParsed.at("apiSeats").at(0).at("event") == "credit-gone" && spentEntry.is_string() - && Dyn::Slice(spentEntry.get(), 0, 19) >= utc(spentStart + 2), - spent.out.substr(0, 800) + spent.err); const Process::Captured none = Self({"seats"}, base / "plain", withFake); const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake); Write(base / "plain" / "bad.env", std::string("\xff\n", 2)); diff --git a/tests/Config/main.cpp b/tests/Config/main.cpp index 6590592..853d815 100644 --- a/tests/Config/main.cpp +++ b/tests/Config/main.cpp @@ -233,11 +233,12 @@ int main() { && ex.at("modes").at("commits").at("lenses").size() == 5 && ex.at("modes").at("series").at("lenses").size() == 10 && ex.at("modes").at("reply").at("lenses").size() == 5 && ex.at("modes").at("commits").at("effort") == "xhigh" && ex.at("modes").at("series").at("effort") == "max" && ex.at("modes").at("reply").at("effort") == "max" && std::ranges::none_of(ex.at("lenses"), [](const Json& entry) { return entry.contains("run"); }), Show(Config::Validate(ex))); - Check("authors: the configured list; null or no key - none, the freeze checks neither author nor committer (2026-11-02, not the git identity)", - Config::Authors({{"authors", {"a@b.c"}}}) == std::vector{"a@b.c"} && !Config::Authors({{"authors", nullptr}}) && !Config::Authors(Json::object()) - && !Config::Authors(Config::Defaults())); - problems = bad({{"authors", Json::array()}}); - Check("an empty authors list is the problem that says null turns the check off", problems.size() == 1 && problems[0].contains("empty list") && problems[0].contains("null"), Show(problems)); + const fs::path repo = td / "r"; + fs::create_directories(repo); + Process::Run({"git", "-C", repo.string(), "init", "-q"}); + Process::Run({"git", "-C", repo.string(), "config", "user.email", "me@example.com"}); + Check("authors: the configured list, else git config user.email", Config::Authors({{"authors", {"a@b.c"}}}, repo) == std::vector{"a@b.c"} + && Config::Authors({{"authors", nullptr}}, repo) == std::vector{"me@example.com"}); std::ofstream(td / "u.json", std::ios::binary) << "{}"; Check("login_fixture: the first candidate that exists", Config::LoginFixture({{"functional", {{"login", {{"fixtures", {"nope.json", "u.json"}}}}}}}, td) == "u.json" && !Config::LoginFixture({{"functional", {{"login", {{"fixtures", Json::array()}}}}}}, td)); diff --git a/tests/Freeze/main.cpp b/tests/Freeze/main.cpp index 930b959..9bbe53e 100644 --- a/tests/Freeze/main.cpp +++ b/tests/Freeze/main.cpp @@ -79,18 +79,14 @@ int main() { if (options.date.empty()) options.date = "2026-10-31"; return Freeze::Run(repo, out, options); }; - Json r = freeze({.base = base, .authors = std::vector {me}}); + Json r = freeze({.base = base}); std::string failures = Joined(r.at("failures")); - Check("trailer (forbid) + foreign author both named, nothing exported", !r.at("ok").get() && failures.contains("Co-Authored-By") + Check("trailer (forbid) + foreign author both named, nothing exported; the author list came from git config", !r.at("ok").get() && failures.contains("Co-Authored-By") && failures.contains("catbot@bot.local") && !fs::exists(out) && r.at("authors") == Json::array({me}), failures); - r = freeze({.base = base, .authors = std::vector {me}, .aiTrailers = "allow"}); + r = freeze({.base = base, .aiTrailers = "allow"}); Check("trailer (allow): a warning, the foreign author still fails", !r.at("ok").get() && Joined(r.at("failures")).contains("catbot") && !Joined(r.at("failures")).contains("Co-Authored-By") && AnyContains(r.at("warnings"), "Co-Authored-By")); - r = freeze({.base = base}); - Check("no authors (null in the config): author and committer unchecked, said in a warning - only the trailer fails", !r.at("ok").get() - && Joined(r.at("failures")).contains("Co-Authored-By") && !Joined(r.at("failures")).contains("catbot") && r.at("authors").is_null() - && AnyContains(r.at("warnings"), "author and committer not checked"), Joined(r.at("failures"))); - r = freeze({.base = base, .authors = std::vector {me, "catbot@bot.local"}, .aiTrailers = "allow"}); + r = freeze({.base = base, .authors = {me, "catbot@bot.local"}, .aiTrailers = "allow"}); Check("both emails in authors + allow -> the range freezes (3 commits)", r.at("ok").get() && r.at("files").size() == 3, Joined(r.at("failures"))); g({"reset", "-q", "--hard", clean}); r = freeze({.base = base, .replace = true}); diff --git a/tests/SeatPool/main.cpp b/tests/SeatPool/main.cpp index 07e5c69..83f1d28 100644 --- a/tests/SeatPool/main.cpp +++ b/tests/SeatPool/main.cpp @@ -187,251 +187,6 @@ int main() { if (SeatPool::CycleStart(at, day) != want) missed.push_back(std::format("{} {} -> {}", at, day, SeatPool::CycleStart(at, day))); } Check("cycle start: this month's billing day once reached, else last month's, clamped to its length", missed.empty(), Dyn::Join(missed, "; ")); - // 2026-10-17 (the Python driver's, ported 2026-11-02): the cycle is the - // seat's grant day - the credits' own, from the Console's expiry minus - // three days - and a key that answered the credit error stays out across - // the rollover until a probe sees it answer again (then: new credits, a - // new cycle). The Python driver's vectors. - { - auto at = [](std::int32_t y, unsigned m, unsigned d) { - using namespace std::chrono; - return static_cast(duration_cast((sys_days(year_month_day(year(y), month(m), day(d))) + hours(12)).time_since_epoch()).count()); - }; - Check("cycle start: a day past the month's end is its last day", - SeatPool::CycleStart(at(2026, 4, 30), 31) == "2026-04-30" && SeatPool::CycleStart(at(2026, 3, 1), 31) == "2026-02-28" && SeatPool::CycleStart(at(2026, 3, 30), 28) == "2026-03-28"); - constexpr double t17 = at(2026, 10, 17); - Check("next credits: the grant day after the cycle's; the cycle's own when the seat went out on credit before it", - SeatPool::NextGrant(t17, 17) == "2026-11-17" && SeatPool::NextGrant(t17, 5) == "2026-11-05" && SeatPool::NextGrant(t17, 17, "2026-10-08T20:19:06") == "2026-10-17" - && SeatPool::NextGrant(t17, 5, "2026-10-13T22:46:02") == "2026-11-05" && SeatPool::NextGrant(at(2026, 12, 20), 31) == "2026-12-31"); - Check("ledger verdict: a credit-out, the budget, else ok", - SeatPool::LedgerVerdict({{"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}, {"spentUsd", 0}}, 200) - == std::pair(true, "ledger: credit balance too low since 2026-10-08T20:19") - && SeatPool::LedgerVerdict({{"spentUsd", 200.0}}, 200) == std::pair(true, "ledger: $200.00 spent this cycle, budget $200") - && SeatPool::LedgerVerdict({{"spentUsd", 1}}, 200) == std::pair(false, "")); - const std::string text2 = "ANTHROPIC_API_KEY3=sk-x\nANTHROPIC_API_KEY3_GRANT_DAY=16\nANTHROPIC_API_KEY4_GRANT_DAY = 5\nANTHROPIC_API_KEY1_GRANT_DAY=32\nANTHROPIC_API_KEY2_GRANT_DAY=x\nANTHROPIC_API_KEY_GRANT_DAY=9\n"; - Check("a grant day: decimal digits of any script, leading zeros of any length; anything else ignored", - Seats::ApiGrantDays({{"ANTHROPIC_API_KEY1_GRANT_DAY", "0000000016"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "\uFF11\uFF16"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "1_6"}, - {"ANTHROPIC_API_KEY4_GRANT_DAY", "99999999999999999999"}, {"ANTHROPIC_API_KEY5_GRANT_DAY", "\u00B2"}, {"ANTHROPIC_API_KEY6_GRANT_DAY", "0"}}) - == std::map {{1, 16}, {2, 16}}); - const std::map tokens2 = Seats::SeatTokensFromEnvFile(text2); - Check("the grant day per seat from the dotenv file, 1..31 only, never a key", - tokens2 == std::map {{"ANTHROPIC_API_KEY3", "sk-x"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "16"}, {"ANTHROPIC_API_KEY4_GRANT_DAY", "5"}, - {"ANTHROPIC_API_KEY1_GRANT_DAY", "32"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "x"}} - && Seats::ApiGrantDays(tokens2) == std::map {{3, 16}, {4, 5}} - && Seats::ApiKeysFromEnv(tokens2) == std::vector> {{3, "sk-x"}}); - SeatPool::Ledger old(td / "old.json", acfg, - {{"api1", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}, - {"api2", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", nullptr}, {"outReason", nullptr}}}, - {"or1", {{"cycle", "1999-01-01"}, {"spentUsd", 50}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}}); - Check("a new cycle resets the spend; a credit-out carries over until a probe lifts it, an OpenRouter key's too (the month's turn puts nothing on it)", - old.Entry("api1").at("spentUsd") == 0 && old.Entry("api1").at("procs") == 0 && old.Entry("api1").at("outSince") == "x" && old.Entry("api2").at("outSince").is_null() - && old.Entry("api2").at("spentUsd") == 0 && old.Entry("or1").at("outSince") == "x" && old.Entry("or1").at("outReason") == "y" && old.Entry("or1").at("spentUsd") == 0); - Check("ledger out: credit, budget, else null", - SeatPool::LedgerOut({{"outSince", "2026-10-08T20:19:06"}, {"spentUsd", 0}}, 200) == "credit" && SeatPool::LedgerOut({{"spentUsd", 200.0}}, 200) == "budget" - && SeatPool::LedgerOut({{"spentUsd", 1}}, 200).is_null()); - const fs::path daysEnv = td / "days.env"; - Write(daysEnv, "ANTHROPIC_API_KEY1=sk-ant-api-one\nANTHROPIC_API_KEY1_GRANT_DAY=17\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=0\n"); - SeatPool::Ledger lg(td / "days.json", acfg, - {{"api1", {{"cycle", "2026-10-01"}, {"spentUsd", 200.18}, {"procs", 112}, {"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}}}, - {"api2", {{"cycle", "2026-09-01"}, {"spentUsd", 150.0}, {"procs", 90}, {"outSince", nullptr}, {"outReason", nullptr}}}}); - std::vector ds = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api", &lg, acfg, t17); - Check("the grant day per seat from the seats file (0 is ignored: the global day), into the ledger", - Ids(ds) == "api1,api2" && ds[0].at("grantDay") == 17 && ds[1].at("grantDay") == 1 && lg.Day("api1") == 17 && lg.Day("api2") == 1, Ids(ds)); - Check("rollover on the grant day: the spend starts over, a credit-out carries - the seat stays out", - ds[0].at("state") == "out" && ds[0].at("ledgerOut") == "credit" && ds[0].at("spentUsd") == 0 && lg.Entry("api1", t17).at("cycle") == "2026-10-17" - && lg.Entry("api1", t17).at("procs") == 0 && ds[0].at("error").get().contains("since 2026-10-08"), - DumpSpaced(ds[0])); - Check("...a budget-out resets with its cycle", - ds[1].at("state") == "unknown" && Dyn::Get(ds[1], "ledgerOut").is_null() && ds[1].at("spentUsd") == 0 && lg.Entry("api2", t17).at("cycle") == "2026-10-01"); - std::vector calls; - std::map answers = {{"api1", "ok"}, {"api2", "out"}}; - const SeatPool::ProbeFunction fake = [&](std::int32_t n, const std::string&, const std::string& model, double, const std::string&, bool, bool) { - const std::string id = std::format("api{}", n); - calls.push_back(id); - const std::string state = answers.at(id); - return Json {{"seat", n}, {"state", state}, {"windows", Json::object()}, {"probedWith", model}, - {"error", state == "ok" ? Json() : Json(state == "out" ? "Your credit balance is too low" : "probe timeout after 1000 ms")}}; - }; - std::vector plog; - auto plogf = [&](const std::string& line) { plog.push_back(line); }; - SeatPool::Probe(ds, "claude", 1, {"claude-opus-5-5"}, plogf, fake, &lg, t17); - const Json e1 = lg.Entry("api1", t17); - Check("the run-start probe re-probes a credit-out api seat; a clean answer is new credits: the seat is back, its cycle restarted on its grant day", - calls == std::vector {"api1", "api2"} && ds[0].at("state") == "ok" && ds[0].at("ledgerOut").is_null() && ds[0].at("error").is_null() && e1.at("outSince").is_null() - && e1.at("cycle") == "2026-10-17" && e1.at("grantSeen") == "2026-10-17T12:00:00+00:00" - && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("new credits"); }), - Dyn::Join(plog, " | ")); - const Json e2 = lg.Entry("api2", t17); - Check("...the credit error on a seat the ledger had as fine is recorded, with the next credits in the reason", - ds[1].at("state") == "out" && ds[1].at("ledgerOut") == "credit" && Dyn::Truthy(e2.at("outSince")) - && e2.at("outSince") == "2026-10-17T12:00:00+00:00" && ds[1].at("error") == "credit balance too low since 2026-10-17T12:00; next credits due 2026-11-01", - Dyn::Str(ds[1].at("error"))); - Check("reconcile: nothing to do when the probe and the ledger agree", lg.Reconcile("api1", "ok", t17, t17).empty() && lg.Reconcile("api2", "out", t17, t17).empty()); - // Two drivers on one ledger (1.1.0 round 2): the decision is made on - // the file's entry under the lock, never on a copy read before the - // probe, and a mark made since the probe started stands. - const std::string outLine = R"("cycle": "2026-10-17", "spentUsd": 0, "procs": 0, "outSince": "2026-10-08T20:19:06", "outReason": "credit balance too low")"; - Write(td / "race1.json", std::format("{{\"api1\": {{{}}}}}", outLine)); - SeatPool::Ledger la = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}); - SeatPool::Ledger lb = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}); - const std::string firstGrant = la.Reconcile("api1", "ok", t17, t17); - la.Add("api1", 5.0, t17); - const std::string secondGrant = lb.Reconcile("api1", "ok", t17 + 60, t17 + 60); - const Json race1 = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60); - Check("two drivers: the second's copy, read before the first saw the grant, lifts nothing - the first's grant and its spend stand", - firstGrant == "new-grant" && secondGrant.empty() && race1.at("spentUsd") == 5.0 && race1.at("procs") == 1 && race1.at("outSince").is_null(), DumpSpaced(race1)); - Write(td / "race2.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 199.4, "procs": 180, "outSince": "2026-10-17T12:00:06", "outReason": "credit balance too low"}})"); - SeatPool::Ledger lc = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}); - const std::string late = lc.Reconcile("api1", "ok", t17, t17 + 30); - const Json race2 = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 30); - Check("a credit error recorded after the probe started outlives its answer: no grant, the spend kept", - late.empty() && race2.at("outSince") == "2026-10-17T12:00:06" && race2.at("spentUsd") == 199.4 && race2.at("procs") == 180, DumpSpaced(race2)); - Write(td / "race3.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-10-17T12:00:10"}})"); - SeatPool::Ledger ld = SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}); - const std::string stale = ld.Reconcile("api1", "out", t17, t17 + 30); - const std::string fresh = ld.Reconcile("api1", "out", t17 + 60, t17 + 60); - Check("...and a credit error read before a grant another driver saw since records nothing; one read after it does", - stale.empty() && fresh == "credit-gone" && SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60).at("spentUsd") == 2); - // The grant seen at 23:59:55, the cycle turning at midnight (grant - // day 17) before a credit error read at 23:59:40 is reconciled. - Write(td / "race6.json", R"({"api1": {"cycle": "2026-11-16", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-11-16T23:59:55"}})"); - SeatPool::Ledger le = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}); - const std::string acrossMidnight = le.Reconcile("api1", "out", at(2026, 11, 16) + 43180, at(2026, 11, 17) - 43198); - const Json race6 = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}).Entry("api1", at(2026, 11, 17) - 43198); - // The stamps carry their UTC offset and Reconcile orders marks by - // instant (1.1.0 round 4). A POSIX rule, not a zone file: Central - // European time, whose summer time ends 2026-10-25 03:00, 02:00-03:00 - // then passing twice. - setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1); - tzset(); - constexpr double fallBack = at(2026, 10, 25) - 39610; - Write(td / "race7.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 150, "procs": 120, "outSince": null, "outReason": null}})"); - SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", fallBack + 15); - const std::string afterFallBack = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", fallBack, fallBack + 16); - const Json race7 = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Entry("api1", fallBack + 16); - // A stamp without an offset, as older drivers wrote, is local time: - // 13:30 CEST is 11:30 UTC, before a probe started at 12:00 UTC (14:00 - // CEST) - new credits; read as UTC it would be after the probe. - Write(td / "race9.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 3, "procs": 1, "outSince": "2026-10-17T13:30:00", "outReason": "credit balance too low"}})"); - const std::string legacy = SeatPool::Ledger::Load(td / "race9.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30); - setenv("TZ", "UTC", 1); - tzset(); - Check("a credit error recorded after the probe started outlives its answer in the hour DST repeats: 02:00:05 CET is after 02:59:50 CEST", - afterFallBack.empty() && race7.at("outSince") == "2026-10-25T02:00:05+01:00" && race7.at("spentUsd") == 150, DumpSpaced(race7)); - Write(td / "race8.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": "2026-10-17T11:00:06-01:00", "outReason": "credit balance too low"}})"); - const std::string otherZone = SeatPool::Ledger::Load(td / "race8.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30); - Check("...and from a driver in another zone: its 11:00:06-01:00 is after a probe started at 12:00:00 UTC", otherZone.empty()); - Check("a stamp without an offset, as older drivers wrote, is read as local time: 13:30 CEST is before a probe started at 14:00 CEST", legacy == "new-grant", legacy); - Write(td / "race10.json", std::format("{{\"api1\": {{{}}}}}", outLine)); - const std::string race10Before = Platform::ReadFile(td / "race10.json").value_or(""); - const std::string unread = SeatPool::Ledger::Load(td / "race10.json", acfg, {{"api1", 17}}).Reconcile("api1", "unknown", t17, t17); - Check("a reading neither ok nor the credit error writes nothing", unread.empty() && Platform::ReadFile(td / "race10.json").value_or("") == race10Before); - // OpenRouter keys (1.1.0, Jorijn: a prepaid key has no cycle of its - // own): one the ledger holds out on credit is probed again through - // the key probe, and room on its limit lifts the mark with its - // cycle's spend kept; the limit reached is recorded; one out on its - // budget is not probed. Their cycle is the cycle day's. - Write(td / "or.env", "OPENROUTER_KEY1=or-one\nOPENROUTER_KEY2=or-two\nOPENROUTER_KEY3=or-three\n"); - Write(td / "or-ledger.json", R"({"or1": {"cycle": "2026-10-01", "spentUsd": 7.5, "procs": 3, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}, - "or2": {"cycle": "2026-10-01", "spentUsd": 1, "procs": 1, "outSince": null, "outReason": null}, - "or3": {"cycle": "2026-10-01", "spentUsd": 150, "procs": 90, "outSince": null, "outReason": null}})"); - SeatPool::Ledger orLedger = SeatPool::Ledger::Load(td / "or-ledger.json", acfg); - std::vector orSeats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or.env").string(), td)), "openrouter", &orLedger, acfg, t17); - std::vector keyCalls; - const SeatPool::OpenRouterProbeFunction fakeKey = [&](const std::string& secret, double) { - keyCalls.push_back(secret); - return secret == "or-one" ? Json {{"state", "ok"}, {"remaining", 3.0}} : Json {{"state", "out"}, {"error", "key limit reached ($10.00 of $10 used)"}, {"remaining", 0}}; - }; - plog.clear(); - SeatPool::Probe(orSeats, "claude", 1, {}, plogf, fake, &orLedger, t17, fakeKey); - SeatPool::Ledger orFile = SeatPool::Ledger::Load(td / "or-ledger.json", acfg); - const Json or1 = orFile.Entry("or1", t17); - const Json or2 = orFile.Entry("or2", t17); - Check("an OpenRouter key held out on credit is probed again, one out on its budget is not; room on its limit lifts the mark and keeps its cycle's spend", - Ids(orSeats) == "or1,or2,or3" && keyCalls == std::vector {"or-one", "or-two"} && orSeats[0].at("state") == "ok" && orSeats[0].at("ledgerOut").is_null() - && orSeats[0].at("spentUsd") == 7.5 && or1.at("outSince").is_null() && or1.at("spentUsd") == 7.5 && or1.at("procs") == 3 && or1.at("cycle") == "2026-10-01" - && or1.at("grantSeen") == "2026-10-17T12:00:00+00:00" && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("limit has room again"); }) - && orSeats[2].at("state") == "out" && orSeats[2].at("ledgerOut") == "budget", - std::format("{} {}", DumpSpaced(or1), Dyn::Join(keyCalls, ","))); - Check("...and the limit reached is recorded as the key's credit-out, the probe's error the seat's reason", - orSeats[1].at("state") == "out" && orSeats[1].at("ledgerOut") == "credit" && orSeats[1].at("error") == "key limit reached ($10.00 of $10 used)" - && or2.at("outSince") == "2026-10-17T12:00:00+00:00" && or2.at("outReason") == "key limit reached", - DumpSpaced(or2)); - // A key that answers with its cycle's budget spent is out on that - // budget, not left ok - neither placed nor out, a pool of it waiting - // forever (1.1.0 round 9): an OpenRouter key held out on credit with - // its spend over the budget, and an API key another driver lifted and - // spent the budget on since this copy was read. - Write(td / "or-spent.env", "OPENROUTER_KEY1=or-one\n"); - Write(td / "or-spent.json", - R"({"or1": {"cycle": "2026-10-01", "spentUsd": 100.4, "procs": 40, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}})"); - SeatPool::Ledger spentLedger = SeatPool::Ledger::Load(td / "or-spent.json", acfg); - std::vector spentSeats = - SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or-spent.env").string(), td)), "openrouter", &spentLedger, acfg, t17); - SeatPool::Probe(spentSeats, "claude", 1, {}, plogf, fake, &spentLedger, t17, fakeKey); - Check("an OpenRouter key held out on credit with its budget spent too: the mark lifted, the seat out on the budget, a pool of it all out", - spentSeats[0].at("state") == "out" && spentSeats[0].at("ledgerOut") == "budget" && SeatPool::AllOut(spentSeats) - && SeatPool::Ledger::Load(td / "or-spent.json", acfg).Entry("or1", t17).at("outSince").is_null(), - DumpSpaced(spentSeats[0])); - Write(td / "race11.json", std::format("{{\"api1\": {{{}}}}}", outLine)); - SeatPool::Ledger stale11(SeatPool::Ledger::Load(td / "race11.json", acfg)); - std::vector mine11 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale11, acfg, t17); - SeatPool::Ledger other11 = SeatPool::Ledger::Load(td / "race11.json", acfg, {{"api1", 17}}); - other11.Reconcile("api1", "ok", t17, t17); - other11.Add("api1", 12.0, t17); - answers["api1"] = "ok"; - SeatPool::Probe(mine11, "claude", 1, {}, plogf, fake, &stale11, t17 + 60); - Check("...and an API key another driver lifted and spent its budget on since: out on the budget", - mine11[0].at("state") == "out" && mine11[0].at("ledgerOut") == "budget" && mine11[0].at("spentUsd") == 12.0 && SeatPool::AllOut(mine11), DumpSpaced(mine11[0])); - Check("an OpenRouter key's cycle starts on the cycle day", orLedger.Day("or1") == 1 && SeatPool::Ledger::Load(td / "or-ledger.json", acfg).Entry("or1", at(2026, 11, 2)).at("cycle") == "2026-11-01"); - Check("...across the cycle's turn too: the grant's time carries over the rollover with an API seat's entry", - acrossMidnight.empty() && race6.at("outSince").is_null() && race6.at("grantSeen") == "2026-11-16T23:59:55" && race6.at("cycle") == "2026-11-17", DumpSpaced(race6)); - Write(td / "race4.json", std::format("{{\"api1\": {{{}}}}}", outLine)); - SeatPool::Ledger stale4(SeatPool::Ledger::Load(td / "race4.json", acfg)); - std::vector mine = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale4, acfg, t17); - SeatPool::Ledger other4 = SeatPool::Ledger::Load(td / "race4.json", acfg, {{"api1", 17}}); - other4.Reconcile("api1", "ok", t17, t17); - other4.Add("api1", 5.0, t17); - answers["api1"] = "ok"; - plog.clear(); - SeatPool::Probe(mine, "claude", 1, {}, plogf, fake, &stale4, t17 + 60); - Check("the run's probe after another driver's grant: the seat is back with that driver's spend, no second grant", - mine[0].at("state") == "ok" && mine[0].at("spentUsd") == 5.0 && mine[0].at("ledgerOut").is_null() - && std::ranges::none_of(plog, [](const std::string& line) { return line.contains("new credits"); }) - && SeatPool::Ledger::Load(td / "race4.json", acfg).Entry("api1", t17 + 60).at("spentUsd") == 5.0, - DumpSpaced(mine[0])); - Write(td / "race5.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null}})"); - SeatPool::Ledger mine5(SeatPool::Ledger::Load(td / "race5.json", acfg)); - std::vector seats5 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &mine5, acfg, t17); - SeatPool::Ledger::Load(td / "race5.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", t17 + 6); - SeatPool::Probe(seats5, "claude", 1, {}, plogf, fake, &mine5, t17); - Check("...and the run's probe that answered before another driver's credit error: the seat is out on the ledger's verdict", - seats5[0].at("state") == "out" && seats5[0].at("ledgerOut") == "credit" && seats5[0].at("error") == "ledger: credit balance too low since 2026-10-17T12:00", - DumpSpaced(seats5[0])); - calls.clear(); - answers["api1"] = "unknown"; - Json apiB = With(ds[1], {{"state", "out"}, {"ledgerOut", "budget"}, {"error", "ledger: $10.00 spent this cycle, budget $10"}}); - Json apiU = With(ds[0], {{"state", "out"}, {"ledgerOut", "credit"}, {"error", "ledger: credit balance too low since 2026-10-08T20:19"}}); - std::vector pair2 = {apiB, apiU}; - SeatPool::Probe(pair2, "claude", 1, {}, plogf, fake, nullptr, t17); - Check("a budget-out seat is not probed; a credit-out seat whose probe is unreadable stays out on the ledger's verdict", - calls == std::vector {"api1"} && pair2[0].at("state") == "out" && pair2[1].at("state") == "out" - && pair2[1].at("error") == "ledger: credit balance too low since 2026-10-08T20:19 (probe unreadable: probe timeout after 1000 ms)", - Dyn::Str(pair2[1].at("error"))); - // A grant's cycle, through the production path: the credit error - // recorded, then a probe that started after it answers. - lg.SetDay("api3", 5); - lg.MarkOut("api3", "credit balance too low", t17 - 60); - const std::string grant3 = lg.Reconcile("api3", "ok", t17, t17); - Check("new credits seen 12 days after the configured day: the cycle starts today, not on that day", - grant3 == "new-grant" && lg.Entry("api3", t17).at("cycle") == "2026-10-17" && lg.Entry("api3", t17).at("spentUsd") == 0); - lg.SetDay("api4", 16); - lg.MarkOut("api4", "credit balance too low", t17 - 60); - const std::string grant4 = lg.Reconcile("api4", "ok", t17, t17); - Check("...and on the day when it is within the week", grant4 == "new-grant" && lg.Entry("api4", t17).at("cycle") == "2026-10-16" - && lg.Entry("api4", at(2026, 11, 15)).at("cycle") == "2026-10-16" - && lg.Entry("api4", at(2026, 11, 16)).at("cycle") == "2026-11-16"); - } const std::string knobs = DumpSpaced(SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", " 1_0 "}, {"REVIEW_GATES_API_CONCURRENCY", "3.9"}, {"REVIEW_GATES_API_CYCLE_DAY", "40"}, @@ -439,7 +194,7 @@ int main() { {"REVIEW_GATES_OPENROUTER_BUDGET_USD", "-1"}, {"REVIEW_GATES_API_LENS_COST_USD", "nan"}})); Check("api config: float() parsing, int() truncation, the cycle day clamped, a negative or NaN value the default", - knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 31, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs); + knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 28, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs); const double cost = SeatPool::OpenRouterCost("openrouter/xiaomi/mimo-v2.6-pro", {{"input", 1234}, {"cacheCreation", 10}, {"cacheRead", 99999}, {"output", 777}}); Check("openrouter cost: tokens x the built-in list prices, an unknown model free", cost == 0.001577 && SeatPool::OpenRouterCost("nope", {{"input", 1}}) == 0.0, Dyn::FloatRepr(cost)); Json described = SeatPool::NewSeat("1", "oauth", "t"); @@ -467,7 +222,7 @@ int main() { lines == std::vector> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}} && settings == std::map {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}} && Seats::SeatTokensFromEnvFile("ANTHROPIC_API_KEY=bare\nANTHROPIC_API_KEY2=k\nANTHROPIC_API_KEY2_GRANT_DAY=17\nCLAUDE_CODE_OAUTH_TOKEN=t\nCLAUDE_CODE_OAUTH_TOKEN7=no\n") - == std::map {{"ANTHROPIC_API_KEY2", "k"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "17"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}}); + == std::map {{"ANTHROPIC_API_KEY2", "k"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}}); const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid()); fs::remove_all(envTd); fs::create_directories(envTd / "repo"); @@ -493,7 +248,7 @@ int main() { && !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0 && !Platform::GetEnv("REVIEW_GATES_SEAT_ORDER") && !Platform::GetEnv("REVIEW_GATES_CLAUDE_BIN") && Platform::GetEnv("REVIEW_GATES_API_BUDGET_USD") == "75" && SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError", - std::format("{} {}", Dyn::Str(api), raised)); + Dyn::Str(api) + " " + raised); // Every name on the list moves a placement reader (the list is the // readers' names, not a guess at them). const Json apiDefault = SeatPool::ApiConfig({}); diff --git a/tests/Seats/main.cpp b/tests/Seats/main.cpp index 6ed6a09..a49c68c 100644 --- a/tests/Seats/main.cpp +++ b/tests/Seats/main.cpp @@ -4,8 +4,6 @@ // Seat usage: the pure functions against reference vectors, numbered in // order, so "check 17" always names the same vector. -#include -#include import std; import Crafter.Review; namespace fs = std::filesystem; @@ -47,10 +45,6 @@ namespace { } int main() { - // The API report's dates (NextGrant, its "today") are the process zone's, - // as for dt.date.fromtimestamp: the vectors below are UTC's. - setenv("TZ", "UTC", 1); - tzset(); const Zone utc = ZoneNamed("UTC"); const Zone ams = ZoneNamed("Europe/Amsterdam"); // 2026-09-07 10:00 UTC @@ -238,44 +232,6 @@ int main() { Eq(ClipReason("token sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123 and jorijn@example.com x\n y", secret), "token [redacted:token] and [redacted:email] x y"); Eq(ClipReason("bad lit-secret here", secret), "bad [redacted:token] here"); Eq(LooksLikeAuthFailure({{"api_error_status", 401}}, ""), true); - // The API report (2026-10-17, ported 2026-11-02): the probe beside the - // driver's verdict, the cycle, the grant day and the next credits (today, - // a date, overdue) - the Python driver's vectors. - { - const Json acfg = SeatPool::ApiConfig({}); - auto api = [&](std::int32_t n, const std::string& state, const Json& error, std::int32_t day, const Json& ledger, const Json& event = nullptr) { - Json s = {{"seat", n}, {"state", state}, {"error", error}, {"grantDay", day}, {"ledger", ledger}, {"ledgerOut", SeatPool::LedgerOut(ledger, acfg.at("budgetUsd").get())}, - {"nextCredits", SeatPool::NextGrant(now, day, Dyn::Get(ledger, "outSince"))}}; - if (!event.is_null()) s["event"] = event; - return s; - }; - auto entry = [](const std::string& cycle, double spent, std::int32_t procs, const Json& outSince) { - return Json {{"cycle", cycle}, {"spentUsd", spent}, {"procs", procs}, {"outSince", outSince}, {"outReason", outSince.is_null() ? Json() : Json("credit balance too low")}}; - }; - const std::string report = RenderApiReport({api(1, "out", "Your credit balance is too low", 7, entry("2026-09-07", 0.0, 0, "2026-08-20T20:19:06")), - api(3, "ok", nullptr, 7, entry("2026-09-07", 0.0, 0, nullptr), "new-grant"), api(4, "ok", nullptr, 5, entry("2026-09-05", 200.0, 100, nullptr)), - api(2, "unknown", "probe timeout after 30000 ms", 17, entry("2026-08-17", 12.5, 9, "2026-09-01T10:00:00")), - api(5, "out", "Your credit balance is too low", 3, entry("2026-09-03", 0.0, 0, "2026-08-10T10:00:00"))}, - acfg, now); - std::string flat; - for (const char c : report) { - if (c != ' ' || !flat.ends_with(' ')) flat += c; - } - Eq(flat.contains("| seat | probe | driver | driver spend this cycle | procs | cycle from | grant day | next credits |"), true); - Eq(flat.contains("| api1 | out (Your credit balance is too low) | out (credit since 2026-08-20 20:19) | $0.00 of $200 | 0 | 2026-09-07 | 7 | today |"), true); - Eq(flat.contains("| api2 | unknown (probe timeout after 30000 ms) | out (credit since 2026-09-01 10:00) | $12.50 of $200 | 9 | 2026-08-17 | 17 | 17 Sept |"), true); - Eq(flat.contains("| api3 | ok | ok | $0.00 of $200 | 0 | 2026-09-07 | 7 | 7 Oct |"), true); - Eq(flat.contains("| api4 | ok | out (budget spent) | $200.00 of $200 | 100 | 2026-09-05 | 5 | 5 Oct |"), true); - Eq(flat.contains("| api5 | out (Your credit balance is too low) | out (credit since 2026-08-10 10:00) | $0.00 of $200 | 0 | 2026-09-03 | 3 | overdue, 3 Sept |"), true); - std::vector notes; - for (const std::string& line : Lines(flat)) { - if (line.starts_with("api") && notes.size() < 4) notes.push_back(line); - } - EqJson(Json(notes), Json::array({"api2: probe unknown - the ledger's verdict stands", "api3: the key answers again - new credits; the ledger cycle restarted (from 2026-09-07)", - "api4: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", - "api seats run first (REVIEW_GATES_SEAT_ORDER=api-first) · budget $200 per seat per cycle · a cycle starts on the seat's grant day " - "(ANTHROPIC_API_KEY_GRANT_DAY, else REVIEW_GATES_API_CYCLE_DAY=1) · 8 processes per seat · placed at +$1 each"})); - } Eq(LooksLikeAuthFailure(nullptr, "Invalid API key · Please run /login"), true); Eq(LooksLikeAuthFailure({{"result", "ok"}}, ""), false); // find_claude_bin: CLAUDE_BIN, then PATH, then the newest editor-bundled