Crafter.Review/implementations/Crafter.Review-State.cpp
Jorijn van der Graaf d5e54ffeb3
feat(config): modes - a mode is an artifact kind, the lenses it runs and its effort
The config defines the modes (modes.<name>): the kind of artifact the mode
reviews - reply, series or commits, the three shipped partitions; a kind
is a mode of its own name - the lenses it runs, in its kind's gate order,
and the effort it runs at, else the kind's default (max for a reply or a
series, xhigh for a commit range). `run --mode` names any of them, the
dump records mode and kind, and everything that decided by the mode's
name - the partition and hash, the prompts, the schemas, the disposition
and reuse-key rules, what the CLI requires - decides by the kind.

Every lens stays off until a mode lists it; lenses.<name>.run is retired
with a warning that says so, and a lens of another kind, a name that is
no lens, a lens listed twice and an unknown effort are config problems.
The defaults run no lens; the template ships the three kinds as modes
with every lens of their kind at the kind's effort, to trim (the owner's
ruling of 2026-11-01), and init says so as it writes it - a kept config
is the repository's own, and only one that lists no lens gets a step.

The readers of the retired key follow: the CI smoke config, the shipped
skill, the procedure (its description of the config as well as its
mechanics), the manifest template, the run's messages and the comments
that named the three modes. The run lock, the run numbers and the dump
names are the kind's, so one full run per hash holds across the modes
of a kind and their rounds count on together; the dump says which mode
ran. The database broker starts for a lens the mode runs, as every other
reader of the lenses off reads them. A named mode runs through the CLI
in the kernel-modes test.
2026-11-02 00:29:00 +01:00

