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

@ -308,39 +308,58 @@ namespace Crafter::Review::Seats {
return out;
}
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text) {
std::map<std::string, std::string> env;
std::vector<std::pair<std::string, std::string>> DotenvLines(std::string_view text) {
std::vector<std::pair<std::string, std::string>> out;
for (const std::string& raw : Dyn::Split(text, "\n")) {
// ^\s*(?:export\s+)?(NAME)\s*=\s*(.*?)\s*$
// ^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$
std::string_view line(raw);
std::size_t at = Dyn::SpaceRun(line, 0);
if (line.substr(at).starts_with("export")) {
const std::size_t gap = Dyn::SpaceRun(line, at + 6);
if (gap > 0) at += 6 + gap;
}
std::string name;
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 (!line.substr(at).starts_with(stem)) continue;
std::size_t end = at + stem.size();
const bool digit = end < line.size() && line[end] >= '1' && line[end] <= '6';
if (digit) ++end;
if (stem == "ANTHROPIC_API_KEY" && !digit) break;
name = std::string(line.substr(at, end - at));
at = end;
break;
}
if (name.empty()) continue;
at += Dyn::SpaceRun(line, at);
auto nameChar = [](char c, bool first) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || (!first && c >= '0' && c <= '9');
};
std::size_t end = at;
while (end < line.size() && nameChar(line[end], end == at)) ++end;
if (end == at) continue;
const std::string name(line.substr(at, end - at));
at = end + Dyn::SpaceRun(line, end);
if (at >= line.size() || line[at] != '=') continue;
++at;
at += Dyn::SpaceRun(line, at);
std::string value = Dyn::RStrip(line.substr(at));
if (value.size() >= 2 && (value.front() == '"' || value.front() == '\'') && value.back() == value.front()) value = value.substr(1, value.size() - 2);
env[name] = value;
out.emplace_back(name, std::move(value));
}
return out;
}
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text) {
std::map<std::string, std::string> env;
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;
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;
env[name] = value;
break;
}
}
return env;
}
std::map<std::string, std::string> SettingsFromEnvFile(std::string_view text) {
std::map<std::string, std::string> out;
for (const auto& [name, value] : DotenvLines(text)) {
if (name.starts_with("REVIEW_GATES_")) out[name] = value;
}
return out;
}
Json Pct(const Json& utilization) {
const std::optional<double> n = FloatOf(utilization);
if (!n || !std::isfinite(*n) || *n < 0) return 0;
@ -977,8 +996,8 @@ namespace Crafter::Review::Seats {
}
return Dyn::Join(std::vector<std::string>{
RenderTable(rows),
std::format("api 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")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))),
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");
}