From 4fd6b934c825d12b8436b2ca5b25ac73fca96d98 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 16:19:42 +0200 Subject: [PATCH] Read the assigner's wants, differences and the haves with tools, so the gap between wants and sends can be read live (issue 1906 D1) --- .../cmd/mesh-delivery-view-tools/main.go | 45 ++++++- .../cmd/mesh-delivery-view-tools/main_test.go | 65 +++++++++ .../mesh-delivery-view/internal/view/read.go | 126 +++++++++++++++++- modules/mesh-delivery-view/manifest_test.go | 4 +- modules/mesh-delivery-view/module.json | 7 +- 5 files changed, 242 insertions(+), 5 deletions(-) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index 645c726..c277b4b 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "strings" stdio "git.novox.be/novox/mesh-sdk/go" "mesh-delivery-view/internal/view" @@ -82,9 +83,50 @@ func Tools(r view.Reader) []stdio.Tool { commit, _ := args["commit"].(string) return r.BuilderBuild(module, fp2, commit) }}, + {Name: view.WantsReadTool, + Description: "The assigner's wants and holds as stored in mesh-delivery-assigner_wants: wants by key (want..) and " + + "held fingerprints by key (held..). Narrow by node and module (a hold has no node and is shown when no node is given). Read-only.", + Input: map[string]any{"type": "object", "properties": nodeModule}, + Run: func(args map[string]any) (any, error) { + node, module := nodeModuleOf(args) + return r.Wants(node, module) + }}, + {Name: view.WantReadTool, + Description: "One want of the assigner as stored: what the node is to run of the module (fingerprint, the newest it is held back from and the rule, removed, words). Read-only.", + Input: map[string]any{"type": "object", "required": []string{"node", "module"}, "properties": nodeModule}, + Run: func(args map[string]any) (any, error) { + node, module := nodeModuleOf(args) + return r.Want(node, module) + }}, + {Name: view.DifferencesReadTool, + Description: "The assigner's differences between a want and what the controller sends, as stored in mesh-delivery-assigner_differences, " + + "by key (.), with the rule that explains each or unexplained. Narrow by node and module. Read-only.", + Input: map[string]any{"type": "object", "properties": nodeModule}, + Run: func(args map[string]any) (any, error) { + node, module := nodeModuleOf(args) + return r.Differences(node, module) + }}, + {Name: view.HavesReadTool, + Description: "What each node has, as the node-engine stored it in node-engine_have: by key . (the fingerprint it runs, its phase and health), " + + "and .node (the node's own facts). Narrow by node and module. Read-only.", + Input: map[string]any{"type": "object", "properties": nodeModule}, + Run: func(args map[string]any) (any, error) { + node, module := nodeModuleOf(args) + return r.Haves(node, module) + }}, } } +var nodeModule = map[string]any{ + "node": map[string]any{"type": "string", "description": "only this node's keys"}, + "module": map[string]any{"type": "string", "description": "only this module's keys"}} + +func nodeModuleOf(args map[string]any) (string, string) { + node, _ := args["node"].(string) + module, _ := args["module"].(string) + return strings.TrimSpace(node), strings.TrimSpace(module) +} + func read(state stdio.KeptState) view.StateRead { return view.StateRead{Keys: state.Keys, GetEntry: func(key string) (json.RawMessage, uint64, error) { e, err := state.Get(key) @@ -102,7 +144,8 @@ func read(state stdio.KeptState) view.StateRead { } func main() { - r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates"))} + r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates")), + WantsState: read(stdio.State("mesh-delivery-assigner.wants")), DifferencesState: read(stdio.State("mesh-delivery-assigner.differences")), HavesState: read(stdio.State("node-engine.have"))} if err := stdio.Serve("", Tools(r)); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index 0c13bdf..008e7d1 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -264,3 +264,68 @@ func TestCandidateToolsAnswerTheStoredValue(t *testing.T) { t.Errorf("by module = %v", c) } } + +// 1906/D1 AC1: assigner_wants, assigner_want, assigner_differences and node_haves answer as stored, narrowed by node and module. +func keptReader() view.Reader { + st := func(f fake) view.StateRead { return f.reader().State } + return view.Reader{ + WantsState: st(fake{ + "want.ace.zsh": `{"fp":"` + fp2A + `"}`, + "want.novox.zsh": `{"fp":"` + fp2B + `"}`, + "want.ace.netcheck": `{"fp":"` + fp2A + `","rule":3}`, + "held." + keyB: `{"node":"ace","words":"failed"}`, + }), + DifferencesState: st(fake{"ace.zsh": `{"unexplained":true}`, "novox.zsh": `{"rule":3}`}), + HavesState: st(fake{"ace.zsh": `{"fp":"x"}`, "ace.node": `{"facts":1}`, "novox.zsh": `{"fp":"y"}`}), + } +} + +func TestAssignerWantsToolAnswersAsStoredAndNarrows(t *testing.T) { + r := keptReader() + out, err := run(t, r, view.WantsReadTool, map[string]any{}) + if err != nil { + t.Fatal(err) + } + a := out.(view.WantsAnswer) + if len(a.Wants) != 3 || len(a.Held) != 1 || string(a.Wants["want.ace.zsh"]) != `{"fp":"`+fp2A+`"}` { + t.Errorf("all = %+v", a) + } + out, _ = run(t, r, view.WantsReadTool, map[string]any{"node": "ace"}) + if got := out.(view.WantsAnswer).Wants; len(got) != 2 { + t.Errorf("node ace = %v", got) + } + out, _ = run(t, r, view.WantsReadTool, map[string]any{"module": "zsh"}) + if got := out.(view.WantsAnswer).Wants; len(got) != 2 { + t.Errorf("module zsh = %v", got) + } +} + +func TestAssignerWantToolAnswersOneAndSaysWhenThereIsNone(t *testing.T) { + r := keptReader() + out, err := run(t, r, view.WantReadTool, map[string]any{"node": "ace", "module": "zsh"}) + if err != nil || string(out.(json.RawMessage)) != `{"fp":"`+fp2A+`"}` { + t.Errorf("want = %v %v", out, err) + } + if _, err := run(t, r, view.WantReadTool, map[string]any{"node": "ace", "module": "gone"}); err == nil || !strings.Contains(err.Error(), "no want") { + t.Errorf("a missing want: %v", err) + } +} + +func TestAssignerDifferencesAndNodeHavesToolsAnswerAsStored(t *testing.T) { + r := keptReader() + out, err := run(t, r, view.DifferencesReadTool, map[string]any{"node": "novox"}) + if err != nil { + t.Fatal(err) + } + if got := out.(view.KeptAnswer).Keys; len(got) != 1 || string(got["novox.zsh"]) != `{"rule":3}` { + t.Errorf("differences = %v", got) + } + out, _ = run(t, r, view.HavesReadTool, map[string]any{"module": "zsh"}) + if got := out.(view.KeptAnswer).Keys; len(got) != 2 { + t.Errorf("haves of zsh = %v", got) + } + out, _ = run(t, r, view.HavesReadTool, map[string]any{"node": "ace"}) + if got := out.(view.KeptAnswer).Keys; len(got) != 2 || got["ace.node"] == nil { + t.Errorf("haves of ace (the node's own key too) = %v", got) + } +} diff --git a/modules/mesh-delivery-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index 8f1546a..bac6085 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -33,7 +33,10 @@ type Reader struct { State StateRead // the indexer's modules Builds StateRead // the builder's builds - CandidatesState StateRead // the indexer's candidates + CandidatesState StateRead // the indexer's candidates + WantsState StateRead // the assigner's wants and holds + DifferencesState StateRead // the assigner's differences + HavesState StateRead // the node-engine's haves } // Module is the stored value of modules., exactly as the indexer wrote it. @@ -328,3 +331,124 @@ func (r Reader) BuilderBuild(module, fp2, commit string) (BuilderRow, error) { } return row, nil } + +// The read tools of the assigner's wants and differences and of the node-engine's haves (issue 1906, D1): the keys of those +// buckets answered as stored, so what the assigner wants, what it found different and what each node has can be read live. +// They write nothing. +const ( + WantsReadTool = "assigner_wants" + WantReadTool = "assigner_want" + DifferencesReadTool = "assigner_differences" + HavesReadTool = "node_haves" +) + +// WantsAnswer is the answer of assigner_wants: the wants by key (`want..`) and the holds by key +// (`held..`), each as stored, and the keys that are neither. +type WantsAnswer struct { + Wants map[string]json.RawMessage `json:"wants"` + Held map[string]json.RawMessage `json:"held"` + Unreadable []string `json:"unreadable,omitempty"` +} + +// KeptAnswer is the answer of assigner_differences and node_haves: the keys of the bucket as stored, and the keys that +// are no `.` key. +type KeptAnswer struct { + Keys map[string]json.RawMessage `json:"keys"` + Unreadable []string `json:"unreadable,omitempty"` +} + +func (s StateRead) all(what string) (map[string]json.RawMessage, error) { + if s.Keys == nil || s.Get == nil { + return nil, fmt.Errorf("%s cannot be read here", what) + } + keys, err := s.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + out := map[string]json.RawMessage{} + for _, k := range keys { + raw, err := s.Get(k) + if err != nil { + return nil, err + } + if len(raw) > 0 { + out[k] = raw + } + } + return out, nil +} + +// Wants is every want and hold of the assigner, narrowed by node and module (a hold has no node and is kept when the module +// matches). +func (r Reader) Wants(node, module string) (WantsAnswer, error) { + all, err := r.WantsState.all("the assigner's wants") + if err != nil { + return WantsAnswer{}, err + } + out := WantsAnswer{Wants: map[string]json.RawMessage{}, Held: map[string]json.RawMessage{}} + for k, raw := range all { + if n, m, err := delivery.ParseWantKey(k); err == nil { + if (node == "" || n == node) && (module == "" || m == module) { + out.Wants[k] = raw + } + continue + } + if m, _, err := delivery.ParseHeldKey(k); err == nil { + if node == "" && (module == "" || m == module) { + out.Held[k] = raw + } + continue + } + out.Unreadable = append(out.Unreadable, k) + } + return out, nil +} + +// Want is the stored want of module on node. +func (r Reader) Want(node, module string) (json.RawMessage, error) { + key, err := delivery.WantKey(strings.TrimSpace(node), strings.TrimSpace(module)) + if err != nil { + return nil, err + } + if r.WantsState.Get == nil { + return nil, fmt.Errorf("the assigner's wants cannot be read here") + } + raw, err := r.WantsState.Get(key) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, fmt.Errorf("the assigner has no want %s", key) + } + return raw, nil +} + +func (r Reader) kept(s StateRead, what, node, module string) (KeptAnswer, error) { + all, err := s.all(what) + if err != nil { + return KeptAnswer{}, err + } + out := KeptAnswer{Keys: map[string]json.RawMessage{}} + for k, raw := range all { + n, m, isNode, err := delivery.ParseHaveKey(k) + if err != nil { + out.Unreadable = append(out.Unreadable, k) + continue + } + if (node == "" || n == node) && (module == "" || (!isNode && m == module)) { + out.Keys[k] = raw + } + } + return out, nil +} + +// Differences is the assigner's differences as stored, narrowed by node and module. +func (r Reader) Differences(node, module string) (KeptAnswer, error) { + return r.kept(r.DifferencesState, "the assigner's differences", node, module) +} + +// Haves is the node-engine's haves (and each node's own key) as stored, narrowed by node and module. +func (r Reader) Haves(node, module string) (KeptAnswer, error) { + return r.kept(r.HavesState, "the haves", node, module) +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 871bca3..10421c3 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -83,7 +83,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -110,7 +110,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 ยง4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, a := range m.Build.Artifacts { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 61ed274..f978e28 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -9,7 +9,11 @@ "indexer_candidate", "indexer_candidates", "builder_build", - "builder_builds" + "builder_builds", + "assigner_wants", + "assigner_want", + "assigner_differences", + "node_haves" ], "state": [ { "name": "answers", "history": 5 } @@ -19,6 +23,7 @@ "mesh-controller.builds", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", + "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates",