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.
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:51:48 +01:00
commit e215ad986e
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
16 changed files with 483 additions and 91 deletions

View file

@ -54,9 +54,9 @@ int main() {
const Json acfg = SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", "10"}, {"REVIEW_GATES_API_LENS_COST_USD", "1"}, {"REVIEW_GATES_API_CONCURRENCY", "2"}});
const Json cfg = Seats::ConfigFromEnv({});
SeatPool::Ledger ledger(td / "ledger.json", acfg);
std::vector<Json> seats = SeatPool::LoadSeats({}, envFile, "", &ledger, acfg);
std::vector<Json> seats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(envFile.string(), td)), "", &ledger, acfg);
Check(".env: api seats, oauth seats, never the bare ANTHROPIC_API_KEY, local only when nothing is configured",
Ids(seats) == "api1,1,2" && Ids(SeatPool::LoadSeats({}, std::nullopt, "")) == "local", Ids(seats));
Ids(seats) == "api1,1,2" && Ids(SeatPool::LoadSeats({}, "")) == "local", Ids(seats));
const std::string summary = DumpSpaced(SeatPool::Summary(seats));
Check("no secret in the summary or the table", !(summary + SeatPool::Table(seats)).contains("tok-one") && !summary.contains("sk-ant"));
Check("the 5-hour share of a pass follows the run's effort (0.125 at xhigh, 0.5 at max and any other); the env overrides every effort",
@ -208,6 +208,100 @@ int main() {
SeatPool::Describe(described) == "ok: 5h 61.5%, 7d 18%, fable 22%, fable 3% (binding five_hour)" && SeatPool::Describe(paid) == "out (budget), $12.35 spent this cycle, $3.00 left on the key",
std::format("{} | {}", SeatPool::Describe(described), SeatPool::Describe(paid)));
// The seats file (2026-11-01): one dotenv grammar for the tokens and the
// REVIEW_GATES_* settings; the settings under the process environment in
// the map the placement readers take (SettingsOf), the process
// environment itself untouched; the file resolved as --env-file, then
// $SEATS_ENV_FILE, <cwd>/.env, the machine's own under $XDG_CONFIG_HOME,
// each read once.
{
const std::vector<std::pair<std::string, std::string>> lines =
Seats::DotenvLines("export REVIEW_GATES_SEAT_ORDER='oauth-first'\n REVIEW_GATES_API_BUDGET_USD = \"50\"\nCLAUDE_CODE_OAUTH_TOKEN1=x\n# a comment\nnot a line\n=\nREVIEW_GATES=nope\n");
const std::map<std::string, std::string> settings = Seats::SettingsFromEnvFile("export REVIEW_GATES_SEAT_ORDER='oauth-first'\n REVIEW_GATES_API_BUDGET_USD = \"50\"\nCLAUDE_CODE_OAUTH_TOKEN1=x\nREVIEW_GATES=nope\n");
Check("the dotenv grammar: `[export ]NAME=value`, blanks around `=` and the value, one pair of matching quotes stripped, everything else skipped; the settings are the REVIEW_GATES_ names; the tokens as before",
lines == std::vector<std::pair<std::string, std::string>> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}}
&& settings == std::map<std::string, std::string> {{"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<std::string, std::string> {{"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");
fs::create_directories(envTd / "xdg" / "crafter-review");
std::ofstream(envTd / "settings.env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=oauth-first\nREVIEW_GATES_API_BUDGET_USD=50\nREVIEW_GATES_CLAUDE_BIN=./evil\nREVIEW_GATES_SEAT_POLICY=\n";
unsetenv("REVIEW_GATES_SEAT_ORDER");
unsetenv("REVIEW_GATES_CLAUDE_BIN");
unsetenv("REVIEW_GATES_SEAT_POLICY");
setenv("REVIEW_GATES_API_BUDGET_USD", "75", 1);
const SeatPool::Settings applied = SeatPool::SettingsOf(SeatPool::ResolveSeatsFile((envTd / "settings.env").string(), envTd / "repo"));
const Json api = SeatPool::ApiConfig(applied.env);
const SeatPool::SeatsFile missing = SeatPool::ResolveSeatsFile((envTd / "none.env").string(), envTd / "repo");
std::string raised;
try {
(void) missing.Text();
} catch (const Dyn::Error& error) {
raised = error.Kind();
}
Check("SettingsOf: the file's placement settings under the process environment - the process environment wins - in the map ApiConfig reads, a blank one no setting, its other REVIEW_GATES_* "
"lines named and never in the map, the process environment itself untouched (no line of the file reaches another reader); no file gives nothing; one it cannot read gives nothing and Text() raises the "
"read's own error",
applied.fromFile == std::vector<std::string> {"REVIEW_GATES_SEAT_ORDER"} && applied.ignored == std::vector<std::string> {"REVIEW_GATES_CLAUDE_BIN"}
&& !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",
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({});
const Json poolDefault = SeatPool::PoolConfig({}, "xhigh");
std::vector<std::string> idle;
for (const std::string_view name : SeatPool::PlacementSettings) {
const std::string value = name.ends_with("SEAT_ORDER") ? "oauth-first" : name.ends_with("SEAT_POLICY") ? "pack" : "3";
const std::map<std::string, std::string> one {{std::string(name), value}};
if (Dyn::Equal(SeatPool::ApiConfig(one), apiDefault) && Dyn::Equal(SeatPool::PoolConfig(one, "xhigh"), poolDefault)) idle.emplace_back(name);
}
Check("PlacementSettings: every name on the list moves ApiConfig or PoolConfig (which takes Seats::ConfigFromEnv)", idle.empty(), Dyn::Join(idle, ", "));
unsetenv("REVIEW_GATES_API_BUDGET_USD");
unsetenv("SEATS_ENV_FILE");
setenv("XDG_CONFIG_HOME", (envTd / "xdg").string().c_str(), 1);
const bool none = !SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path && !SeatPool::GlobalEnvFile();
std::ofstream(envTd / "xdg" / "crafter-review" / "env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=oauth-first\n";
const std::optional<fs::path> global = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
// An application's .env (no seat, no placement setting), one whose only
// REVIEW_GATES_* line is no placement setting, one of blank placeholder
// lines (an empty token or setting is none), and a .env that is no
// file (a Python venv) are passed over and recorded so; one with a
// placement setting alone is a seats file, and so is one that is not
// UTF-8 (its error is the command's).
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "VITE_API_URL=http://localhost:54321\nSUPABASE_ANON_KEY=anon\n";
const SeatPool::SeatsFile appFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=api-first\n";
const std::optional<fs::path> settingsOnly = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "REVIEW_GATES_SCRATCH=/elsewhere\n";
const SeatPool::SeatsFile otherSetting = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "CLAUDE_CODE_OAUTH_TOKEN1=\nANTHROPIC_API_KEY1= \nOPENROUTER_KEY=\nREVIEW_GATES_SEAT_ORDER=\n";
const SeatPool::SeatsFile blanks = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << std::string("\xff\n", 2);
const SeatPool::SeatsFile notTextFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
fs::remove(envTd / "repo" / ".env");
fs::create_directories(envTd / "repo" / ".env");
const SeatPool::SeatsFile venv = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
fs::remove_all(envTd / "repo" / ".env");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "CLAUDE_CODE_OAUTH_TOKEN1=x\n";
const SeatPool::SeatsFile localFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
setenv("SEATS_ENV_FILE", (envTd / "settings.env").string().c_str(), 1);
const std::optional<fs::path> named = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
const std::optional<fs::path> given = SeatPool::ResolveSeatsFile(std::string("explicit.env"), envTd / "repo").path;
const std::optional<fs::path> givenNone = SeatPool::ResolveSeatsFile(std::string(), envTd / "repo").path;
Check("the seats file resolves as --env-file (an empty one: none), else $SEATS_ENV_FILE, else <cwd>/.env when it is a file holding a seat or a placement setting with a value (an "
"application's .env, one whose only REVIEW_GATES_* line is no placement setting, one of blank lines, and a venv passed over and recorded so; one that is not UTF-8 taken, its read error kept), else $XDG_CONFIG_HOME/crafter-review/env, else none; the global file's settings reach SettingsOf",
none && global == envTd / "xdg" / "crafter-review" / "env" && appFile.path == global && appFile.passedOver == envTd / "repo" / ".env"
&& SeatPool::SettingsOf(appFile).fromFile == std::vector<std::string> {"REVIEW_GATES_SEAT_ORDER"} && settingsOnly == envTd / "repo" / ".env" && otherSetting.path == global && otherSetting.passedOver == envTd / "repo" / ".env" && blanks.path == global && blanks.passedOver == envTd / "repo" / ".env"
&& notTextFile.path == envTd / "repo" / ".env" && notTextFile.error && venv.path == global && venv.passedOver == envTd / "repo" / ".env" && !venv.error
&& localFile.path == envTd / "repo" / ".env" && !localFile.passedOver && named == envTd / "settings.env" && given == fs::path("explicit.env") && !givenNone);
unsetenv("SEATS_ENV_FILE");
unsetenv("XDG_CONFIG_HOME");
fs::remove_all(envTd);
}
fs::remove_all(td);
return Failures == 0 ? 0 : 1;
}