diff --git a/bag/lib/bag/action_result.ex b/bag/lib/bag/action_result.ex new file mode 100644 index 0000000..85ddd49 --- /dev/null +++ b/bag/lib/bag/action_result.ex @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Bag.ActionResult do + @moduledoc """ + The result of an action, carrying structured **residue** (the echo-types idea: + lossy/partial transformations retain a proof-relevant residue, not just + success/failure). + + `residue` is one of: + * `:clean` — nothing owed, nothing to repair + * `{:owes, obligation}` — succeeded, but an obligation remains + (e.g. a *relegated* check still owes the + GitHub required-status-check it can't + natively satisfy) + * `{:dirty, repair_obligation}` — a partial/failed action left damage that + must be repaired or discarded + + A cheap action is only truly cheap if its residue is cheap to verify/repair. + """ + defstruct [:verdict, :node, :residue, :baton] + + @type residue :: :clean | {:owes, term()} | {:dirty, term()} + @type t :: %__MODULE__{verdict: atom(), node: String.t() | nil, residue: residue(), baton: any()} + + @doc """ + Classify the residue from a verdict + context. + + Opts: `:relegated` (ran off the canonical paid route → owes its native gate), + `:repair` (the repair obligation to attach to a dirty/partial verdict). + """ + def classify(verdict, opts \\ []) do + cond do + verdict in [:dirty_partial, :catastrophic_partial] -> + {:dirty, Keyword.get(opts, :repair, :unspecified_repair_obligation)} + + verdict == :pass and Keyword.get(opts, :relegated, false) -> + {:owes, :github_required_check} + + true -> + :clean + end + end + + @doc "Build a result, classifying residue from the verdict + context." + def new(verdict, node, opts \\ []) do + %__MODULE__{ + verdict: verdict, + node: node, + residue: classify(verdict, opts), + baton: Keyword.get(opts, :baton) + } + end + + @doc "The repair obligation a dirty result imposes, or `:none`." + def repair_obligation(%__MODULE__{residue: {:dirty, ob}}), do: {:ok, ob} + def repair_obligation(%__MODULE__{}), do: :none +end diff --git a/bag/lib/bag/budget.ex b/bag/lib/bag/budget.ex new file mode 100644 index 0000000..6c8c5a2 --- /dev/null +++ b/bag/lib/bag/budget.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Bag.Budget do + @moduledoc """ + Typed, **non-fungible** resource budgets. + + Budgets are NOT a single scalar: a paid-runner money budget, a mutation + budget, human-review attention, and rollback/repair capacity are not + interchangeable. Budget exhaustion is a *context change* — when a typed budget + runs out, plans that relied on it stop type-checking (the resource is simply + not available to the planner), which is how a depleted paid quota removes the + paid route while cheaper/owned routes remain. + + Each field is either a number (remaining units) or `:infinity` (untracked). + """ + defstruct money: :infinity, mutation: :infinity, human_review: :infinity, repair: :infinity + + @type level :: number() | :infinity + @type t :: %__MODULE__{money: level, mutation: level, human_review: level, repair: level} + + @doc "A new budget from keyword/map fields; unset dimensions are `:infinity`." + def new(fields \\ []), do: struct(__MODULE__, fields) + + @doc "An untracked (unlimited) budget — the back-compatible default." + def unlimited, do: %__MODULE__{} + + @doc "True if `key` has at least `amount` units left (`:infinity` always affords)." + def affords?(%__MODULE__{} = b, key, amount) do + case Map.get(b, key) do + :infinity -> true + have when is_number(have) -> have >= amount + _ -> false + end + end + + @doc "True if `key` cannot afford even one unit (the resource is exhausted)." + def exhausted?(%__MODULE__{} = b, key), do: not affords?(b, key, 1) + + @doc "Spend `amount` of `key` (floored at 0; `:infinity` is unchanged)." + def debit(%__MODULE__{} = b, key, amount) do + case Map.get(b, key) do + :infinity -> b + have when is_number(have) -> Map.put(b, key, max(have - amount, 0)) + _ -> b + end + end +end diff --git a/bag/lib/bag/ci_baton.ex b/bag/lib/bag/ci_baton.ex index 2eb6224..4df728a 100644 --- a/bag/lib/bag/ci_baton.ex +++ b/bag/lib/bag/ci_baton.ex @@ -11,7 +11,17 @@ defmodule Bag.CiBaton do that replaces a paid GitHub Actions run — it can migrate to, and be consumed on, any other node without re-executing the check. """ - defstruct [:id, :check_id, :node, :required_cap, :command, :verdict, :exit_code] + defstruct [ + :id, + :check_id, + :node, + :required_cap, + :command, + :verdict, + :exit_code, + mutating: false, + risk: :low + ] @type verdict :: :pending | :pass | :fail | :suspended | :error @@ -40,7 +50,9 @@ defmodule Bag.CiBaton do required_cap: Keyword.get(opts, :required_cap, "linux"), command: command, verdict: :pending, - exit_code: nil + exit_code: nil, + mutating: Keyword.get(opts, :mutating, false), + risk: Keyword.get(opts, :risk, :low) } end end diff --git a/bag/lib/bag/executor.ex b/bag/lib/bag/executor.ex index f76c3f0..4c87b49 100644 --- a/bag/lib/bag/executor.ex +++ b/bag/lib/bag/executor.ex @@ -13,10 +13,27 @@ defmodule Bag.Executor do (`estate.zig` ← `verification/proofs/Bag/Estate.idr`) via the Zig host — so the orchestrator never keeps its own copy of the node list to drift out of step. """ - def list_nodes do + def list_nodes, do: Map.keys(node_costs()) + + @doc """ + Returns `%{node_name => cost}` — the tropical (min-plus) money grade of each + node, read from the same mirrored manifest. Owned nodes are cheap; the paid + github-runner is expensive. Drives the planner's cheapest-capable routing. + """ + def node_costs do case System.cmd(@executor_path, ["nodes"], cd: Path.expand("../../../", __DIR__)) do - {output, 0} -> String.split(output, "\n", trim: true) - {_output, _code} -> [] + {output, 0} -> + output + |> String.split("\n", trim: true) + |> Map.new(fn line -> + case String.split(line, "\t") do + [name, cost] -> {name, String.to_integer(cost)} + [name] -> {name, 0} + end + end) + + {_output, _code} -> + %{} end end diff --git a/bag/lib/bag/mesh.ex b/bag/lib/bag/mesh.ex index 1e10c0a..7c1b966 100644 --- a/bag/lib/bag/mesh.ex +++ b/bag/lib/bag/mesh.ex @@ -5,7 +5,7 @@ defmodule Bag.Mesh do The distributed orchestrator for Batons. """ use GenServer - alias Bag.{Baton, CiBaton, Executor} + alias Bag.{ActionResult, Baton, CiBaton, Budget, Executor, Planner} def start_link(opts) do {:ok, pid} = GenServer.start_link(__MODULE__, opts, name: __MODULE__) @@ -27,6 +27,18 @@ defmodule Bag.Mesh do GenServer.call(__MODULE__, {:submit_check, check_id, command, opts}, 60_000) end + @doc """ + Budget-and-capability-aware submission. The planner selects the cheapest + capable node the budget can afford (gating mutating work on a verifier); the + check runs there; the result carries structured residue. + + `spec`: `%{check_id, command, required_cap, mutating?, risk?, verifier?}`. + Returns a `Bag.ActionResult` (verdict `:pass | :fail | :rejected | :suspended`). + """ + def submit_planned(spec, %Budget{} = budget \\ Budget.unlimited()) do + GenServer.call(__MODULE__, {:submit_planned, spec, budget}, 60_000) + end + @doc """ Submits a new Baton to the Mesh. """ @@ -97,18 +109,50 @@ defmodule Bag.Mesh do case capable do [] -> - IO.puts("Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended.") + # Operational logs go to stderr — stdout is reserved for machine output. + IO.puts(:stderr, "Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended.") {:reply, {:suspended, nil, nil}, state} [node | _] -> baton = CiBaton.new(check_id, command, node: node, required_cap: required_cap) - IO.puts("Mesh: routing check #{check_id} → #{node} (cap: #{required_cap})") + IO.puts(:stderr, "Mesh: routing check #{check_id} → #{node} (cap: #{required_cap})") {verdict, updated, _output} = Executor.run_check(baton, freeze_path) - IO.puts("Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)") + IO.puts(:stderr, "Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)") {:reply, {verdict, node, updated}, state} end end + @impl true + def handle_call({:submit_planned, spec, budget}, _from, state) do + case Planner.plan(spec, budget) do + {:ok, node, cost} -> + IO.puts(:stderr, "Mesh: planned #{spec.check_id} → #{node} (money cost #{cost})") + + baton = + CiBaton.new(spec.check_id, spec.command, + node: node, + required_cap: spec.required_cap, + mutating: Map.get(spec, :mutating, false), + risk: Map.get(spec, :risk, :low) + ) + + freeze_path = Path.join(System.tmp_dir!(), "#{spec.check_id}.baton") + {verdict, updated, _output} = Executor.run_check(baton, freeze_path) + # Relegated = ran on an owned node, not GitHub's required-check route. + relegated = node != "mesh-github-runner" + result = ActionResult.new(verdict, node, relegated: relegated, baton: updated) + {:reply, result, state} + + {:rejected, reason} -> + IO.puts(:stderr, "Mesh: REJECTED #{spec.check_id}: #{reason}") + {:reply, %ActionResult{verdict: :rejected, node: nil, residue: {:owes, reason}}, state} + + {:suspended, reason} -> + IO.puts(:stderr, "Mesh: SUSPENDED #{spec.check_id}: #{reason} (budget/capability)") + {:reply, %ActionResult{verdict: :suspended, node: nil, residue: :clean}, state} + end + end + @impl true def handle_cast({:process, baton}, state) do IO.puts("Mesh [#{Node.self()}]: Claimed Baton #{baton.id} with count #{baton.counter}") diff --git a/bag/lib/bag/planner.ex b/bag/lib/bag/planner.ex new file mode 100644 index 0000000..1783551 --- /dev/null +++ b/bag/lib/bag/planner.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Bag.Planner do + @moduledoc """ + Capability- and budget-typed action selection. + + Mirrors the formal routing objective `Bag.Estate.cheapestCapable` (Idris): + among nodes that satisfy the required capability AND that the current typed + budget can still afford, choose the least-cost (tropical ⊕ = min) one. + + Policy realised here: + * relegate to the cheapest capable node (reserve the paid route for work + whose capability only it provides); + * an exhausted/insufficient typed budget removes a route (context change) — + work that needs an unaffordable route is *suspended*, not run unsafely; + * mutating / hard-to-undo work requires a verifier before it is planned. + + A `spec` is a map: `%{required_cap: String, mutating: bool (default false), + risk: atom (default :low), verifier: any | nil}`. + Returns `{:ok, node, cost}` | `{:rejected, reason}` | `{:suspended, reason}`. + """ + alias Bag.{Budget, Executor} + + def plan(spec, %Budget{} = budget) when is_map(spec) do + required_cap = Map.fetch!(spec, :required_cap) + mutating = Map.get(spec, :mutating, false) + verifier = Map.get(spec, :verifier) + + cond do + # Gate: mutating / irreversible work needs a verifier or approval path. + mutating and is_nil(verifier) -> + {:rejected, :mutation_requires_verifier} + + true -> + feasible = + Executor.node_costs() + |> Enum.filter(fn {name, cost} -> + Executor.node_satisfies?(name, [required_cap]) and Budget.affords?(budget, :money, cost) + end) + + case feasible do + [] -> + {:suspended, :no_affordable_capable_node} + + nodes -> + {name, cost} = Enum.min_by(nodes, fn {_name, cost} -> cost end) + {:ok, name, cost} + end + end + end +end diff --git a/bag/test/mesh_check_test.exs b/bag/test/mesh_check_test.exs index 9991855..0d72ba8 100644 --- a/bag/test/mesh_check_test.exs +++ b/bag/test/mesh_check_test.exs @@ -7,7 +7,7 @@ defmodule Bag.MeshCheckTest do emitter — all on owned compute, zero GitHub Actions minutes. """ use ExUnit.Case, async: false - alias Bag.{Mesh, CiSweep, Executor} + alias Bag.{Mesh, CiSweep, Executor, Budget} @repo_root Path.expand("../..", __DIR__) @@ -46,6 +46,16 @@ defmodule Bag.MeshCheckTest do Mesh.submit_check("mesh-susp", ["true"], required_cap: "bogus-cap") end + test "submit_planned routes by budget, runs the check, and returns residue" do + spec = %{check_id: "planned-fmt", command: ["zig", "fmt", "--check", "build.zig"], required_cap: "zig"} + result = Mesh.submit_planned(spec, Budget.unlimited()) + + assert result.verdict == :pass + assert result.node in ["mesh-server-1", "mesh-laptop"] + # Ran on owned compute (relegated) → still owes the GitHub-native gate. + assert result.residue == {:owes, :github_required_check} + end + test "CiSweep runs a batch of checks and summarises verdicts" do checks = [ %{check_id: "sweep-ok", command: ["zig", "fmt", "--check", "build.zig"], required_cap: "zig"}, diff --git a/bag/test/planner_test.exs b/bag/test/planner_test.exs new file mode 100644 index 0000000..a2b3a25 --- /dev/null +++ b/bag/test/planner_test.exs @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Bag.PlannerTest do + @moduledoc """ + Typed-budget + capability action selection, and structured residue. + Demonstrates the acceptance scenarios: an exhausted/insufficient paid budget + removes the paid route (the "Claude/Codex out, cheaper route remains" analog), + cheap reversible work relegates to the cheapest capable node, mutating work is + gated on a verifier, and partial results carry repair obligations. + """ + use ExUnit.Case, async: false + alias Bag.{Planner, Budget, ActionResult} + + @repo_root Path.expand("../..", __DIR__) + + setup_all do + {_out, 0} = System.cmd("zig", ["build"], cd: @repo_root, stderr_to_stdout: true) + :ok + end + + test "work that NEEDS the paid route uses it when the budget can afford it" do + spec = %{check_id: "scan", command: ["true"], required_cap: "secret_access"} + assert {:ok, "mesh-github-runner", 100} = Planner.plan(spec, Budget.unlimited()) + end + + test "insufficient paid budget removes the paid route → work needing it is SUSPENDED" do + # Only mesh-github-runner (cost 100) has secret_access; a $50 budget can't + # afford it, and nothing cheaper can do the work → suspend, don't run unsafely. + spec = %{check_id: "scan", command: ["true"], required_cap: "secret_access"} + assert {:suspended, :no_affordable_capable_node} = Planner.plan(spec, Budget.new(money: 50)) + end + + test "cheap reversible work relegates to the cheapest capable node" do + spec = %{check_id: "fmt", command: ["true"], required_cap: "zig"} + # mesh-laptop (2) and mesh-server-1 (1) both have zig; the min-cost wins. + assert {:ok, "mesh-server-1", 1} = Planner.plan(spec, Budget.new(money: 50)) + end + + test "mutating work is rejected without a verifier, permitted with one" do + base = %{check_id: "apply", command: ["true"], required_cap: "linux", mutating: true} + assert {:rejected, :mutation_requires_verifier} = Planner.plan(base, Budget.unlimited()) + assert {:ok, _node, _cost} = Planner.plan(Map.put(base, :verifier, :human_approved), Budget.unlimited()) + end + + test "non-fungible budgets: money exhaustion does not touch other dimensions" do + b = Budget.new(money: 0, mutation: 5) + assert Budget.exhausted?(b, :money) + refute Budget.exhausted?(b, :mutation) + assert Budget.affords?(b, :mutation, 3) + end + + test "a relegated pass still OWES the GitHub required-status-check (echo residue)" do + r = ActionResult.new(:pass, "mesh-server-1", relegated: true) + assert r.residue == {:owes, :github_required_check} + end + + test "a dirty partial yields a repair obligation" do + r = ActionResult.new(:dirty_partial, "mesh-server-1", repair: :revert_touched_files) + assert {:dirty, :revert_touched_files} = r.residue + assert {:ok, :revert_touched_files} = ActionResult.repair_obligation(r) + end +end diff --git a/src/estate.zig b/src/estate.zig index 0b4e8fc..336b573 100644 --- a/src/estate.zig +++ b/src/estate.zig @@ -34,6 +34,9 @@ pub const Capability = enum(u32) { pub const Node = struct { name: []const u8, capabilities: []const Capability, + /// Tropical (min-plus) money grade of running a unit of work here. Owned + /// nodes ≈ free; the github-runner is the paid route. Mirrors Estate.idr. + cost: u32, }; /// The Estate Manifest (Hand-translated from verification/proofs/Bag/Estate.idr) @@ -41,26 +44,20 @@ pub const estate = [_]Node{ .{ .name = "mesh-laptop", .capabilities = &[_]Capability{ .macos, .guix, .trusted_host, .zig }, + .cost = 2, }, .{ .name = "mesh-server-1", .capabilities = &[_]Capability{ .linux, .gpu, .guix, .trusted_host, .zig, .rust, .cargo, .deno }, + .cost = 1, }, .{ .name = "mesh-github-runner", .capabilities = &[_]Capability{ .linux, .secret_access }, + .cost = 100, }, }; -/// Print the estate node names, one per line. Lets the Elixir orchestrator read -/// the node list from this single mirrored manifest instead of duplicating it. -pub fn listNodeNames(writer: anytype) !void { - for (estate) |node| { - try writer.writeAll(node.name); - try writer.writeAll("\n"); - } -} - pub fn findNode(name: []const u8) ?Node { for (estate) |node| { if (std.mem.eql(u8, node.name, name)) return node; diff --git a/src/main.zig b/src/main.zig index acaecf9..3a9f700 100644 --- a/src/main.zig +++ b/src/main.zig @@ -219,8 +219,12 @@ pub fn main() !void { } if (std.mem.eql(u8, args[1], "nodes")) { - // Print the estate node names from the single mirrored manifest. - try estate.listNodeNames(std.fs.File.stdout()); + // Print "namecost" per node from the single mirrored manifest, so + // the Elixir planner reads both the node list and its tropical grade + // from here instead of keeping its own copy. + for (estate.estate) |node| { + try printOut(allocator, "{s}\t{d}\n", .{ node.name, node.cost }); + } return; } @@ -250,11 +254,11 @@ pub fn main() !void { } } + // Exit code is the signal (0 = match, 1 = no match); the Elixir + // Executor reads the status, not any stdout/stderr text. if (!has_unknown and estate.nodeSatisfies(node_name, reqs[0..valid_count])) { - std.debug.print("MATCH: TRUE\n", .{}); std.process.exit(0); } else { - std.debug.print("MATCH: FALSE\n", .{}); std.process.exit(1); } } else if (std.mem.eql(u8, args[1], "check")) { diff --git a/verification/proofs/Bag/Estate.idr b/verification/proofs/Bag/Estate.idr index 1fd2fe3..215703a 100644 --- a/verification/proofs/Bag/Estate.idr +++ b/verification/proofs/Bag/Estate.idr @@ -9,20 +9,25 @@ import Data.List %default total ||| A Node in the Bag-of-Actions mesh. +||| `cost` is the tropical (min-plus) money grade of running a unit of work here: +||| owned nodes ≈ free (electricity only); the github-runner is the paid route. +||| The planner minimises this subject to capability ⇒ "relegate to the cheapest +||| capable node; reserve the paid route for work whose capabilities only it has". public export record Node where constructor MkNode name : String capabilities : List Capability + cost : Nat ||| The canonical Estate Manifest. ||| This serves as the ground truth for the orchestrator's routing decisions. public export estate : List Node estate = - [ MkNode "mesh-laptop" [MacOS, Guix, TrustedHost "Jonathan", Zig] - , MkNode "mesh-server-1" [Linux, GPU, Guix, TrustedHost "Core-Infrastructure", Zig, Rust, Cargo, Deno] - , MkNode "mesh-github-runner" [Linux, SecretAccess "GitHub-Deploy-Token"] + [ MkNode "mesh-laptop" [MacOS, Guix, TrustedHost "Jonathan", Zig] 2 + , MkNode "mesh-server-1" [Linux, GPU, Guix, TrustedHost "Core-Infrastructure", Zig, Rust, Cargo, Deno] 1 + , MkNode "mesh-github-runner" [Linux, SecretAccess "GitHub-Deploy-Token"] 100 ] ||| Lookup a node by name in the estate. @@ -37,3 +42,14 @@ nodeSatisfies name reqs = case findNode name of Nothing => False Just n => satisfies reqs (capabilities n) + +||| The routing objective (tropical ⊕ over feasible nodes): among nodes that +||| satisfy the requirements, the least-cost one. Infeasible nodes contribute +||| nothing (they are simply not in `feasible`) — capability and cost are one +||| decision. This is the formal spec the Elixir planner mirrors. +public export +cheapestCapable : (requirements : List Capability) -> Maybe Node +cheapestCapable reqs = + case filter (\n => satisfies reqs (capabilities n)) estate of + [] => Nothing + (x :: xs) => Just (foldl (\best, n => if cost n < cost best then n else best) x xs)