290 lines
16 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <cerrno>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
module Crafter.Review:State_impl;
import :State;
import :Json;
import :Text;
import :Platform;
import std;
namespace fs = std::filesystem;
namespace Crafter::Review::State {
namespace {
std::string Formatted(double value, std::int32_t precision) {
return std::format("{:.{}f}", value, precision);
}
double AsDouble(const Json& value) {
if (value.is_number_float()) return value.get<double>();
if (value.is_boolean()) return value.get<bool>() ? 1.0 : 0.0;
if (value.is_number_unsigned()) return static_cast<double>(value.get<std::uint64_t>());
if (value.is_number_integer()) return static_cast<double>(value.get<std::int64_t>());
throw Dyn::Error("TypeError", std::format("unsupported operand type(s) for /: '{}' and 'float'", Dyn::TypeName(value)));
}
// `value or 0` for a number that is summed.
Json OrZero(const Json& value) {
return Dyn::Truthy(value) ? value : Json(0);
}
// The key's value, or the fallback only when the key is absent.
const Json& GetOr(const Json& dict, std::string_view key, const Json& fallback) {
return Dyn::Has(dict, key) ? Dyn::At(dict, key) : fallback;
}
std::size_t Len(const Json& value) {
return value.is_string() ? Dyn::Length(value.get_ref<const std::string&>()) : value.size();
}
// A string, or Error (TypeError): only a string is concatenated.
const std::string& Text(const Json& value) {
if (!value.is_string()) throw Dyn::Error("TypeError", std::format("can only concatenate str (not \"{}\") to str", Dyn::TypeName(value)));
return value.get_ref<const std::string&>();
}
// f'{value:<width}' of a str.
std::string Padded(const Json& value, std::size_t width) {
if (!value.is_string()) throw Dyn::Error("TypeError", std::format("unsupported format string passed to {}.__format__", Dyn::TypeName(value)));
return Dyn::LJust(value.get_ref<const std::string&>(), width);
}
}
Json EmptyState() {
return {{"version", 1}, {"entries", Json::object()}, {"personaPass", nullptr}};
}
Json LoadState(const fs::path& path) {
if (!path.empty() && fs::exists(path)) {
try {
return Json::parse(Dyn::ReadText(path));
} catch (const std::exception&) {
// A damaged file is the empty state.
}
}
return EmptyState();
}
Json MergeState(const Json& disk, const Json& sets, std::span<const std::string> drops, const Json& read, const Json& persona) {
const bool ok = disk.is_object() && Dyn::Get(disk, "entries").is_object();
Json state = {
{"version", 1},
{"entries", ok ? Dyn::At(disk, "entries") : Json::object()},
{"personaPass", ok ? Dyn::Get(disk, "personaPass") : Json()},
};
Json& entries = state["entries"];
for (const std::string& key : drops) entries.erase(key);
for (auto it = sets.begin(); it != sets.end(); ++it) {
if (std::ranges::contains(drops, it.key())) continue;
Json entry = it.value();
if (!entry.is_object()) throw Dyn::Error("TypeError", std::format("'{}' object is not iterable", Dyn::TypeName(entry)));
if (!read.is_null() && !Dyn::Equal(Dyn::Get(entry, "kind"), Json("whole"))) {
// One clean read per round: a second store of the same run, or
// the relaunch of an unfinished run, adds nothing.
const Json& previous = Dyn::Get(entries, it.key());
Json reads = Json::array();
if (Dyn::Truthy(previous)) {
const Json& stored = Dyn::Get(previous, "reads");
if (Dyn::Truthy(stored)) {
reads = stored;
} else {
const Json& hash = Dyn::Get(previous, "hash");
const std::string head = Dyn::Truthy(hash) ? Dyn::Slice(Text(hash), 0, 8) : std::string();
reads.push_back({{"hash", head}, {"round", 0}, {"run", Dyn::Get(previous, "run")}});
}
}
const bool counted = std::ranges::any_of(reads, [&](const Json& x) {
return Dyn::Equal(Dyn::Get(x, "hash"), Dyn::At(read, "hash")) && Dyn::Equal(Dyn::Get(x, "round"), Dyn::At(read, "round"));
});
if (!counted) reads.push_back(read);
entry["reads"] = std::move(reads);
}
entries[it.key()] = std::move(entry);
}
if (!persona.is_null()) state["personaPass"] = persona;
return state;
}
void SaveWhole(const fs::path& path, const Json& state) {
const fs::path temp = std::format("{}.tmp", path.string());
{
std::ofstream out(temp, std::ios::binary | std::ios::trunc);
if (!out) throw std::runtime_error(std::format("{}: cannot write", temp.string()));
out << DumpFile(state);
}
fs::rename(temp, path);
}
Json SaveState(const fs::path& path, const Json& sets, std::span<const std::string> drops, const Json& read, const Json& persona) {
// Without the lock the write is still made; a failed write throws.
const Platform::FileLock lock(std::format("{}.lock", path.string()));
Json merged = MergeState(LoadState(path), sets, drops, read, persona);
Platform::WriteAtomic(path, DumpFile(merged), 0666);
return merged;
}
fs::path LockPath(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
return gateDir / std::format(".run-{}-{}.lock", mode, hash8);
}
Json ReadLockInfo(const fs::path& path) {
try {
const std::string text = Dyn::ReadText(path);
const Json info = Dyn::Strip(text).empty() ? Json::object() : Json::parse(text);
return info.is_object() ? info : Json::object();
} catch (const std::exception&) {
return Json::object();
}
}
std::string Describe(const Json& info) {
if (!Dyn::Truthy(Dyn::Get(info, "pid"))) return "holder not identified yet (it has just started)";
const Json unknown = "?";
return std::format("pid {} since {}, run {}, scratch {}", Dyn::Str(Dyn::At(info, "pid")), Dyn::Str(GetOr(info, "since", unknown)),
Dyn::Str(GetOr(info, "run_n", unknown)), Dyn::Str(GetOr(info, "scratch", unknown)));
}
RunLock::RunLock(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
const fs::path path = LockPath(gateDir, mode, hash8);
fd = open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
if (fd < 0) throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(errno), Dyn::Repr(std::string_view(path.string()))));
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
const Json info = ReadLockInfo(path);
close(fd);
fd = -1;
throw RunLocked(Describe(info));
}
}
RunLock::~RunLock() {
if (fd >= 0) close(fd);
}
void RunLock::WriteInfo(const Json& info) {
const std::string text = DumpSpaced(info);
if (lseek(fd, 0, SEEK_SET) < 0 || ftruncate(fd, 0) != 0) return;
std::size_t written = 0;
while (written < text.size()) {
const ssize_t step = write(fd, text.data() + written, text.size() - written);
if (step < 0) {
if (errno == EINTR) continue;
return;
}
written += static_cast<std::size_t>(step);
}
}
std::optional<Json> LockHolder(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
const fs::path path = LockPath(gateDir, mode, hash8);
const std::int32_t handle = open(path.c_str(), O_RDONLY | O_CLOEXEC);
if (handle < 0) return std::nullopt;
std::optional<Json> holder;
if (flock(handle, LOCK_EX | LOCK_NB) == 0) {
flock(handle, LOCK_UN);
} else {
holder = ReadLockInfo(path);
}
close(handle);
return holder;
}
std::int64_t NextRunNumber(const fs::path& gateDir, std::string_view mode, std::string_view hash8) {
std::int64_t n = 1;
while (fs::exists(gateDir / std::format("{}-{}-run-{}.json", mode, hash8, n)) || fs::exists(gateDir / std::format("{}-{}-run-{}-test.json", mode, hash8, n))) ++n;
return n;
}
Json UsageTotals(std::span<const Json> records) {
Json totals = {
{"input", 0}, {"cacheCreation", 0}, {"cacheRead", 0}, {"output", 0}, {"thinking", 0},
{"turns", 0}, {"processes", 0}, {"costUsd", 0.0}, {"denials", 0},
};
const Json zero = 0;
for (const Json& record : records) {
const Json& usage = Dyn::Or(Dyn::Get(record, "usage"), Dyn::EmptyDict());
for (const std::string_view key : {"input", "cacheCreation", "cacheRead", "output", "thinking"}) {
totals[std::string(key)] = Dyn::Add(totals[std::string(key)], OrZero(GetOr(usage, key, zero)));
}
totals["costUsd"] = Dyn::Add(totals["costUsd"], OrZero(Dyn::Get(record, "costUsd")));
totals["denials"] = Dyn::Add(totals["denials"], Json(Len(Dyn::Or(Dyn::Get(record, "denials"), Dyn::EmptyList()))));
totals["turns"] = Dyn::Add(totals["turns"], OrZero(GetOr(record, "turns", zero)));
const Json& status = Dyn::Get(record, "status");
const bool counted = !status.is_null() && status != "dry-run" && status != "reused";
totals["processes"] = Dyn::Add(totals["processes"], Json(counted ? 1 : 0));
}
// input + 1.25 * cacheCreation + 0.1 * cacheRead + 5 * output: 5 * an
// integer stays an integer until it meets the float, so the dump
// prints it the same.
const Json& output = totals["output"];
const double five = output.is_number_integer() ? static_cast<double>(5 * output.get<std::int64_t>()) : 5.0 * AsDouble(output);
double costUnits = AsDouble(totals["input"]) + 1.25 * AsDouble(totals["cacheCreation"]);
costUnits = costUnits + 0.1 * AsDouble(totals["cacheRead"]);
costUnits = costUnits + five;
totals["costUnits"] = costUnits;
return totals;
}
std::string Summary(const Json& dump) {
std::vector<std::string> lines;
std::string overall = std::format("overall: {}", Dyn::Str(Dyn::At(dump, "overall")));
if (Dyn::Truthy(Dyn::Get(dump, "stoppedAt"))) overall += std::format(" (stopped at {})", Dyn::Str(Dyn::At(dump, "stoppedAt")));
lines.push_back(std::move(overall));
for (const Json& result : Dyn::At(dump, "results")) {
const Json& parts = Dyn::Or(Dyn::Get(result, "parts"), Dyn::EmptyList());
const auto reused = std::ranges::count_if(parts, [](const Json& part) { return Dyn::Truthy(Dyn::Get(part, "reused")); });
std::string line = std::format(" {} {} ", Padded(Dyn::At(result, "lens"), 28), Padded(Dyn::At(result, "verdict"), 12));
if (Dyn::Truthy(Dyn::Get(result, "resumed"))) line += std::format("[resumed {}] ", Text(Dyn::At(result, "resumed")));
line += std::format("findings {} (undischarged {}), notes {}, passes {}", Len(Dyn::At(result, "findings")), Dyn::Str(Dyn::At(result, "undischarged")),
Len(Dyn::At(result, "notes")), Len(parts));
if (reused) line += std::format(" ({} reused)", reused);
if (Dyn::Truthy(Dyn::Get(result, "coverageMissing"))) line += std::format(", missing {}", Len(Dyn::At(result, "coverageMissing")));
if (Dyn::Truthy(Dyn::Get(result, "stoppedEarly"))) line += std::format(" <- {}", Text(Dyn::At(result, "stoppedEarly")));
lines.push_back(std::move(line));
}
const Json& coverage = Dyn::Or(Dyn::Get(dump, "coverage"), Dyn::EmptyDict());
if (Dyn::Truthy(coverage)) {
const Json kind = Dyn::Or(Dyn::Get(dump, "kind"), Dyn::Get(dump, "mode"));
if (kind == "reply" || kind == "series") {
lines.push_back(std::format("matrix: {} units x {} lenses; holes {}, persona holes {}, thin {}, modelled {}, units with a defect verdict {}", Dyn::Str(Dyn::At(coverage, "units")),
Len(Dyn::At(coverage, "lenses")), Len(Dyn::At(coverage, "holes")), Len(Dyn::Or(Dyn::Get(coverage, "personaHoles"), Dyn::EmptyList())),
Len(Dyn::Or(Dyn::Get(coverage, "thin"), Dyn::EmptyList())), Len(Dyn::Or(Dyn::Get(coverage, "modelled"), Dyn::EmptyList())), Len(Dyn::At(coverage, "defects"))));
} else {
lines.push_back(std::format("matrix: {} units x {} lenses; holes {}, not reviewed by any lens that runs (messages, diffstats, documentation, the units of a lens the config turns off) {}, units with a defect verdict {}",
Dyn::Str(Dyn::At(coverage, "units")), Len(Dyn::At(coverage, "lenses")), Len(Dyn::At(coverage, "holes")),
Len(Dyn::Or(Dyn::Get(coverage, "notReviewed"), Dyn::EmptyList())), Len(Dyn::At(coverage, "defects"))));
}
}
const Json& usageAll = Dyn::Has(dump, "usage") ? Dyn::At(dump, "usage") : Dyn::EmptyDict();
const Json& usage = Dyn::Or(Dyn::Get(usageAll, "run"), Dyn::EmptyDict());
if (Dyn::Truthy(usage)) {
const Json zero = 0;
lines.push_back(std::format("usage: {} processes, {} turns, cache write {}M, cache read {}M, output {}K, cost-units {}M, list cost ${}, denied calls {}, wall {} min, prefix hit rate {}",
Dyn::Str(Dyn::At(usage, "processes")), Dyn::Str(Dyn::At(usage, "turns")),
Formatted(AsDouble(Dyn::At(usage, "cacheCreation")) / 1e6, 2), Formatted(AsDouble(Dyn::At(usage, "cacheRead")) / 1e6, 2),
Formatted(AsDouble(Dyn::At(usage, "output")) / 1e3, 0), Formatted(AsDouble(Dyn::At(usage, "costUnits")) / 1e6, 2),
Formatted(AsDouble(Dyn::At(usage, "costUsd")), 2), Dyn::Str(Dyn::At(usage, "denials")),
Formatted(AsDouble(GetOr(dump, "wallMin", zero)), 0), Dyn::Str(Dyn::Get(usageAll, "prefixHitRate"))));
}
const Json& reads = Dyn::Get(dump, "reads");
if (Dyn::Truthy(reads)) {
std::vector<std::string> owed;
const Json& owedBy = Dyn::At(reads, "owed");
for (auto it = owedBy.begin(); it != owedBy.end(); ++it) {
owed.push_back(std::format("{} {} unit(s) / {} read(s)", it.key(), Dyn::Str(Dyn::At(it.value(), "units")), Dyn::Str(Dyn::At(it.value(), "reads"))));
}
std::string owedText = Dyn::Join(owed, "; ");
if (owedText.empty()) owedText = "none";
lines.push_back(std::format("reads: x = {}, round {} on this hash{}; read this run: {}; owed: {}", Dyn::Str(Dyn::At(reads, "x")), Dyn::Str(Dyn::At(reads, "round")),
Dyn::Truthy(Dyn::Get(reads, "continued")) ? " (continued)" : "", Dyn::Str(Dyn::Or(Dyn::Get(reads, "readThisRun"), Dyn::EmptyDict())), owedText));
}
lines.push_back(std::format("dump: {}", Dyn::Str(Dyn::At(dump, "dumpPath"))));
return Dyn::Join(lines, "\n");
}
}