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

@ -192,7 +192,11 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY") || k.starts_with("FAKE_");
if (!review && !seat) Env[k] = v;
}
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"GIT_AUTHOR_NAME", "Me"}, {"GIT_AUTHOR_EMAIL", "me@example.com"},
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
// over it - a directory of this run's own, which nothing creates.
const std::string noConfig = (base / "no-config").string();
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"GIT_AUTHOR_NAME", "Me"}, {"GIT_AUTHOR_EMAIL", "me@example.com"},
{"GIT_COMMITTER_NAME", "Me"}, {"GIT_COMMITTER_EMAIL", "me@example.com"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
{"REVIEW_GATES_BOARD", (base / "board.json").string()}, {"FAKE_DIR", fakeDir.string()}});
@ -395,6 +399,38 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
Check("preflight: an env file that is not UTF-8 stops the run, exit 3, with the decoder's own words",
badEnv.returnCode == 3 && badEnv.err.contains("preflight: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte"), badEnv.err);
// One seats file for the settings and the tokens: the one --env-file
// names as the run's own parser reads it - a unique prefix, the last
// of a repeat - with the machine's own file as the decoy the usual
// lookup would find; the log names it with the settings put in.
const fs::path decoyXdg = base / "decoy-xdg";
Write(decoyXdg / "crafter-review" / "env", "REVIEW_GATES_API_BUDGET_USD=5\nCLAUDE_CODE_OAUTH_TOKEN1=tok-decoy\nANTHROPIC_API_KEY1=sk-ant-decoy\n");
Write(base / "other.env", "REVIEW_GATES_API_BUDGET_USD=7\nREVIEW_GATES_CLAUDE_BIN=/nowhere/claude\nCLAUDE_CODE_OAUTH_TOKEN2=tok-other\nANTHROPIC_API_KEY2=sk-ant-other\n");
const std::map<std::string, std::string> withDecoy = {{"XDG_CONFIG_HOME", decoyXdg.string()}, {"HOST_XDG_CONFIG_HOME", decoyXdg.string()}};
const std::string other = (base / "other.env").string();
const Process::Captured prefixed = run({"--env", other, "--seats", "auto", "--dry-run", "--scratch", (base / "env-prefix").string()}, withDecoy);
const Process::Captured repeated = run({"--env-file", (decoyXdg / "crafter-review" / "env").string(), "--env-file", other, "--seats", "auto", "--dry-run", "--scratch", (base / "env-repeat").string()}, withDecoy);
auto oneFile = [&](const Process::Captured& dry) {
return dry.returnCode == 2 && dry.out.contains(std::format("settings: REVIEW_GATES_API_BUDGET_USD from {} (under", other)) && dry.out.contains("api2 (api)") && dry.out.contains("2 (oauth)")
&& dry.out.contains(std::format("seats file: REVIEW_GATES_CLAUDE_BIN in {} - no placement setting, read from the process environment alone", other))
&& dry.out.contains(std::format("from {}; api-first, budget $7 per api seat per cycle", other)) && !dry.out.contains("api1") && !dry.out.contains("1 (oauth)");
};
Check("--env (a prefix) and the last of a repeated --env-file name the one file the settings are applied from and the tokens are loaded from, never the decoy",
oneFile(prefixed) && oneFile(repeated), std::format("{} {}\n{} {}", prefixed.returnCode, prefixed.out + prefixed.err, repeated.returnCode, repeated.out + repeated.err));
// A seats file that can be read only once - a FIFO, as `--env-file
// <(gpg -d seats.env.gpg)` gives one - is read once: the run's
// settings and its seats both come from that read (round 10).
const fs::path fifo = base / "seats.fifo";
std::vector<std::string> viaFifo = {
"sh", "-c",
std::format("mkfifo '{0}' && (printf 'REVIEW_GATES_API_BUDGET_USD=9\\nCLAUDE_CODE_OAUTH_TOKEN5=tok-five\\nANTHROPIC_API_KEY5=sk-ant-five\\n' > '{0}' &) && exec \"$@\"", fifo.string()), "sh"};
std::ranges::move(runArgs({"--env-file", fifo.string(), "--seats", "auto", "--dry-run", "--scratch", (base / "env-fifo").string()}), std::back_inserter(viaFifo));
const Process::Captured fromFifo = Process::Run(viaFifo, repo, std::chrono::seconds(60), {}, Env);
Check("a seats file that can be read only once (a FIFO) is read once: the run's settings and its seats both come from it",
fromFifo.returnCode == 2 && fromFifo.out.contains(std::format("settings: REVIEW_GATES_API_BUDGET_USD from {} (under", fifo.string())) && fromFifo.out.contains("api5 (api)")
&& fromFifo.out.contains("5 (oauth)") && fromFifo.out.contains(std::format("from {}; api-first, budget $9 per api seat per cycle", fifo.string())),
std::format("{} {}{}", fromFifo.returnCode, fromFifo.out, fromFifo.err));
// A filter that names nothing is refused: skipping its empty items
// must not leave no filter, which is a full run.
const Process::Captured noUnits = run({"--units", ","});