test(run): the store rules, stacks and memory, and the review fixes, check by check
StoreRules (23 checks), StacksMemory (24) and ReviewFixes (19), each a test of its own. Run and SlotsPlan now have seams that start as the real ones: appReady, probe, query, bypassesRls, migrationsAtHead, migrationsDrift, treeDrift, promptFor, runPass, resumable, instrumentPreflight, and a Machine for SlotsPlan. MigrationsAtHead is split out of MigrationsDrift for it; the HEAD side is still worked out once per tree and base. The Postgres stand-in and the raw broker call move to tests/Common, shared by PgWire and ReviewFixes. ReviewFixes runs this binary as `sql`, and a real server that resets every connection makes the startup message either fail to go out or never get its answer - a DbError either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0f803d90bd
commit
dd0929b705
8 changed files with 1328 additions and 185 deletions
|
|
@ -215,7 +215,8 @@ namespace Crafter::Review::Run {
|
|||
}
|
||||
}
|
||||
|
||||
std::pair<std::int64_t, std::vector<std::string>> SlotsPlan(const Json& cfg, const fs::path& repo, const std::optional<fs::path>& artifact, const std::optional<fs::path>& scratchRoot, const Log& given) {
|
||||
std::pair<std::int64_t, std::vector<std::string>> SlotsPlan(const Json& cfg, const fs::path& repo, const std::optional<fs::path>& artifact, const std::optional<fs::path>& scratchRoot, const Log& given,
|
||||
const Machine& machine) {
|
||||
const Log log = given ? given : Log([](const std::string& line) { std::println("{}", line); });
|
||||
const Json& sl = cfg.at("slots");
|
||||
if (!Dyn::Truthy(Dyn::Get(sl, "dir"))) {
|
||||
|
|
@ -244,8 +245,8 @@ namespace Crafter::Review::Run {
|
|||
if (!best || Number(cost.megabytes) > Number(best->megabytes)) best = std::move(cost);
|
||||
}
|
||||
const double passMb = Number(best->megabytes);
|
||||
const std::optional<std::int64_t> avail = Memory::Available();
|
||||
const std::int64_t reserve = Memory::Reserve();
|
||||
const std::optional<std::int64_t> avail = machine.available ? machine.available() : Memory::Available();
|
||||
const std::int64_t reserve = machine.reserve ? machine.reserve() : Memory::Reserve();
|
||||
const auto upCount = static_cast<std::int64_t>(up.size());
|
||||
if (!avail) {
|
||||
log("slots: no /proc/meminfo - cannot size the slots by memory");
|
||||
|
|
@ -308,6 +309,19 @@ namespace Crafter::Review::Run {
|
|||
// ---- the Run ---------------------------------------------------------------------------
|
||||
Run::Run() : repo(fs::current_path()), t0(Platform::Now()) {
|
||||
log = [this](const std::string& message) { Message(message); };
|
||||
appReady = [](std::string_view url, std::string_view path) { return AppReady(url, path); };
|
||||
probe = [](std::string_view url) { return PgWire::Probe(url); };
|
||||
query = [](std::string_view url, std::string_view sql) { return PgWire::Query(url, sql); };
|
||||
bypassesRls = [](std::string_view url) { return PgWire::BypassesRls(url); };
|
||||
migrationsAtHead = [this] { return MigrationsAtHead(); };
|
||||
migrationsDrift = [this](const std::string& url) { return MigrationsDrift(url); };
|
||||
treeDrift = [this] { return TreeDrift(); };
|
||||
promptFor = [this](const Json& job) { return PromptFor(job); };
|
||||
runPass = [this](const Json& job, const std::string& prompt, const fs::path& systemPath, std::string_view schema, std::int64_t maxTurns, std::int64_t timeoutMin, bool persist) {
|
||||
return runner->Run(job, prompt, systemPath, schema, trees, maxTurns, timeoutMin, persist);
|
||||
};
|
||||
resumable = [this](const std::string& name, const std::string& lensFile, std::string_view title) { return Resumable(name, lensFile, title); };
|
||||
instrumentPreflight = [this](std::span<const std::string> lensFiles) { InstrumentPreflight(lensFiles); };
|
||||
}
|
||||
|
||||
Run::Run(Arguments given, Json config) : Run() {
|
||||
|
|
@ -368,7 +382,7 @@ namespace Crafter::Review::Run {
|
|||
for (const std::string& t : trees) {
|
||||
if (!Artifact::IsToplevel(t)) Die(std::format("--trees: {} is not the toplevel of a git checkout", t));
|
||||
}
|
||||
if (const std::optional<std::string> drift = TreeDrift()) Die(std::format("{} - re-freeze, or check out the frozen worktree (freeze --worktree)", *drift));
|
||||
if (const std::optional<std::string> drift = treeDrift()) Die(std::format("{} - re-freeze, or check out the frozen worktree (freeze --worktree)", *drift));
|
||||
gateDir = Dyn::JoinPath(a.artifact, "gate-state");
|
||||
fs::create_directories(gateDir);
|
||||
Inventory::Built built;
|
||||
|
|
@ -738,41 +752,45 @@ namespace Crafter::Review::Run {
|
|||
return Dyn::Split(base, "_", 1).front();
|
||||
}
|
||||
|
||||
MigrationsHead Run::MigrationsAtHead() {
|
||||
constexpr std::string_view M = "supabase/migrations/";
|
||||
auto git = [&](std::vector<std::string> args) {
|
||||
std::vector<std::string> argv = {"git", "-C", trees.front()};
|
||||
std::ranges::move(args, std::back_inserter(argv));
|
||||
return Process::Run(argv).out;
|
||||
};
|
||||
std::map<std::string, std::string> atHead;
|
||||
for (const std::string& f : Dyn::Split(git({"ls-tree", "--name-only", "HEAD", std::string(M)}))) {
|
||||
if (const std::optional<std::string> v = MigrationVersion(f)) atHead[*v] = f;
|
||||
}
|
||||
std::vector<std::string> inRange;
|
||||
if (!a.base.empty() && !atHead.empty()) {
|
||||
for (const std::string& f : Dyn::Split(git({"diff", "--name-only", "--diff-filter=AM", std::format("{}..HEAD", a.base), "--", std::string(M)}))) {
|
||||
if (MigrationVersion(f)) inRange.push_back(f);
|
||||
}
|
||||
}
|
||||
std::map<std::string, std::string> headFp;
|
||||
for (const std::string& f : inRange) headFp[f] = PgWire::Fingerprint(git({"show", std::format("HEAD:{}", f)}));
|
||||
return {atHead, inRange, headFp};
|
||||
}
|
||||
|
||||
std::pair<std::optional<std::string>, std::optional<std::string>> Run::MigrationsDrift(const std::string& url) {
|
||||
// The HEAD side is the same for every stack: worked out once per tree
|
||||
// and base.
|
||||
// and base (a tree's HEAD does not move during a run - TreeDrift
|
||||
// aborts it first).
|
||||
constexpr std::string_view M = "supabase/migrations/";
|
||||
const std::pair<std::string, std::string> key = {trees.front(), a.base};
|
||||
if (!migCache.contains(key)) {
|
||||
auto git = [&](std::vector<std::string> args) {
|
||||
std::vector<std::string> argv = {"git", "-C", trees.front()};
|
||||
std::ranges::move(args, std::back_inserter(argv));
|
||||
return Process::Run(argv).out;
|
||||
};
|
||||
std::map<std::string, std::string> atHead;
|
||||
for (const std::string& f : Dyn::Split(git({"ls-tree", "--name-only", "HEAD", std::string(M)}))) {
|
||||
if (const std::optional<std::string> v = MigrationVersion(f)) atHead[*v] = f;
|
||||
}
|
||||
std::vector<std::string> inRange;
|
||||
if (!a.base.empty() && !atHead.empty()) {
|
||||
for (const std::string& f : Dyn::Split(git({"diff", "--name-only", "--diff-filter=AM", std::format("{}..HEAD", a.base), "--", std::string(M)}))) {
|
||||
if (MigrationVersion(f)) inRange.push_back(f);
|
||||
}
|
||||
}
|
||||
std::map<std::string, std::string> headFp;
|
||||
for (const std::string& f : inRange) headFp[f] = PgWire::Fingerprint(git({"show", std::format("HEAD:{}", f)}));
|
||||
migCache[key] = {atHead, inRange, headFp};
|
||||
}
|
||||
if (!migCache.contains(key)) migCache[key] = migrationsAtHead();
|
||||
const auto& [atHead, inRange, headFp] = migCache.at(key);
|
||||
if (atHead.empty()) return {std::nullopt, std::format("no {} at the range HEAD - applied migrations not compared", M)};
|
||||
std::vector<std::vector<std::optional<std::string>>> rows;
|
||||
try {
|
||||
try {
|
||||
rows = PgWire::Query(url, "select version, array_to_string(statements, ' ') from supabase_migrations.schema_migrations").second;
|
||||
rows = query(url, "select version, array_to_string(statements, ' ') from supabase_migrations.schema_migrations").second;
|
||||
} catch (const PgWire::SqlError& e) {
|
||||
// No statements column (an old CLI): versions alone.
|
||||
if (!e.fields.contains('C') || e.fields.at('C') != "42703") throw;
|
||||
rows = PgWire::Query(url, "select version, null from supabase_migrations.schema_migrations").second;
|
||||
rows = query(url, "select version, null from supabase_migrations.schema_migrations").second;
|
||||
}
|
||||
} catch (const PgWire::SqlError& e) {
|
||||
const std::string code = e.fields.contains('C') ? e.fields.at('C') : std::string();
|
||||
|
|
@ -912,7 +930,7 @@ namespace Crafter::Review::Run {
|
|||
if (!app.empty()) apps = stacks->All();
|
||||
for (const Json& s : apps) {
|
||||
const Json& readyPath = Dyn::Get(fcfg, "readyPath");
|
||||
const auto [ok, detail] = AppReady(Dyn::Str(s.at("appUrl")), Dyn::Truthy(readyPath) ? Dyn::Str(readyPath) : std::string("/"));
|
||||
const auto [ok, detail] = appReady(Dyn::Str(s.at("appUrl")), Dyn::Truthy(readyPath) ? Dyn::Str(readyPath) : std::string("/"));
|
||||
if (!ok) {
|
||||
const Json& how = Dyn::Truthy(Dyn::Get(s, "slot")) ? Dyn::Get(sl, "start") : Dyn::Get(fcfg, "start");
|
||||
Die(std::format("stacks ({}): {}: the application does not answer ({}) - start it from the frozen tree first{}", who, Dyn::Str(s.at("id")), detail,
|
||||
|
|
@ -924,18 +942,18 @@ namespace Crafter::Review::Run {
|
|||
std::optional<std::string> note;
|
||||
for (const Json& s : dbs) {
|
||||
const std::string url = Dyn::Str(s.at("dbUrl"));
|
||||
const auto [ok, detail] = PgWire::Probe(url);
|
||||
const auto [ok, detail] = probe(url);
|
||||
if (!ok) {
|
||||
const Json& supabaseStart = Dyn::Get(cfg.at("supabase"), "start");
|
||||
const Json& how = Dyn::Truthy(Dyn::Get(s, "slot")) ? Dyn::Get(sl, "start") : (Dyn::Truthy(supabaseStart) ? supabaseStart : Dyn::Get(fcfg, "start"));
|
||||
Die(std::format("stacks ({}): {}: the database does not answer ({}) - start the stack from the frozen tree first{}", who, Dyn::Str(s.at("id")), detail,
|
||||
Dyn::Truthy(how) ? std::format(" ({})", Dyn::Str(how)) : std::string()));
|
||||
}
|
||||
const auto [drift, n] = MigrationsDrift(url);
|
||||
const auto [drift, n] = migrationsDrift(url);
|
||||
if (drift) Die(std::format("stacks ({}): {}: the database: {}", who, Dyn::Str(s.at("id")), *drift));
|
||||
if (!firstDetail || firstDetail->empty()) firstDetail = detail;
|
||||
if (!note || note->empty()) note = n;
|
||||
bypass.insert(PgWire::BypassesRls(url));
|
||||
bypass.insert(bypassesRls(url));
|
||||
}
|
||||
if (!dbs.empty()) {
|
||||
dbChecked = dbs.size() == 1 ? std::format("it answers ({}); {}", firstDetail.value_or("None"), note.value_or("None"))
|
||||
|
|
@ -978,7 +996,7 @@ namespace Crafter::Review::Run {
|
|||
}
|
||||
if (email.empty() || PgWire::CheckUrl(Dyn::Get(stack, "dbUrl"))) return std::nullopt;
|
||||
try {
|
||||
const auto [columns, rows] = PgWire::Query(Dyn::Str(stack.at("dbUrl")), std::format("select 1 from auth.users where lower(email) = '{}'", Dyn::Replace(email, "'", "''")));
|
||||
const auto [columns, rows] = query(Dyn::Str(stack.at("dbUrl")), std::format("select 1 from auth.users where lower(email) = '{}'", Dyn::Replace(email, "'", "''")));
|
||||
return rows.empty() ? std::optional<std::string>(email) : std::nullopt;
|
||||
} catch (const PgWire::SqlError&) {
|
||||
return std::nullopt;
|
||||
|
|
@ -1283,14 +1301,14 @@ namespace Crafter::Review::Run {
|
|||
abortPool(std::format("artifact changed during the run ({} != {})", now.substr(0, 8), h8));
|
||||
return {{{"label", job.at("label")}, {"status", "error"}, {"reason", "artifact changed during the run - aborted"}}, nullptr};
|
||||
}
|
||||
if (const std::optional<std::string> drift = TreeDrift()) {
|
||||
if (const std::optional<std::string> drift = treeDrift()) {
|
||||
abortPool(std::format("evidence drift during the run ({})", *drift));
|
||||
return {{{"label", job.at("label")}, {"status", "error"}, {"reason", std::format("{} - aborted", *drift)}}, nullptr};
|
||||
}
|
||||
const auto [systemPath, prompt] = PromptFor(job);
|
||||
const auto [systemPath, prompt] = promptFor(job);
|
||||
const bool whole = job.at("kind") == "whole";
|
||||
auto [rec, raw] = runner->Run(job, prompt, systemPath, schemas.at(whole ? "whole" : "block"), trees, Dyn::Has(job, "maxTurns") ? Int(job.at("maxTurns")) : 20,
|
||||
Dyn::Has(job, "timeoutMin") ? Int(job.at("timeoutMin")) : 20, whole);
|
||||
auto [rec, raw] = runPass(job, prompt, systemPath, schemas.at(whole ? "whole" : "block"), Dyn::Has(job, "maxTurns") ? Int(job.at("maxTurns")) : 20,
|
||||
Dyn::Has(job, "timeoutMin") ? Int(job.at("timeoutMin")) : 20, whole);
|
||||
if (Dyn::Truthy(Dyn::Get(job, "stack"))) rec["stack"] = job.at("stack").at("id");
|
||||
if (Dyn::Truthy(Dyn::Get(rec, "memPeakMb"))) {
|
||||
const Json& configured = Dyn::Get(environments, Dyn::Str(job.at("lensFile")));
|
||||
|
|
@ -1346,7 +1364,7 @@ namespace Crafter::Review::Run {
|
|||
std::vector<Json> resumed;
|
||||
std::vector<std::string> live;
|
||||
for (const std::string& lf : lensFiles) {
|
||||
if (std::optional<Json> r = Resumable(Plan::LensName(lf), lf, title)) {
|
||||
if (std::optional<Json> r = resumable(Plan::LensName(lf), lf, title)) {
|
||||
resumed.push_back(std::move(*r));
|
||||
} else {
|
||||
live.push_back(lf);
|
||||
|
|
@ -1356,7 +1374,7 @@ namespace Crafter::Review::Run {
|
|||
// Nothing runs: no stack, database or application is needed for a
|
||||
// result already bought.
|
||||
if (lensFiles.empty()) return resumed;
|
||||
InstrumentPreflight(lensFiles);
|
||||
instrumentPreflight(lensFiles);
|
||||
std::map<std::string, std::vector<Json>> parts;
|
||||
std::map<std::string, std::set<std::string>> missing;
|
||||
std::map<std::string, std::set<std::string>> planned;
|
||||
|
|
|
|||
Loading…
Reference in a new issue