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)

This commit is contained in:
2026-10-24 16:19:42 +02:00
parent fa46e7df7d
commit 4fd6b934c8
5 changed files with 242 additions and 5 deletions
@@ -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.<node>.<module>) and " +
"held fingerprints by key (held.<module>.<hex>). 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 (<node>.<module>), 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 <node>.<module> (the fingerprint it runs, its phase and health), " +
"and <node>.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)
@@ -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)
}
}
@@ -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.<module>, 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.<node>.<module>`) and the holds by key
// (`held.<module>.<hex>`), 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 `<node>.<module>` 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)
}
+2 -2
View File
@@ -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 {
+6 -1
View File
@@ -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",