From 0f0028785c3cb7abdb9231ca1c4d0398347c4415 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 00:37:14 +0200 Subject: [PATCH] Refuse a verb argument the seat would pass over, and say a push is of the whole mesh A push naming one machine reached the verb without it and pushed every machine behind (hq issue 244). The controller now refuses any argument a verb does not declare, any it composed its command line without, and a switch that is not true or false; a push that names no machine says first that it is the whole mesh. Tests walk every served verb: no argument is ever ignored, and every flag of a verb's command, read from the source, is in its schema or accounted for. plan gains files, push behind, builds and plans limit. --- cmd/mesh-controller/push.go | 20 +- cmd/mesh-controller/seatverbs.go | 283 ++++++++++++-- cmd/mesh-controller/seatverbs_schema_test.go | 365 +++++++++++++++++++ cmd/mesh-controller/seatverbs_test.go | 34 +- internal/catalogue/verbs.go | 48 ++- 5 files changed, 674 insertions(+), 76 deletions(-) create mode 100644 cmd/mesh-controller/seatverbs_schema_test.go diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index dc8d5bb..011fbe7 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -322,7 +322,7 @@ func pushCommand(ctx context.Context, args []string) error { if len(needsOne) == 0 { // Said rather than doing nothing quietly. "Nothing needed one" and "this did not run" // must never look the same. - fmt.Println("every machine is doing what it was told") + fmt.Println("no machine named: a push of the whole mesh, and every machine is doing what it was told — nothing sent") return nil } } @@ -363,6 +363,18 @@ func pushCommand(ctx context.Context, args []string) error { } asked = append(asked, n.Name) } + // **A push that named no machine says so, first.** Through the console a machine the caller + // meant to name could be lost on the way (novox/hq issue 244): the call arrived empty, ran as + // `push --behind`, and every machine behind was pushed by someone who thought they had pushed one. + // Its whole-mesh reach is the first line of the answer, with the machines it is about to send. + if len(args) == 0 { + which := "every machine" + if *behind { + which = "every machine that is behind" + } + fmt.Printf("no machine named: this is a push of the WHOLE mesh — %s (%d): %s\n", + which, len(asked), strings.Join(asked, ", ")) + } // **The machine holding the bus first** (novox/hq issue 249): its declaration carries the bus's // user list, and a module's new grants are refused by the bus until that list says them. Among @@ -430,7 +442,11 @@ func pushCommand(ctx context.Context, args []string) error { return err } release() - fmt.Printf("\n%d node(s) told\n", len(sending)) + told := make([]string, 0, len(sending)) + for _, r := range sending { + told = append(told, r.node) + } + fmt.Printf("\n%d node(s) told: %s\n", len(sending), strings.Join(told, ", ")) reportUnheldPushed(os.Stdout, len(args) == 1, asked, unheld) // **A named push leaves the mesh consistent, not just the machine it named** (novox/hq diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index a3fdfc0..245937e 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -36,19 +36,194 @@ type verbAnswer struct { // argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and // only the arguments each declares: a caller cannot reach a flag the schema did not name. +// +// **Nothing a caller sends is passed over** (novox/hq issue 244). An argument the verb does not +// declare is refused, naming it; a switch that is not "true" or "false" is refused; and an argument +// the verb declares but did not use for the command line it composed — given beside another that +// wins, or half of a shape — is refused too. On 2026-10-05 a push naming one machine reached the +// verb without the machine and ran as a push of every machine behind; a verb that answers "I did +// not take that" would have stopped it before anything was sent. func argvFor(verb string, args map[string]any) ([]string, error) { - str := func(key string) string { - v, _ := args[key].(string) - return strings.TrimSpace(v) + a, err := readArguments(verb, args) + if err != nil { + return nil, err } - need := func(keys ...string) error { - for _, k := range keys { - if str(k) == "" { - return fmt.Errorf("%s needs %q", verb, k) + argv, err := a.commandLine() + if len(a.misread) > 0 { + // The table and the command line disagree: the verb reads an argument no caller can see + // in its schema, so no caller could ever pass it. + return nil, fmt.Errorf("%s reads %s, which its schema does not declare — this build's verb "+ + "table and its command lines disagree", verb, quoteAll(a.misread)) + } + if err != nil { + return nil, err + } + if unused := a.unused(); len(unused) > 0 { + return nil, fmt.Errorf("%s did not use %s together with %s, and an argument a verb would pass over "+ + "is refused: nothing was done", verb, quoteAll(unused), quoteAll(a.usedGiven())) + } + return argv, nil +} + +// verbArguments are one call's arguments, checked against the verb's schema, and which of them the +// command line was composed from. +type verbArguments struct { + verb string + given map[string]string + used map[string]bool + declared map[string]bool + misread []string // arguments the command line read that the schema does not declare: a bug here +} + +// controllerVerb is this binary's own definition of a verb: what it runs is what it declares, so the +// arguments are checked against the table compiled beside argvFor, not a row a newer or older build +// wrote. +func controllerVerb(name string) (catalogue.Verb, bool) { + for _, v := range catalogue.ControllerVerbs { + if v.Name == name { + return v, true + } + } + return catalogue.Verb{}, false +} + +// declaredArguments are a schema's properties, and which of them are switches. +func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bool) { + switches = map[string]bool{} + props, _ := v.Input["properties"].(map[string]any) + for name, p := range props { + names = append(names, name) + desc, _ := p.(map[string]any) + switch enum := desc["enum"].(type) { + case []string: + switches[name] = len(enum) == 2 && enum[0] == "true" && enum[1] == "false" + case []any: + switches[name] = len(enum) == 2 && enum[0] == "true" && enum[1] == "false" + } + } + sort.Strings(names) + return names, switches +} + +// readArguments refuses what the verb does not take, before anything is composed. +func readArguments(verb string, args map[string]any) (*verbArguments, error) { + v, known := controllerVerb(verb) + if !known { + return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName) + } + names, switches := declaredArguments(v) + declared := map[string]bool{} + for _, n := range names { + declared[n] = true + } + takes := "none" + if len(names) > 0 { + takes = quoteAll(names) + } + a := &verbArguments{verb: verb, given: map[string]string{}, used: map[string]bool{}, declared: declared} + keys := make([]string, 0, len(args)) + for k := range args { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if !declared[k] { + return nil, fmt.Errorf("%s takes no argument %q — it takes %s; nothing was done", verb, k, takes) + } + var value string + switch x := args[k].(type) { + case nil: + continue + case string: + value = strings.TrimSpace(x) + case bool: + if !switches[k] { + return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k) + } + value = fmt.Sprint(x) + default: + return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x) + } + if switches[k] { + switch value { + case "true": + case "false", "": + continue // said and off: the same as not given, and nothing passed over + default: + return nil, fmt.Errorf("%s: %q is \"true\" or \"false\", not %q", verb, k, value) } } - return nil + if value != "" { + a.given[k] = value + } } + return a, nil +} + +// str is one argument's value, marked as used. +func (a *verbArguments) str(key string) string { + if !a.declared[key] { + a.misread = append(a.misread, key) + } + a.used[key] = true + return a.given[key] +} + +// on is a switch, marked as used. +func (a *verbArguments) on(key string) bool { return a.str(key) == "true" } + +// need refuses a call missing a required argument, in the verb's own words. +func (a *verbArguments) need(keys ...string) error { + for _, k := range keys { + if a.str(k) == "" { + return fmt.Errorf("%s needs %q", a.verb, k) + } + } + return nil +} + +// unused are the arguments given that the command line was not composed from. +func (a *verbArguments) unused() []string { + var out []string + for k := range a.given { + if !a.used[k] { + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +func (a *verbArguments) usedGiven() []string { + var out []string + for k := range a.given { + if a.used[k] { + out = append(out, k) + } + } + sort.Strings(out) + if len(out) == 0 { + return []string{"nothing"} + } + return out +} + +func quoteAll(xs []string) string { + q := make([]string, len(xs)) + for i, x := range xs { + if x == "nothing" { + q[i] = x + continue + } + q[i] = fmt.Sprintf("%q", x) + } + return strings.Join(q, ", ") +} + +// commandLine composes the command. Every argument it reads is one it uses: a branch that reads an +// argument and then drops it would pass it over, which is what the check after it exists to refuse. +func (a *verbArguments) commandLine() ([]string, error) { + verb, str, on, need := a.verb, a.str, a.on, a.need switch verb { case "command": // The generic verb: the command line as given, split as a shell would split it, with @@ -65,6 +240,8 @@ func argvFor(verb string, args map[string]any) ([]string, error) { return nil, errors.New("command names no command") } return argv, nil + case "tools": + return nil, errors.New("tools is answered from the records, not by a command") case "status": return []string{"status", "--json"}, nil case "nodes": @@ -82,10 +259,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if id := str("log"); id != "" { return []string{"builds", "--log", id}, nil } - if m := str("module"); m != "" { - return []string{"builds", m}, nil + argv := []string{"builds"} + if n := str("limit"); n != "" { + argv = append(argv, "-n", n) } - return []string{"builds"}, nil + if m := str("module"); m != "" { + argv = append(argv, m) + } + return argv, nil case "plans": if r := str("repository"); r != "" { argv := []string{"plans", "--what-if", r} @@ -97,19 +278,19 @@ func argvFor(verb string, args map[string]any) ([]string, error) { } return argv, nil } - if id := str("stop"); id != "" { - return []string{"plans", "stop", id}, nil - } - if id := str("close"); id != "" { - return []string{"plans", "close", id}, nil - } - if id := str("retry"); id != "" { - return []string{"plans", "retry", id}, nil + for _, act := range []string{"stop", "close", "retry"} { + if id := str(act); id != "" { + return []string{"plans", act, id}, nil + } } if id := str("id"); id != "" { return []string{"plans", id}, nil } - return []string{"plans"}, nil + argv := []string{"plans"} + if n := str("limit"); n != "" { + argv = append(argv, "-n", n) + } + return argv, nil // The build queue (novox/hq ADR 0219). case "queue": return []string{"queue"}, nil @@ -119,7 +300,7 @@ func argvFor(verb string, args map[string]any) ([]string, error) { } return []string{verb, str("id")}, nil case "clear": - if str("dead") == "true" { + if on("dead") { return []string{"clear", "--dead"}, nil } return []string{"clear"}, nil @@ -133,10 +314,10 @@ func argvFor(verb string, args map[string]any) ([]string, error) { return nil, err } argv := []string{"replay", str("id")} - if str("register") == "true" { + if on("register") { argv = append(argv, "--register") } - if str("older") == "true" { + if on("older") { argv = append(argv, "--older") } return argv, nil @@ -149,6 +330,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if err := need("node"); err != nil { return nil, err } + if on("files") { + return []string{"plan", str("node"), "--files"}, nil + } return []string{"plan", str("node"), "--json"}, nil case "assign", "unassign": if err := need("node", "module"); err != nil { @@ -172,8 +356,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) { // what a person at a shell does too. A tool call that blocked for a push's whole apply would // time out on every machine that takes a minute, and say nothing about the ones that did not. if n := str("node"); n != "" { + // behind is not read here: given with a machine, it is refused as passed over — naming + // a machine and asking for every machine behind are two requests, and guessing one + // would push a machine nobody named, or not push one somebody did. return []string{"push", n, "--wait", "0"}, nil } + // No machine: the whole mesh, whether or not behind said so. The command's answer says it + // first, so a caller who meant one machine reads that it was not one. + on("behind") return []string{"push", "--behind", "--wait", "0"}, nil case "rotate": if p := str("provision"); p != "" { @@ -183,11 +373,17 @@ func argvFor(verb string, args map[string]any) ([]string, error) { } return argv, nil } - if str("node") != "" && str("module") != "" && str("secret") != "" { + _, node := a.given["node"] + _, module := a.given["module"] + _, secret := a.given["secret"] + if node || module || secret { + if err := need("node", "module", "secret"); err != nil { + return nil, fmt.Errorf("%w: a module's own secret is named by node, module and secret together", err) + } return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil } - // Half of either shape: the command says its usage, which names both shapes, and that is - // the answer the caller needs. + // Neither shape: the command says its usage, which names both, and that is the answer the + // caller needs. return []string{"rotate"}, nil case "settings": // `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument @@ -195,15 +391,16 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if err := need("module"); err != nil { return nil, err } - argv := []string{"settings", "set", str("module")} - switch { - case str("clear") == "true": + var argv []string + if on("clear") { argv = []string{"settings", "clear", str("module")} - case str("values") != "": - argv = append(argv, str("values")) + } else { + argv = []string{"settings", "set", str("module")} + // Neither values nor clear: the command says its usage, which names both. + if v := str("values"); v != "" { + argv = append(argv, v) + } } - // Neither values nor clear: the command says its usage, which names both, and that is the - // answer the caller needs — the same as `rotate` given half of either shape. if n := str("node"); n != "" { argv = append(argv, "--node", n) } @@ -235,7 +432,8 @@ func argvFor(verb string, args map[string]any) ([]string, error) { } return argv, nil } - return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName) + return nil, fmt.Errorf("%q is a verb of the %s seat's table that this binary has no command line for", + verb, catalogue.ControllerSeatName) } // jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well. @@ -288,7 +486,17 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { for _, v := range seat.Serves { verb := v.Name if verb == "tools" { - handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) { + handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) { + args := map[string]any{} + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("the arguments are not a JSON object: %w", err) + } + } + // Refused like any verb's: tools takes nothing, and something given is not ignored. + if _, err := readArguments(verb, args); err != nil { + return nil, err + } return seatTools(), nil } continue @@ -353,9 +561,10 @@ func seatTools() map[string]any { } // sampleArguments is one of every argument a verb's schema requires, so the check at start proves the -// verb runnable rather than that it happens to want the arguments the check guessed. +// verb runnable rather than that it happens to want the arguments the check guessed — and nothing +// more, since an argument a verb does not declare is refused. func sampleArguments(v catalogue.Verb) map[string]any { - sample := map[string]any{"node": "x", "module": "x", "repository": "x"} + sample := map[string]any{} switch required := v.Input["required"].(type) { case []string: for _, k := range required { diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go new file mode 100644 index 0000000..b93a47b --- /dev/null +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -0,0 +1,365 @@ +package main + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The schemas the controller serves, verb by verb, as the console receives them: from the +// announcement, not the table — what is checked is what a caller is shown (novox/hq issue 244). +func servedSchemas(t *testing.T) map[string]catalogue.Verb { + t.Helper() + handlers, behind, err := seatToolHandlers() + if err != nil { + t.Fatal(err) + } + if len(behind) != 0 { + t.Fatalf("this build cannot run %v of its own seat's verbs", behind) + } + served := map[string]catalogue.Verb{} + for _, e := range seatAnnouncement(handlers).Endpoints { + var input map[string]any + if err := json.Unmarshal([]byte(e.Metadata["schema"]), &input); err != nil { + t.Fatalf("%s announces a schema that is not JSON: %v", e.Name, err) + } + served[e.Metadata["tool"]] = catalogue.Verb{Name: e.Metadata["tool"], Input: input} + } + if len(served) != len(catalogue.ControllerVerbs) { + t.Fatalf("%d verbs served for %d in the table", len(served), len(catalogue.ControllerVerbs)) + } + return served +} + +// subsetsOf is every subset of the names, the empty one included. +func subsetsOf(names []string) [][]string { + var out [][]string + for mask := 0; mask < 1< 0; n-- { + if _, has := flags[strings.Join(words[:n], " ")]; has { + if reached[name] == nil { + reached[name] = map[string]bool{} + } + reached[name][strings.Join(words[:n], " ")] = true + break + } + } + } + } + used := map[string]map[string]bool{} + verbs := make([]string, 0, len(reached)) + for verb := range reached { + verbs = append(verbs, verb) + } + sort.Strings(verbs) + for _, verb := range verbs { + declared, _ := declaredArguments(served[verb]) + for set := range reached[verb] { + if used[set] == nil { + used[set] = map[string]bool{} + } + for _, flagName := range flags[set] { + why, accounted := accountedFlags[set][flagName] + switch { + case accounted && strings.HasPrefix(why, "="): + used[set][flagName] = true + if !contains(declared, strings.TrimPrefix(why, "=")) { + t.Errorf("%s: --%s of `%s` is said to be carried by %q, which the schema does not declare", + verb, flagName, set, strings.TrimPrefix(why, "=")) + } + case accounted: + used[set][flagName] = true + case contains(declared, flagName): + default: + t.Errorf("%s runs `%s`, which takes --%s, and the verb's schema has no %q: declare it, "+ + "or say in accountedFlags why a caller does not reach it", verb, set, flagName, flagName) + } + } + } + } + for set, fs := range accountedFlags { + for flagName := range fs { + if !used[set][flagName] { + t.Errorf("accountedFlags names --%s of `%s`, which no verb's command takes any more", flagName, set) + } + } + } +} + +// Every verb's required arguments are properties of its schema: a schema that requires what it +// does not describe is the uncallable verb of issue 244 from the other side. +func TestEveryRequiredArgumentIsDescribed(t *testing.T) { + for name, v := range servedSchemas(t) { + names, _ := declaredArguments(v) + for k := range sampleArguments(v) { + if !contains(names, k) { + t.Errorf("%s requires %q and does not describe it", name, k) + } + } + } +} diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index edfdb8e..5cc0eca 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -10,29 +10,6 @@ import ( "github.com/novox/mesh-controller/internal/catalogue" ) -// Every verb the mesh-controller seat declares is one this binary can run, with the arguments the -// schema names and no other (novox/hq ADR 0154, ADR 0035). -func TestEveryDeclaredVerbHasACommandLine(t *testing.T) { - for _, v := range catalogue.ControllerVerbs { - if v.Name == "tools" { - continue - } - args := map[string]any{} - props, _ := v.Input["properties"].(map[string]any) - for name := range props { - args[name] = "x" - } - argv, err := argvFor(v.Name, args) - if err != nil { - t.Errorf("%s: %v", v.Name, err) - continue - } - if argv[0] == "" { - t.Errorf("%s: empty command", v.Name) - } - } -} - // `builds` given a build's id reads that build's log from the bus rather than listing builds // (novox/hq ADR 0157). func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) { @@ -70,9 +47,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { if strings.Join(argv, " ") != "secret rotate ace nodered api-token" { t.Fatalf("an own secret: %v", argv) } - argv, _ = argvFor("rotate", map[string]any{"node": "ace"}) - if strings.Join(argv, " ") != "rotate" { - t.Fatalf("half an own secret falls to the command's usage: %v", argv) + if _, err := argvFor("rotate", map[string]any{"node": "ace"}); err == nil || !strings.Contains(err.Error(), `"module"`) { + t.Fatalf("half an own secret is refused, naming what it lacks: %v", err) + } + if argv, _ := argvFor("rotate", nil); strings.Join(argv, " ") != "rotate" { + t.Fatalf("neither shape falls to the command's usage: %v", argv) + } + if _, err := argvFor("rotate", map[string]any{"provision": "p", "node": "ace", "module": "m", "secret": "s"}); err == nil { + t.Fatal("both shapes at once were taken, and one of them passed over") } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index decb038..98accf2 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -90,6 +90,7 @@ var ControllerVerbs = []Verb{ Input: schema(map[string]string{ "module": "one module's name; every module when absent", "log": "a build's id (as `builds` lists it): print what the build machine said, line by line", + "limit": "how many builds to list (default 20); not with log", }, nil)}, {Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " + "the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.", @@ -101,9 +102,14 @@ var ControllerVerbs = []Verb{ "repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules", "paths": "with repository: the files the merge would change, comma-separated, from the repository's root", "modules": "with repository: or the modules it would change, comma-separated", + "limit": "how many plans to list (default 10); only when listing", }, nil)}, - {Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.", - Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + {Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " + + "or, with files, the files it would be given.", + Input: schema(map[string]string{ + "node": "the machine's name", + "files": "\"true\": the files this machine would be given, instead of the declaration as JSON", + }, []string{"node"}, "files")}, {Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " + "or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).", Input: schema(map[string]string{"node": "the machine's name", @@ -121,8 +127,12 @@ var ControllerVerbs = []Verb{ }, []string{"node", "provision", "from", "module"})}, {Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.", Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})}, - {Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.", - Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)}, + {Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " + + "WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright.", + Input: schema(map[string]string{ + "node": "the machine's name; without it, every machine that is behind", + "behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node", + }, nil, "behind")}, {Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " + "else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " + "name: made anew and the machine sent, so the module starts again on it — only for a secret its " + @@ -148,8 +158,8 @@ var ControllerVerbs = []Verb{ "module": "the module's name", "values": "the settings as a JSON object, for set", "node": "one machine; the whole mesh when absent", - "clear": "\"true\" to remove the layer instead of setting it", - }, []string{"module"})}, + "clear": "\"true\" to remove the layer instead of setting it; not with values", + }, []string{"module"}, "clear")}, {Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + "shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " + "generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " + @@ -167,7 +177,7 @@ var ControllerVerbs = []Verb{ Input: schema(map[string]string{"id": "the ask's build id, as `queue` lists it"}, []string{"id"})}, {Name: "clear", Description: "Cancel every waiting ask in the build queue — and with dead, every dead one too — each " + "recorded failed, cancelled by hand. Never touches one in flight.", - Input: schema(map[string]string{"dead": "\"true\" to cancel the dead asks as well"}, nil)}, + Input: schema(map[string]string{"dead": "\"true\" to cancel the dead asks as well"}, nil, "dead")}, {Name: "rebuild", Description: "Ask a module's current source again under a new id — the branch it follows — or, given a " + "build's id, that build's repository, path and ref. A module a plan holds unbuilt or failed joins that plan. Answers the new id.", Input: schema(map[string]string{"what": "a module's name, or a build's id"}, []string{"what"})}, @@ -178,7 +188,7 @@ var ControllerVerbs = []Verb{ "id": "the build's id", "register": "\"true\" to register what it builds", "older": "\"true\", with register: even though a newer build of the module is registered", - }, []string{"id"})}, + }, []string{"id"}, "register", "older")}, {Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " + "announces it failed, killed by hand — settled, never handed to another machine.", Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})}, @@ -197,11 +207,27 @@ var ControllerVerbs = []Verb{ } // schema is a JSON schema for an object of string properties, which is every argument the verbs -// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. -func schema(properties map[string]string, required []string) map[string]any { +// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The +// switches are the properties that are "true" or "false" and nothing else, said in the schema as an +// enum so a caller sees it and the verb can refuse any other word rather than read it as false. +// +// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not +// name is refused when the verb is called, never passed over. +func schema(properties map[string]string, required []string, switches ...string) map[string]any { + isSwitch := map[string]bool{} + for _, s := range switches { + if _, declared := properties[s]; !declared { + panic("a switch that is not a property: " + s) + } + isSwitch[s] = true + } props := map[string]any{} for name, description := range properties { - props[name] = map[string]any{"type": "string", "description": description} + p := map[string]any{"type": "string", "description": description} + if isSwitch[name] { + p["enum"] = []string{"true", "false"} + } + props[name] = p } out := map[string]any{"type": "object", "properties": props} if len(required) > 0 {