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

@ -310,7 +310,7 @@ inventory unit, against a frozen commit range.)";
{"--models", true, "MODELS", models},
{"--scratch", true, "SCRATCH", ""},
{"--seats", true, "SEATS", "the seats the lenses run on: auto (every Anthropic seat in the env file, else local), local, api, oauth, openrouter (never in auto), or a list like api1,1,2,local"},
{"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 (default $SEATS_ENV_FILE, else the repository .env)"},
{"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 and the placement settings (REVIEW_GATES_SEAT_ORDER, the budgets, the concurrency): default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"},
{"--no-probe", false, "", "skip the one-request probe of every seat at the start"},
{"--api-budget", true, "API_BUDGET", "USD an API-credit seat may spend per cycle (default REVIEW_GATES_API_BUDGET_USD or 200)", Kind::Float},
{"--harness", true, "{claude,opencode}",
@ -477,7 +477,7 @@ inventory unit, against a frozen commit range.)";
// ---- seats: the report, the ledger and the board beside it ------------------
constexpr std::array<OptionSpec, 5> SeatsOptions = {{
{"--env-file", true, "ENV_FILE", "dotenv file holding the seat tokens (default: $SEATS_ENV_FILE, else ./.env when it exists)"},
{"--env-file", true, "ENV_FILE", "dotenv file holding the seat tokens and the placement settings: default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"},
{"--json", false, "", "the probe results as JSON instead of the report"},
{"--no-fable", false, "", "probe on Haiku instead of Fable (cheaper; the Fable weekly window stays unread)"},
{"--local", false, "", "also probe the CLI's own login, shown as the seat `local`"},
@ -541,18 +541,24 @@ inventory unit, against a frozen commit range.)";
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SeatsOptions), Prog, parsed.error);
const std::optional<std::string> timeoutText = parsed.Value("timeout");
const double timeout = timeoutText ? Dyn::ParseFloat(*timeoutText).value_or(Seats::ProbeTimeoutS) : Seats::ProbeTimeoutS;
// The seats file, read once: its placement settings, under the
// process environment, for the placement readers alone
// (SeatPool::SettingsOf; any other REVIEW_GATES_* line of it is
// named on stderr), its tokens over the process environment for
// the probes (SeatPool::WithFileTokens).
const SeatPool::SeatsFile seatsFile = SeatPool::ResolveSeatsFile(parsed.Value("env-file"), fs::current_path());
const std::optional<fs::path> envPath = seatsFile.path;
const std::map<std::string, std::string> process = Platform::Environment();
const Json cfg = Seats::ConfigFromEnv(process);
std::string envFile = parsed.Value("env-file").value_or("");
if (envFile.empty()) envFile = Platform::GetEnv("SEATS_ENV_FILE").value_or("");
std::error_code error;
if (envFile.empty() && fs::exists(".env", error)) envFile = ".env";
std::map<std::string, std::string> env = process;
if (!envFile.empty()) {
// The env file, read as text: one that cannot be read, or is not
// UTF-8, ends the command.
for (const auto& [name, value] : Seats::SeatTokensFromEnvFile(Dyn::ReadText(envFile))) env[name] = value;
const SeatPool::Settings fileSettings = SeatPool::SettingsOf(seatsFile);
const std::map<std::string, std::string>& settings = fileSettings.env;
if (!fileSettings.ignored.empty() && envPath) {
std::println(std::cerr, "seats file: {} in {} - no placement setting, read from the process environment alone", Dyn::Join(fileSettings.ignored, ", "), envPath->string());
}
const Json cfg = Seats::ConfigFromEnv(settings);
std::string envFile = envPath ? envPath->string() : std::string();
// The file's one read: one that could not be read, or is not UTF-8,
// ends the command with the read's error.
std::map<std::string, std::string> env = SeatPool::WithFileTokens(process, seatsFile);
std::vector<SeatJob> jobs;
for (const auto& [seat, token] : Seats::SeatTokensFromEnv(env)) jobs.push_back({seat, token, false});
if (parsed.Flag("local")) jobs.insert(jobs.begin(), SeatJob {0, "", true});
@ -589,7 +595,7 @@ inventory unit, against a frozen commit range.)";
seats.push_back(Seats::MergeFableProbe(found != standIns.end() ? found->second : s, s));
}
}
const Json apiConfig = SeatPool::ApiConfig(process);
const Json apiConfig = SeatPool::ApiConfig(settings);
std::vector<Json> apiSeats;
if (!apiJobs.empty()) {
std::vector<SeatJob> todo;

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(),

View file

@ -347,11 +347,97 @@ namespace Crafter::Review::SeatPool {
};
}
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, const std::optional<fs::path>& envFile, std::string_view specText, Ledger* ledger, const Json& apiConfig) {
std::map<std::string, std::string> merged = env;
if (envFile && !envFile->empty()) {
for (const auto& [key, value] : Seats::SeatTokensFromEnvFile(Dyn::ReadText(*envFile))) merged[key] = value;
namespace {
// The one read of a seats file: its text, or the read's error for
// Text() to raise where a command loads its tokens.
void ReadOnce(SeatsFile& out, const fs::path& file) {
out.path = file;
try {
out.text = Dyn::ReadText(file);
} catch (const Dyn::Error&) {
out.error = std::current_exception();
}
}
}
std::optional<fs::path> GlobalEnvFile() {
fs::path dir;
if (const std::optional<std::string> xdg = Platform::GetEnv("XDG_CONFIG_HOME"); xdg && !xdg->empty()) {
dir = fs::path(*xdg);
} else {
dir = fs::path(Platform::Home()) / ".config";
}
const fs::path file = dir / "crafter-review" / "env";
std::error_code error;
if (fs::is_regular_file(file, error)) return file;
return std::nullopt;
}
const std::string& SeatsFile::Text() const {
if (error) std::rethrow_exception(error);
return text;
}
namespace {
bool IsPlacementSetting(std::string_view name) {
return std::ranges::contains(PlacementSettings, name);
}
// A seat as the seat readers count one (a token with a value), or a
// placement setting with a value: a blank line is neither.
bool HoldsSeatOrSetting(const std::string& text) {
const std::map<std::string, std::string> tokens = Seats::SeatTokensFromEnvFile(text);
if (!Seats::SeatTokensFromEnv(tokens).empty() || !Seats::ApiKeysFromEnv(tokens).empty() || !Seats::OpenRouterKeysFromEnv(tokens).empty()) return true;
return std::ranges::any_of(Seats::SettingsFromEnvFile(text), [](const auto& entry) { return IsPlacementSetting(entry.first) && !Dyn::Strip(entry.second).empty(); });
}
}
SeatsFile ResolveSeatsFile(const std::optional<std::string>& given, const fs::path& cwd) {
SeatsFile out;
if (given) {
if (!given->empty()) ReadOnce(out, *given);
return out;
}
if (const std::optional<std::string> named = Platform::GetEnv("SEATS_ENV_FILE"); named && !named->empty()) {
ReadOnce(out, *named);
return out;
}
// The working directory's .env is the seats file when it is a file
// holding a seat or a placement setting, each with a value (a blank
// placeholder line is neither), or one that cannot be read as UTF-8
// text (its error is then the command's to report). A
// repository's application config (its Vite or Supabase lines), or a
// .env that is no file (a Python venv), is passed over for the
// machine's own file (2026-11-01, the self-review's rounds 9 and 10).
std::error_code error;
if (fs::exists(cwd / ".env", error)) {
if (fs::is_regular_file(cwd / ".env", error)) {
ReadOnce(out, cwd / ".env");
if (out.error || HoldsSeatOrSetting(out.text)) return out;
out = SeatsFile{};
}
out.passedOver = cwd / ".env";
}
if (const std::optional<fs::path> global = GlobalEnvFile()) ReadOnce(out, *global);
return out;
}
Settings SettingsOf(const SeatsFile& file) {
Settings out {Platform::Environment(), {}, {}};
for (const auto& [name, value] : Seats::SettingsFromEnvFile(file.text)) {
if (!IsPlacementSetting(name)) out.ignored.push_back(name);
else if (!Dyn::Strip(value).empty() && out.env.try_emplace(name, value).second) out.fromFile.push_back(name);
}
return out;
}
std::map<std::string, std::string> WithFileTokens(std::map<std::string, std::string> env, const SeatsFile& file) {
for (const auto& [key, value] : Seats::SeatTokensFromEnvFile(file.Text())) env[key] = value;
return env;
}
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) {
const std::map<std::string, std::string>& merged = env;
std::vector<std::pair<std::string, Json>> pool;
for (const auto& [n, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key));
for (const auto& [n, token] : Seats::SeatTokensFromEnv(merged)) pool.emplace_back(std::to_string(n), NewSeat(std::to_string(n), "oauth", token));

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");
}