The mesh MCP server gains mesh_approvals: with no argument the calls asked on the machine in the last day, newest first, with state and fingerprint, the marks the gate applies with their sources and the catalog revision, and the state's fill against its cap (every machine's keys together); an ask read whole with the act's digest; record asking the hand-act again, and cancel taking an open ask back, each recorded in the hand-act log first and refused when it cannot be. mesh_describe answers needs-approval with its source, and a marked call that reached no one names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §18, §6 (the mesh MCP server's withheld answer), §22 (eight tools) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
317 lines
13 KiB
Go
317 lines
13 KiB
Go
package console
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 the families ADR 0367 §5 names, as the controller
|
|
// composes them from its manifest: 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)
|
|
m := mt.MembershipOf("node-tools", "desk", false, nil)
|
|
m.Waits = []bus.WaitIssued{
|
|
{Event: "mesh-knowledge.reviewed", Subject: "mesh.mod.mesh-knowledge.event.reviewed"},
|
|
{Event: "operator-channel.decided.claude-code", Subject: "mesh.seat.operator-channel.event.decided.claude-code"},
|
|
}
|
|
mesh.Issue(t, m)
|
|
conn := connect(t, "node-tools", "desk")
|
|
mt.Until(t, func() error {
|
|
if got := conn.Membership("node-tools"); got == nil || len(got.Waits) == 0 {
|
|
return os.ErrNotExist
|
|
}
|
|
return nil
|
|
})
|
|
return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh
|
|
}
|
|
|
|
// 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: mesh_wait answers the first event that matches — by its family and its filter — with
|
|
// its subject, body and time, 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
|
|
}
|
|
done := make(chan answer, 1)
|
|
go func() {
|
|
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"},
|
|
"filter": map[string]any{"kind": "decision", "number": 367}, "timeout": 20})
|
|
done <- answer{text, isErr, time.Now()}
|
|
}()
|
|
// Once it listens: another record's verdict, another family's event, then the one it waits for.
|
|
time.Sleep(300 * time.Millisecond)
|
|
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 366, "what": "approved"})
|
|
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.proposed", map[string]any{"kind": "decision", "number": 367})
|
|
published := time.Now()
|
|
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 367, "what": "approved"})
|
|
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 struct {
|
|
Heard struct {
|
|
Subject string `json:"subject"`
|
|
Body map[string]any `json:"body"`
|
|
At time.Time `json:"at"`
|
|
} `json:"heard"`
|
|
}
|
|
if err := json.Unmarshal([]byte(got.text), &heard); err != nil {
|
|
t.Fatalf("not an answer: %s", got.text)
|
|
}
|
|
if heard.Heard.Subject != "mesh-knowledge.reviewed" || heard.Heard.Body["number"] != float64(367) || heard.Heard.At.IsZero() {
|
|
t.Errorf("answered %+v, want record 367's verdict with its time", heard.Heard)
|
|
}
|
|
// And the subject it is granted on the asker's own `decided`, by the name a session asks for.
|
|
go func() {
|
|
text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided.claude-code", "timeout": 20})
|
|
done <- answer{text, isErr, time.Now()}
|
|
}()
|
|
time.Sleep(300 * time.Millisecond)
|
|
// 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:
|
|
if got.isErr || !strings.Contains(got.text, `"subject": "operator-channel.decided.claude-code"`) {
|
|
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)
|
|
}
|
|
if isErr || !strings.Contains(text, `"timed-out": true`) {
|
|
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 0367 §2 and §6: an event outside the granted families is refused, naming the families and how
|
|
// one is added; no argument — a wildcard, another asker's `decided`, a subject spelled out — widens it.
|
|
func TestMeshWaitRefusesAnEventOutsideTheGrantedFamilies(t *testing.T) {
|
|
s, _ := waitingRuntime(t)
|
|
for _, subjects := range []any{
|
|
[]any{"mesh-knowledge.proposed"},
|
|
[]any{"mesh-knowledge.reviewed", "gitea.forge.push"},
|
|
[]any{"mesh-knowledge.*"},
|
|
[]any{"operator-channel.decided.shop"},
|
|
[]any{"operator-channel.decided.*"},
|
|
[]any{"mesh.mod.mesh-knowledge.event.reviewed"},
|
|
[]any{">"},
|
|
[]any{},
|
|
} {
|
|
text, isErr := callWait(s, map[string]any{"subjects": subjects, "timeout": 1})
|
|
if !isErr {
|
|
t.Errorf("%v was waited on: %s", subjects, text)
|
|
continue
|
|
}
|
|
if len(subjects.([]any)) > 0 && (!strings.Contains(text, "mesh-knowledge.reviewed") || !strings.Contains(text, "manifest")) {
|
|
t.Errorf("%v refused without naming the families and how one is added: %s", subjects, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A tool runner issued no families waits on nothing, and says so.
|
|
func TestMeshWaitWithNoFamiliesIssuedWaitsOnNothing(t *testing.T) {
|
|
mt.New(t)
|
|
s := NewSurface(connect(t, "node-tools", "desk"), nil, "desk.node-tools")
|
|
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1})
|
|
if !isErr || !strings.Contains(text, "no event families") {
|
|
t.Errorf("a runtime issued no families: %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
|
|
})
|
|
}
|