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

@ -696,20 +696,32 @@ namespace Crafter::Review::Run {
.sampler = sampler.get(),
.broker = broker.get(),
});
const std::map<std::string, std::string> processEnv = Platform::Environment();
apiCfg = SeatPool::ApiConfig(processEnv);
if (a.apiBudget) apiCfg["budgetUsd"] = *a.apiBudget;
ledger = std::make_unique<SeatPool::Ledger>(SeatPool::Ledger::Load(SeatPool::LedgerPath(scratchRoot), apiCfg));
std::optional<std::string> envFile = a.envFile;
if (!envFile) {
if (const std::optional<std::string> configured = Platform::GetEnv("SEATS_ENV_FILE"); configured && !configured->empty()) {
envFile = configured;
} else if (fs::exists(repo / ".env")) {
envFile = (repo / ".env").string();
// The seats file (--env-file, else the lookup from the working
// directory), read once: its placement settings, under the process
// environment, go to the placement readers alone (SeatPool::SettingsOf:
// ApiConfig here, PoolConfig here and in the Scheduler) and its other
// REVIEW_GATES_* lines are named, its tokens over the process
// environment's into the seats below (SeatPool::WithFileTokens).
const SeatPool::SeatsFile seatsFile = SeatPool::ResolveSeatsFile(a.envFile, fs::current_path());
const SeatPool::Settings settings = SeatPool::SettingsOf(seatsFile);
settingsEnv = settings.env;
if (seatsFile.path) {
log(std::format("settings: {} from {} (under the process environment, for the placement settings)", settings.fromFile.empty() ? std::string("none") : Dyn::Join(settings.fromFile, ", "),
seatsFile.path->string()));
if (!settings.ignored.empty()) {
log(std::format("seats file: {} in {} - no placement setting, read from the process environment alone", Dyn::Join(settings.ignored, ", "), seatsFile.path->string()));
}
}
if (seatsFile.passedOver) log(std::format("seats file: {} is no seats file (no seat, no placement setting) - passed over", seatsFile.passedOver->string()));
const std::map<std::string, std::string> processEnv = Platform::Environment();
const std::optional<std::string> envFile = seatsFile.path ? std::optional<std::string>(seatsFile.path->string()) : std::nullopt;
apiCfg = SeatPool::ApiConfig(settingsEnv);
if (a.apiBudget) apiCfg["budgetUsd"] = *a.apiBudget;
ledger = std::make_unique<SeatPool::Ledger>(SeatPool::Ledger::Load(SeatPool::LedgerPath(scratchRoot), apiCfg));
try {
seats = SeatPool::LoadSeats(processEnv, envFile && !envFile->empty() ? std::optional<fs::path>(*envFile) : std::nullopt, a.seats.value_or(""), ledger.get(), apiCfg);
// The tokens from the file's one read, over the process
// environment's; a read that failed raises its error here.
seats = SeatPool::LoadSeats(SeatPool::WithFileTokens(processEnv, seatsFile), a.seats.value_or(""), ledger.get(), apiCfg);
} catch (const std::invalid_argument& e) {
Die(e.what());
} catch (const Dyn::Error& e) {
@ -731,7 +743,7 @@ namespace Crafter::Review::Run {
} catch (const std::runtime_error& e) {
Die(e.what());
}
const Json pc = SeatPool::PoolConfig(processEnv, a.effort);
const Json pc = SeatPool::PoolConfig(settingsEnv, a.effort);
board = std::make_unique<SeatPool::Board>(SeatPool::BoardPath(), std::string(), std::nullopt, log);
std::vector<std::string> runParts = {a.mode};
if (a.subsystem && !a.subsystem->empty()) runParts.push_back(*a.subsystem);
@ -762,7 +774,7 @@ namespace Crafter::Review::Run {
const double now = Platform::Now();
board->Publish(seats, now, !a.noProbe);
board->Fold(seats, now);
for (const std::string& line : board->OthersLines(seats, now, SeatPool::PoolConfig(processEnv, a.effort), apiCfg, a.concurrency)) log(line);
for (const std::string& line : board->OthersLines(seats, now, SeatPool::PoolConfig(settingsEnv, a.effort), apiCfg, a.concurrency)) log(line);
}
if (a.harness == "opencode") {
runner->opencodeHome = OpenCode::PrepareHome(scratch, repo, modelIds);
@ -1624,7 +1636,7 @@ namespace Crafter::Review::Run {
.cacheKey = warm ? std::function<std::optional<std::string>(Json&)>([this](Json& job) { return CacheKey(job); }) : nullptr,
.warmProbe = warm ? std::function<bool(const Json&)>([this](const Json& job) { return WarmProbe(job); }) : nullptr,
.seats = &seats,
.seatConfig = SeatPool::PoolConfig(Platform::Environment(), a.effort),
.seatConfig = SeatPool::PoolConfig(settingsEnv, a.effort),
.apiConfig = apiCfg,
.ledger = ledger.get(),
.admission = admission.get(),