Answer the what-if as data, let the build seat read the gate's pull keys, and prove the parts' and the gate's grants (design 47 R3d, ADR 0450) #401

Merged
mesh-tools merged 4 commits from feat/r3d-sdk-and-controller into main 2026-10-19 22:33:11 +00:00
5 changed files with 209 additions and 14 deletions
Showing only changes of commit 23a209f3d0 - Show all commits
+24 -10
View File
@@ -784,6 +784,27 @@ func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[s
return out
}
// changeMember is one change as the planner is asked about it, the same for `delivery plan` and `plans --what-if`.
func changeMember(repository, base, head string, paths, removed []string, moduleDirs *[]string) orderMember {
m := orderMember{ID: repository + "@" + head, Repository: repository, Base: base, Head: head,
Paths: paths, Removed: removed}
if moduleDirs != nil {
m.ModuleDirs, m.ModuleDirsSaid = *moduleDirs, true
}
return m
}
// deliveryPlanDocument is the answer of `delivery plan` and of `plans --what-if … --data`: one change's
// change plan with its reach, the mesh's own part in it and whether the gate would run. One function, so
// the two verbs cannot differ (novox/hq ADR 0450 §2, issue 1324).
func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge, policy policyOf) map[string]any {
s := pullScope(m.pull(), entries, read, edges)
plan := changePlanOf(m.Repository, m.Base, m.Head, s.Reach, entries, policy)
return map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()}
}
// deliveryCommand is `delivery`, the controller's verbs for the delivery's owner:
//
// delivery plan --repository owner/repo --head <commit> [--base main] --paths a,b [--module-dirs …] [--removed …]
@@ -855,22 +876,15 @@ func deliveryCommand(ctx context.Context, args []string) error {
if *repository == "" || *head == "" && *paths == "" {
return errors.New("delivery plan --repository owner/repo --head <commit> --paths a,b")
}
m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head,
Paths: splitList(*paths), Removed: splitList(*removed)}
if d := moduleDirsOf(*moduleDirs); d != nil {
m.ModuleDirs, m.ModuleDirsSaid = *d, true
}
m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
entries, read, edges, err := theGraph(ctx, inv)
if err != nil {
return err
}
s := pullScope(m.pull(), entries, read, edges)
plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) {
return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
u, err := inv.UpgradeOf(ctx, module)
return u, err == nil
})
return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()})
}))
case "order":
if len(members) == 0 {
return errors.New("delivery order --members <json>: a group has members")
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// flagsOf reads a command line's flags (and its first positional after --what-if) into a map.
func flagsOf(argv []string) map[string]string {
out := map[string]string{}
for i := 0; i < len(argv); i++ {
if !strings.HasPrefix(argv[i], "--") {
continue
}
name := strings.TrimPrefix(argv[i], "--")
if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "--") {
out[name] = argv[i+1]
i++
} else {
out[name] = "true"
}
}
return out
}
// **The plan of a change as data from `plans`** (novox/hq ADR 0450 §2, issue 1324 AC1): asked for one change
// both ways, `plans` with data and `delivery-plan` answer the same plan in every field.
func TestPlansWhatIfAsDataAnswersWhatDeliveryPlanAnswers(t *testing.T) {
change := map[string]any{"repository": "novox/mesh-catalog", "paths": "modules/gitea/index.ts,modules/gitea/old.txt",
"head": "abc123", "base": "main", "module-dirs": "modules/gitea", "removed": "modules/gitea/old.txt"}
with := func(extra map[string]any) map[string]any {
out := map[string]any{}
for k, v := range change {
out[k] = v
}
for k, v := range extra {
out[k] = v
}
return out
}
plansArgv, err := argvFor("plans", with(map[string]any{"data": "true"}))
if err != nil {
t.Fatal(err)
}
deliveryArgv, err := argvFor("delivery-plan", change)
if err != nil {
t.Fatal(err)
}
if plansArgv[0] != "plans" || !slicesHas(plansArgv, "--data") || !slicesHas(plansArgv, "--what-if") {
t.Fatalf("plans with data became %q", strings.Join(plansArgv, " "))
}
p, d := flagsOf(plansArgv), flagsOf(deliveryArgv)
for _, name := range []string{"paths", "head", "base", "module-dirs", "removed"} {
if p[name] == "" || p[name] != d[name] {
t.Errorf("--%s: plans says %q, delivery-plan %q", name, p[name], d[name])
}
}
if p["what-if"] != d["repository"] {
t.Errorf("repository: plans says %q, delivery-plan %q", p["what-if"], d["repository"])
}
// Both commands build their document from the one function, from the same member.
const src = "http://forge.internal:20000/novox/mesh-catalog.git"
gitea := fromRepo("gitea", src, "modules/gitea")
gitea.Source.BuiltFrom = "old"
gitea.On = []string{"anchor", "laptop"}
entries := []inventory.Entry{gitea}
policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
member := func(f map[string]string, repository string) orderMember {
return changeMember(repository, f["base"], f["head"], splitList(f["paths"]), splitList(f["removed"]),
moduleDirsOf(f["module-dirs"]))
}
a := deliveryPlanDocument(member(p, p["what-if"]), entries, nil, nil, policy)
b := deliveryPlanDocument(member(d, d["repository"]), entries, nil, nil, policy)
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
if !reflect.DeepEqual(ja, jb) {
t.Errorf("the two plans differ:\n%s\n%s", ja, jb)
}
plan := a["plan"].(interface{})
raw, _ := json.Marshal(plan)
var fields map[string]any
_ = json.Unmarshal(raw, &fields)
for _, want := range []string{"moved", "tiers", "machines", "summary", "class", "head", "base", "repository"} {
if _, ok := fields[want]; !ok {
t.Errorf("the plan has no %q: %s", want, raw)
}
}
if fields["head"] != "abc123" || fields["base"] != "main" {
t.Errorf("head and base are %v and %v", fields["head"], fields["base"])
}
}
// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed.
func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) {
argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"})
if err != nil {
t.Fatal(err)
}
if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a,b" {
t.Errorf("the text what-if reads %q", got)
}
if _, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}); err == nil {
t.Error("a head without data was passed over, not refused")
}
argv, _ = argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "data": "false"})
if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a" {
t.Errorf("data false reads %q", got)
}
}
// delivery-plan stays and answers as before (AC3, ADR 0450 §8).
func TestDeliveryPlanStaysAsItWas(t *testing.T) {
argv, err := argvFor("delivery-plan", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"})
if err != nil {
t.Fatal(err)
}
if got := strings.Join(argv, " "); got != "delivery plan --repository novox/x --paths a --head h" {
t.Errorf("delivery-plan reads %q", got)
}
if _, ok := catalogueVerb("delivery-plan"); !ok {
t.Error("delivery-plan is gone")
}
}
func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) }
func slicesHas(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
+26
View File
@@ -6,6 +6,7 @@ import (
"errors"
"flag"
"fmt"
"os"
"slices"
"sort"
"strings"
@@ -2037,6 +2038,12 @@ func plansCommand(ctx context.Context, args []string) error {
modules := set.String("modules", "", "or the modules it would change, comma-separated")
moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+
"comma-separated, as the forge's announcer says them (novox/hq issue 278)")
// The what-if as data, the document `delivery plan` answers (novox/hq ADR 0450 §2, issue 1324).
data := set.Bool("data", false, "with --what-if: answer the change plan as data, the document `delivery plan` answers; "+
"without it the answer is the text a person reads")
head := set.String("head", "", "with --data: the commit at hand")
base := set.String("base", "main", "with --data: the branch it merges into")
removed := set.String("removed", "", "with --data: the files among --paths it deletes, comma-separated")
// Following a plan (novox/hq issue 229): `plans wait <id>`.
until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)")
bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+
@@ -2152,6 +2159,25 @@ func plansCommand(ctx context.Context, args []string) error {
}
return nil
}
if *data && *whatIf == "" {
return errors.New("--data answers a what-if: give --what-if owner/repository")
}
if *whatIf != "" && *data {
if *paths == "" {
return errors.New("plans --what-if --data: the change plan is of the files a change touches; give --paths")
}
entries, read, edges, err := theGraph(ctx, inv)
if err != nil {
return err
}
m := changeMember(*whatIf, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
u, err := inv.UpgradeOf(ctx, module)
return u, err == nil
}))
}
if *whatIf != "" {
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs))
}
+9
View File
@@ -352,6 +352,15 @@ func (a *verbArguments) commandLine() ([]string, error) {
if d := str("module-dirs"); d != "" {
argv = append(argv, "--module-dirs", d)
}
// The plan as data, the document delivery-plan answers (novox/hq ADR 0450 §2, issue 1324).
if str("data") == "true" {
argv = append(argv, "--data")
for _, flag := range []string{"head", "base", "removed"} {
if v := str(flag); v != "" {
argv = append(argv, "--"+flag, v)
}
}
}
return argv, nil
}
for _, act := range []string{"stop", "close", "retry", "go"} {
+10 -4
View File
@@ -135,10 +135,16 @@ var ControllerVerbs = []Verb{
"modules": "with repository: or the modules it would change, comma-separated",
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
"limit": "how many plans to list (default 10); only when listing",
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
}, nil)},
"data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " +
"(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " +
"without it the answer is text (novox/hq ADR 0450 §2)",
"head": "with data: the commit at hand",
"base": "with data: the branch it merges into (default main)",
"removed": "with data: the files among paths the change deletes, comma-separated",
"limit": "how many plans to list (default 10); only when listing",
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
}, nil, "data")},
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +