Crafter.Review/interfaces/Crafter.Review-Seats.cppm

157 lines
9.1 KiB
Text
Raw Permalink Normal View History

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Review:Seats;
import std;
import :Json;
// How much usage each Claude seat has left: the dotenv loader, the one-word
// probe, the window maths, the ranking and the report. The seat pool
// (:SeatPool) places on these; `crafter-review seats` prints the report (its
// glue, which needs the ledger and the board, is in :Driver).
//
// Seats are CLAUDE_CODE_OAUTH_TOKEN1..6 by fixed name (the legacy
// CLAUDE_CODE_OAUTH_TOKEN is seat 1 when TOKEN1 is empty), API-credit seats
// ANTHROPIC_API_KEY1..6, OpenRouter seats OPENROUTER_KEY[1..6]; never printed:
// the table and the JSON carry indices and percentages only. A probe result
// and a seat are JSON: the dump and --json show them.
export namespace Crafter::Review::Seats {
constexpr std::int32_t MaxSeats = 6;
constexpr std::string_view ProbeModel = "haiku";
// The Fable weekly limit is a window of its own, reported ONLY in the
// rate_limit_event of a Fable request (verified 2026-09-08). The CLI has
// named it seven_day_overage_included and seven_day_fable; either lands
// under the canonical key.
constexpr std::string_view FableProbeModel = "claude-fable-5-1";
constexpr std::string_view FableWindow = "seven_day_overage_included";
constexpr double ProbeTimeoutS = 30;
// Fixed rules, not knobs: utilisation is rounded to RankQuantum before
// ranking, and a seat whose 7-day window is at or above LongWindowCutoff is
// out regardless — its reset is days away. The Fable window is exempt here.
// 99 since 2026-10-15 (95 before): the weekly window is the budget the
// lenses run out of, and the pool projects its running passes against the
// cutoff (SeatPool::LensCost7dPct), so the last percent is spent a pass at
// a time, not in a burst.
constexpr std::int32_t RankQuantum = 5;
constexpr std::int32_t LongWindowCutoff = 99;
// JavaScript's Math.round: halves go up, never to even.
double JsRound(double x);
// A number the way a JS template prints it: 5 not 5.0, 62.5 as is.
std::string Fmt(const Json& value);
// {maxUtil, lensCostPct, waitMaxMin, policy, handoverPct}: the placement
// knobs; garbage leaves the default.
Json DefaultConfig();
Json ConfigFromEnv(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> SeatTokensFromEnv(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> ApiKeysFromEnv(const std::map<std::string, std::string>& env);
// {n: day}: ANTHROPIC_API_KEY<n>_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<std::int32_t, std::int32_t> ApiGrantDays(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(const std::map<std::string, std::string>& env);
feat(seats): the env file's settings count, and a machine's own seats file ApiConfig and PoolConfig read the seat order, the budgets and the concurrency from the process environment alone, so a `.env` that said REVIEW_GATES_SEAT_ORDER=oauth-first ordered nothing: the Python driver reads those lines from the file under the process environment (config_env, 2026-10-17: a setting lives beside the seats it orders). SeatPool::SettingsOf does the same - the file's placement settings under the process environment, in a map handed to the placement readers alone (ApiConfig, PoolConfig, Seats::ConfigFromEnv) - and leaves the process environment untouched, 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 placement settings are one list, SeatPool::PlacementSettings - the fifteen names those readers read - for the map, the run's log and what makes a working directory's .env a seats file; a REVIEW_GATES_* line of any other name stays out of the map and is named, by the run's log and on `seats`'s stderr. One dotenv grammar serves the tokens and the settings (Seats::DotenvLines). `run` and `seats` resolve the file from their own --env-file (SeatPool::ResolveSeatsFile): the --env-file, else $SEATS_ENV_FILE, else the working directory's .env - the operator's, as the Python driver and `seats` read it, not the repository under review's - when it is a file holding a seat or a placement setting, each with a value, or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application .env, its Vite or Supabase lines, one of blank placeholder lines or one that is no file is passed over, and the run says so - else the machine's own $XDG_CONFIG_HOME/crafter-review/env, else ~/.config/crafter-review/env: one file for every repository on a machine, a repository's seats .env winning over it. Each file is read once and the settings and the tokens are taken from that read (SeatsFile::Text), so a file that can be read only once - a pipe, `--env-file <(gpg -d seats.env.gpg)`, a FIFO - 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, for `run` and `seats` alike, and LoadSeats takes the merged environment and no longer reads a file by path. An empty `seats --env-file` means no file now, as it did for `run`, and the `seats` report names the side that runs first (it said "api seats run first" whatever the order). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point XDG_CONFIG_HOME, and HOST_XDG_CONFIG_HOME for the Flatpak, at a path of their own that nothing creates, so a global file stays out of them; the grammar, the settings and their precedence, the lookup and the one read have their tests, a run with `--env` (a prefix) and with a repeated --env-file, and a `seats` with `--env`, take tokens and settings from the one file, an application's .env and a venv are passed over, a FIFO is read once, no line of the file reaches `stop`, every listed placement setting moves a reader, another REVIEW_GATES_* line is named and a .env of blank lines passed over, and the README, the environment table and the procedure say the new rule.
2026-11-01 01:51:48 +01:00
// 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<std::pair<std::string, std::string>> 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.
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text);
feat(seats): the env file's settings count, and a machine's own seats file ApiConfig and PoolConfig read the seat order, the budgets and the concurrency from the process environment alone, so a `.env` that said REVIEW_GATES_SEAT_ORDER=oauth-first ordered nothing: the Python driver reads those lines from the file under the process environment (config_env, 2026-10-17: a setting lives beside the seats it orders). SeatPool::SettingsOf does the same - the file's placement settings under the process environment, in a map handed to the placement readers alone (ApiConfig, PoolConfig, Seats::ConfigFromEnv) - and leaves the process environment untouched, 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 placement settings are one list, SeatPool::PlacementSettings - the fifteen names those readers read - for the map, the run's log and what makes a working directory's .env a seats file; a REVIEW_GATES_* line of any other name stays out of the map and is named, by the run's log and on `seats`'s stderr. One dotenv grammar serves the tokens and the settings (Seats::DotenvLines). `run` and `seats` resolve the file from their own --env-file (SeatPool::ResolveSeatsFile): the --env-file, else $SEATS_ENV_FILE, else the working directory's .env - the operator's, as the Python driver and `seats` read it, not the repository under review's - when it is a file holding a seat or a placement setting, each with a value, or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application .env, its Vite or Supabase lines, one of blank placeholder lines or one that is no file is passed over, and the run says so - else the machine's own $XDG_CONFIG_HOME/crafter-review/env, else ~/.config/crafter-review/env: one file for every repository on a machine, a repository's seats .env winning over it. Each file is read once and the settings and the tokens are taken from that read (SeatsFile::Text), so a file that can be read only once - a pipe, `--env-file <(gpg -d seats.env.gpg)`, a FIFO - 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, for `run` and `seats` alike, and LoadSeats takes the merged environment and no longer reads a file by path. An empty `seats --env-file` means no file now, as it did for `run`, and the `seats` report names the side that runs first (it said "api seats run first" whatever the order). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point XDG_CONFIG_HOME, and HOST_XDG_CONFIG_HOME for the Flatpak, at a path of their own that nothing creates, so a global file stays out of them; the grammar, the settings and their precedence, the lookup and the one read have their tests, a run with `--env` (a prefix) and with a repeated --env-file, and a `seats` with `--env`, take tokens and settings from the one file, an application's .env and a venv are passed over, a FIFO is read once, no line of the file reaches `stop`, every listed placement setting moves a reader, another REVIEW_GATES_* line is named and a .env of blank lines passed over, and the README, the environment table and the procedure say the new rule.
2026-11-01 01:51:48 +01:00
// The REVIEW_GATES_* lines of a dotenv file (quotes and `export`
// stripped). SeatPool::SettingsOf lays the placement settings among them
// (SeatPool::PlacementSettings) under the process environment in the map
// the placement readers take, where no other reader sees them, and names
// the rest.
std::map<std::string, std::string> SettingsFromEnvFile(std::string_view text);
// The CLI's fraction as a percent, past 100 for a seat over its limit.
Json Pct(const Json& utilization);
// {status, rateLimitType, resetsAt, windows, overageReason}, or null.
Json ParseRateLimitInfo(const Json& raw);
// The last result line, the last rate_limit_event and the session id.
Json ParseStream(std::string_view text);
std::vector<std::string> ProbeArgs(std::string_view model = ProbeModel);
// The probe child's whole environment: one token under the name the CLI
// reads, an isolated config dir, thinking off, no nonessential traffic, no
// updater, and none of the other pool tokens.
std::map<std::string, std::string> ProbeEnv(std::string_view token, const std::string& configDir, const std::map<std::string, std::string>& base);
// The CLI's own login: no token or API key from the environment.
std::map<std::string, std::string> LocalEnv(const std::map<std::string, std::string>& base);
// The key's approval in the config dir's .claude.json, once.
std::filesystem::path WriteApiKeyApproval(const std::filesystem::path& configDir, std::string_view key);
std::map<std::string, std::string> ApiEnv(std::string_view key, const std::string& configDir, const std::map<std::string, std::string>& base);
bool LooksLikeCreditExhaustion(const Json& result, std::string_view text);
bool LooksLikeAuthFailure(const Json& result, std::string_view text);
// One line, at most 160 characters, the seat's own secrets and anything
// token-shaped redacted.
std::string ClipReason(std::string_view text, std::span<const std::string> secrets = {});
struct ProbeOptions {
std::string model = std::string(ProbeModel);
double timeoutS = ProbeTimeoutS;
std::string claudeBin;
bool local = false;
bool api = false;
};
// One probe of one seat: {seat, probedWith, windows, state: ok | limited |
// unknown | invalid | out, util, binding, resetsAt, error, costUsd}.
Json ProbeSeat(std::int32_t seat, const std::string& token, const ProbeOptions& options);
double Quantize(double util);
// The most-used window and its utilisation, raw.
std::pair<Json, std::string> BindingWindow(const Json& windows);
// Quantised for ranking.
std::pair<Json, std::string> SeatUtil(const Json& windows);
// The window a burst of passes moves: the 5-hour one, else the weekly one.
// Anthropic removes the 5-hour limit in stages (from 2026-10-31), and an
// account without it reports no 5-hour window. nullopt for a seat with no
// reading.
std::optional<std::string> BurstWindow(const Json& windows);
// A window's utilisation as a report cell: `no limit` in the 5-hour column
// of an account without that limit.
std::string WindowCell(const Json& windows, std::string_view window);
std::string ModelFamily(std::string_view model);
bool IsFableWindow(std::string_view key);
const Json* FableWindowOf(const Json& seat);
bool LongWindowsOk(const Json& seat);
bool IsEligible(const Json& seat, const Json& config);
bool FableOpen(const Json& seat, const Json& config);
Json PickNext(const std::vector<Json>& seats, const Json& config);
bool NeedsHaiku(const Json& seat);
std::string FableState(const Json& fable);
Json MergeFableProbe(Json base, const Json& fable);
// Where the CLI is: CLAUDE_BIN / REVIEW_GATES_CLAUDE_BIN, then PATH, then
// the editor extensions' bundled binary (newest first), then the
// standalone installs. The bare name when nothing is found.
std::string FindClaudeBin(const std::map<std::string, std::string>& env, const std::string& home);
std::string FindClaudeBin();
// The report's zone: $TZ, else /etc/localtime. A zone by name, as
// zoneinfo reads it (this libc++ has no tzdb, so the C library converts,
// with TZ set to the name); an empty name is the process's own zone.
struct Zone {
std::string name;
std::string label;
};
Zone LocalZone();
Zone ZoneNamed(std::string_view name);
// "02:40": the clock in the zone.
std::string Clock(double epoch, const Zone& zone);
// "02:40 (in 1h 50m)"; more than a day out with its date: "Wed 16 Sept,
// 11:00 (in 6d 10h)".
std::string FormatWhen(double epoch, double now, const Zone& zone);
std::string RenderTable(const std::vector<std::vector<std::string>>& rows);
std::string RenderStatusReport(const std::vector<Json>& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr,
const std::map<std::int32_t, std::string>& 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<Json>& apiSeats, const Json& apiConfig, double now, const Json& board = nullptr,
const std::map<std::int32_t, std::string>& keys = {});
std::string RenderDrivers(const Json& board, double now, const Zone& zone);
}