diff --git a/implementations/Crafter.Review-Mcp.cpp b/implementations/Crafter.Review-Mcp.cpp new file mode 100644 index 0000000..8a39501 --- /dev/null +++ b/implementations/Crafter.Review-Mcp.cpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Review:Mcp_impl; +import :Mcp; +import :Json; +import :Text; +import :Tools; +import std; + +namespace fs = std::filesystem; + +namespace Crafter::Review::Mcp { + std::vector DefaultArgs() { + return {"-y", std::format("@playwright/mcp@{}", PlaywrightMcpVersion), "--headless", "--isolated", "--browser", "chromium"}; + } + + Json PlaywrightConfig(const Json& functional, const std::string& outputDir) { + const Json& mcp = Dyn::Or(Dyn::Get(Dyn::Or(functional, Dyn::EmptyDict()), "mcp"), Dyn::EmptyDict()); + Json args = Json::array(); + const Json& configured = Dyn::Get(mcp, "args"); + if (!configured.is_null()) { + for (const Json& arg : configured) args.push_back(arg); + } else { + for (const std::string& arg : DefaultArgs()) args.push_back(arg); + } + if (!Dyn::In(Json("--output-dir"), args)) { + args.push_back("--output-dir"); + args.push_back(outputDir); + } + const Json& command = Dyn::Get(mcp, "command"); + return {{"mcpServers", {{std::string(Tools::McpServer), {{"command", Dyn::Truthy(command) ? command : Json("npx")}, {"args", std::move(args)}}}}}}; + } + + std::string ConfigJson(const Json& config) { + return config.dump(-1, ' ', true, Json::error_handler_t::replace); + } + + fs::path OutputDir(const fs::path& scratch, std::string_view label) { + const fs::path dir = scratch / "playwright" / Dyn::Replace(Dyn::Replace(label, "/", "_"), " ", "_"); + fs::create_directories(dir); + return dir; + } +} diff --git a/implementations/Crafter.Review-Memory.cpp b/implementations/Crafter.Review-Memory.cpp new file mode 100644 index 0000000..4a94d82 --- /dev/null +++ b/implementations/Crafter.Review-Memory.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Review:Memory_impl; +import :Memory; +import :Json; +import :Text; +import :Platform; +import std; + +namespace fs = std::filesystem; + +namespace Crafter::Review::Memory { + std::optional Available(const fs::path& path) { + const std::map info = Platform::MemInfo(path); + const auto found = info.find("MemAvailable"); + if (found == info.end()) return std::nullopt; + return found->second; + } + + std::int64_t Reserve(const std::optional& configured, const fs::path& path) { + if (configured && !configured->empty()) { + const std::optional value = Dyn::ParseFloat(*configured); + if (!value) throw std::invalid_argument(Dyn::FloatError(*configured)); + return static_cast(*value); + } + const std::map info = Platform::MemInfo(path); + const auto total = info.find("MemTotal"); + return std::max(2048, (total == info.end() ? 0 : total->second) / 10); + } + + std::int64_t Reserve() { + return Reserve(Platform::GetEnv("REVIEW_GATES_MEM_RESERVE_MB")); + } + + struct Sampler::State { + double interval = 3.0; + Measure measure; + Scan scan; + std::mutex lock; + std::condition_variable wake; + std::map watched; + std::map peaks; + bool started = false; + bool stopping = false; + std::thread thread; + + double Sample() { + const auto start = std::chrono::steady_clock::now(); + std::vector> items; + { + const std::lock_guard guard(lock); + items.assign(watched.begin(), watched.end()); + } + if (!items.empty()) { + const std::map> kids = scan(); + for (const auto& [key, pid] : items) { + const double value = measure(pid, kids); + const std::lock_guard guard(lock); + const auto peak = peaks.find(key); + if (peak != peaks.end()) peak->second = std::max(peak->second, value); + } + } + return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + } + + void Loop() { + double sweep = 0.0; + std::unique_lock guard(lock); + while (!stopping) { + wake.wait_for(guard, std::chrono::duration(Pause(interval, sweep)), [this] { return stopping; }); + if (stopping) break; + guard.unlock(); + try { + sweep = Sample(); + } catch (const std::exception&) { + // A measurement never stops the run. + sweep = interval; + } + guard.lock(); + } + } + }; + + Sampler::Sampler(double interval, Measure measure, Scan scan) : state(std::make_shared()) { + state->interval = interval; + state->measure = measure ? std::move(measure) : Measure([](std::int64_t pid, const std::map>& kids) { + return Platform::PssMb(Platform::Tree(pid, kids)); + }); + state->scan = scan ? std::move(scan) : Scan([] { return Platform::PpidMap(); }); + } + + Sampler::~Sampler() { + { + const std::lock_guard guard(state->lock); + state->stopping = true; + } + state->wake.notify_all(); + if (state->thread.joinable()) state->thread.join(); + } + + void Sampler::Watch(const std::string& key, std::int64_t pid) { + const std::lock_guard guard(state->lock); + state->watched[key] = pid; + state->peaks[key] = 0.0; + if (!state->started) { + state->started = true; + state->thread = std::thread([owner = state.get()] { owner->Loop(); }); + } + } + + double Sampler::Sample() { + return state->Sample(); + } + + std::optional Sampler::Done(const std::string& key) { + const std::lock_guard guard(state->lock); + state->watched.erase(key); + const auto peak = state->peaks.find(key); + if (peak == state->peaks.end()) return std::nullopt; + const double value = peak->second; + state->peaks.erase(peak); + if (value == 0.0) return std::nullopt; + return Dyn::Round(value, 1); + } + + double Sampler::Pause(double interval, double sweep) { + return std::max(interval, 3 * sweep); + } + + namespace { + // A peak worth keeping: a number, no bool, above zero. + bool Positive(const Json& value) { + if (!value.is_number()) return false; + return value.is_number_float() ? value.get() > 0.0 : (value.is_number_unsigned() ? value.get() > 0 : value.get() > 0); + } + + double AsDouble(const Json& value) { + return value.is_number_float() ? value.get() : (value.is_number_unsigned() ? static_cast(value.get()) : static_cast(value.get())); + } + + Json Clean(const Json& value) { + Json out = Json::array(); + if (!value.is_array()) return out; + for (const Json& item : value) { + if (Positive(item)) out.push_back(item); + } + return out; + } + } + + Calibration::Calibration(fs::path path, const fs::path& repo, std::map defaults) + : path(std::move(path)), defaults(std::move(defaults)) { + std::error_code error; + this->repo = fs::weakly_canonical(fs::absolute(repo), error).string(); + } + + Json Calibration::Load() const { + try { + Json document = Dyn::LoadJsonFile(path); + return document.is_object() ? document : Json::object(); + } catch (const std::exception&) { + return Json::object(); + } + } + + std::vector Calibration::Peaks(std::string_view environment) const { + const Json document = Load(); + const Json& mine = Dyn::Get(document, repo); + const Json peaks = Clean(mine.is_object() ? Dyn::Get(mine, environment) : Json()); + return {peaks.begin(), peaks.end()}; + } + + Calibration::Cost Calibration::CostOf(std::string_view environment) const { + const std::vector peaks = Peaks(environment); + if (!peaks.empty()) { + // max(): the first of equal maxima. + const Json* best = &peaks.front(); + for (const Json& peak : peaks) { + if (AsDouble(peak) > AsDouble(*best)) best = &peak; + } + return {*best, std::format("the highest of its last {} measured peaks", peaks.size())}; + } + const auto found = defaults.find(std::string(environment)); + std::int64_t fallback = 1024; + if (found != defaults.end()) { + fallback = found->second; + } else if (!defaults.empty()) { + fallback = std::ranges::max(std::views::values(defaults)); + } + return {Json(fallback), "the default measured 2026-10-22; none measured in this repository yet"}; + } + + bool Calibration::Record(std::string_view environment, double megabytes) { + if (!(megabytes > 0.0) || !std::isfinite(megabytes)) return false; + try { + const std::lock_guard guard(lock); + // Without the lock the write is still made. + const Platform::FileLock fileLock(std::format("{}.lock", path.string())); + Json document = Load(); + Json mine = Dyn::Get(document, repo); + if (!mine.is_object()) mine = Json::object(); + Json peaks = Clean(Dyn::Get(mine, environment)); + peaks.push_back(Dyn::Round(megabytes, 1)); + if (peaks.size() > Keep) peaks.erase(peaks.begin(), peaks.begin() + static_cast(peaks.size() - Keep)); + mine[std::string(environment)] = std::move(peaks); + document[repo] = std::move(mine); + Platform::WriteAtomic(path, DumpFile(document), 0666); + return true; + } catch (const std::exception&) { + return false; + } + } +} diff --git a/implementations/Crafter.Review-Seats.cpp b/implementations/Crafter.Review-Seats.cpp new file mode 100644 index 0000000..d2cfd14 --- /dev/null +++ b/implementations/Crafter.Review-Seats.cpp @@ -0,0 +1,997 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include + +module Crafter.Review:Seats_impl; +import :Seats; +import :Json; +import :Text; +import :Stream; +import :Platform; +import :Process; +import :Crypto; +import std; + +namespace fs = std::filesystem; + +namespace Crafter::Review::Seats { + namespace { + constexpr std::array Weekdays = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; + // en-GB, as Intl.DateTimeFormat abbreviates them (September is "Sept"). + constexpr std::array Months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"}; + constexpr std::array ModelFamilies = {"fable", "opus", "sonnet", "haiku"}; + + const std::string& EnvOf(const std::map& env, const std::string& key) { + static const std::string none; + const auto found = env.find(key); + return found == env.end() ? none : found->second; + } + + double AsDouble(const Json& value) { + if (value.is_number_float()) return value.get(); + if (value.is_boolean()) return value.get() ? 1.0 : 0.0; + if (value.is_number_unsigned()) return static_cast(value.get()); + if (value.is_number_integer()) return static_cast(value.get()); + return 0.0; + } + + // The value as a float, or nullopt for one that is none. + std::optional FloatOf(const Json& value) { + if (value.is_number() || value.is_boolean()) return AsDouble(value); + if (value.is_string()) return Dyn::ParseFloat(value.get_ref()); + return std::nullopt; + } + + // An integer when n is integral, else n. + Json IntIfWhole(double n) { + if (std::isfinite(n) && n == std::floor(n) && std::fabs(n) < 9.0e18) return Json(static_cast(n)); + return Json(n); + } + + // A number field as a float, 0 when it is falsy. + double NumberOr0(const Json& value) { + return Dyn::Truthy(value) ? AsDouble(value) : 0.0; + } + + bool Less(const Json& a, const Json& b) { + return AsDouble(a) < AsDouble(b); + } + + // An ASCII literal, case-insensitive as re.I reads it, at `at`. + bool MatchCi(std::u32string_view text, std::size_t at, std::string_view word) { + if (at + word.size() > text.size()) return false; + for (std::size_t i = 0; i < word.size(); ++i) { + const char p = word[i]; + if (p >= 'a' && p <= 'z') { + if (Dyn::IgnoreCaseLetter(text[at + i]) != p) return false; + } else if (text[at + i] != static_cast(static_cast(p))) { + return false; + } + } + return true; + } + + bool ContainsCi(std::string_view text, std::string_view word) { + const std::u32string decoded = Dyn::Decode(text); + for (std::size_t at = 0; at + word.size() <= decoded.size(); ++at) { + if (MatchCi(decoded, at, word)) return true; + } + return false; + } + + // seats.CREDIT_RE: the organization's credit balance is gone, or a + // spend limit was reached. + bool CreditText(std::string_view text) { + constexpr std::array Phrases = { + "credit balance too low", "credit balance is too low", "add funds", "reached your api usage limits", "reached your specified api usage limits", + "reached your workspace api usage limits", "reached your specified workspace api usage limits", "crossed its monthly api usage threshold", + "enforced_spend_limit_reached", + }; + return std::ranges::any_of(Phrases, [&](std::string_view phrase) { return ContainsCi(text, phrase); }); + } + + std::string ResultText(const Json& result) { + return Dyn::StrOr(Dyn::Get(Dyn::Or(result, Dyn::EmptyDict()), "result"), ""); + } + + bool TokenChar(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + } + + std::size_t RunOf(std::string_view s, std::size_t at, const std::function& in) { + std::size_t end = at; + while (end < s.size() && in(s[end])) ++end; + return end - at; + } + + // re.sub(pattern, replacement, text) for a pattern given as a matcher: + // the length of the match at a position, or 0. + std::string SubAll(std::string_view text, const std::function& match, std::string_view replacement) { + std::string out; + std::size_t at = 0; + while (at < text.size()) { + const std::size_t length = match(text, at); + if (length > 0) { + out += replacement; + at += length; + } else { + out.push_back(text[at]); + ++at; + } + } + return out; + } + + // eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,} + std::size_t Jwt(std::string_view s, std::size_t at) { + if (!s.substr(at).starts_with("eyJ")) return 0; + std::size_t i = at + 3; + for (std::int32_t part = 0; part < 3; ++part) { + if (part > 0) { + if (i >= s.size() || s[i] != '.') return 0; + ++i; + } + const std::size_t run = RunOf(s, i, TokenChar); + if (run < 10) return 0; + i += run; + } + return i - at; + } + + // sk-ant-[A-Za-z0-9_-]{20,} + std::size_t AnthropicKey(std::string_view s, std::size_t at) { + if (!s.substr(at).starts_with("sk-ant-")) return 0; + const std::size_t run = RunOf(s, at + 7, TokenChar); + return run >= 20 ? 7 + run : 0; + } + + // Bearer\s+[A-Za-z0-9._~+/=-]{16,} + std::size_t Bearer(std::string_view s, std::size_t at) { + if (!s.substr(at).starts_with("Bearer")) return 0; + const std::size_t gap = Dyn::SpaceRun(s, at + 6); + if (gap == 0) return 0; + const std::size_t run = RunOf(s, at + 6 + gap, [](char c) { return TokenChar(c) || std::string_view("._~+/=").contains(c); }); + return run >= 16 ? 6 + gap + run : 0; + } + + // [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}, as the regex engine + // backs off: the local part runs up to the @; the domain is the + // longest prefix of its run that ends in a dot and two letters or more. + std::size_t Email(std::string_view s, std::size_t at) { + auto local = [](char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || std::string_view("._%+-").contains(c); }; + auto domain = [](char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-'; }; + auto letter = [](char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); }; + const std::size_t name = RunOf(s, at, local); + if (name == 0 || at + name >= s.size() || s[at + name] != '@') return 0; + const std::size_t start = at + name + 1; + const std::size_t run = RunOf(s, start, domain); + for (std::size_t k = run; k-- > 1;) { + if (s[start + k] != '.') continue; + const std::size_t letters = RunOf(s, start + k + 1, letter); + if (letters >= 2) return name + 1 + k + 1 + letters; + } + return 0; + } + + // shutil.which(name, path=...): an executable file on the path. + std::optional Which(std::string_view name, std::string_view path) { + for (const std::string& dir : Dyn::Split(path, ":")) { + // An empty PATH entry gives the bare name. + const fs::path candidate = dir.empty() ? fs::path(name) : fs::path(dir) / name; + std::error_code error; + if (fs::is_regular_file(candidate, error) && access(candidate.c_str(), X_OK) == 0) return candidate.string(); + } + return std::nullopt; + } + + // _version_key: claude-code-... + std::array VersionKey(std::string_view name) { + const std::size_t at = name.find("claude-code-"); + if (at == std::string_view::npos) return {0, 0, 0}; + for (std::size_t from = at; from != std::string_view::npos; from = name.find("claude-code-", from + 1)) { + std::array parts {}; + std::size_t i = from + 12; + bool ok = true; + for (std::int32_t p = 0; p < 3 && ok; ++p) { + if (p > 0) { + if (i >= name.size() || name[i] != '.') { + ok = false; + break; + } + ++i; + } + const std::size_t digits = Dyn::DecimalRun(name, i); + if (digits == 0) { + ok = false; + break; + } + parts[static_cast(p)] = Dyn::ParseInt(name.substr(i, digits)).value_or(0); + i += digits; + } + if (ok) return parts; + } + return {0, 0, 0}; + } + + const Json& Window(const Json& seat, std::string_view key) { + return Dyn::Get(Dyn::Or(Dyn::Get(seat, "windows"), Dyn::EmptyDict()), key); + } + + std::string SeatName(const Json& seat) { + const Json& id = Dyn::At(seat, "seat"); + return Dyn::Equal(id, Json(0)) ? std::string("local") : std::format("seat {}", Dyn::Str(id)); + } + + std::string SeatCell(const Json& seat) { + const Json& id = Dyn::At(seat, "seat"); + return Dyn::Equal(id, Json(0)) ? std::string("local") : Dyn::Str(id); + } + + std::int64_t Inflight(const Json& board, const std::map& keys, const Json& seat) { + const auto key = keys.find(Dyn::At(seat, "seat").get()); + if (key == keys.end() || !board.is_object()) return 0; + const Json& record = Dyn::Get(Dyn::Or(Dyn::Get(board, "seats"), Dyn::EmptyDict()), key->second); + return static_cast(AsDouble(Dyn::Get(Dyn::Or(record, Dyn::EmptyDict()), "inflight"))); + } + + std::vector SortedBySeat(const std::vector& seats) { + std::vector out = seats; + std::ranges::stable_sort(out, [](const Json& a, const Json& b) { return Less(Dyn::At(a, "seat"), Dyn::At(b, "seat")); }); + return out; + } + } + + double JsRound(double x) { + return std::floor(x + 0.5); + } + + std::string Fmt(const Json& value) { + if ((value.is_number() || value.is_boolean())) { + const double n = AsDouble(value); + if (std::isfinite(n) && n == std::floor(n)) return std::to_string(static_cast(n)); + } + return Dyn::Str(value); + } + + Json DefaultConfig() { + return {{"maxUtil", 85}, {"lensCostPct", 10}, {"waitMaxMin", 15}, {"policy", "spread"}, {"handoverPct", 90}}; + } + + Json ConfigFromEnv(const std::map& env) { + Json config = DefaultConfig(); + const Json defaults = DefaultConfig(); + for (const auto& [key, variable] : std::array, 4>{{ + {"maxUtil", "REVIEW_GATES_SEAT_MAX_UTIL"}, {"lensCostPct", "REVIEW_GATES_LENS_COST_PCT"}, + {"waitMaxMin", "REVIEW_GATES_SEAT_WAIT_MIN"}, {"handoverPct", "REVIEW_GATES_SEAT_HANDOVER_PCT"}}}) { + const auto found = env.find(std::string(variable)); + Json value = defaults.at(std::string(key)); + if (found != env.end()) { + const std::optional n = Dyn::ParseFloat(found->second); + if (n && !Dyn::Strip(found->second).empty() && std::isfinite(*n) && *n >= 0) value = IntIfWhole(*n); + } + config[std::string(key)] = value; + } + config["policy"] = EnvOf(env, "REVIEW_GATES_SEAT_POLICY") == "pack" ? "pack" : "spread"; + return config; + } + + std::vector> SeatTokensFromEnv(const std::map& env) { + std::vector> out; + for (std::int32_t i = 1; i <= MaxSeats; ++i) { + std::string token = Dyn::Strip(EnvOf(env, std::format("CLAUDE_CODE_OAUTH_TOKEN{}", i))); + if (token.empty() && i == 1) token = Dyn::Strip(EnvOf(env, "CLAUDE_CODE_OAUTH_TOKEN")); + if (!token.empty()) out.emplace_back(i, token); + } + return out; + } + + std::vector> ApiKeysFromEnv(const std::map& env) { + std::vector> out; + for (std::int32_t i = 1; i <= MaxSeats; ++i) { + const std::string key = Dyn::Strip(EnvOf(env, std::format("ANTHROPIC_API_KEY{}", i))); + if (!key.empty()) out.emplace_back(i, key); + } + return out; + } + + std::vector> OpenRouterKeysFromEnv(const std::map& env) { + std::vector> out; + for (std::int32_t i = 1; i <= MaxSeats; ++i) { + std::string key = Dyn::Strip(EnvOf(env, std::format("OPENROUTER_KEY{}", i))); + if (key.empty() && i == 1) key = Dyn::Strip(EnvOf(env, "OPENROUTER_KEY")); + if (!key.empty()) out.emplace_back(i, key); + } + return out; + } + + std::map SeatTokensFromEnvFile(std::string_view text) { + std::map env; + for (const std::string& raw : Dyn::Split(text, "\n")) { + // ^\s*(?:export\s+)?(NAME)\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); + 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; + } + return env; + } + + Json Pct(const Json& utilization) { + const std::optional n = FloatOf(utilization); + if (!n || !std::isfinite(*n) || *n < 0) return 0; + return IntIfWhole(JsRound(*n * 1000) / 10); + } + + namespace { + std::int64_t IntOf(const Json& value) { + const std::optional n = FloatOf(value); + if (!n || !std::isfinite(*n)) return 0; + return static_cast(*n); + } + } + + Json ParseRateLimitInfo(const Json& raw) { + if (!raw.is_object()) return nullptr; + Json windows = Json::object(); + const Json& unified = Dyn::Get(raw, "unifiedWindows"); + if (unified.is_object()) { + for (auto it = unified.begin(); it != unified.end(); ++it) { + if (!it.value().is_object()) continue; + windows[it.key()] = {{"utilization", Pct(Dyn::Get(it.value(), "utilization"))}, {"resetsAt", IntOf(Dyn::Get(it.value(), "resetsAt"))}}; + } + } + const std::int64_t resets = IntOf(Dyn::Get(raw, "resetsAt")); + return { + {"status", Dyn::StrOr(Dyn::Get(raw, "status"), "")}, + {"rateLimitType", Dyn::Truthy(Dyn::Get(raw, "rateLimitType")) ? Json(Dyn::Str(Dyn::At(raw, "rateLimitType"))) : Json()}, + {"resetsAt", resets ? Json(resets) : Json()}, + {"windows", std::move(windows)}, + // overageDisabledReason, e.g. out_of_credits: a limit is a hard 429, + // not a charge. + {"overageReason", Dyn::Truthy(Dyn::Get(raw, "overageDisabledReason")) ? Json(Dyn::Str(Dyn::At(raw, "overageDisabledReason"))) : Json()}, + }; + } + + Json ParseStream(std::string_view text) { + Json result; + Json rate; + Json session; + if (Dyn::Strip(text).empty()) return {{"result", nullptr}, {"rateLimit", nullptr}, {"sessionId", nullptr}}; + for (const std::string& raw : Dyn::Split(text, "\n")) { + const std::string line = Dyn::Strip(raw); + if (line.empty()) continue; + Json o; + try { + o = ParseLoose(line); + } catch (const std::exception&) { + continue; + } + if (!o.is_object()) continue; + const Json& id = Dyn::Get(o, "session_id"); + if (!Dyn::Truthy(session) && id.is_string()) { + const std::string& value = id.get_ref(); + const bool shaped = value.size() == 36 && std::ranges::all_of(value, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c == '-'; }); + if (shaped) session = id; + } + if (Dyn::Get(o, "type") == "result") { + result = o; + } else if (Dyn::Get(o, "type") == "rate_limit_event") { + const Json parsed = ParseRateLimitInfo(Dyn::Get(o, "rate_limit_info")); + if (Dyn::Truthy(parsed)) rate = parsed; + } + } + return {{"result", result}, {"rateLimit", rate}, {"sessionId", session}}; + } + + std::vector ProbeArgs(std::string_view model) { + return {"--print", "ok", "--system-prompt", "Reply ok.", "--model", std::string(model), "--max-turns", "1", "--tools", "", + "--strict-mcp-config", "--disable-slash-commands", "--output-format", "stream-json", "--verbose"}; + } + + std::map ProbeEnv(std::string_view token, const std::string& configDir, const std::map& base) { + const std::string& home = EnvOf(base, "HOME"); + return { + {"PATH", EnvOf(base, "PATH")}, + {"HOME", home.empty() ? fs::temp_directory_path().string() : home}, + {"CLAUDE_CONFIG_DIR", configDir}, + {"CLAUDE_CODE_OAUTH_TOKEN", std::string(token)}, + {"MAX_THINKING_TOKENS", "0"}, + {"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"}, + {"DISABLE_AUTOUPDATER", "1"}, + }; + } + + std::map LocalEnv(const std::map& base) { + std::map env = base; + for (const std::string_view key : {"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"}) env.erase(std::string(key)); + env["MAX_THINKING_TOKENS"] = "0"; + env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"; + env["DISABLE_AUTOUPDATER"] = "1"; + return env; + } + + fs::path WriteApiKeyApproval(const fs::path& configDir, std::string_view key) { + const fs::path path = configDir / ".claude.json"; + if (fs::exists(path)) return path; + const std::size_t length = Dyn::Length(key); + const std::string tail = Dyn::Slice(key, static_cast(length) - std::min(20, static_cast(length))); + const Json approval = {{"hasCompletedOnboarding", true}, {"customApiKeyResponses", {{"approved", {tail}}, {"rejected", Json::array()}}}}; + // O_EXCL: two passes of a fresh seat write it in the same instant; the + // contents are the same, so the one that loses leaves the other's. + const fs::path temp = std::format("{}.{}.{}.tmp", path.string(), Platform::PublishedPid(), TokenHex(4)); + { + std::ofstream out(temp, std::ios::binary); + out << DumpSpaced(approval); + } + fs::permissions(temp, fs::perms::owner_read | fs::perms::owner_write, fs::perm_options::replace); + std::error_code error; + if (!fs::exists(path)) fs::rename(temp, path, error); + fs::remove(temp, error); + return path; + } + + std::map ApiEnv(std::string_view key, const std::string& configDir, const std::map& base) { + // Minimal by construction: no CLAUDE_CODE_ENTRYPOINT of a launcher (it + // turns the subscriber credits' answer into "Credit balance is too + // low"). + std::map env = ProbeEnv("", configDir, base); + env.erase("CLAUDE_CODE_OAUTH_TOKEN"); + env["ANTHROPIC_API_KEY"] = std::string(key); + WriteApiKeyApproval(configDir, key); + return env; + } + + bool LooksLikeCreditExhaustion(const Json& result, std::string_view text) { + return CreditText(ResultText(result)) || CreditText(text); + } + + bool LooksLikeAuthFailure(const Json& result, std::string_view text) { + const Json& status = Dyn::Get(Dyn::Or(result, Dyn::EmptyDict()), "api_error_status"); + if (Dyn::Equal(status, Json(401)) || Dyn::Equal(status, Json(403))) return true; + return Stream::AuthText(ResultText(result)) || Stream::AuthText(text); + } + + std::string ClipReason(std::string_view input, std::span secrets) { + std::string s(input); + for (const std::string& secret : secrets) { + if (!secret.empty()) s = Dyn::Replace(s, secret, "[redacted:token]"); + } + s = SubAll(s, Jwt, "[redacted:jwt]"); + s = SubAll(s, AnthropicKey, "[redacted:token]"); + s = SubAll(s, Bearer, "Bearer [redacted]"); + s = SubAll(s, Email, "[redacted:email]"); + // re.sub(r'\s+', ' ', s).strip()[:160] + std::string collapsed; + for (std::size_t at = 0; at < s.size();) { + const std::size_t gap = Dyn::SpaceRun(s, at); + if (gap > 0) { + collapsed.push_back(' '); + at += gap; + continue; + } + const std::size_t used = Dyn::CodePointAt(s, at).second; + collapsed.append(s, at, used); + at += used; + } + return Dyn::Slice(Dyn::Strip(collapsed), 0, 160); + } + + Json ProbeSeat(std::int32_t seat, const std::string& token, const ProbeOptions& options) { + const std::string claude = options.claudeBin.empty() ? FindClaudeBin() : options.claudeBin; + Json base = {{"seat", seat}, {"probedWith", options.model}, {"windows", Json::object()}}; + auto with = [&](Json extra) { + Json out = base; + for (auto it = extra.begin(); it != extra.end(); ++it) out[it.key()] = it.value(); + return out; + }; + const std::vector secrets = {token}; + std::optional configDir; + if (!options.local) { + for (;;) { + configDir = fs::temp_directory_path() / std::format("rg-seat-{}-{}", seat, TokenHex(4)); + if (fs::create_directory(*configDir)) break; + } + fs::permissions(*configDir, fs::perms::owner_all, fs::perm_options::replace); + } + const std::map parent = Platform::Environment(); + const std::map env = options.local ? LocalEnv(parent) : (options.api ? ApiEnv(token, configDir->string(), parent) : ProbeEnv(token, configDir->string(), parent)); + std::vector argv = {claude}; + std::ranges::move(ProbeArgs(options.model), std::back_inserter(argv)); + if (options.local) { + argv.emplace_back("--setting-sources"); + argv.emplace_back(""); + argv.emplace_back("--no-session-persistence"); + } + auto cleanup = [&] { + std::error_code error; + if (configDir) fs::remove_all(*configDir, error); + }; + // A binary that cannot be started is said here, as an OSError, before + // the supervisor would only report it as an exit code. + const std::string path = claude.contains('/') ? claude : Which(claude, EnvOf(env, "PATH")).value_or(""); + if (path.empty() || access(path.c_str(), X_OK) != 0) { + cleanup(); + return with({{"state", "unknown"}, {"error", ClipReason(std::format("probe spawn failed: {}: {}", Dyn::ErrnoText(2), Dyn::Repr(claude)), secrets)}}); + } + Process::Captured run; + try { + run = Process::Run(Process::Supervised(argv), std::nullopt, std::chrono::milliseconds(static_cast(options.timeoutS * 1000)), {}, env); + } catch (const std::exception& error) { + cleanup(); + return with({{"state", "unknown"}, {"error", ClipReason(std::format("probe spawn failed: {}", error.what()), secrets)}}); + } + cleanup(); + if (run.timedOut) return with({{"state", "unknown"}, {"error", std::format("probe timeout after {} ms", static_cast(options.timeoutS * 1000))}}); + const std::string out = Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out)); + const std::string err = Dyn::UniversalNewlines(Dyn::DecodeReplace(run.err)); + const std::int32_t code = Process::SupervisedReturnCode(run.returnCode); + const Json parsed = ParseStream(out); + const Json& rate = parsed.at("rateLimit"); + const Json& result = parsed.at("result"); + const Json cost = Dyn::Get(Dyn::Or(result, Dyn::EmptyDict()), "total_cost_usd"); + auto said = [&](std::string_view fallback) { + const std::string text = ResultText(result); + return !text.empty() ? text : (!err.empty() ? err : std::string(fallback)); + }; + if (options.api) { + // No rate windows on a Console key: the probe's result line is the + // whole reading. + if (Dyn::Truthy(result) && !Dyn::Truthy(Dyn::Get(result, "is_error"))) return with({{"state", "ok"}, {"costUsd", cost}}); + if (LooksLikeCreditExhaustion(result, err)) return with({{"state", "out"}, {"error", ClipReason(said("credit balance too low"), secrets)}}); + if (LooksLikeAuthFailure(result, err)) return with({{"state", "invalid"}, {"error", ClipReason(said("authentication failed"), secrets)}}); + return with({{"state", "unknown"}, {"error", ClipReason(std::format("probe failed (exit {}) {}", code, said("")), secrets)}}); + } + if (Dyn::Truthy(rate) && Dyn::Truthy(rate.at("windows"))) { + const auto [util, binding] = SeatUtil(rate.at("windows")); + Json resets = Dyn::Get(Dyn::Or(Dyn::Get(rate.at("windows"), "five_hour"), Dyn::EmptyDict()), "resetsAt"); + if (!Dyn::Truthy(resets)) resets = Dyn::Get(rate, "resetsAt"); + if (rate.at("status") == "rejected") { + // The one-word probe was refused: the seat takes nothing until + // its reset, whatever its windows read. + std::string why = std::format("{} rejected", Dyn::StrOr(Dyn::Get(rate, "rateLimitType"), "window")); + if (Dyn::Truthy(Dyn::Get(rate, "overageReason"))) why += std::format(", overage {}", Dyn::Str(rate.at("overageReason"))); + const Json limitedUtil = AsDouble(util) > 100 ? util : Json(100); + return with({{"state", "limited"}, {"windows", rate.at("windows")}, {"util", limitedUtil}, + {"binding", Dyn::Truthy(Dyn::Get(rate, "rateLimitType")) ? rate.at("rateLimitType") : Json(binding)}, {"resetsAt", resets}, {"error", why}}); + } + return with({{"state", "ok"}, {"windows", rate.at("windows")}, {"util", util}, {"binding", binding}, {"resetsAt", resets}}); + } + if (LooksLikeAuthFailure(result, err)) return with({{"state", "invalid"}, {"error", ClipReason(said("authentication failed"), secrets)}}); + return with({{"state", "unknown"}, {"error", ClipReason(std::format("no rate_limit_event (exit {}) {}", code, said("")), secrets)}}); + } + + double Quantize(double util) { + return JsRound(util / RankQuantum) * RankQuantum; + } + + std::pair BindingWindow(const Json& windows) { + Json util = 0; + std::string binding; + for (auto it = windows.begin(); it != windows.end(); ++it) { + const Json& value = Dyn::At(it.value(), "utilization"); + if (Less(util, value) || binding.empty()) { + util = value; + binding = it.key(); + } + } + return {util, binding}; + } + + std::pair SeatUtil(const Json& windows) { + const auto [util, binding] = BindingWindow(windows); + return {Json(static_cast(Quantize(AsDouble(util)))), binding}; + } + + std::string ModelFamily(std::string_view model) { + const std::u32string text = Dyn::Decode(model); + for (std::size_t at = 0; at < text.size(); ++at) { + for (const std::string_view family : ModelFamilies) { + if (MatchCi(text, at, family)) return std::string(family); + } + } + return ""; + } + + bool IsFableWindow(std::string_view key) { + return key == FableWindow || ModelFamily(key) == "fable"; + } + + const Json* FableWindowOf(const Json& seat) { + const Json& windows = Dyn::Or(Dyn::Get(seat, "windows"), Dyn::EmptyDict()); + for (auto it = windows.begin(); it != windows.end(); ++it) { + if (IsFableWindow(it.key())) return &it.value(); + } + return nullptr; + } + + bool LongWindowsOk(const Json& seat) { + const Json& windows = Dyn::Or(Dyn::Get(seat, "windows"), Dyn::EmptyDict()); + for (auto it = windows.begin(); it != windows.end(); ++it) { + if (it.key() == "five_hour" || IsFableWindow(it.key())) continue; + if (AsDouble(Dyn::At(it.value(), "utilization")) >= LongWindowCutoff) return false; + } + return true; + } + + bool IsEligible(const Json& seat, const Json& config) { + return Dyn::At(seat, "state") == "ok" && NumberOr0(Dyn::Get(seat, "util")) <= AsDouble(config.at("maxUtil")) && LongWindowsOk(seat); + } + + bool FableOpen(const Json& seat, const Json& config) { + const Json& fable = Dyn::Or(Dyn::Get(seat, "fable"), Dyn::EmptyDict()); + if (Dyn::Get(fable, "state") == "limited" || (Dyn::At(seat, "state") == "limited" && IsFableWindow(Dyn::StrOr(Dyn::Get(seat, "binding"), "")))) return false; + const Json* w = FableWindowOf(seat); + if (!w) return true; + const double u = AsDouble(Dyn::At(*w, "utilization")); + return u <= AsDouble(config.at("maxUtil")) && u < LongWindowCutoff; + } + + Json PickNext(const std::vector& seats, const Json& config) { + if (seats.empty()) return {{"kind", "stop"}, {"status", "error"}, {"reason", "no seat token configured"}}; + std::vector usable; + for (const Json& s : SortedBySeat(seats)) { + if (Dyn::At(s, "state") != "invalid") usable.push_back(s); + } + if (usable.empty()) { + std::vector ids; + for (const Json& s : seats) ids.push_back(Dyn::Str(Dyn::At(s, "seat"))); + return {{"kind", "stop"}, {"status", "error"}, {"reason", std::format("no valid seat token (seat{} {} rejected)", seats.size() == 1 ? "" : "s", Dyn::Join(ids, ", "))}}; + } + if (std::ranges::all_of(usable, [](const Json& s) { return Dyn::At(s, "state") == "unknown"; })) { + return {{"kind", "seat"}, {"seat", usable[0].at("seat")}, {"source", "round-robin"}}; + } + const double maxUtil = AsDouble(config.at("maxUtil")); + const double cost = AsDouble(config.at("lensCostPct")); + auto projected = [&](const Json& s) { + return Dyn::At(s, "state") == "unknown" || Dyn::Get(s, "util").is_null() ? maxUtil : AsDouble(Dyn::At(s, "util")); + }; + auto longOk = [&](const Json& s) { return Dyn::At(s, "state") == "unknown" || LongWindowsOk(s); }; + std::vector pool; + for (const Json& s : usable) { + if (projected(s) + cost <= maxUtil && longOk(s)) pool.push_back(&s); + } + const Json* chosen = nullptr; + auto least = [&](const std::vector& candidates) { + const Json* best = candidates.front(); + for (const Json* s : candidates) { + if (projected(*s) < projected(*best)) best = s; + } + return best; + }; + if (!pool.empty()) { + chosen = config.at("policy") == "pack" ? pool.front() : least(pool); + } else { + // Nothing fits under the ceiling: the most headroom among the seats + // whose long windows are open, else any. + std::vector candidates; + for (const Json& s : usable) { + if (longOk(s)) candidates.push_back(&s); + } + if (candidates.empty()) { + for (const Json& s : usable) candidates.push_back(&s); + } + chosen = least(candidates); + } + return {{"kind", "seat"}, {"seat", chosen->at("seat")}, {"source", "probe"}}; + } + + bool NeedsHaiku(const Json& seat) { + const Json& state = Dyn::At(seat, "state"); + return state == "unknown" || state == "invalid" || (state == "limited" && IsFableWindow(Dyn::StrOr(Dyn::Get(seat, "binding"), ""))); + } + + std::string FableState(const Json& fable) { + if (Dyn::At(fable, "state") == "limited") return IsFableWindow(Dyn::StrOr(Dyn::Get(fable, "binding"), "")) ? "limited" : "ok"; + return Dyn::Str(Dyn::At(fable, "state")); + } + + Json MergeFableProbe(Json base, const Json& fable) { + if (const Json* w = FableWindowOf(fable)) { + Json windows = Json::object(); + const Json& own = Dyn::Or(Dyn::Get(base, "windows"), Dyn::EmptyDict()); + for (auto it = own.begin(); it != own.end(); ++it) { + if (!IsFableWindow(it.key())) windows[it.key()] = it.value(); + } + windows[std::string(FableWindow)] = *w; + base["windows"] = std::move(windows); + } + if (Dyn::At(base, "state") == "ok") { + // The seat's utilisation is its own windows' — the Fable window + // gates Fable work only (FableOpen): a drained Fable window makes an + // Opus seat, never an idle one. + Json own = Json::object(); + for (auto it = base.at("windows").begin(); it != base.at("windows").end(); ++it) { + if (!IsFableWindow(it.key())) own[it.key()] = it.value(); + } + const auto [util, binding] = SeatUtil(own); + base["util"] = util; + base["binding"] = binding; + } + base["fable"] = {{"state", FableState(fable)}}; + const Json& state = Dyn::At(fable, "state"); + if (state != "ok" && state != "limited" && Dyn::Truthy(Dyn::Get(fable, "error"))) base["fable"]["error"] = fable.at("error"); + return base; + } + + std::string FindClaudeBin(const std::map& env, const std::string& home) { + for (const std::string_view variable : {"CLAUDE_BIN", "REVIEW_GATES_CLAUDE_BIN"}) { + if (const std::string& value = EnvOf(env, std::string(variable)); !value.empty()) return value; + } + if (const std::string& path = EnvOf(env, "PATH"); !path.empty()) { + if (const std::optional found = Which("claude", path)) return *found; + } + std::vector found; + for (const std::string_view extensions : {".vscode-oss/extensions", ".vscode/extensions", ".vscode-insiders/extensions", ".cursor/extensions"}) { + const fs::path dir = fs::path(home) / extensions; + std::error_code error; + if (!fs::is_directory(dir, error)) continue; + std::vector names; + for (const fs::directory_entry& entry : fs::directory_iterator(dir, error)) { + const std::string name = entry.path().filename().string(); + if (name.starts_with("anthropic.claude-code-")) names.push_back(name); + } + std::ranges::stable_sort(names, [](const std::string& a, const std::string& b) { return VersionKey(a) > VersionKey(b); }); + for (const std::string& name : names) { + const fs::path binary = dir / name / "resources" / "native-binary" / "claude"; + if (fs::exists(binary, error)) found.push_back(binary.string()); + } + } + for (const fs::path local : {fs::path(home) / ".claude" / "local" / "claude", fs::path(home) / ".local" / "bin" / "claude"}) { + std::error_code error; + if (fs::exists(local, error)) found.push_back(local.string()); + } + return found.empty() ? std::string("claude") : found.front(); + } + + std::string FindClaudeBin() { + return FindClaudeBin(Platform::Environment(), Platform::Home()); + } + + Zone ZoneNamed(std::string_view name) { + // ZoneInfo(name): a zone the system's zoneinfo holds, else none. + std::error_code error; + const bool known = name == "UTC" || fs::is_regular_file(fs::path("/usr/share/zoneinfo") / name, error); + return {known ? std::string(name) : std::string(), std::string(name)}; + } + + Zone LocalZone() { + std::string name = Dyn::LStrip(Platform::GetEnv("TZ").value_or(""), ":"); + if (name.empty()) { + std::error_code error; + const std::string target = fs::weakly_canonical("/etc/localtime", error).string(); + const std::size_t at = target.find("/zoneinfo/"); + if (at != std::string::npos) name = target.substr(at + 10); + } + if (!name.empty()) { + Zone zone = ZoneNamed(name); + if (!zone.name.empty()) return zone; + } + return {"", name.empty() ? Platform::LocalTime("%Z") : name}; + } + + namespace { + // The broken-down time of `t` in the zone: the C library's, with TZ + // set to the zone's name for the call. + std::tm InZone(std::time_t t, const Zone& zone) { + static std::mutex lock; + const std::lock_guard guard(lock); + std::tm out {}; + if (zone.name.empty()) { + localtime_r(&t, &out); + return out; + } + const char* before = getenv("TZ"); + const std::optional saved = before ? std::optional(before) : std::nullopt; + setenv("TZ", zone.name.c_str(), 1); + tzset(); + localtime_r(&t, &out); + if (saved) { + setenv("TZ", saved->c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); + return out; + } + } + + std::string FormatWhen(double epoch, double now, const Zone& zone) { + const auto diffMin = static_cast(JsRound((epoch - now) / 60)); + std::string relative; + if (diffMin <= 0) { + relative = "now"; + } else if (diffMin < 60) { + relative = std::format("in {} min", diffMin); + } else if (diffMin < 48 * 60) { + relative = std::format("in {}h {:02d}m", diffMin / 60, diffMin % 60); + } else { + relative = std::format("in {}d {}h", diffMin / 1440, (diffMin % 1440) / 60); + } + const std::tm d = InZone(static_cast(std::floor(epoch)), zone); + std::string clock = std::format("{:02d}:{:02d}", d.tm_hour, d.tm_min); + if (epoch - now > 24 * 3600) { + // Monday is 0 here; tm_wday counts from Sunday. + const auto index = static_cast((d.tm_wday + 6) % 7); + clock = std::format("{} {:02d} {}, {}", Weekdays[index], d.tm_mday, Months[static_cast(d.tm_mon)], clock); + } + return std::format("{} ({})", clock, relative); + } + + std::string RenderTable(const std::vector>& rows) { + std::vector widths(rows.at(0).size(), 0); + for (const std::vector& row : rows) { + for (std::size_t i = 0; i < widths.size(); ++i) widths[i] = std::max(widths[i], Dyn::Length(row.at(i))); + } + auto line = [&](const std::vector& row) { + std::vector cells; + for (std::size_t i = 0; i < widths.size(); ++i) cells.push_back(Dyn::LJust(row[i], widths[i])); + return std::format("| {} |", Dyn::Join(cells, " | ")); + }; + std::vector out = {line(rows[0])}; + std::vector dashes; + for (const std::size_t width : widths) dashes.emplace_back(width + 2, '-'); + out.push_back(std::format("|{}|", Dyn::Join(dashes, "|"))); + for (std::size_t r = 1; r < rows.size(); ++r) out.push_back(line(rows[r])); + return Dyn::Join(out, "\n"); + } + + std::string RenderStatusReport(const std::vector& seats, const Json& config, double now, const Zone& zone, const Json& board, + const std::map& keys) { + const std::vector ordered = SortedBySeat(seats); + const bool anyInflight = std::ranges::any_of(ordered, [&](const Json& s) { return Inflight(board, keys, s) != 0; }); + std::vector> rows; + std::vector header = {"seat", "5h", "resets", "7d", "resets", "fable 7d"}; + if (anyInflight) header.emplace_back("in flight"); + rows.push_back(header); + for (const Json& s : ordered) { + auto w = [&](std::string_view k) { + if (!Dyn::Has(Dyn::Or(Dyn::Get(s, "windows"), Dyn::EmptyDict()), k)) return std::string("—"); + return std::format("{}%", Fmt(Dyn::At(Window(s, k), "utilization"))); + }; + auto when = [&](std::string_view k) { + const Json& at = Dyn::Get(Dyn::Or(Window(s, k), Dyn::EmptyDict()), "resetsAt"); + return Dyn::Truthy(at) ? FormatWhen(AsDouble(at), now, zone) : std::string("—"); + }; + const Json* fw = FableWindowOf(s); + const Json& f = Dyn::Get(s, "fable"); + std::string fable; + if (Dyn::Truthy(f) && Dyn::Get(f, "state") == "limited") { + fable = std::format("limited{}", fw ? std::format(" ({}%)", Fmt(Dyn::At(*fw, "utilization"))) : std::string()); + } else if (fw) { + fable = std::format("{}%", Fmt(Dyn::At(*fw, "utilization"))); + } else { + fable = Dyn::Truthy(f) ? Dyn::Str(Dyn::At(f, "state")) : std::string("—"); + } + std::vector row = {SeatCell(s), w("five_hour"), when("five_hour"), w("seven_day"), when("seven_day"), fable}; + if (anyInflight) { + const std::int64_t n = Inflight(board, keys, s); + row.push_back(n ? std::to_string(n) : "—"); + } + rows.push_back(std::move(row)); + } + std::vector eligible; + for (const Json& s : ordered) { + if (IsEligible(s, config)) eligible.push_back(s); + } + std::ranges::stable_sort(eligible, [](const Json& a, const Json& b) { return NumberOr0(Dyn::Get(a, "util")) < NumberOr0(Dyn::Get(b, "util")); }); + const Json pick = PickNext(seats, config); + const std::string next = pick.at("kind") == "seat" ? SeatName({{"seat", pick.at("seat")}}) : std::format("{}: {}", Dyn::Str(pick.at("status")), Dyn::Str(pick.at("reason"))); + std::vector limited; + std::vector unusable; + std::vector fableDead; + for (const Json& s : ordered) { + const Json& state = Dyn::At(s, "state"); + if (state == "limited") { + const Json& resets = Dyn::Get(s, "resetsAt"); + limited.push_back(std::format("{} until {}", SeatName(s), Dyn::Truthy(resets) ? FormatWhen(AsDouble(resets), now, zone) : std::string("?"))); + } + if (state != "ok" && state != "limited") { + unusable.push_back(std::format("{} {}{}", SeatName(s), Dyn::Str(state), Dyn::Truthy(Dyn::Get(s, "error")) ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string())); + } + const Json& fableOfSeat = Dyn::Or(Dyn::Get(s, "fable"), Dyn::EmptyDict()); + const Json& fableStateOfSeat = Dyn::Get(fableOfSeat, "state"); + if ((state == "ok" || state == "limited") && !fableStateOfSeat.is_null() && fableStateOfSeat != "ok" && fableStateOfSeat != "limited") { + fableDead.push_back(std::format("{} {}{}", SeatName(s), Dyn::Str(fableStateOfSeat), + Dyn::Truthy(Dyn::Get(fableOfSeat, "error")) ? std::format(" ({})", Dyn::Str(fableOfSeat.at("error"))) : std::string())); + } + } + const bool fableSeen = std::ranges::any_of(seats, [](const Json& s) { return FableWindowOf(s) != nullptr; }); + std::vector fableRoom; + for (const Json& s : ordered) { + if (Dyn::At(s, "state") == "ok" && FableWindowOf(s) && FableOpen(s, config)) fableRoom.push_back(s); + } + std::ranges::stable_sort(fableRoom, [](const Json& a, const Json& b) { return Less(Dyn::At(*FableWindowOf(a), "utilization"), Dyn::At(*FableWindowOf(b), "utilization")); }); + const double maxUtil = AsDouble(config.at("maxUtil")); + std::vector lines = {RenderTable(rows), ""}; + lines.push_back(std::format("policy {} · ceiling {}% · handover at {}% · a lens is placed at +{}% · 7d cutoff {}%", Dyn::Str(config.at("policy")), Fmt(config.at("maxUtil")), + Fmt(config.at("handoverPct")), Fmt(config.at("lensCostPct")), LongWindowCutoff)); + std::vector eligibleText; + for (const Json& s : eligible) eligibleText.push_back(std::format("{} ({}% headroom)", SeatName(s), Fmt(Json(maxUtil - NumberOr0(Dyn::Get(s, "util")))))); + lines.push_back(std::format("eligible now: {} → next lens: {}", eligible.empty() ? std::string("none") : Dyn::Join(eligibleText, ", "), next)); + if (fableSeen) { + std::vector room; + for (const Json& s : fableRoom) room.push_back(std::format("{} ({}% fable headroom)", SeatName(s), Fmt(Json(maxUtil - AsDouble(Dyn::At(*FableWindowOf(s), "utilization")))))); + lines.push_back(std::format("fable lenses: {}", fableRoom.empty() ? std::string("no seat with room on its Fable window — Fable lenses run on their fallback model") : Dyn::Join(room, ", "))); + } + if (!limited.empty()) lines.push_back(std::format("limited: {}", Dyn::Join(limited, "; "))); + if (!unusable.empty()) lines.push_back(std::format("not usable: {}", Dyn::Join(unusable, "; "))); + if (!fableDead.empty()) lines.push_back(std::format("fable probe: {}", Dyn::Join(fableDead, "; "))); + return Dyn::Join(lines, "\n"); + } + + std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, const Json& board, const std::map& keys) { + std::vector> rows = {{"seat", "state", "driver spend this cycle", "procs", "cycle from", "ledger: out", "in flight"}}; + for (const Json& s : SortedBySeat(apiSeats)) { + const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict()); + const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd")); + const std::string state = Dyn::Str(Dyn::At(s, "state")) + (Dyn::Truthy(Dyn::Get(s, "error")) && Dyn::At(s, "state") != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string()); + const Json& outSince = Dyn::Get(ledger, "outSince"); + const std::int64_t n = Inflight(board, keys, s); + rows.push_back({ + std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))), state, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"), + Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::Truthy(outSince) ? std::format("since {}", Dyn::Replace(Dyn::Slice(Dyn::Str(outSince), 0, 16), "T", " ")) : std::string("—"), + n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—"), + }); + } + return Dyn::Join(std::vector{ + 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"))), + "the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent", + }, "\n"); + } + + std::string RenderDrivers(const Json& board, double now, const Zone& zone) { + const Json& drivers = Dyn::Or(Dyn::Get(Dyn::Or(board, Dyn::EmptyDict()), "drivers"), Dyn::EmptyDict()); + if (!Dyn::Truthy(drivers)) return std::format("drivers: none running (the board at {})", Dyn::StrOr(Dyn::Get(Dyn::Or(board, Dyn::EmptyDict()), "path"), "?")); + std::vector> sorted; + for (auto it = drivers.begin(); it != drivers.end(); ++it) sorted.emplace_back(it.key(), &it.value()); + std::ranges::stable_sort(sorted, [](const auto& a, const auto& b) { return NumberOr0(Dyn::Get(*a.second, "since")) < NumberOr0(Dyn::Get(*b.second, "since")); }); + std::vector parts; + std::int64_t total = 0; + for (const auto& [key, driver] : sorted) { + const Json& d = *driver; + const Json& settings = Dyn::Or(Dyn::Get(d, "settings"), Dyn::EmptyDict()); + std::string held; + if (Dyn::Truthy(Dyn::Get(d, "mb"))) held += std::format(", {:.0f} MB", AsDouble(d.at("mb"))); + if (Dyn::Truthy(Dyn::Get(d, "stacks"))) { + std::vector stacks; + for (const Json& stack : d.at("stacks")) stacks.push_back(Dyn::Str(stack)); + std::ranges::sort(stacks); + held += std::format(", stack(s) {}", Dyn::Join(stacks, ", ")); + } + const Json unknown = "?"; + const Json zero = 0; + parts.push_back(std::format("pid {} ({}, {}, width {}) since {} - {} in flight{}", Dyn::Split(key, ":")[0], Dyn::Str(Dyn::Has(d, "run") ? d.at("run") : unknown), + Dyn::Str(Dyn::Has(d, "effort") ? d.at("effort") : unknown), Dyn::Str(Dyn::Has(settings, "concurrency") ? settings.at("concurrency") : unknown), + FormatWhen(NumberOr0(Dyn::Get(d, "since")), now, zone), Dyn::Str(Dyn::Has(d, "inflight") ? d.at("inflight") : zero), held)); + total += static_cast(NumberOr0(Dyn::Get(d, "inflight"))); + } + return std::format("drivers ({} in flight on this machine; the width is the machine's cap): {}", total, Dyn::Join(parts, "; ")); + } +} diff --git a/interfaces/Crafter.Review-Mcp.cppm b/interfaces/Crafter.Review-Mcp.cppm new file mode 100644 index 0000000..feb044c --- /dev/null +++ b/interfaces/Crafter.Review-Mcp.cppm @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Review:Mcp; +import std; +import :Json; + +// The Playwright MCP server a lens with the browser environment drives the +// application with (2026-10-15): one server per pass, launched by the CLI +// from --mcp-config (never from the lens's Bash), headless, an in-memory +// profile, its screenshots under the pass's own output dir in the scratch +// tree. The tools are allowlisted by name (:Tools); browser_run_code_unsafe +// runs arbitrary Node code in the server's process and is denied. +export namespace Crafter::Review::Mcp { + // @playwright/mcp, verified 2026-10-15 with CLI 2.1.300. + constexpr std::string_view PlaywrightMcpVersion = "0.0.83"; + // The Chromium build that version's Playwright drives, as `npx playwright + // install chromium` puts it under ~/.cache/ms-playwright: what + // `crafter-review browser install` provides. + constexpr std::string_view ChromiumRevision = "1247"; + // --browser chromium: Playwright's own Chromium; without it the server + // wants the `chrome` channel, Google Chrome at /opt/google/chrome + // (2026-10-15: every navigate of the second firing run failed on it). + std::vector DefaultArgs(); + + // The --mcp-config value for one pass: the consumer's command and args (the + // pipeline's pinned default when the config names none) plus this pass's + // output dir. + Json PlaywrightConfig(const Json& functional, const std::string& outputDir); + // The config as compact JSON (DumpCompact), keys in their order. + std::string ConfigJson(const Json& config); + // /playwright/