E-5 marked waits: a wait on a marked event runs only on the operator's warrant for that one wait
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
This commit is contained in:
@@ -20,6 +20,8 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
)
|
||||
|
||||
// WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last
|
||||
@@ -107,9 +109,42 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
|
||||
}
|
||||
}
|
||||
|
||||
listen := func(within time.Duration) (any, error) {
|
||||
return s.hear(ctx, names, subjects, undeclared, known.source, unchecked, filter, within)
|
||||
}
|
||||
// A wait whose subjects overlap a marked event's runs only on the operator's warrant for that one wait (design 34
|
||||
// every-event §34 to §36), judged by the runner's approval gate.
|
||||
var answer any
|
||||
if g, ok := s.Approvals.(waitGate); ok {
|
||||
given := map[string]any{}
|
||||
for _, k := range []string{"subjects", "from", "filter", "timeout"} {
|
||||
if v, has := args[k]; has {
|
||||
given[k] = v
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(given)
|
||||
answer, err = g.Waited(ctx, approval.Call{Address: verbWait, Name: verbWait, Caller: s.who, Args: raw},
|
||||
approval.Wait{Names: names, From: from, Subjects: subjects, Filter: filter, Timeout: timeout}, listen)
|
||||
} else {
|
||||
answer, err = listen(timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return failure(err.Error())
|
||||
}
|
||||
return answerText(answer)
|
||||
}
|
||||
|
||||
// waitGate is the runner's approval gate as mesh_wait goes through it (design 34 every-event §34 to §36).
|
||||
type waitGate interface {
|
||||
Waited(ctx context.Context, c approval.Call, w approval.Wait, hear func(within time.Duration) (any, error)) (any, error)
|
||||
}
|
||||
|
||||
// hear subscribes the wait's subjects for within and answers the first matching event, or that it timed out.
|
||||
func (s *Surface) hear(ctx context.Context, names, subjects, undeclared []string, source, unchecked string, filter map[string]any,
|
||||
timeout time.Duration) (map[string]any, error) {
|
||||
heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) })
|
||||
if err != nil {
|
||||
return failure("mesh_wait could not wait: " + err.Error())
|
||||
return nil, fmt.Errorf("mesh_wait could not wait: %w", err)
|
||||
}
|
||||
var answer map[string]any
|
||||
if !ok {
|
||||
@@ -117,7 +152,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
|
||||
"read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"}
|
||||
if len(undeclared) > 0 {
|
||||
answer["undeclared"] = fmt.Sprintf("no declaration names %s: no seat definition and no module's entry in %s "+
|
||||
"declares that event", strings.Join(undeclared, ", "), known.source)
|
||||
"declares that event", strings.Join(undeclared, ", "), source)
|
||||
}
|
||||
} else {
|
||||
name, from, _ := buscompose.EventName(heard.Subject)
|
||||
@@ -139,7 +174,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
|
||||
if len(refused) > 0 {
|
||||
answer["refused"] = refused
|
||||
}
|
||||
return answerText(answer)
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait
|
||||
|
||||
@@ -10,11 +10,16 @@ import (
|
||||
"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"
|
||||
|
||||
@@ -197,7 +202,7 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) {
|
||||
// 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)
|
||||
s, mesh := waitingRuntime(t)
|
||||
var seventeen []any
|
||||
for i := 0; i < 17; i++ {
|
||||
seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i))
|
||||
@@ -274,8 +279,232 @@ func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) {
|
||||
!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
|
||||
|
||||
Reference in New Issue
Block a user