Files
mesh-tools/node-tools/internal/console/wait_test.go
T
jschoubben 4116809162 Merge branch 'feat/47-G-fix11-a2' into feat/47-G-fix11-a3
# Conflicts:
#	node-tools/internal/console/wait_test.go
2026-11-12 05:27:26 +01:00

408 lines
17 KiB
Go

package console
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"reflect"
"sort"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-tools/node-tools/internal/approval"
"github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// waitingRuntime is the tool runner on a machine, issued no waits (novox/hq ADR 0670 §2): the surface over its
// connection, and the mesh to publish on.
func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
t.Helper()
mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("node-tools", "desk", false, nil))
conn := connect(t, "node-tools", "desk")
mt.Until(t, func() error {
if conn.Membership("node-tools") == nil {
return os.ErrNotExist
}
return nil
})
return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh
}
// halfSource is a Source that also holds a catalog half, as the tool runner's first views do.
type halfSource struct {
Source
halves map[string]meshcatalogclient.Half
}
func (h halfSource) Halves() (map[string]meshcatalogclient.Half, error) { return h.halves, nil }
// listening waits until the surface's connection holds n more subscriptions than before: a wait has subscribed.
func listening(t *testing.T, s *Surface, before, n int) {
t.Helper()
mt.Until(t, func() error {
if s.conn.Subscriptions() < before+n {
return os.ErrNotExist
}
return nil
})
}
// callWait is one tools/call of mesh_wait, answered as an agent host reads it.
func callWait(s *Surface, args map[string]any) (text string, isError bool) {
raw, _ := json.Marshal(map[string]any{"name": verbWait, "arguments": args})
r := s.Handle(Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call", Params: raw})
result, _ := r.Result.(map[string]any)
content, _ := result["content"].([]map[string]any)
var parts []string
for _, c := range content {
parts = append(parts, c["text"].(string))
}
isErr, _ := result["isError"].(bool)
return strings.Join(parts, "\n"), isErr
}
// novox/hq ADR 0367 §1, ADR 0670 §1 (design 34 every-event §13, §15, §19): mesh_wait answers the first event that
// matches — by its name and its filter — with no membership naming it: its whole name, the namespace it arrived on, its
// body, when it arrived and when it was published, within a second of its publication, and ignores the others.
func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
s, mesh := waitingRuntime(t)
type answer struct {
text string
isErr bool
at time.Time
}
type heardAnswer struct {
Heard struct {
Subject string `json:"subject"`
From string `json:"from"`
Body map[string]any `json:"body"`
At time.Time `json:"at"`
Published *string `json:"published"`
} `json:"heard"`
Read string `json:"read"`
}
nc, err := nats.Connect(mt.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
done := make(chan answer, 1)
before := s.conn.Subscriptions()
go func() {
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-delivery-view.delivered"},
"filter": map[string]any{"module": "netcheck"}, "timeout": 20})
done <- answer{text, isErr, time.Now()}
}()
// Once it listens: another module's delivery, another event, then the one it waits for, with its own time.
listening(t, s, before, 6)
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.delivered", map[string]any{"module": "grafana"})
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.planned", map[string]any{"module": "netcheck"})
msg := nats.NewMsg("mesh.mod.mesh-delivery-view.event.delivered")
msg.Header.Set("x-time", "2026-11-12T03:00:00.5Z")
msg.Data = []byte(`{"module":"netcheck"}`)
published := time.Now()
if err := nc.PublishMsg(msg); err != nil {
t.Fatal(err)
}
var got answer
select {
case got = <-done:
case <-time.After(10 * time.Second):
t.Fatal("mesh_wait did not answer the matching event")
}
if got.isErr {
t.Fatalf("mesh_wait failed: %s", got.text)
}
if got.at.Sub(published) > time.Second {
t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published))
}
var heard heardAnswer
if err := json.Unmarshal([]byte(got.text), &heard); err != nil {
t.Fatalf("not an answer: %s", got.text)
}
if h := heard.Heard; h.Subject != "mesh-delivery-view.delivered" || h.From != "module" || h.Body["module"] != "netcheck" ||
h.At.IsZero() || h.Published == nil || *h.Published != "2026-11-12T03:00:00.5Z" || !strings.Contains(heard.Read, "only wakes you") {
t.Errorf("answered %s, want netcheck's delivery from a module with its times", got.text)
}
// And a seat's `decided` of one asker, heard by `operator-channel.decided`: its whole name, from the seat, with no
// time of its own, and the answer read only with claude_code_answer.
before = s.conn.Subscriptions()
go func() {
text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided", "timeout": 20})
done <- answer{text, isErr, time.Now()}
}()
listening(t, s, before, 6)
// A seat's event, said by the router on core NATS here: no stream of the test's bus carries the seat.
if err := connect(t, "messenger", "").Say("mesh.seat.operator-channel.event.decided.claude-code", []byte(`{"ask":"a1"}`)); err != nil {
t.Fatal(err)
}
select {
case got = <-done:
heard = heardAnswer{}
_ = json.Unmarshal([]byte(got.text), &heard)
if h := heard.Heard; got.isErr || h.Subject != "operator-channel.decided.claude-code" || h.From != "seat" ||
h.Published != nil || !strings.Contains(heard.Read, "claude_code_answer") {
t.Errorf("the asker's decided: %s", got.text)
}
case <-time.After(10 * time.Second):
t.Fatal("mesh_wait did not answer the asker's decided")
}
}
// It says it timed out at its timeout, and leaves no subscription behind (ADR 0367 §1, §2).
func TestMeshWaitSaysItTimedOutAndKeepsNothing(t *testing.T) {
s, _ := waitingRuntime(t)
before := s.conn.Subscriptions()
start := time.Now()
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1})
if took := time.Since(start); took < time.Second || took > 3*time.Second {
t.Errorf("a one-second wait took %s", took)
}
// Before the catalog half is read, names are not checked, and the answer says so (design 34 every-event §14).
if isErr || !strings.Contains(text, `"timed-out": true`) || !strings.Contains(text, `"unchecked"`) {
t.Errorf("a wait that heard nothing: %s", text)
}
if after := s.conn.Subscriptions(); after != before {
t.Errorf("%d subscriptions before the wait, %d after it", before, after)
}
}
// Its timeout is four minutes when none is given and ten at most: a longer one, or none at all, is refused
// with the bound, never shortened in silence.
func TestMeshWaitsTimeoutIsBounded(t *testing.T) {
s, _ := waitingRuntime(t)
if WaitDefault != 4*time.Minute || WaitLongest != 10*time.Minute {
t.Errorf("the bounds are %s and %s, want 4m and 10m (ADR 0367 §1)", WaitDefault, WaitLongest)
}
for _, timeout := range []any{601, 0, -5, "soon"} {
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": timeout})
if !isErr || !strings.Contains(text, "600") {
t.Errorf("a timeout of %v: %s", timeout, text)
}
}
}
// novox/hq ADR 0670 §1, §2 (design 34 every-event §13, §16, §19): mesh_wait refuses loudly and never loops: a name with
// a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a
// wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant.
func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) {
s, _ := waitingRuntime(t)
var seventeen []any
for i := 0; i < 17; i++ {
seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i))
}
for _, c := range []struct {
args map[string]any
says string
}{
{map[string]any{"subjects": []any{"mesh-knowledge.*"}}, "[A-Za-z0-9_-]+"},
{map[string]any{"subjects": []any{"operator-channel.decided.>"}}, "[A-Za-z0-9_-]+"},
{map[string]any{"subjects": []any{"mesh-knowledge"}}, "fewer than two tokens"},
{map[string]any{"subjects": seventeen}, "at most 16"},
{map[string]any{"subjects": []any{}}, "at least one event"},
{map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "from": "bench"}, "seat, module or stream"},
} {
c.args["timeout"] = 1
start := time.Now()
text, isErr := callWait(s, c.args)
if !isErr || !strings.Contains(text, c.says) || strings.Contains(text, "manifest") {
t.Errorf("%v: %s, want refused saying %q", c.args, text, c.says)
}
if took := time.Since(start); took > 500*time.Millisecond {
t.Errorf("%v was refused after %s, want at once", c.args, took)
}
}
// E §14: against the catalog half, a misspelt emitter is refused naming the half and its revision, a known emitter's
// undeclared event is waited on and its timeout says so, and a declared one says nothing of it.
half := NewSurface(s.conn, halfSource{busSource{s.conn}, map[string]meshcatalogclient.Half{"main": {
Revision: catalogseat.CatalogRevision{Revision: 7, Definitions: []catalogseat.CatalogRevisionDefinitionsItem{{Seat: "mesh-catalog"}}},
Entries: map[string]catalogseat.CatalogEntry{"mesh-delivery-view": {Module: "mesh-delivery-view",
Manifest: map[string]any{"emits": []any{"delivered"}}}},
}}}, "desk.node-tools")
for _, c := range []struct {
name, says string
refused bool
}{
{"mesh-delivry-view.delivered", "mesh-delivry-view is neither a seat nor a module of the catalog half (mesh-catalog.revision, revision 7 of main)", true},
{"mesh-delivery-view.planned", "no declaration names mesh-delivery-view.planned", false},
{"mesh-catalog.catalog-revision", "", false},
} {
text, isErr := callWait(half, map[string]any{"subjects": []any{c.name}, "timeout": 1})
if isErr != c.refused || c.says != "" && !strings.Contains(text, c.says) ||
c.says == "" && (strings.Contains(text, "undeclared") || strings.Contains(text, "unchecked")) {
t.Errorf("%s against the catalog half: %s", c.name, text)
}
}
// The bus refusing every subscription of the call: answered after the timeout, in §16's words.
b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true,
Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{
Subscribe: &server.SubjectPermission{Allow: []string{"_INBOX.desk.node-tools.>", "mesh.assignment.desk.*"}},
}}}})
if err != nil {
t.Fatal(err)
}
go b.Start()
if !b.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(b.Shutdown)
conn, err := bus.Connect(bus.Credential{URL: b.ClientURL(), Module: "node-tools", Node: "desk", User: "desk.node-tools", Password: "pw"})
if err != nil {
t.Fatal(err)
}
conn.Logf = func(string, ...any) {}
t.Cleanup(conn.Close)
start := time.Now()
text, isErr := callWait(NewSurface(conn, nil, "desk.node-tools"), map[string]any{"subjects": []any{"mesh-delivery-view.delivered"}, "timeout": 1})
if took := time.Since(start); took < time.Second || took > 3*time.Second {
t.Errorf("a wait the bus refused whole answered after %s, want its timeout of 1s", took)
}
if !isErr || !strings.Contains(text, "the bus refused this account a subscription on mesh.seat.mesh-delivery-view.event.delivered") ||
!strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") {
t.Errorf("a wait the bus refused whole: %s", text)
}
}
// The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded.
func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
var names []string
var wait map[string]any
for _, tool := range discovery() {
names = append(names, tool["name"].(string))
if tool["name"] == verbWait {
wait = tool
}
}
sort.Strings(names)
want := []string{"mesh_approvals", "mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"}
if !reflect.DeepEqual(names, want) {
t.Errorf("announced %v, want %v", names, want)
}
if wait == nil || !isDiscovery(verbWait) {
t.Fatal("mesh_wait is not announced")
}
schema, _ := json.Marshal(wait["inputSchema"])
for _, word := range []string{`"subjects"`, `"filter"`, `"timeout"`} {
if !strings.Contains(string(schema), word) {
t.Errorf("mesh_wait's schema has no %s: %s", word, schema)
}
}
// Design 46 7-approval-mark §18: mesh_approvals lists, and records a hand act before it cancels; refused without a record.
gate := &fakeApprovals{}
var recorded []string
s := &Surface{who: "n1.node-tools", Approvals: gate, Record: func(a map[string]any) error {
if a["why"] == "unrecordable" {
return errors.New("mesh-hand-acts does not answer")
}
recorded = append(recorded, a["what"].(string))
return nil
}}
if out := s.discover(context.Background(), verbApprovals, map[string]any{}); out["isError"] == true || !strings.Contains(fmtAnswer(out), "every machine") {
t.Errorf("the list %v", out)
}
if out := s.discover(context.Background(), verbApprovals, map[string]any{"cancel": "a1", "why": "no longer needed"}); out["isError"] == true ||
gate.cancelled != "a1" || len(recorded) != 1 || recorded[0] != "cancelled ask a1" {
t.Errorf("a cancel %v, recorded %v", out, recorded)
}
if out := s.discover(context.Background(), verbApprovals, map[string]any{"cancel": "a2", "why": "unrecordable"}); out["isError"] != true || gate.cancelled != "a1" {
t.Errorf("a cancel without a record %v", out)
}
}
func fmtAnswer(out map[string]any) string { raw, _ := json.Marshal(out); return string(raw) }
// fakeApprovals is a gate as mesh_approvals reads it.
type fakeApprovals struct{ cancelled string }
func (f *fakeApprovals) List() ([]approval.Listed, error) {
return []approval.Listed{{Ask: "a1", State: "asked"}}, nil
}
func (f *fakeApprovals) Read(id string) (approval.Whole, error) { return approval.Whole{}, nil }
func (f *fakeApprovals) Applied() []approval.Marks { return nil }
func (f *fakeApprovals) Describe(_, _, _ string) (bool, string) { return false, "" }
func (f *fakeApprovals) Again(string) error { return nil }
func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil }
func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil }
// A client that hangs up or cancels ends its wait at once: the subscription is closed and nothing is left
// waiting for the timeout (the review of issue 745). Over the console's own HTTP, as an agent host calls it.
func TestMeshWaitEndsWhenItsClientHangsUp(t *testing.T) {
s, _ := waitingRuntime(t)
up, err := Serve(s, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = up.Close() })
before := s.conn.Subscriptions()
ctx, cancel := context.WithCancel(context.Background())
body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh_wait",` +
`"arguments":{"subjects":["mesh-knowledge.reviewed"],"timeout":600}}}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+up.Address+"/mcp", strings.NewReader(body))
req.Header.Set("content-type", "application/json")
go func() { _, _ = http.DefaultClient.Do(req) }()
mt.Until(t, func() error {
if s.conn.Subscriptions() <= before {
return os.ErrNotExist
}
return nil
})
cancel()
start := time.Now()
for s.conn.Subscriptions() > before {
if time.Since(start) > 3*time.Second {
t.Fatalf("%d subscriptions 3s after the client hung up, %d before the wait", s.conn.Subscriptions(), before)
}
time.Sleep(20 * time.Millisecond)
}
}
// The tool runner stopping ends every wait in flight at once (the second review of issue 745): Close cancels
// the requests' context rather than waiting up to ten minutes for a wait to time out.
func TestMeshWaitEndsWhenTheConsoleCloses(t *testing.T) {
s, _ := waitingRuntime(t)
up, err := Serve(s, "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
before := s.conn.Subscriptions()
body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh_wait",` +
`"arguments":{"subjects":["mesh-knowledge.reviewed"],"timeout":600}}}`
go func() { _, _ = http.Post("http://"+up.Address+"/mcp", "application/json", strings.NewReader(body)) }()
mt.Until(t, func() error {
if s.conn.Subscriptions() <= before {
return os.ErrNotExist
}
return nil
})
closed := make(chan error, 1)
start := time.Now()
go func() { closed <- up.Close() }()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("closing the console waited on a mesh_wait in flight")
}
if took := time.Since(start); took > 3*time.Second {
t.Errorf("closing took %s", took)
}
mt.Until(t, func() error {
if s.conn.Subscriptions() > before {
return os.ErrExist
}
return nil
})
}