mesh_wait goes through the runner's approval gate (node-tools/internal/approval, Gate.Waited): a wait whose
subjects overlap a marked subject of any module the catalog half lists (the newest entry of every followed
branch, with the served entry of each module this runner serves) is an act of the runner's own module and
build (MESH_FINGERPRINT at its start), asked once on operator-channel as "Wait on <names> on <node>?", and
held, subscribing nothing but its ask's decided, until its timeout, its caller's going or the choice. On a
warrant it subscribes, its timeout running from then, and answers {answer, approval}. Before the marks are
read nothing is asked; a wait from a seat is never marked. The state fixture moves to meshtest so the
console's table can hold a gate.
Builds: design 34 every-event §34, §35, §36, §37; design 47 G lead answer A-168
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
637 lines
27 KiB
Go
637 lines
27 KiB
Go
package console
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
|
opseat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
|
"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, mesh := 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)
|
|
}
|
|
// Design 34 every-event §37: a wait whose subjects overlap a marked event's, or a stream's with a `grant`, runs only on the
|
|
// operator's warrant for that one wait; with none it subscribes nothing, asks once and answers at its timeout or the choice.
|
|
markedWaits(t, s, mesh)
|
|
}
|
|
|
|
// markedWaits are §37's rows, against a gate on the test bus, the router's records written there as the router writes them.
|
|
func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) {
|
|
gw := newGatedWaits(t, s, mesh, "src2:runner")
|
|
g := gw.surface
|
|
took := func(args map[string]any) (string, bool, time.Duration) {
|
|
start := time.Now()
|
|
text, isErr := callWait(g, args)
|
|
return text, isErr, time.Since(start)
|
|
}
|
|
// A fixture module's marked event, no warrant: nothing of it subscribed (only its ask's decided), asked once, §12's
|
|
// words at its timeout; the same wait again asks nothing new.
|
|
before := g.conn.Subscriptions()
|
|
go func() {
|
|
listening(t, g, before, 1)
|
|
mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 1})
|
|
}()
|
|
text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1})
|
|
if !isErr || !strings.Contains(text, "this wait needs the operator's approval. Asked on telegram") || gw.asks() != 1 ||
|
|
d < time.Second || strings.Contains(text, "heard") {
|
|
t.Errorf("a marked event's wait, no warrant: %s after %s, %d asks", text, d, gw.asks())
|
|
}
|
|
if text, isErr, _ = took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr ||
|
|
!strings.Contains(text, "still waiting") || gw.asks() != 1 {
|
|
t.Errorf("the same marked wait again: %s, %d asks", text, gw.asks())
|
|
}
|
|
// A stream with a `grant` is marked whole; a name with the node after a marked event's name is marked.
|
|
for i, name := range []string{"mail.inbox", "mail.message.desk"} {
|
|
if text, isErr, _ := took(map[string]any{"subjects": []any{name}, "timeout": 1}); !isErr || !strings.Contains(text, "Not run") || gw.asks() != 2+i {
|
|
t.Errorf("%s: %s, %d asks", name, text, gw.asks())
|
|
}
|
|
}
|
|
// The same names from a seat ask nothing.
|
|
if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "from": "seat", "timeout": 1}); isErr ||
|
|
!strings.Contains(text, `"timed-out": true`) || gw.asks() != 3 {
|
|
t.Errorf("from a seat: %s, %d asks", text, gw.asks())
|
|
}
|
|
// The most timeout a wait takes, its ask chosen while it waits: answered at the choice; the same call made then
|
|
// subscribes and hears the event published after it, answered with its approval; a warrant for other names, another
|
|
// filter or another timeout is refused, asked anew.
|
|
long := map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 600}
|
|
done := make(chan string, 1)
|
|
go func() { text, _, d := took(long); done <- fmt.Sprintf("%s after %s", text, d) }()
|
|
mt.Until(t, func() error {
|
|
if gw.asks() < 4 {
|
|
return os.ErrNotExist
|
|
}
|
|
return nil
|
|
})
|
|
gw.approve(t)
|
|
select {
|
|
case got := <-done:
|
|
if !strings.Contains(got, "make this wait again within 30 minutes") {
|
|
t.Errorf("a long wait chosen while it waits: %s", got)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("a long marked wait was not answered at the choice")
|
|
}
|
|
before = g.conn.Subscriptions()
|
|
go func() {
|
|
listening(t, g, before, 6)
|
|
mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 2})
|
|
}()
|
|
if text, isErr, _ := took(long); isErr || !strings.Contains(text, `"approval": "run on the operator's approval`) ||
|
|
!strings.Contains(text, `"subject": "mail.message"`) {
|
|
t.Errorf("the same wait on its warrant: %s", text)
|
|
}
|
|
for _, other := range []map[string]any{
|
|
{"subjects": []any{"mail.message", "mail.inbox"}, "filter": map[string]any{"n": 2}, "timeout": 1},
|
|
{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 3}, "timeout": 1},
|
|
{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 2},
|
|
} {
|
|
asked := gw.asks()
|
|
if text, isErr, _ := took(other); !isErr || !strings.Contains(text, "Not run") || gw.asks() != asked+1 {
|
|
t.Errorf("another wait than the warrant's %v: %s", other, text)
|
|
}
|
|
}
|
|
// Before the catalog half is read nothing is asked: judged when its first view comes within the timeout, refused at
|
|
// the timeout when it does not.
|
|
gw.cat.mu.Lock()
|
|
gw.cat.ready = errors.New("no first view yet")
|
|
gw.cat.mu.Unlock()
|
|
asked := gw.asks()
|
|
if text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr ||
|
|
!strings.Contains(text, "the marks are not yet read") || gw.asks() != asked || d < time.Second {
|
|
t.Errorf("before the catalog half is read: %s after %s", text, d)
|
|
}
|
|
gw.cat.came = make(chan chan struct{})
|
|
go func() { close(<-gw.cat.came) }()
|
|
if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 9}, "timeout": 1}); !isErr ||
|
|
!strings.Contains(text, "this wait needs the operator's approval") || gw.asks() != asked+1 {
|
|
t.Errorf("the catalog half read within the timeout: %s", text)
|
|
}
|
|
// While the runner's own fingerprint is unknown a marked wait is refused, saying so.
|
|
unknown := newGatedWaits(t, s, mesh, "")
|
|
if text, isErr := callWait(unknown.surface, map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr ||
|
|
!strings.Contains(text, "own fingerprint is unknown") || unknown.asks() != 0 {
|
|
t.Errorf("the runner's fingerprint unknown: %s", text)
|
|
}
|
|
}
|
|
|
|
// gatedWaits is the surface with the runner's approval gate: the fixture module mail marks its event message and declares
|
|
// the stream inbox with a grant; the router takes each ask as it is published, its records on the test bus.
|
|
type gatedWaits struct {
|
|
surface *Surface
|
|
cat *waitCatalog
|
|
records nats.KeyValue
|
|
mu sync.Mutex
|
|
asked []opseat.Ask
|
|
say *bus.Conn
|
|
}
|
|
|
|
func newGatedWaits(t *testing.T, s *Surface, mesh *mt.Mesh, build string) *gatedWaits {
|
|
gw := &gatedWaits{cat: &waitCatalog{entries: []catalogseat.CatalogEntry{{Module: "mail", Fp: "src2:mail", Manifest: map[string]any{
|
|
"emits": []any{"message"}, "needs-approval": []any{"message"},
|
|
"streams": []any{map[string]any{"name": "inbox", "events": []any{"inbox.*"}, "grant": map[string]any{"subscribe": []any{"x"}}}}}}}},
|
|
records: mesh.Bucket(t, "operator-channel-asks"), say: connect(t, "messenger", "")}
|
|
gate := approval.New(approval.Deps{Node: "desk", Runner: "node-tools", Build: build, Catalog: gw.cat, Wait: gw.cat.wait,
|
|
State: mt.NewState(), Holder: func(string) (bool, error) { return true, nil },
|
|
Hear: func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) {
|
|
_, ok, _, err := s.conn.HearFirst(ctx, subjects, within, match)
|
|
return ok, err
|
|
},
|
|
Publish: func(module, subject string, body any, id string) error {
|
|
a, ok := body.(opseat.Ask)
|
|
if subject != opseat.AcceptSubjectAsk(module) || !ok {
|
|
return nil
|
|
}
|
|
gw.mu.Lock()
|
|
gw.asked = append(gw.asked, a)
|
|
gw.mu.Unlock()
|
|
gw.write(t, opseat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", Headline: a.Headline,
|
|
Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}})
|
|
return nil
|
|
},
|
|
Record: func(module, id string) (*stdio.StateEntry, error) {
|
|
e, err := gw.records.Get(module + "." + id)
|
|
if errors.Is(err, nats.ErrKeyNotFound) {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
return &stdio.StateEntry{Key: e.Key(), Value: e.Value(), Revision: e.Revision()}, nil
|
|
}})
|
|
gw.surface = NewSurface(s.conn, busSource{s.conn}, "desk.node-tools")
|
|
gw.surface.Approvals = gateApprovals{gate}
|
|
return gw
|
|
}
|
|
|
|
func (gw *gatedWaits) write(t *testing.T, rec opseat.AsksByAskerIDValue) {
|
|
raw, _ := json.Marshal(rec)
|
|
if _, err := gw.records.Put(rec.Key, raw); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func (gw *gatedWaits) asks() int {
|
|
gw.mu.Lock()
|
|
defer gw.mu.Unlock()
|
|
return len(gw.asked)
|
|
}
|
|
|
|
// approve writes the router's record of the newest ask as a person's Approve, and says its decided.
|
|
func (gw *gatedWaits) approve(t *testing.T) {
|
|
gw.mu.Lock()
|
|
a := gw.asked[len(gw.asked)-1]
|
|
gw.mu.Unlock()
|
|
gw.write(t, opseat.AsksByAskerIDValue{Asker: "node-tools", Key: "node-tools." + a.ID, State: "chosen", Expires: a.Expires,
|
|
OfferedOn: []string{"telegram"}, Warrant: &opseat.Warrant{Ask: a.ID, Asker: "node-tools", Outcome: operatorchannelclient.OutcomeChosen,
|
|
Option: approval.OptionApprove, Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(time.Now()),
|
|
AskDigest: operatorchannelclient.Digest(a), Channel: "telegram",
|
|
By: &opseat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}})
|
|
if err := gw.say.Say(opseat.SubjectDecided("node-tools"), []byte(`{"ask":"`+a.ID+`"}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// gateApprovals is the gate as the surface holds it: mesh_approvals' reads unused here, mesh_wait's gate.
|
|
type gateApprovals struct{ *approval.Gate }
|
|
|
|
func (gateApprovals) Fill() (uint64, int64, error) { return 0, 0, nil }
|
|
|
|
// waitCatalog is a catalog half as a fixture: its entries, read or not; with came set, its first view comes once a wait
|
|
// waits for it (waiting is told on it, then it closes).
|
|
type waitCatalog struct {
|
|
entries []catalogseat.CatalogEntry
|
|
mu sync.Mutex
|
|
ready error
|
|
came chan chan struct{}
|
|
}
|
|
|
|
func (c *waitCatalog) Ready() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.ready
|
|
}
|
|
func (c *waitCatalog) wait(within time.Duration) error {
|
|
if c.Ready() == nil {
|
|
return nil
|
|
}
|
|
if c.came != nil {
|
|
view := make(chan struct{})
|
|
c.came <- view
|
|
<-view
|
|
c.mu.Lock()
|
|
c.ready = nil
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
<-time.After(within) // the bound of the runner's own wait for the first view
|
|
return c.Ready()
|
|
}
|
|
func (c *waitCatalog) Revision() string { return "7" }
|
|
func (c *waitCatalog) Served(string) (catalogseat.CatalogEntry, bool, string) {
|
|
return catalogseat.CatalogEntry{}, false, "not-found"
|
|
}
|
|
func (c *waitCatalog) Newest(string) (catalogseat.CatalogEntry, bool) {
|
|
return catalogseat.CatalogEntry{}, false
|
|
}
|
|
func (c *waitCatalog) Definitions() map[string]seatdef.Definition { return nil }
|
|
func (c *waitCatalog) Entries() []catalogseat.CatalogEntry { return c.entries }
|
|
|
|
// 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
|
|
})
|
|
}
|