From c33b83dce7d53a26e9b23d5d973f206e910968dc Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:57:16 +0000 Subject: [PATCH] Read a build module's steps and toolchains states with its own tools, so a step's result and the reported toolchain can be checked live (issue 1562) --- modules/mesh-build-go/README.md | 2 + .../mesh-build-go/cmd/mesh-build-go/main.go | 20 +- .../mesh-build-go/cmd/mesh-build-go/module.go | 2 + .../cmd/mesh-build-go/module_test.go | 2 +- .../mesh-build-go/cmd/mesh-build-go/read.go | 199 ++++++++++++++++++ .../cmd/mesh-build-go/read_test.go | 147 +++++++++++++ .../mesh-build-go/cmd/mesh-build-go/tools.go | 1 + modules/mesh-build-go/module.json | 3 + modules/mesh-build-node/README.md | 2 + .../cmd/mesh-build-node/main.go | 24 ++- .../cmd/mesh-build-node/module.go | 2 + .../cmd/mesh-build-node/module_test.go | 2 +- .../cmd/mesh-build-node/read.go | 199 ++++++++++++++++++ .../cmd/mesh-build-node/read_test.go | 147 +++++++++++++ .../cmd/mesh-build-node/tools.go | 1 + modules/mesh-build-node/module.json | 3 + 16 files changed, 734 insertions(+), 22 deletions(-) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/read.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/read_test.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/read.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/read_test.go diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index 9bf8b620..6b92aaaf 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -29,6 +29,8 @@ cause named, and the health is then unhealthy with the same text, so the control `build_go_digest`, `build_go_build`) are added by their own issues to `StepTools` and run behind the guard: a long step answers at once with its id and ends on `.` of `steps` (ADR 0469 §2); the digest answers in its call. +Three read tools answer what the module stored in its own states, without the guard and without a grant (issue 1562): `build_go_step {id}` is a step's stored value (state, reason, result, such as a fetch's workspace id), `build_go_steps {limit}` this machine's latest steps without their results, and `build_go_toolchains` every `.` key of `toolchains`. + ## Fetch `build_go_fetch {repository, commit, ref?}` fetches the commit over SSH as the module's account (`mesh-forge` is the diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index 723b5d45..3e2c3a5e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -31,15 +31,17 @@ func main() { } ctx := context.Background() m := &Module{ - Node: strings.TrimSpace(os.Getenv("MESH_NODE")), - Recipe: Current(), - Toolchains: stdio.State(delivery.ToolchainsState).Put, - Emit: func(t string, b any) error { return stdio.Emit(t, b) }, - Steps: stdio.State(delivery.StepsState).Put, - Ctx: ctx, - Say: logf, - Work: WorkDir, - Cache: CacheDir, + Node: strings.TrimSpace(os.Getenv("MESH_NODE")), + Recipe: Current(), + Toolchains: stdio.State(delivery.ToolchainsState).Put, + Emit: func(t string, b any) error { return stdio.Emit(t, b) }, + Steps: stdio.State(delivery.StepsState).Put, + ReadSteps: readOf(stdio.State(delivery.StepsState)), + ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)), + Ctx: ctx, + Say: logf, + Work: WorkDir, + Cache: CacheDir, } if u, err := user.Current(); err == nil { m.Home = u.HomeDir diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index 728ba4ee..736f44d3 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -24,6 +24,8 @@ type Module struct { Probe func(ctx context.Context) (string, error) // Toolchains and Steps are the Put of the states of those names. Toolchains, Steps buildstep.Put + // ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562). + ReadSteps, ReadToolchainsState StateRead // Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of // the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444). Emit func(eventType string, body any) error diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module_test.go b/modules/mesh-build-go/cmd/mesh-build-go/module_test.go index 99bdcf5a..02a84396 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module_test.go @@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) { {Name: "short_probe", Run: run}, }, m.StepTools()...) for _, tool := range m.Tools(steps) { - if tool.Name == StatusTool || tool.Name == HealthTool { + if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool { continue } var reason string diff --git a/modules/mesh-build-go/cmd/mesh-build-go/read.go b/modules/mesh-build-go/cmd/mesh-build-go/read.go new file mode 100644 index 00000000..fcb6ef4a --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/read.go @@ -0,0 +1,199 @@ +package main + +// The module's read tools (novox/hq issue 1562): what the module wrote to its own `steps` and `toolchains` states, +// answered as stored. A long step ends on its key in the steps state (ADR 0469 §2) and its result, such as the +// workspace a fetch made, is only there; a module reading its own bucket needs no grant. + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The read tools, named as the module is: build_go_ and the verb. +const ( + StepReadTool = "build_go_step" + StepsReadTool = "build_go_steps" + ToolchainsReadTool = "build_go_toolchains" +) + +// StateRead reads one of the module's own states: its keys and a key's stored value (nil for a key with none). +type StateRead struct { + Keys func() ([]string, error) + Get func(key string) (json.RawMessage, error) +} + +// readOf is the read of a state the process keeps. +func readOf(s stdio.KeptState) StateRead { + return StateRead{Keys: s.Keys, Get: func(key string) (json.RawMessage, error) { + e, err := s.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }} +} + +// stepKey is the key of a step of this node: the id alone, or the whole key . as a step tool answered it. +func (m *Module) stepKey(id string) (string, error) { + id = strings.TrimSpace(id) + if node, rest, ok := strings.Cut(id, "."); ok { + if node != m.Node { + return "", fmt.Errorf("the step %q is of node %q, this module runs on %q", id, node, m.Node) + } + id = rest + } + return delivery.StepKey(m.Node, id) +} + +// ReadStep is the stored value of this node's step id (state, reason, result). +func (m *Module) ReadStep(id string) (delivery.Step, error) { + key, err := m.stepKey(id) + if err != nil { + return delivery.Step{}, err + } + if m.ReadSteps.Get == nil { + return delivery.Step{}, fmt.Errorf("the steps state cannot be read here") + } + raw, err := m.ReadSteps.Get(key) + if err != nil { + return delivery.Step{}, err + } + if len(raw) == 0 { + return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key) + } + return delivery.DecodeStep(raw) +} + +// StepLine is a step without its result: the list answers these. +type StepLine struct { + ID string `json:"id"` + Tool string `json:"tool"` + State string `json:"state"` + Started string `json:"started"` + Ended string `json:"ended,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). +func (m *Module) RecentSteps(limit int) ([]StepLine, error) { + if limit <= 0 { + limit = 10 + } + if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil { + return nil, fmt.Errorf("the steps state cannot be read here") + } + keys, err := m.ReadSteps.Keys() + if err != nil { + return nil, err + } + var steps []delivery.Step + for _, k := range keys { + if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node { + continue + } + raw, err := m.ReadSteps.Get(k) + if err != nil { + return nil, err + } + if len(raw) == 0 { + continue + } + s, err := delivery.DecodeStep(raw) + if err != nil { + continue // a value this module cannot read is not one of its own steps + } + steps = append(steps, s) + } + sort.SliceStable(steps, func(i, j int) bool { return steps[i].Started.After(steps[j].Started) }) + if len(steps) > limit { + steps = steps[:limit] + } + out := make([]StepLine, 0, len(steps)) + for _, s := range steps { + l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason} + if !s.Ended.IsZero() { + l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z") + } + out = append(out, l) + } + return out, nil +} + +// ReadToolchains is every key of the toolchains state (.) with its stored value. +func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) { + if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil { + return nil, fmt.Errorf("the toolchains state cannot be read here") + } + keys, err := m.ReadToolchainsState.Keys() + if err != nil { + return nil, err + } + out := map[string]delivery.Toolchain{} + for _, k := range keys { + raw, err := m.ReadToolchainsState.Get(k) + if err != nil { + return nil, err + } + if len(raw) == 0 { + continue + } + t, err := delivery.DecodeToolchain(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", k, err) + } + out[k] = t + } + return out, nil +} + +// ReadTools are the module's read tools. All are read-only. +func (m *Module) ReadTools() []stdio.Tool { + return []stdio.Tool{ + {Name: StepReadTool, + Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " + + "(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " + + "(a fetch's workspace id is there). id is the step id a step tool answered, or the whole key .. Read-only.", + Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{ + "id": map[string]any{"type": "string", "description": "the step id, or the key ."}}}, + Run: func(args map[string]any) (any, error) { + id, _ := args["id"].(string) + return m.ReadStep(id) + }}, + {Name: StepsReadTool, + Description: "This machine's latest steps from the module's steps state, newest first, each with its id, tool, state, " + + "times and reason but not its result (read one with " + StepReadTool + "). Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}}, + Run: func(args map[string]any) (any, error) { + steps, err := m.RecentSteps(intArg(args["limit"])) + return map[string]any{"node": m.Node, "steps": steps}, err + }}, + {Name: ToolchainsReadTool, + Description: "What the module's toolchains state holds: each key . with the version installed, the " + + "recipe version and when the module saw them. Read-only.", + Run: func(map[string]any) (any, error) { + keys, err := m.ReadToolchains() + return map[string]any{"node": m.Node, "toolchains": keys}, err + }}, + } +} + +// intArg is a tool argument that is a number, or a string of one; zero otherwise. +func intArg(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case string: + i, _ := strconv.Atoi(n) + return i + } + return 0 +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/read_test.go b/modules/mesh-build-go/cmd/mesh-build-go/read_test.go new file mode 100644 index 00000000..38accef1 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/read_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// fakeState is a state as stored: key to raw value. +type fakeState map[string]json.RawMessage + +func (f fakeState) read() StateRead { + return StateRead{ + Keys: func() ([]string, error) { + var k []string + for key := range f { + k = append(k, key) + } + return k, nil + }, + Get: func(key string) (json.RawMessage, error) { return f[key], nil }, + } +} + +func putStep(t *testing.T, f fakeState, s delivery.Step) { + t.Helper() + b, err := delivery.EncodeStep(s) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.StepKey(s.Node, s.ID) + f[key] = b +} + +func readModule(steps, toolchains fakeState) *Module { + m := moduleWith(newStates(), "go1.27.1") + m.ReadSteps, m.ReadToolchainsState = steps.read(), toolchains.read() + return m +} + +func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) { + t.Helper() + for _, tl := range m.Tools(m.StepTools()) { + if tl.Name == name { + return tl.Run + } + } + t.Fatalf("no tool %s", name) + return nil +} + +// 1562/AC1: build_go_step answers the stored value of a step: state, reason and result (a fetch's workspace id). +func TestStepToolAnswersTheStoredStep(t *testing.T) { + steps := fakeState{} + t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) + putStep(t, steps, delivery.Step{ID: "abc1", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0.Add(time.Second), + Result: json.RawMessage(`{"workspace":"w42"}`)}) + putStep(t, steps, delivery.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: delivery.StepRefused, Started: t0, Reason: "go cannot be run"}) + m := readModule(steps, fakeState{}) + for _, id := range []string{"abc1", "ace.abc1"} { + out, err := tool(t, m, StepReadTool)(map[string]any{"id": id}) + if err != nil { + t.Fatal(err) + } + st := out.(delivery.Step) + if st.State != delivery.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_go_fetch" { + t.Errorf("id %s: step = %+v", id, st) + } + } + out, _ := tool(t, m, StepReadTool)(map[string]any{"id": "abc2"}) + if st := out.(delivery.Step); st.State != delivery.StepRefused || st.Reason != "go cannot be run" { + t.Errorf("refused step = %+v", st) + } +} + +func TestStepToolRefusesAnUnknownOrForeignOrBadID(t *testing.T) { + m := readModule(fakeState{}, fakeState{}) + for id, want := range map[string]string{"nothere": "no step ace.nothere", "g14.abc1": "of node", "Bad Id": "lower-case"} { + _, err := tool(t, m, StepReadTool)(map[string]any{"id": id}) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("id %q: err = %v, want it to say %q", id, err, want) + } + } +} + +// 1562: build_go_steps lists this node's steps newest first without results, at most limit. +func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { + steps := fakeState{} + t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) + putStep(t, steps, delivery.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)}) + putStep(t, steps, delivery.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)}) + putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"}) + putStep(t, steps, delivery.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)}) + m := readModule(steps, fakeState{}) + out, err := tool(t, m, StepsReadTool)(map[string]any{}) + if err != nil { + t.Fatal(err) + } + lines := out.(map[string]any)["steps"].([]StepLine) + var ids []string + for _, l := range lines { + ids = append(ids, l.ID) + } + if !slices.Equal(ids, []string{"c", "b", "a"}) { + t.Errorf("ids = %v, want c b a", ids) + } + if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed { + t.Errorf("first = %+v", lines[0]) + } + out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)}) + if n := len(out.(map[string]any)["steps"].([]StepLine)); n != 1 { + t.Errorf("limit 1 answered %d steps", n) + } +} + +// 1562 (1483/AC5): build_go_toolchains answers each toolchains key as stored. +func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) { + tcs := fakeState{} + b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatal(err) + } + tcs["ace.go"] = b + m := readModule(fakeState{}, tcs) + out, err := tool(t, m, ToolchainsReadTool)(map[string]any{}) + if err != nil { + t.Fatal(err) + } + got := out.(map[string]any)["toolchains"].(map[string]delivery.Toolchain) + if got["ace.go"].Version != "go1.27.1" || got["ace.go"].Recipe != "1" { + t.Errorf("toolchains = %+v", got) + } +} + +func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) { + m := readModule(fakeState{}, fakeState{}) + m.Probe = func(context.Context) (string, error) { return "", context.DeadlineExceeded } + // a node whose go cannot be run can still be asked what its steps said + if _, err := tool(t, m, StepsReadTool)(map[string]any{}); err != nil { + t.Errorf("the read refused: %v", err) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/tools.go b/modules/mesh-build-go/cmd/mesh-build-go/tools.go index 91c2fd1a..06ff4d66 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/tools.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/tools.go @@ -39,6 +39,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool { } tools = append(tools, t) } + tools = append(tools, m.ReadTools()...) return append(tools, stdio.Tool{Name: StatusTool, Description: "What the Go build module is on this machine: the Go version its recipe builds with and the one installed, " + diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index bb7f65de..bd35abef 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -17,6 +17,9 @@ "build_go_test", "build_go_digest", "build_go_build", + "build_go_step", + "build_go_steps", + "build_go_toolchains", "build_go_status", "build_go_health" ], diff --git a/modules/mesh-build-node/README.md b/modules/mesh-build-node/README.md index 556cb1ee..421601b4 100644 --- a/modules/mesh-build-node/README.md +++ b/modules/mesh-build-node/README.md @@ -41,6 +41,8 @@ state. Whatever the version, the module is healthy and `build_node_health` (the `steps` with the cause named; the health is then unhealthy with the same text, so the controller raises the machine's condition. A step runs only after the toolchain directory is installed. +Three read tools answer what the module stored in its own states, without a grant (issue 1562): `build_node_step {id}` is a step's stored value (state, reason, result), `build_node_steps {limit}` this machine's latest steps without their results, and `build_node_toolchains` every `.` key of `toolchains`. + ## Tools `build_node_status` and `build_node_health` answer in their call. The step tools run behind the guard and the diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index 0e148a4f..841d4bbd 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -38,17 +38,19 @@ func main() { return ReadRegistry(filepath.Join(home, "package-registry.json"), filepath.Join(home, "package-registry.secret")) } m := &Module{ - Node: strings.TrimSpace(os.Getenv("MESH_NODE")), - Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) }, - Probe: ProbeNode, - Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry}, - Toolchains: stdio.State(delivery.ToolchainsState).Put, - Emit: func(t string, b any) error { return stdio.Emit(t, b) }, - Steps: stdio.State(delivery.StepsState).Put, - Work: WorkDir, - Run: forgekey.ExecRunnerClean(0), - Ctx: ctx, - Say: logf, + Node: strings.TrimSpace(os.Getenv("MESH_NODE")), + Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) }, + Probe: ProbeNode, + Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry}, + Toolchains: stdio.State(delivery.ToolchainsState).Put, + Emit: func(t string, b any) error { return stdio.Emit(t, b) }, + Steps: stdio.State(delivery.StepsState).Put, + ReadSteps: readOf(stdio.State(delivery.StepsState)), + ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)), + Work: WorkDir, + Run: forgekey.ExecRunnerClean(0), + Ctx: ctx, + Say: logf, } // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index f5364cab..1cc1460c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -36,6 +36,8 @@ type Module struct { Clock func() time.Time // Toolchains and Steps are the Put of the states of those names. Toolchains, Steps buildstep.Put + // ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562). + ReadSteps, ReadToolchainsState StateRead // Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of // the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444). Emit func(eventType string, body any) error diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module_test.go b/modules/mesh-build-node/cmd/mesh-build-node/module_test.go index 552c1048..c144060d 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module_test.go @@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {Name: "short_probe", Run: run}, }, (&Module{}).StepTools()...) for _, tool := range m.Tools(steps) { - if tool.Name == StatusTool || tool.Name == HealthTool { + if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool { continue } var reason string diff --git a/modules/mesh-build-node/cmd/mesh-build-node/read.go b/modules/mesh-build-node/cmd/mesh-build-node/read.go new file mode 100644 index 00000000..983587fd --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/read.go @@ -0,0 +1,199 @@ +package main + +// The module's read tools (novox/hq issue 1562): what the module wrote to its own `steps` and `toolchains` states, +// answered as stored. A long step ends on its key in the steps state (ADR 0469 §2) and its result, such as the +// workspace a fetch made, is only there; a module reading its own bucket needs no grant. + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The read tools, named as the module is: build_node_ and the verb. +const ( + StepReadTool = "build_node_step" + StepsReadTool = "build_node_steps" + ToolchainsReadTool = "build_node_toolchains" +) + +// StateRead reads one of the module's own states: its keys and a key's stored value (nil for a key with none). +type StateRead struct { + Keys func() ([]string, error) + Get func(key string) (json.RawMessage, error) +} + +// readOf is the read of a state the process keeps. +func readOf(s stdio.KeptState) StateRead { + return StateRead{Keys: s.Keys, Get: func(key string) (json.RawMessage, error) { + e, err := s.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }} +} + +// stepKey is the key of a step of this node: the id alone, or the whole key . as a step tool answered it. +func (m *Module) stepKey(id string) (string, error) { + id = strings.TrimSpace(id) + if node, rest, ok := strings.Cut(id, "."); ok { + if node != m.Node { + return "", fmt.Errorf("the step %q is of node %q, this module runs on %q", id, node, m.Node) + } + id = rest + } + return delivery.StepKey(m.Node, id) +} + +// ReadStep is the stored value of this node's step id (state, reason, result). +func (m *Module) ReadStep(id string) (delivery.Step, error) { + key, err := m.stepKey(id) + if err != nil { + return delivery.Step{}, err + } + if m.ReadSteps.Get == nil { + return delivery.Step{}, fmt.Errorf("the steps state cannot be read here") + } + raw, err := m.ReadSteps.Get(key) + if err != nil { + return delivery.Step{}, err + } + if len(raw) == 0 { + return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key) + } + return delivery.DecodeStep(raw) +} + +// StepLine is a step without its result: the list answers these. +type StepLine struct { + ID string `json:"id"` + Tool string `json:"tool"` + State string `json:"state"` + Started string `json:"started"` + Ended string `json:"ended,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). +func (m *Module) RecentSteps(limit int) ([]StepLine, error) { + if limit <= 0 { + limit = 10 + } + if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil { + return nil, fmt.Errorf("the steps state cannot be read here") + } + keys, err := m.ReadSteps.Keys() + if err != nil { + return nil, err + } + var steps []delivery.Step + for _, k := range keys { + if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node { + continue + } + raw, err := m.ReadSteps.Get(k) + if err != nil { + return nil, err + } + if len(raw) == 0 { + continue + } + s, err := delivery.DecodeStep(raw) + if err != nil { + continue // a value this module cannot read is not one of its own steps + } + steps = append(steps, s) + } + sort.SliceStable(steps, func(i, j int) bool { return steps[i].Started.After(steps[j].Started) }) + if len(steps) > limit { + steps = steps[:limit] + } + out := make([]StepLine, 0, len(steps)) + for _, s := range steps { + l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason} + if !s.Ended.IsZero() { + l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z") + } + out = append(out, l) + } + return out, nil +} + +// ReadToolchains is every key of the toolchains state (.) with its stored value. +func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) { + if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil { + return nil, fmt.Errorf("the toolchains state cannot be read here") + } + keys, err := m.ReadToolchainsState.Keys() + if err != nil { + return nil, err + } + out := map[string]delivery.Toolchain{} + for _, k := range keys { + raw, err := m.ReadToolchainsState.Get(k) + if err != nil { + return nil, err + } + if len(raw) == 0 { + continue + } + t, err := delivery.DecodeToolchain(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", k, err) + } + out[k] = t + } + return out, nil +} + +// ReadTools are the module's read tools. All are read-only. +func (m *Module) ReadTools() []stdio.Tool { + return []stdio.Tool{ + {Name: StepReadTool, + Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " + + "(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " + + "(a fetch's workspace id is there). id is the step id a step tool answered, or the whole key .. Read-only.", + Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{ + "id": map[string]any{"type": "string", "description": "the step id, or the key ."}}}, + Run: func(args map[string]any) (any, error) { + id, _ := args["id"].(string) + return m.ReadStep(id) + }}, + {Name: StepsReadTool, + Description: "This machine's latest steps from the module's steps state, newest first, each with its id, tool, state, " + + "times and reason but not its result (read one with " + StepReadTool + "). Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}}, + Run: func(args map[string]any) (any, error) { + steps, err := m.RecentSteps(intArg(args["limit"])) + return map[string]any{"node": m.Node, "steps": steps}, err + }}, + {Name: ToolchainsReadTool, + Description: "What the module's toolchains state holds: each key . with the version installed, the " + + "recipe version and when the module saw them. Read-only.", + Run: func(map[string]any) (any, error) { + keys, err := m.ReadToolchains() + return map[string]any{"node": m.Node, "toolchains": keys}, err + }}, + } +} + +// intArg is a tool argument that is a number, or a string of one; zero otherwise. +func intArg(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case string: + i, _ := strconv.Atoi(n) + return i + } + return 0 +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/read_test.go b/modules/mesh-build-node/cmd/mesh-build-node/read_test.go new file mode 100644 index 00000000..11cfa6fc --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/read_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// fakeState is a state as stored: key to raw value. +type fakeState map[string]json.RawMessage + +func (f fakeState) read() StateRead { + return StateRead{ + Keys: func() ([]string, error) { + var k []string + for key := range f { + k = append(k, key) + } + return k, nil + }, + Get: func(key string) (json.RawMessage, error) { return f[key], nil }, + } +} + +func putStep(t *testing.T, f fakeState, s delivery.Step) { + t.Helper() + b, err := delivery.EncodeStep(s) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.StepKey(s.Node, s.ID) + f[key] = b +} + +func readModule(steps, toolchains fakeState) *Module { + m := moduleWith(newStates(), "v22.1.0") + m.ReadSteps, m.ReadToolchainsState = steps.read(), toolchains.read() + return m +} + +func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) { + t.Helper() + for _, tl := range m.Tools(m.StepTools()) { + if tl.Name == name { + return tl.Run + } + } + t.Fatalf("no tool %s", name) + return nil +} + +// 1562/AC1: build_node_step answers the stored value of a step: state, reason and result (a fetch's workspace id). +func TestStepToolAnswersTheStoredStep(t *testing.T) { + steps := fakeState{} + t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) + putStep(t, steps, delivery.Step{ID: "abc1", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0.Add(time.Second), + Result: json.RawMessage(`{"workspace":"w42"}`)}) + putStep(t, steps, delivery.Step{ID: "abc2", Tool: "build_node_test", Node: "ace", State: delivery.StepRefused, Started: t0, Reason: "node cannot be run"}) + m := readModule(steps, fakeState{}) + for _, id := range []string{"abc1", "ace.abc1"} { + out, err := tool(t, m, StepReadTool)(map[string]any{"id": id}) + if err != nil { + t.Fatal(err) + } + st := out.(delivery.Step) + if st.State != delivery.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_node_fetch" { + t.Errorf("id %s: step = %+v", id, st) + } + } + out, _ := tool(t, m, StepReadTool)(map[string]any{"id": "abc2"}) + if st := out.(delivery.Step); st.State != delivery.StepRefused || st.Reason != "node cannot be run" { + t.Errorf("refused step = %+v", st) + } +} + +func TestStepToolRefusesAnUnknownOrForeignOrBadID(t *testing.T) { + m := readModule(fakeState{}, fakeState{}) + for id, want := range map[string]string{"nothere": "no step ace.nothere", "g14.abc1": "of node", "Bad Id": "lower-case"} { + _, err := tool(t, m, StepReadTool)(map[string]any{"id": id}) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("id %q: err = %v, want it to say %q", id, err, want) + } + } +} + +// 1562: build_node_steps lists this node's steps newest first without results, at most limit. +func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { + steps := fakeState{} + t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) + putStep(t, steps, delivery.Step{ID: "a", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)}) + putStep(t, steps, delivery.Step{ID: "b", Tool: "build_node_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)}) + putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"}) + putStep(t, steps, delivery.Step{ID: "d", Tool: "build_node_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)}) + m := readModule(steps, fakeState{}) + out, err := tool(t, m, StepsReadTool)(map[string]any{}) + if err != nil { + t.Fatal(err) + } + lines := out.(map[string]any)["steps"].([]StepLine) + var ids []string + for _, l := range lines { + ids = append(ids, l.ID) + } + if !slices.Equal(ids, []string{"c", "b", "a"}) { + t.Errorf("ids = %v, want c b a", ids) + } + if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed { + t.Errorf("first = %+v", lines[0]) + } + out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)}) + if n := len(out.(map[string]any)["steps"].([]StepLine)); n != 1 { + t.Errorf("limit 1 answered %d steps", n) + } +} + +// 1562 (1483/AC5): build_node_toolchains answers each toolchains key as stored. +func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) { + tcs := fakeState{} + b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "typescript", Version: "v22.1.0", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatal(err) + } + tcs["ace.typescript"] = b + m := readModule(fakeState{}, tcs) + out, err := tool(t, m, ToolchainsReadTool)(map[string]any{}) + if err != nil { + t.Fatal(err) + } + got := out.(map[string]any)["toolchains"].(map[string]delivery.Toolchain) + if got["ace.typescript"].Version != "v22.1.0" || got["ace.typescript"].Recipe != "1" { + t.Errorf("toolchains = %+v", got) + } +} + +func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) { + m := readModule(fakeState{}, fakeState{}) + m.Probe = func(context.Context) (string, error) { return "", context.DeadlineExceeded } + // a node whose go cannot be run can still be asked what its steps said + if _, err := tool(t, m, StepsReadTool)(map[string]any{}); err != nil { + t.Errorf("the read refused: %v", err) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/tools.go b/modules/mesh-build-node/cmd/mesh-build-node/tools.go index 5d3cf589..55b4e721 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/tools.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/tools.go @@ -39,6 +39,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool { } tools = append(tools, t) } + tools = append(tools, m.ReadTools()...) return append(tools, stdio.Tool{Name: StatusTool, Description: "What the Node build module is on this machine: the Node version its recipe builds with and the one installed, " + diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index 6c06d5e2..86af8632 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -16,6 +16,9 @@ "build_node_fetch", "build_node_test", "build_node_build", + "build_node_step", + "build_node_steps", + "build_node_toolchains", "build_node_status", "build_node_health" ],