Crafter.Review/project.cpp
Jorijn van der Graaf 0f803d90bd
feat(run): crafter-review run and slots, the driver of the gates
Run: gate 0 (the artifact, the trees, the inventory, the run lock, the
scratch dir, dispositions, reuse state, resume, reads per round, seats,
the board), the stack and migration preflights, the prompts, reuse of
whole and block verdicts, salvage, a gate on the pool, checkpoints, the
dump, the dry-run report and the unit-state store; slots_plan and
app_ready beside it. The CLI parses run's 28 options, types and choices
included.

A full run takes its lifetime lock beside the board before it joins it.
A signal - or the stop file `crafter-review stop` writes, looked at
every 2 s - stops the run once gate 0 is through, and the stop stays in
force for the gates after; before that it ends the process with its
descendants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:16:41 +01:00

319 lines
16 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
// Pinned: the Flatpak builds with the base app's crafter-build, which can be
// older than the rules' master. Move the pin together with the base.
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "7d932892017800b4e1f1a15fe7aa49b3daee9ceb"
import Crafter.LintRules;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
// The umbrella, then the partitions in import order: each after the ones
// it imports, which is also the order the module map in AGENTS.md uses.
constexpr std::array<std::string_view, 31> Interfaces = {
"interfaces/Crafter.Review",
"interfaces/Crafter.Review-Unicode",
"interfaces/Crafter.Review-Json",
"interfaces/Crafter.Review-Text",
"interfaces/Crafter.Review-Crypto",
"interfaces/Crafter.Review-Platform",
"interfaces/Crafter.Review-Process",
"interfaces/Crafter.Review-Http",
"interfaces/Crafter.Review-Core",
"interfaces/Crafter.Review-Schema",
"interfaces/Crafter.Review-Tools",
"interfaces/Crafter.Review-Plan",
"interfaces/Crafter.Review-Artifact",
"interfaces/Crafter.Review-Inventory",
"interfaces/Crafter.Review-Dispositions",
"interfaces/Crafter.Review-State",
"interfaces/Crafter.Review-Normalize",
"interfaces/Crafter.Review-PgWire",
"interfaces/Crafter.Review-Totp",
"interfaces/Crafter.Review-Stream",
"interfaces/Crafter.Review-Prompt",
"interfaces/Crafter.Review-Config",
"interfaces/Crafter.Review-Memory",
"interfaces/Crafter.Review-Mcp",
"interfaces/Crafter.Review-Seats",
"interfaces/Crafter.Review-SeatPool",
"interfaces/Crafter.Review-OpenCode",
"interfaces/Crafter.Review-Pool",
"interfaces/Crafter.Review-Freeze",
"interfaces/Crafter.Review-Run",
"interfaces/Crafter.Review-Driver",
};
constexpr std::array<std::string_view, 28> Implementations = {
"implementations/Crafter.Review-Text",
"implementations/Crafter.Review-Crypto",
"implementations/Crafter.Review-Platform",
"implementations/Crafter.Review-Process",
"implementations/Crafter.Review-Http",
"implementations/Crafter.Review-Core",
"implementations/Crafter.Review-Schema",
"implementations/Crafter.Review-Tools",
"implementations/Crafter.Review-Plan",
"implementations/Crafter.Review-Artifact",
"implementations/Crafter.Review-Inventory",
"implementations/Crafter.Review-Dispositions",
"implementations/Crafter.Review-State",
"implementations/Crafter.Review-Normalize",
"implementations/Crafter.Review-PgWire",
"implementations/Crafter.Review-Totp",
"implementations/Crafter.Review-Stream",
"implementations/Crafter.Review-Prompt",
"implementations/Crafter.Review-Config",
"implementations/Crafter.Review-Memory",
"implementations/Crafter.Review-Mcp",
"implementations/Crafter.Review-Seats",
"implementations/Crafter.Review-SeatPool",
"implementations/Crafter.Review-OpenCode",
"implementations/Crafter.Review-Pool",
"implementations/Crafter.Review-Freeze",
"implementations/Crafter.Review-Run",
"implementations/Crafter.Review-Driver",
};
// With -march=native clang would fuse a multiply and an add: a cost
// estimate, a usage total or a memory threshold must come out to the
// same last bit on every machine, as the goldens record it.
constexpr std::string_view NoContraction = "-ffp-contract=off";
// One per directory under tests/, each an ordinary main() that links the
// library: exit 0 passes, nonzero fails, 77 skips.
constexpr std::array<std::string_view, 20> Tests = {
"Crypto",
"Text",
"Platform",
"Process",
"Goldens",
"Stream",
"Normalize",
"Merge",
"Dispositions",
"Config",
"CommitsMode",
"CodeKeys",
"Freeze",
"PgWire",
"Seats",
"SeatPool",
"Drivers",
"OpenCode",
"Runner",
"Scheduler",
};
// The version the binary reports: CI sets it from the tag, a checkout
// describes itself, anything else is a development build. A define on
// the library, so each version is its own library variant.
std::string Version() {
if (const char* env = std::getenv("CRAFTER_REVIEW_VERSION"); env && *env) return env;
auto described = RunCommandWithTimeout("git describe --tags --always --dirty --exclude=latest 2>/dev/null", std::chrono::seconds(10));
std::string version = described.output;
while (!version.empty() && (version.back() == '\n' || version.back() == '\r')) version.pop_back();
if (described.exitCode != 0 || version.empty()) return "0.0.0-dev";
return version;
}
// The local Flatpak: `crafter-build run flatpak` builds it from this
// checkout and `crafter-build run install` installs that build, as
// Crafter.Build's own scripts do. --base=<branch> builds on another branch
// of the Crafter.Build base app than stable: until a Crafter.Build release
// with scripts is on stable, `--base=local` (the branch Crafter.Build's own
// `run install` makes) is what builds this project.
struct FlatpakPaths {
fs::path manifest;
// flatpak-builder's downloads, ccache and per-module cache.
fs::path state;
// The OSTree repo the build is exported to; `install` reads it.
fs::path repo;
fs::path buildDir;
};
// Everything under <project>/build/flatpak, a fixed name rather than a
// BuildDir(): the Flatpak is compiled by the toolchain inside the sandbox,
// not by the host variant. flatpak-builder keys its cache on the
// manifest's absolute path, so a branch other than stable gets a manifest
// copy of its own next to the original (gitignored), and with it a cache
// of its own.
FlatpakPaths FlatpakLayout(const fs::path& projectDir, std::string_view base) {
const fs::path root = projectDir / "build" / "flatpak";
const fs::path packaging = projectDir / "packaging" / "flatpak";
return {
.manifest = base == "stable" ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}-base.yaml", base),
.state = root / "state",
.repo = root / "repo",
.buildDir = root / "build-dir",
};
}
// Both scripts drive the host's flatpak, which the sandbox of a Flatpak
// crafter-build does not have.
bool RefusedInSandbox(std::string_view script) {
if (!fs::exists("/.flatpak-info")) return false;
std::println(std::cerr, "run {0}: needs the host's flatpak, which the Flatpak build of crafter-build cannot reach. Use a native crafter-build", script);
return true;
}
// flatpak-builder as a host package, else the org.flatpak.Builder app.
// Empty when neither is installed.
std::string FlatpakBuilder() {
if (RunCommandWithTimeout("flatpak-builder --version", std::chrono::seconds(10)).exitCode == 0) return "flatpak-builder";
if (RunCommandWithTimeout("flatpak info org.flatpak.Builder", std::chrono::seconds(10)).exitCode == 0) return "flatpak run org.flatpak.Builder";
return "";
}
// A repo path as the file:// URL of a remote. Percent-encoded, because
// `remote-modify --url=` stores what it is given as it is.
std::string FileUrl(const fs::path& path) {
std::string url = "file://";
for (const char c : path.string()) {
const bool plain = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("/-._~").contains(c);
if (plain) {
url.push_back(c);
} else {
url += std::format("%{:02X}", static_cast<std::uint8_t>(c));
}
}
return url;
}
std::int32_t BuildFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("flatpak")) return 1;
std::string jobs;
std::string base = "stable";
for (const std::string& arg : ctx.args) {
if (arg.starts_with("--base=") && arg.size() > 7) {
base = arg.substr(7);
continue;
}
const std::string_view count = arg.starts_with("--jobs=") ? std::string_view(arg).substr(7) : std::string_view();
if (count.empty() || !std::ranges::all_of(count, [](char c) { return c >= '0' && c <= '9'; })) {
std::println(std::cerr, "run flatpak: unknown argument '{}' (only --jobs=<N> and --base=<branch> are accepted)", arg);
return 1;
}
jobs = std::format(" --jobs={}", count);
}
if (!std::ranges::all_of(base, [](char c) { return std::isalnum(static_cast<std::uint8_t>(c)) || c == '-' || c == '_' || c == '.'; })) {
std::println(std::cerr, "run flatpak: '{}' is not a branch name", base);
return 1;
}
if (RunCommandWithTimeout(std::format("flatpak info net.catcrafts.CrafterBuild//{}", base), std::chrono::seconds(10)).exitCode != 0) {
std::println(std::cerr, "run flatpak: the base app is not installed: flatpak install catcrafts net.catcrafts.CrafterBuild//{}", base);
return 1;
}
const std::string builder = FlatpakBuilder();
if (builder.empty()) {
std::println(std::cerr, "run flatpak: neither flatpak-builder nor the org.flatpak.Builder app is installed (flatpak install flathub org.flatpak.Builder)");
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, base);
if (base != "stable") {
// The manifest with only base-version changed.
std::ifstream in(FlatpakLayout(ctx.projectDir, "stable").manifest);
const std::string manifest((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
constexpr std::string_view Line = "base-version: stable\n";
const std::size_t at = manifest.find(Line);
if (at == std::string::npos) {
std::println(std::cerr, "run flatpak: the manifest has no `{}` line to replace", Line.substr(0, Line.size() - 1));
return 1;
}
std::ofstream(paths.manifest) << manifest.substr(0, at) << std::format("base-version: {}\n", base) << manifest.substr(at + Line.size());
}
const std::string buildDir = ShellQuote(paths.buildDir.string());
if (const std::int32_t code = ctx.Run(std::format("{}{} --user --force-clean --ccache --state-dir={} --repo={} --default-branch=local {} {}", builder, jobs, ShellQuote(paths.state.string()), ShellQuote(paths.repo.string()), buildDir, ShellQuote(paths.manifest.string()))); code != 0) return code;
// The smoke test, through `flatpak build` and not `<builder> --run`:
// the org.flatpak.Builder app reports --run's exit status inverted.
if (const std::int32_t code = ctx.Run(std::format("flatpak build {} crafter-review --version", buildDir)); code != 0) return code;
return ctx.Run(std::format("flatpak build {} sh -c 'ldd /app/bin/crafter-review | grep \"not found\" && exit 1 || exit 0'", buildDir));
}
std::int32_t InstallFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("install")) return 1;
if (!ctx.args.empty()) {
std::println(std::cerr, "run install: takes no arguments (got '{}')", ctx.args.front());
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, "stable");
if (!fs::exists(paths.repo / "config")) {
std::println(std::cerr, "run install: nothing built yet, run: crafter-build run flatpak");
return 1;
}
const std::string url = ShellQuote(FileUrl(paths.repo));
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-add --user --if-not-exists --no-gpg-verify crafter-review-local {}", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-modify --user --url={} crafter-review-local", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak install --user --noninteractive --or-update crafter-review-local net.catcrafts.CrafterReview//local"); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak make-current --user net.catcrafts.CrafterReview local"); code != 0) return code;
std::println(
R"(
`flatpak run net.catcrafts.CrafterReview` now starts this build.
Back to a published branch, keeping this one installed:
flatpak make-current --user net.catcrafts.CrafterReview <branch>
Remove it (do this before deleting build/, which holds the repo the remote points at):
flatpak uninstall --user net.catcrafts.CrafterReview//local
flatpak remote-delete --user crafter-review-local)");
return 0;
}
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// Crafter.Network for its TCP and HTTP/1.1 clients and listener, without
// QUIC: msquic is most of a fresh build and nothing here speaks HTTP/3.
// From forgejo at a pinned commit, or with --local from the checkout next
// to this one (which is also how Crafter.Network then finds
// Crafter.Thread).
std::vector<std::string> depArgs(args.begin(), args.end());
depArgs.emplace_back("--no-quic");
const bool useLocal = std::ranges::find(args, std::string_view("--local")) != args.end();
Configuration* network = useLocal
? LocalProject({ .projectFile = "../Crafter.Network/project.cpp", .args = depArgs })
: GitProject({
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git", .commit = "f68321685006be65f6bc0436fd0175611f1e0a55" },
.args = depArgs,
});
static auto ReviewLib = std::make_unique<Configuration>();
ReviewLib->path = "./";
ReviewLib->name = "crafter.review-lib";
ReviewLib->outputName = "crafter-review";
ApplyStandardArgs(*ReviewLib, args);
ReviewLib->type = ConfigurationType::LibraryStatic;
ReviewLib->dependencies = { network };
ReviewLib->compileFlags.emplace_back(NoContraction);
// Backslash-escaped quotes: the define travels through the shell before
// it reaches clang, which must still see a string literal.
ReviewLib->defines.push_back({"CRAFTER_REVIEW_VERSION", std::format("\\\"{}\\\"", Version())});
{
std::vector<fs::path> interfaces(Interfaces.begin(), Interfaces.end());
std::vector<fs::path> implementations(Implementations.begin(), Implementations.end());
ReviewLib->GetInterfacesAndImplementations(interfaces, implementations);
}
Configuration cfg;
cfg.path = "./";
cfg.name = "crafter.review-exe";
cfg.outputName = "crafter-review";
ApplyStandardArgs(cfg, args);
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ReviewLib.get() };
cfg.compileFlags.emplace_back(NoContraction);
{
std::array<fs::path, 0> interfaces = {};
std::array<fs::path, 1> implementations = { "implementations/main" };
cfg.GetInterfacesAndImplementations(interfaces, implementations);
}
for (const std::string_view test : Tests) {
cfg.AddTest(test).Dependencies({ ReviewLib.get() }).CompileFlag(std::string(NoContraction));
}
LintRules::AddLiteRules(cfg);
cfg.scripts.push_back({"flatpak", BuildFlatpak});
cfg.scripts.push_back({"install", InstallFlatpak});
return cfg;
}