Merge pull request 'Milestone 47 G: mesh-tools's part of the one release' (#71) from feat/47-G-join12 into main

This commit was merged in pull request #71.
This commit is contained in:
2026-11-13 13:09:37 +00:00
63 changed files with 5697 additions and 553 deletions
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
"git.novox.be/novox/mesh-sdk/go/busretry"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/approval"
"github.com/novox/mesh-tools/node-tools/internal/bus"
)
// The gate's reach through the runner's own connection, in this one place (design 46 7-approval-mark §7, §13, §14): its
// state approvals, its publish of an ask under the marked module's name, and its read of the router's record.
// approvalsState is the runner's state approvals over its connection: a lost compare-and-set is stdio.ErrChanged, as
// operator-channel's client reads it.
type approvalsState struct {
conn *bus.Conn
module string
}
func (s approvalsState) Get(key string) (*stdio.StateEntry, error) {
e, err := s.conn.StateGet(s.module, approval.StateName, key)
if err != nil || e == nil {
return nil, err
}
return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil
}
func (s approvalsState) Create(key string, value any) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
return changedAs(s.conn.StateCreate(s.module, approval.StateName, key, raw))
}
func (s approvalsState) Update(key string, value any, revision uint64) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
return changedAs(s.conn.StateUpdate(s.module, approval.StateName, key, raw, revision))
}
func (s approvalsState) Put(key string, value any) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
return s.conn.StatePut(s.module, approval.StateName, key, raw)
}
func (s approvalsState) Keys() ([]string, error) {
return s.conn.StateKeys(s.module, approval.StateName)
}
func changedAs(rev uint64, err error) (uint64, error) {
if errors.Is(err, bus.ErrStateChanged) {
return 0, stdio.ErrChanged
}
return rev, err
}
// Bound says the runner's membership binds its state approvals now (§13): a membership issued later binds it with no restart.
func (s approvalsState) Bound() bool {
m := s.conn.Membership(s.module)
if m == nil {
return false
}
for _, st := range m.State {
if st.Name == approval.StateName && st.Writes {
return true
}
}
return false
}
// publishAsk says an accept on a seat under the module's name, on the runner's own account (§7).
func publishAsk(conn *bus.Conn) func(module, subject string, body any, id string) error {
return func(module, subject string, body any, id string) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
_, err = conn.SeatPublish(module, subject, raw, id)
return err
}
}
// routerRecord reads the router's record `<module>.<id>` of the seat's state asks, on the runner's own account (§14).
func routerRecord(conn *bus.Conn) func(module, id string) (*stdio.StateEntry, error) {
return func(module, id string) (*stdio.StateEntry, error) {
e, err := conn.SeatRecord(module, "", id)
if err != nil || e == nil {
return nil, err
}
return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil
}
}
// boundState is the runner's key-ttl state bound (lead answers A-157, A-158).
type boundState struct {
conn *bus.Conn
module string
}
// BoundState is the runner's state its bound keys live in.
const BoundState = "bound"
func (b boundState) CreateTTL(key string, value any, ttl time.Duration) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
_, err = b.conn.StateCreateTTL(b.module, BoundState, key, raw, ttl)
return err
}
func (b boundState) Delete(key string) error { return b.conn.StateDelete(b.module, BoundState, key) }
func (b boundState) Watch(pattern string, expired func(key string)) (func(), error) {
return b.conn.StateWatch(b.module, BoundState, pattern, func(c bus.StateChange) error {
if c.Op == "expire" {
expired(c.Key)
}
return nil
})
}
// warranted asks mesh-hand-acts.warranted for one acted ask, each try a kept call open while open says so (design 46
// 7-approval-mark §16; lead answer A-157).
func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error)) error {
return func(asker, ask string, open func() (bool, error)) error {
_, err := busretry.DoKept("mesh-hand-acts.warranted of "+asker+"."+ask,
busretry.Open{Key: asker + "." + ask, Open: func(string) (bool, error) { return open() }, Logf: log.Printf},
func() (bus.Answered, error) {
return conn.Ask("seat:"+handacts.Seat+"."+handacts.VerbWarranted, handacts.WarrantedInput{Asker: asker, Ask: ask},
"mesh.seat."+handacts.Seat+".tool."+handacts.VerbWarranted)
}, nil)
return err
}
}
// hearOne waits on the runner's connection for the first event on the subjects whose body match takes (design 34
// every-event §36: a marked wait's ask's decided).
func hearOne(conn *bus.Conn) func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) {
return func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) {
_, ok, _, err := conn.HearFirst(ctx, subjects, within, match)
return ok, err
}
}
// gateTools is the gate as mesh_approvals reads it, with its state's fill read from the bucket (§13, §18).
type gateTools struct {
*approval.Gate
state approvalsState
}
func (t gateTools) Fill() (uint64, int64, error) {
info, err := t.state.conn.StateInfo(t.state.module, approval.StateName)
if err != nil || info == nil {
return 0, 0, fmt.Errorf("the state %s's fill cannot be read: %v", approval.StateName, err)
}
return info.Bytes, info.MaxBytes, nil
}
@@ -0,0 +1,295 @@
//go:build integration
package main
import (
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"github.com/nats-io/nats.go"
runnerapproval "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"
)
// marked is the fixture marked module's catalog half: its build src2:1 marks its tool wipe (design 46 7-approval-mark §1).
type marked struct{}
var wipeEntry = catalogseat.CatalogEntry{Module: "shell", Fp: "src2:1", Manifest: map[string]any{"needs-approval": []any{"wipe"}}}
func (marked) Ready() error { return nil }
func (marked) Revision() string { return "7" }
func (marked) Served(fp string) (approval.Entry, bool, string) {
return runnerapproval.Entry(wipeEntry), fp == wipeEntry.Fp, "not-found"
}
func (marked) Newest(m string) (approval.Entry, bool) {
return runnerapproval.Entry(wipeEntry), m == "shell"
}
func (marked) Definitions() map[string]seatdef.Definition { return map[string]seatdef.Definition{} }
func (marked) Entries() []approval.Entry { return []approval.Entry{runnerapproval.Entry(wipeEntry)} }
// bed is the runner's gate wired as main wires it, over the runner's own connection to a real bus, with stand-ins of the
// operator channel's router (it takes each ask into its record asks, as the router writes it) and of the hand-act seat.
type bed struct {
t *testing.T
conn *bus.Conn
asks nats.KeyValue
taken chan struct{}
done chan struct{}
mu sync.Mutex
asked []seat.Ask
recorded []string
holders map[string][]byte
}
func newBed(t *testing.T, bindState bool) *bed {
t.Helper()
mesh := mt.New(t)
nc, err := nats.Connect(mt.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, _ := nc.JetStream()
if _, err := js.AddStream(&nats.StreamConfig{Name: "SEAT_OPERATOR_CHANNEL", Subjects: []string{"mesh.seat.operator-channel.accept.>"},
Retention: nats.WorkQueuePolicy}); err != nil {
t.Fatal(err)
}
b := &bed{t: t, asks: mesh.Bucket(t, "messenger_asks"), taken: make(chan struct{}, 1), done: make(chan struct{}),
holders: map[string][]byte{"operator-channel": []byte(`{"seat":"operator-channel","scope":"mesh","holders":[{"module":"messenger"}]}`)}}
t.Cleanup(func() { close(b.done) })
mesh.Bucket(t, "node-tools_approvals")
mesh.BucketKeyTTL(t, "node-tools_bound")
// The router's stand-in: each ask accepted under the marked module's name is taken into its record, state open.
sub, err := js.PullSubscribe("mesh.seat.operator-channel.accept.>", "router", nats.BindStream("SEAT_OPERATOR_CHANNEL"))
if err != nil {
t.Fatal(err)
}
go func() {
for {
select {
case <-b.done:
return
default:
}
msgs, _ := sub.Fetch(1, nats.MaxWait(200*time.Millisecond))
for _, m := range msgs {
var a seat.Ask
if err := json.Unmarshal(m.Data, &a); err == nil {
module := strings.TrimPrefix(m.Subject, "mesh.seat.operator-channel.accept.ask.")
b.mu.Lock()
b.asked = append(b.asked, a)
b.mu.Unlock()
b.write(seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", Headline: a.Headline,
Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}})
select {
case b.taken <- struct{}{}:
default:
}
}
_ = m.Ack()
}
}
}()
// The hand-act seat's stand-in: warranted {asker, ask} answered, each one kept.
if _, err := nc.Subscribe("mesh.seat.mesh-hand-acts.>", func(m *nats.Msg) {
b.mu.Lock()
b.recorded = append(b.recorded, string(m.Data))
b.mu.Unlock()
_ = m.Respond([]byte(`{"result":{"recorded":true}}`))
}); err != nil {
t.Fatal(err)
}
runner := mt.MembershipOf("node-tools", "n1", false, nil)
if bindState {
runner.State = []bus.StateIssued{{Name: approval.StateName, Bucket: "node-tools_approvals", Writes: true},
{Name: BoundState, Bucket: "node-tools_bound", Writes: true}}
}
shell := mt.MembershipOf("shell", "n1", false, nil)
shell.SeatTraffic = &bus.SeatTraffic{Publish: []string{seat.AcceptSubjectAsk("shell")},
Records: []string{"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.shell.>"}}
mesh.Issue(t, runner)
mesh.Issue(t, shell)
b.conn, err = bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "n1"})
if err != nil {
t.Fatal(err)
}
b.conn.Logf = func(string, ...any) {}
t.Cleanup(b.conn.Close)
b.conn.Follow("node-tools")
b.conn.Follow("shell")
mt.Until(t, func() error {
if b.conn.Membership("node-tools") == nil || b.conn.Membership("shell") == nil {
return errors.New("not issued yet")
}
return nil
})
return b
}
// gate is a runner's gate as main makes it; record says how the run's recording goes beside the call (nil: at once).
func (b *bed) gate(record func(func())) *approval.Gate {
if record == nil {
record = func(f func()) { f() }
}
g := approval.New(approval.Deps{Node: "n1", Runner: "node-tools", Catalog: marked{}, Logf: b.t.Logf,
State: approvalsState{conn: b.conn, module: "node-tools"},
Channel: &operatorchannelclient.GateChannel{State: approvalsState{conn: b.conn, module: "node-tools"}, KeyPrefix: "n1.",
Publish: publishAsk(b.conn), ReadRecord: routerRecord(b.conn)},
Warranted: warranted(b.conn), Bounds: boundState{conn: b.conn, module: "node-tools"}, Go: record,
Holder: runnerapproval.HoldersIn(func() (map[string][]byte, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.holders, nil
}),
// The pause while the router takes the ask ends when the stand-in has taken one: no sleeping.
After: func(time.Duration) <-chan time.Time {
c := make(chan time.Time, 1)
go func() {
select {
case <-b.taken:
case <-b.done:
}
c <- time.Now()
}()
return c
}})
_ = g.Unserved("shell", "src2:1", []string{"wipe"})
return g
}
func (b *bed) write(rec seat.AsksByAskerIDValue) {
raw, _ := json.Marshal(rec)
if _, err := b.asks.Put(rec.Key, raw); err != nil {
b.t.Fatal(err)
}
}
// decide writes the router's record of the newest ask as the router writes the operator's choice: chosen, with its warrant.
func (b *bed) decide(option string) seat.Ask {
b.mu.Lock()
a := b.asked[len(b.asked)-1]
b.mu.Unlock()
e, err := b.asks.Get("shell." + a.ID)
if err != nil {
b.t.Fatal(err)
}
var rec seat.AsksByAskerIDValue
_ = json.Unmarshal(e.Value(), &rec)
rec.State = "chosen"
rec.Warrant = &seat.Warrant{Ask: a.ID, Asker: "shell", Outcome: operatorchannelclient.OutcomeChosen, Option: option,
Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(time.Now()), AskDigest: operatorchannelclient.Digest(a),
Channel: "telegram", By: &seat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
b.write(rec)
return a
}
func wipe(args string) approval.Call {
return approval.Call{Address: "shell.wipe", Module: "shell", Name: "wipe", Fingerprint: "src2:1", Caller: "agent",
Args: json.RawMessage(args), Description: "Wipes a scratch directory."}
}
// Design 46 7-approval-mark §22: the runner's gate against the real bus with a fixture marked module: a call asked and taken
// by the operator channel's stand-in, the operator's Approve written as the router's record, the call again run once and
// recorded in the hand-act log, mesh_approvals' list, a restart between the run and warranted's answer recorded once; a
// Decline runs nothing; a gate missing a part of its check runs nothing.
func TestAMarkedCallOnTheBusIsAskedRunOnTheApprovalAndRecorded(t *testing.T) {
t.Run("approved: asked, run once, recorded, listed", func(t *testing.T) {
b := newBed(t, true)
g := b.gate(nil)
runs := 0
run := func() (any, error) { runs++; return "wiped", nil }
if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil ||
!strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") || runs != 0 {
t.Fatalf("first call: %v, runs %d", err, runs)
}
if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || !strings.Contains(err.Error(), "still waiting for the operator's answer") {
t.Fatalf("before the answer: %v", err)
}
a := b.decide(approval.OptionApprove)
got, err := g.Run(wipe(`{"dir":"scratch"}`), run)
answered, ok := got.(approval.Answered)
if err != nil || !ok || answered.Answer != "wiped" || !strings.Contains(answered.Approval, "ask "+a.ID) || runs != 1 {
t.Fatalf("on the approval: %+v, %v, runs %d", got, err, runs)
}
if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || runs != 1 {
t.Fatalf("the warrant ran twice: %v, runs %d", err, runs)
}
b.mu.Lock()
recorded := strings.Join(b.recorded, "\n")
b.mu.Unlock()
if !strings.Contains(recorded, a.ID) || !strings.Contains(recorded, "shell") {
t.Fatalf("hand-act log heard %q", recorded)
}
list, err := g.List()
if err != nil || len(list) == 0 || list[len(list)-1].Ask != a.ID || list[len(list)-1].State != "run, recorded" {
t.Fatalf("mesh_approvals lists %+v, %v", list, err)
}
})
t.Run("a restart between the run and warranted's answer records the run once", func(t *testing.T) {
b := newBed(t, true)
stopped := b.gate(func(func()) {}) // the runner stops before warranted answers
runs := 0
run := func() (any, error) { runs++; return "wiped", nil }
_, _ = stopped.Run(wipe(`{"dir":"tmp"}`), run)
a := b.decide(approval.OptionApprove)
if _, err := stopped.Run(wipe(`{"dir":"tmp"}`), run); err != nil || runs != 1 {
t.Fatalf("run: %v, runs %d", err, runs)
}
if list, _ := stopped.List(); len(list) != 1 || list[0].State != "run, not yet recorded" {
t.Fatalf("before the restart: %+v", list)
}
started := b.gate(nil)
started.Recover()
started.Recover()
b.mu.Lock()
n := len(b.recorded)
b.mu.Unlock()
if list, _ := started.List(); n != 1 || len(list) != 1 || list[0].Ask != a.ID || list[0].State != "run, recorded" || runs != 1 {
t.Fatalf("after the restart: recorded %d, %+v, runs %d", n, list, runs)
}
})
t.Run("declined: refused, nothing runs", func(t *testing.T) {
b := newBed(t, true)
g := b.gate(nil)
runs := 0
run := func() (any, error) { runs++; return nil, nil }
_, _ = g.Run(wipe(`{"dir":"scratch"}`), run)
b.decide(approval.OptionDecline)
if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || !strings.Contains(err.Error(), "the operator declined ask") || runs != 0 {
t.Fatalf("declined: %v, runs %d", err, runs)
}
})
t.Run("failing closed: no state bound, or nobody to ask, runs nothing and asks nothing", func(t *testing.T) {
unbound := newBed(t, false)
runs := 0
run := func() (any, error) { runs++; return nil, nil }
if _, err := unbound.gate(nil).Run(wipe(`{"dir":"scratch"}`), run); err == nil ||
!strings.Contains(err.Error(), "binds no state approvals") || runs != 0 || len(unbound.asked) != 0 {
t.Fatalf("no state: %v, runs %d", err, runs)
}
b := newBed(t, true)
b.holders = map[string][]byte{}
if _, err := b.gate(nil).Run(wipe(`{"dir":"scratch"}`), run); err == nil ||
!strings.Contains(err.Error(), "no module holds operator-channel") || runs != 0 {
t.Fatalf("no holder: %v, runs %d", err, runs)
}
if got, err := b.gate(nil).Run(approval.Call{Address: "shell.tidy", Module: "shell", Name: "tidy", Fingerprint: "src2:1",
Args: json.RawMessage(`{}`)}, func() (any, error) { return "tidied", nil }); err != nil || got != "tidied" {
t.Fatalf("an unmarked tool: %v, %v", got, err)
}
})
}
+33 -6
View File
@@ -25,7 +25,12 @@ import (
"syscall" "syscall"
"time" "time"
handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/approval"
"github.com/novox/mesh-tools/node-tools/internal/alive" "github.com/novox/mesh-tools/node-tools/internal/alive"
runnerapproval "github.com/novox/mesh-tools/node-tools/internal/approval"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/console" "github.com/novox/mesh-tools/node-tools/internal/console"
"github.com/novox/mesh-tools/node-tools/internal/firstview" "github.com/novox/mesh-tools/node-tools/internal/firstview"
@@ -51,7 +56,7 @@ func main() {
cred := credential(until) cred := credential(until)
conn := connectPatiently(cred, until) conn := connectPatiently(cred, until)
// And the seat list it describes the seats by, read through the seat mesh-catalog (novox/hq ADR 0636 §4). // And the seat list it describes the seats by, read through the seat mesh-catalog (novox/hq ADR 0636 §4).
_ = startSeatList(conn) seatList := startSeatList(conn)
served, err := runtime.ServedModulesFrom(os.Getenv(runtime.ToolModules), cred.Module) served, err := runtime.ServedModulesFrom(os.Getenv(runtime.ToolModules), cred.Module)
if err != nil { if err != nil {
@@ -69,6 +74,30 @@ func main() {
defer ledgers.Stop() defer ledgers.Stop()
opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer)) opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer))
} }
// The indexer's and the assigner's states the console indexes the mesh by, and the gate reads its marks from, come to it as
// first views, asked of the owners' own tools and followed on their views families, never read from their state (novox/hq
// ADR 0615 §1; design 46 7-approval-mark §5).
source, stopViews := firstview.Start(context.Background(), conn, log.Printf)
defer stopViews()
approvals := approvalsState{conn: conn, module: cred.Module}
gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog,
Findings: runnerapproval.Findings(source.Findings(), conn.Node(), cred.Module, nil), Logf: log.Printf, State: approvals,
Channel: &operatorchannelclient.GateChannel{State: approvals, KeyPrefix: conn.Node() + ".", Publish: publishAsk(conn), ReadRecord: routerRecord(conn)},
Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module},
Holder: runnerapproval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) }),
// The runner's own build, the one a marked wait's act binds (design 34 every-event §35).
Build: os.Getenv(runtime.FingerprintWord), Hear: hearOne(conn)})
// A failed read or write of the state approvals is made again at the next membership change the runner hears (A-157).
conn.OnMembership(func(bus.Membership) { go gate.StateAgain() })
opts = append(opts, runtime.WithGate(gate), runtime.WithSeatDefinitions(definitionsOf(seatList)))
// A run still unrecorded is asked again at each start and when the hand-act seat's holder is heard again (lead answer A-157).
if stopBounds, err := gate.WatchBounds(); err != nil {
log.Printf("[mesh-tools] the gate's bound keys are not watched (%v): an unanswered hand-act is said in the log only", err)
} else {
defer stopBounds()
}
source.OnSeatHolder(handacts.Seat, gate.Recover)
go gate.Recover()
stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) stop, err := runtime.Run(conn, served, envs, log.Printf, opts...)
if err != nil { if err != nil {
log.Fatalf("node-tools: %v", err) log.Fatalf("node-tools: %v", err)
@@ -90,16 +119,14 @@ func main() {
} }
var up *console.Listening var up *console.Listening
if listen != "" { if listen != "" {
// The indexer's and the assigner's states the console indexes the mesh by come to it as first views, asked of the owners'
// own tools and followed on their views families, never read from their state (novox/hq ADR 0615 §1).
source, stopViews := firstview.Start(context.Background(), conn, log.Printf)
defer stopViews()
node := cred.Node node := cred.Node
if node == "" { if node == "" {
node = "?" node = "?"
} }
who := node + "." + cred.Module who := node + "." + cred.Module
up, err = console.Serve(console.NewSurface(conn, source, who), listen) surface := console.NewSurface(conn, source, who)
surface.Approvals = gateTools{Gate: gate, state: approvalsState{conn: conn, module: cred.Module}}
up, err = console.Serve(surface, listen)
if err != nil { if err != nil {
log.Fatalf("node-tools: %v", err) log.Fatalf("node-tools: %v", err)
} }
+9 -2
View File
@@ -13,10 +13,17 @@ import (
"github.com/novox/mesh-tools/node-tools/internal/runtime" "github.com/novox/mesh-tools/node-tools/internal/runtime"
) )
// The runner's start waits for its credential and its bus within the start's bound (issue 1496): one test, two cases, so
// design 46 7-approval-mark §22's whole test of the gate adds no test to the module (design 47 G §419, §626).
func TestTheRunnersStartWaitsForItsCredentialAndItsBus(t *testing.T) {
t.Run("the credential", awaitsCredential)
t.Run("the bus", connectsWithin)
}
// A runtime started before its credential is on the machine waits for it, woken by the watch of its directory, and gives // A runtime started before its credential is on the machine waits for it, woken by the watch of its directory, and gives
// up loudly at its bound; a file that is no credential ends the wait at once (issue 1496: on ace mesh-build-node's and // up loudly at its bound; a file that is no credential ends the wait at once (issue 1496: on ace mesh-build-node's and
// mesh-build-image's runtimes exited twice on "cannot read the broker credential ... no such file"). // mesh-build-image's runtimes exited twice on "cannot read the broker credential ... no such file").
func TestAwaitCredential(t *testing.T) { func awaitsCredential(t *testing.T) {
const good = `{"url":"tls://bus.example:4222","module":"m"}` const good = `{"url":"tls://bus.example:4222","module":"m"}`
rereadEvery = time.Hour // only the watch can wake the read in time rereadEvery = time.Hour // only the watch can wake the read in time
settle := runtime.Settle settle := runtime.Settle
@@ -82,7 +89,7 @@ func TestAwaitCredential(t *testing.T) {
// A bus that refuses the account at a start is asked again until the start's bound, said once; a refusal past it, a pin // A bus that refuses the account at a start is asked again until the start's bound, said once; a refusal past it, a pin
// that does not match and an address that is no URL give up (issue 1496, issue 058). // that does not match and an address that is no URL give up (issue 1496, issue 058).
func TestConnectWithin(t *testing.T) { func connectsWithin(t *testing.T) {
refused := errors.New("nats: Authorization Violation") refused := errors.New("nats: Authorization Violation")
unreachable := errors.New("nats: no servers available for connection") unreachable := errors.New("nats: no servers available for connection")
for _, c := range []struct { for _, c := range []struct {
+11
View File
@@ -71,3 +71,14 @@ func startSeatListOn(b seatlist.Bus, bound <-chan time.Time, logf func(string, .
} }
return reader return reader
} }
// definitionsOf is the seat definitions the reader holds, by seat, which carry the verbs' marks (design 46 7-approval-mark
// §2, §5); none while the reader did not start.
func definitionsOf(reader *seatlist.Reader) func() map[string]seatdef.Definition {
return func() map[string]seatdef.Definition {
if reader == nil {
return nil
}
return reader.Definitions()
}
}
+9 -3
View File
@@ -5,11 +5,13 @@ go 1.26.0
require ( require (
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.48.0
) )
@@ -27,3 +29,7 @@ require (
golang.org/x/crypto v0.57.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/time v0.16.0 // indirect golang.org/x/time v0.16.0 // indirect
) )
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
+5
View File
@@ -48,6 +48,11 @@ const Digest = "digest"
// module needing one is placed only where the runtime states it. // module needing one is placed only where the runtime states it.
const Capabilities = "capabilities" const Capabilities = "capabilities"
// Fingerprint is the service metadata key the tool runner states its own fingerprint under, beside Capabilities: the build
// its composed environment names (MESH_FINGERPRINT, design 47 G §424), so the node-engine ties the capabilities it takes
// into the node's record to the runner that stated them (design 46 approval mark §6, lead answer A-203).
const Fingerprint = "fingerprint"
// Shortened is the service metadata key a runtime sets when its announcement was cut to fit the bus. // Shortened is the service metadata key a runtime sets when its announcement was cut to fit the bus.
const Shortened = "shortened" const Shortened = "shortened"
@@ -0,0 +1,592 @@
package approval
import (
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
stdio "git.novox.be/novox/mesh-sdk/go"
gate "git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/views"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// mem is the runner's state approvals as a fixture (meshtest.State).
type mem = mt.State
func newMem() *mem { return mt.NewState() }
// world is a gate on a fixture mesh: the catalog half, the router's records as the router writes them (mesh-catalog's
// messenger, asks.go), the asks published, the clock.
type world struct {
g *gate.Gate
state *mem
cat *fakeCatalog
records map[string]seat.AsksByAskerIDValue // by `<module>.<id>`
takes bool // the router takes an ask as it is published
asked []seat.Ask
now time.Time
runs int
findings *findings
bounds *bounds
// holders is the assigner's view of the seats' holders, by key; holdersErr says it is not answered yet.
holders map[string][]byte
holdersErr error
// warranted is what mesh-hand-acts.warranted answers, and recorded each ask it answered for.
warranted error
recorded []string
// deps is the gate's, made anew by rebuild; publish and record are the bus's publish and the router's record read.
deps gate.Deps
publish func(module, subject string, body any, id string) error
record func(module, id string) (*stdio.StateEntry, error)
}
// rebuild makes the gate anew from w.deps, its module's bundle loaded.
func (w *world) rebuild() {
w.g = gate.New(w.deps)
_ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"})
}
// bounds is the runner's key-ttl state bound as a fixture: expire hands a key's expiry to the watch.
type bounds struct {
keys map[string]time.Duration
expired func(string)
}
func (b *bounds) CreateTTL(key string, _ any, ttl time.Duration) error { b.keys[key] = ttl; return nil }
func (b *bounds) Delete(key string) error { delete(b.keys, key); return nil }
func (b *bounds) Watch(_ string, expired func(string)) (func(), error) {
b.expired = expired
return func() {}, nil
}
func (b *bounds) expire(key string) {
delete(b.keys, key)
b.expired(key)
}
func newWorld() *world {
shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}})
w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{},
bounds: &bounds{keys: map[string]time.Duration{}},
holders: map[string][]byte{"operator-channel": []byte(`{"seat":"operator-channel","scope":"mesh","holders":[{"module":"messenger"}]}`)},
now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC),
cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell},
newest: map[string]catalogseat.CatalogEntry{"shell": shell},
defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell",
Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}}
w.deps = gate.Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Bounds: w.bounds,
Findings: Findings(w.findings, "n1", "node-tools", func() time.Time { return w.now }),
Channel: &operatorchannelclient.GateChannel{State: w.state, KeyPrefix: "n1.", Now: func() time.Time { return w.now },
Publish: func(module, subject string, body any, id string) error { return w.publish(module, subject, body, id) },
ReadRecord: func(module, id string) (*stdio.StateEntry, error) { return w.record(module, id) }},
Go: func(f func()) { f() },
Holder: HoldersIn(func() (map[string][]byte, error) { return w.holders, w.holdersErr }),
Warranted: func(asker, ask string, open func() (bool, error)) error {
if still, _ := open(); !still {
return nil
}
if w.warranted == nil {
w.recorded = append(w.recorded, asker+"."+ask)
}
return w.warranted
},
Now: func() time.Time { return w.now },
After: func(d time.Duration) <-chan time.Time {
w.now = w.now.Add(d)
c := make(chan time.Time, 1)
c <- w.now
return c
},
}
w.publish = func(module, subject string, body any, id string) error {
if subject != seat.AcceptSubjectAsk(module) {
return nil
}
a := body.(seat.Ask)
w.asked = append(w.asked, a)
if w.takes {
w.records[module+"."+a.ID] = seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open",
Headline: a.Headline, Expires: a.Expires, Opened: operatorchannelclient.FormatTime(w.now), OfferedOn: []string{"telegram"}}
}
return nil
}
w.record = func(module, id string) (*stdio.StateEntry, error) {
rec, ok := w.records[module+"."+id]
if !ok {
return nil, nil
}
raw, _ := json.Marshal(rec)
return &stdio.StateEntry{Key: module + "." + id, Value: raw}, nil
}
w.rebuild()
return w
}
// decide writes the router's record of the newest ask as the router writes a person's choice: its state and warrant.
func (w *world) decide(option string, at time.Time) seat.Ask {
a := w.asked[len(w.asked)-1]
key := "shell." + a.ID
rec := w.records[key]
rec.State = "chosen"
rec.Warrant = &seat.Warrant{Ask: a.ID, Asker: "shell", Outcome: operatorchannelclient.OutcomeChosen, Option: option,
Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(at), AskDigest: operatorchannelclient.Digest(a),
By: &seat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
w.records[key] = rec
return a
}
// do makes a call through the gate, counting the runs.
func (w *world) do(c gate.Call) (any, error) {
return w.g.Run(c, func() (any, error) { w.runs++; return "done", nil })
}
// call is a fixture call of the marked tool wipe, or of another tool of the module.
func call(name, args string) gate.Call {
return gate.Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args),
Description: "Wipes a scratch directory.\nMore words."}
}
// Design 46 7-approval-mark §22: the refusals and answers of a marked call, on the fixture mesh, the router's record and
// the warrant written as the router writes them.
func TestTheRefusalsAndAnswers(t *testing.T) {
for _, tc := range []struct {
name string
run func(t *testing.T, w *world)
}{
{"§9: a secret in the arguments is refused asking nothing", func(t *testing.T, w *world) {
_, err := w.do(call("wipe", `{"token":"ghp_0123456789abcdefghijABCDEFGHIJ012345"}`))
if err == nil || !strings.Contains(err.Error(), "which no ask may carry") || len(w.asked) != 0 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§9: more than 16 KiB of arguments is refused asking nothing", func(t *testing.T, w *world) {
big, _ := json.Marshal(map[string]string{"text": strings.Repeat("a b ", 5000)})
_, err := w.do(call("wipe", string(big)))
if err == nil || !strings.Contains(err.Error(), "more than the 16384") || len(w.asked) != 0 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§10, §12: the published ask's fields, and Not run with the router's record", func(t *testing.T, w *world) {
_, err := w.do(call("wipe", `{"dir":"scratch"}`))
if err == nil || !strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") ||
!strings.Contains(err.Error(), "make the same call again within 30 minutes of the approval; the ask expires at ") {
t.Fatalf("answer %v", err)
}
if len(w.asked) != 1 {
t.Fatalf("asked %d", len(w.asked))
}
a := w.asked[0]
act, _ := gate.ActOf(call("wipe", `{"dir":"scratch"}`), "n1")
d, _ := operatorchannelclient.ActDigest(act)
if a.About != d || a.Who != "operator" || a.OnExpiry != gate.OnExpiry || a.Headline != "Run wipe on n1?" ||
a.Expires != operatorchannelclient.FormatTime(time.Date(2026, 11, 12, 11, 0, 0, 0, time.UTC)) || len(a.Options) != 2 ||
a.Options[0].ID != "approve" || a.Options[1].ID != "decline" || a.Options[0].Binds != d || a.Options[1].Binds != d ||
a.Options[1].Level != "approve" {
t.Errorf("ask %+v", a)
}
if !strings.HasPrefix(a.Explanation, "Wipes a scratch directory.\nOn n1, called through the mesh's tools, which cannot tell who called.\ndir: scratch\nFingerprint ") {
t.Errorf("explanation %q", a.Explanation)
}
var k operatorchannelclient.Kept
_ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k)
if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.Vals["n1.act."+keys.EscapeName(d)]) != `"`+a.ID+`"` {
t.Errorf("kept %+v, act key %s", k, w.state.Vals["n1.act."+keys.EscapeName(d)])
}
}},
{"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) {
w.takes = false
start := w.now
_, err := w.do(call("wipe", `{}`))
if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > gate.RecordWithin {
t.Fatalf("answer %v after %s", err, w.now.Sub(start))
}
}},
{"§10, §17: a second identical call publishes nothing and is told it still waits", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
_, err := w.do(call("wipe", `{"dir":"scratch"}`))
if len(w.asked) != 1 || err == nil || !strings.HasPrefix(err.Error(), "Not run: still waiting for the operator's answer") || w.runs != 0 {
t.Fatalf("asked %d, %v, runs %d", len(w.asked), err, w.runs)
}
}},
{"§17: an ask the router holds back as the fourth open one still waits", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
a := w.asked[0]
rec := w.records["shell."+a.ID]
rec.State, rec.HeldSince = "held-back", operatorchannelclient.FormatTime(w.now)
w.records["shell."+a.ID] = rec
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "still waiting") || len(w.asked) != 1 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§14, §15: approved, the same call runs once; made again it is already run and asks anew", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
w.decide(gate.OptionApprove, w.now.Add(time.Minute))
w.now = w.now.Add(2 * time.Minute)
if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got.(gate.Answered).Answer != "done" || w.runs != 1 ||
!strings.HasPrefix(got.(gate.Answered).Approval, "run on the operator's approval via telegram at 2026-11-12T10:01:00Z, ask ") {
t.Fatalf("%v %v, runs %d", got, err, w.runs)
}
_, err := w.do(call("wipe", `{"dir":"scratch"}`))
if err == nil || !strings.Contains(err.Error(), "was already run; asked again") || w.runs != 1 || len(w.asked) != 2 {
t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked))
}
}},
{"§15: two calls at the same moment run it once", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
w.decide(gate.OptionApprove, w.now)
var wg sync.WaitGroup
var mu sync.Mutex
runs := 0
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = w.g.Run(call("wipe", `{}`), func() (any, error) { mu.Lock(); runs++; mu.Unlock(); return "done", nil })
}()
}
wg.Wait()
if runs != 1 {
t.Fatalf("ran %d times", runs)
}
}},
{"§14: approved for other arguments runs nothing", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
a := w.decide(gate.OptionApprove, w.now)
rec := w.records["shell."+a.ID]
other, _ := gate.ActOf(call("wipe", `{"dir":"home"}`), "n1")
d, _ := operatorchannelclient.ActDigest(other)
a.Options[0].Binds = d // the ask the person was shown bound another act
rec.Warrant.AskDigest = operatorchannelclient.Digest(a)
w.records["shell."+a.ID] = rec
var k operatorchannelclient.Kept
_ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k)
k.Ask = a
raw, _ := json.Marshal(k)
w.state.Vals["n1.ask."+a.ID] = raw
if _, err := w.do(call("wipe", `{"dir":"scratch"}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "nothing is done") {
t.Fatalf("%v, runs %d", err, w.runs)
}
}},
{"§14: approved 31 minutes ago asks anew", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
w.decide(gate.OptionApprove, w.now)
w.now = w.now.Add(31 * time.Minute)
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "approved too long ago") || w.runs != 0 || len(w.asked) != 2 {
t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked))
}
}},
{"§14, §17: declined, the same call is not asked again for 10 minutes", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
w.decide(gate.OptionDecline, w.now)
w.now = w.now.Add(9 * time.Minute)
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "declined") || len(w.asked) != 1 || w.runs != 0 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
w.now = w.now.Add(2 * time.Minute)
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "asked again") || len(w.asked) != 2 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§14, §17: expired asks anew", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
a := w.asked[0]
rec := w.records["shell."+a.ID]
rec.State = "expired"
w.records["shell."+a.ID] = rec
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "ended expired; asked again") || len(w.asked) != 2 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§14: another asker's warrant runs nothing", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
a := w.decide(gate.OptionApprove, w.now)
rec := w.records["shell."+a.ID]
rec.Warrant.Asker = "mail"
w.records["shell."+a.ID] = rec
if _, err := w.do(call("wipe", `{}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "its warrant does not hold") {
t.Fatalf("%v, runs %d", err, w.runs)
}
}},
{"§17: the router's refusal is said with its words", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
a := w.asked[0]
rec := w.records["shell."+a.ID]
rec.State, rec.Answer = "refused", "the asker has too many open asks"
w.records["shell."+a.ID] = rec
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the asker has too many open asks") {
t.Fatalf("%v", err)
}
}},
{"§20: a runner's own tool marked is refused unasked, never run", func(t *testing.T, w *world) {
c := call("wipe", `{}`)
c.Own = true
if _, err := w.do(c); err == nil || w.runs != 0 || len(w.asked) != 0 {
t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked))
}
}},
{"§13: with no binding, an unmarked call runs and a marked one is refused", func(t *testing.T, w *world) {
w.deps.State = nil
w.rebuild()
if got, err := w.do(call("tidy", `{}`)); err != nil || got != "done" {
t.Fatalf("unmarked: %v %v", got, err)
}
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "binds no state approvals") || w.runs != 1 {
t.Fatalf("marked: %v, runs %d", err, w.runs)
}
}},
{"§13: the fixture state refuses a key the bus refuses, so every key the gate writes here is one the bus holds (A-209)", func(t *testing.T, w *world) {
if _, err := w.state.Put("n1.act.sha256:00", "x"); err == nil {
t.Fatal("the fixture state held a key with a colon, which the bus refuses")
}
if _, err := w.state.Create("n1..ask", "x"); err == nil {
t.Fatal("the fixture state held a key with an empty name")
}
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
if len(w.asked) != 1 || len(w.state.Vals) == 0 {
t.Fatalf("the gate kept nothing through the fixture state: asked %d, keys %d", len(w.asked), len(w.state.Vals))
}
}},
{"§13: an ask over 256 KiB is refused naming the cause", func(t *testing.T, w *world) {
c := call("wipe", `{}`)
c.Description = strings.Repeat("w", 300*1024)
if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "more than the 262144 a value of the state approvals holds") || len(w.asked) != 0 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§3, §2: a seat verb its seat marks is asked for its holder", func(t *testing.T, w *world) {
c := gate.Call{Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1", Args: json.RawMessage(`{"command":"ls"}`)}
if _, err := w.do(c); err == nil || !strings.HasPrefix(err.Error(), "Not run") || len(w.asked) != 1 || w.asked[0].Headline != "Run node-login-shell.execute on n1?" {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§16: the run is recorded once; unanswered within five minutes it is a finding, cleared when warranted answers", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{}`))
a := w.decide(gate.OptionApprove, w.now)
stop, _ := w.g.WatchBounds()
defer stop()
w.warranted = errors.New("no responders")
if _, err := w.do(call("wipe", `{}`)); err != nil || w.runs != 1 || len(w.recorded) != 0 {
t.Fatalf("%v, runs %d, recorded %v", err, w.runs, w.recorded)
}
if w.bounds.keys["n1.unanswered."+a.ID] != gate.UnansweredAfter || w.bounds.keys["n1.unrecorded."+a.ID] != gate.KeptFor-gate.SaidBefore {
t.Fatalf("bounds %v", w.bounds.keys)
}
w.bounds.expire("n1.unanswered." + a.ID)
if w.findings.raised[keys.EscapeName("n1."+a.ID)] != gate.KindCallUnanswered {
t.Fatalf("findings %v", w.findings.raised)
}
w.warranted = nil
// At a start each acted, unrecorded ask is pending in the runner's findings-stated until warranted is made again
// (design 47 G §384).
pendingKey := views.FindingKey("machine", keys.EscapeName("n1."+a.ID), gate.KindCallUnanswered)
warranted := w.deps.Warranted
var pendingAtCall bool
w.deps.Warranted = func(asker, ask string, open func() (bool, error)) error {
_, pendingAtCall = w.findings.pends[pendingKey]
return warranted(asker, ask, open)
}
w.rebuild() // a start
w.g.Recover() // a start, or the hand-act seat's holder heard again
w.g.Recover()
if !pendingAtCall || len(w.findings.pended) != 1 || len(w.findings.pends) != 0 || w.findings.pended[0] != pendingKey {
t.Fatalf("pending at the call %v, pended %v, pending after %v", pendingAtCall, w.findings.pended, w.findings.pends)
}
if len(w.recorded) != 1 || w.recorded[0] != "shell."+a.ID || len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 {
t.Fatalf("recorded %v, findings %v, bounds %v", w.recorded, w.findings.raised, w.bounds.keys)
}
var k operatorchannelclient.Kept
_ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k)
if !k.Recorded || !k.Acted {
t.Fatalf("kept %+v", k)
}
}},
{"§19: with no holder of operator-channel in the view, a marked call is refused asking nothing", func(t *testing.T, w *world) {
w.holders["operator-channel"] = []byte(`{"seat":"operator-channel","scope":"mesh","holders":[]}`)
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "no module holds operator-channel") || len(w.asked) != 0 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
w.holdersErr = errors.New("the first view has not been answered yet")
if _, err := w.do(call("wipe", `{}`)); err == nil || len(w.asked) != 0 {
t.Fatalf("an unread view: %v, asked %d", err, len(w.asked))
}
}},
{"§19: a publish that fails and a record that cannot be read refuse, running nothing", func(t *testing.T, w *world) {
pub := w.publish
w.publish = func(string, string, any, string) error { return errors.New("the bus refused it") }
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the ask could not be raised") {
t.Fatalf("%v", err)
}
w.publish = pub
w.state.Vals = map[string]json.RawMessage{}
_, _ = w.do(call("wipe", `{}`))
w.record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") }
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the router's record of its ask") || w.runs != 0 {
t.Fatalf("%v, runs %d", err, w.runs)
}
}},
{"§5, §19: a served entry that cannot be read refuses every marked call on that runtime", func(t *testing.T, w *world) {
c := call("wipe", `{}`)
c.Fingerprint = "src2:unknown"
if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "cannot be read") || len(w.asked) != 0 {
t.Fatalf("%v, asked %d", err, len(w.asked))
}
}},
{"§19: the marks unread, a call waits within 5 seconds and is refused naming mesh_first_views", func(t *testing.T, w *world) {
w.cat.ready = errors.New("the catalog half has no first view of main yet")
waited := time.Duration(0)
w.deps.Wait = func(d time.Duration) error { waited = d; return w.cat.ready }
w.rebuild()
if _, err := w.do(call("tidy", `{}`)); err == nil || !strings.Contains(err.Error(), "mesh_first_views") || waited != gate.RecordWithin || w.runs != 0 {
t.Fatalf("%v, waited %s", err, waited)
}
w.deps.Wait = func(time.Duration) error { w.cat.ready = nil; return nil }
w.rebuild()
if _, err := w.do(call("tidy", `{}`)); err != nil || w.runs != 1 {
t.Fatalf("read within the wait: %v, runs %d", err, w.runs)
}
}},
{"§19: the state failing refuses, raises approvals-unreadable after five minutes, and clears when read again", func(t *testing.T, w *world) {
stop, _ := w.g.WatchBounds()
defer stop()
w.state.Fail = errors.New("the bus is unreachable")
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "cannot be read") {
t.Fatalf("%v", err)
}
if w.bounds.keys["n1.unreadable.approvals"] != gate.UnreadableAfter || len(w.findings.raised) != 0 {
t.Fatalf("bounds %v, findings %v", w.bounds.keys, w.findings.raised)
}
w.bounds.expire("n1.unreadable.approvals")
if w.findings.raised[keys.EscapeName("n1.approvals")] != gate.KindApprovalsUnreadable {
t.Fatalf("findings %v", w.findings.raised)
}
w.state.Fail = nil
w.g.StateAgain() // a membership change heard, no further call
if len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 {
t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys)
}
}},
{"§18: the asks of the last day, one read whole, the marks applied with their sources, a record again and a cancel", func(t *testing.T, w *world) {
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
list, err := w.g.List()
if err != nil || len(list) != 1 || list[0].State != "asked" || list[0].Address != "shell.wipe" || list[0].Fingerprint == "" {
t.Fatalf("%+v %v", list, err)
}
whole, err := w.g.Read(list[0].Ask)
if err != nil || string(whole.Arguments) != `{"dir":"scratch"}` || !strings.HasPrefix(whole.Digest, "sha256:") {
t.Fatalf("%+v %v", whole, err)
}
applied := w.g.Applied()
if len(applied) != 1 || applied[0].Tools["wipe"] != "entry shell src2:1" || applied[0].Verbs["node-login-shell.execute"] != "seat node-login-shell" || applied[0].Revision != "7" {
t.Fatalf("applied %+v", applied)
}
if ok, src := w.g.Describe("shell", "", "wipe"); !ok || src != "entry shell src2:1" {
t.Errorf("describe %v %q", ok, src)
}
if ok, src := w.g.Describe("", "node-login-shell", "execute"); !ok || src != "seat node-login-shell" {
t.Errorf("describe a seat verb %v %q", ok, src)
}
if err := w.g.Again(list[0].Ask); err == nil {
t.Error("an ask not run was asked its hand-act again")
}
cancelled := ""
w.publish = func(module, subject string, body any, id string) error { cancelled = subject + " " + id; return nil }
if err := w.g.Cancel(list[0].Ask); err != nil || cancelled != "mesh.seat.operator-channel.accept.cancel.shell cancel."+list[0].Ask {
t.Fatalf("cancel %v %q", err, cancelled)
}
}},
} {
t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) })
}
// Design 46 7-approval-mark §5, §3, §4 and design 34 every-event §33: the marks a module's calls are gated by, derived from
// fixture catalog halves and served entries.
t.Run("TheMarks", func(t *testing.T) {
login := map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell",
Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}, {Name: "sessions"}}}}
v1 := entry("src2:1", []string{"node-login-shell.execute", "node-login-shell.sessions"},
map[string]any{"needs-approval": []any{"wipe"}})
v2 := entry("src2:2", []string{"node-login-shell.execute", "node-login-shell.sessions"},
map[string]any{"needs-approval": []any{"erase", "node-login-shell.sessions"}})
mail := catalogseat.CatalogEntry{Module: "mail", Fp: "src2:m", Manifest: map[string]any{
"needs-approval": []any{"mail.received"}, "emits": []any{"mail.received"},
"streams": []any{map[string]any{"name": "MAIL", "events": []any{"mail.**"}, "grant": map[string]any{"config": "readers", "events": "mail.{key}.**"}}}}}
for _, tc := range []struct {
name string
cat *fakeCatalog
module string
fp string
tools []string
verbs map[string]string
subjects []string
unread bool
}{
{name: "a seat's mark stands for every holder, and the holder's own adds up", cat: &fakeCatalog{revision: "7",
byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login},
module: "shell", fp: "src2:1", tools: []string{"wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell"}},
{name: "a holder's needs-approval cannot unmark its seat's verb, and marks another", cat: &fakeCatalog{revision: "8",
byFp: map[string]catalogseat.CatalogEntry{"src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login},
module: "shell", fp: "src2:2", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell",
"node-login-shell.sessions": "entry shell src2:2"}},
{name: "across a version change the union stands: a dropped mark is asked while the old bundle runs", cat: &fakeCatalog{revision: "9",
byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1, "src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login},
module: "shell", fp: "src2:1", tools: []string{"erase", "wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell",
"node-login-shell.sessions": "entry shell src2:2"}},
{name: "a served entry that cannot be read leaves the newest marks, every marked call refused", cat: &fakeCatalog{revision: "9",
newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login},
module: "shell", fp: "src2:1", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell",
"node-login-shell.sessions": "entry shell src2:2"}, unread: true},
{name: "a bundle with no fingerprint has no served entry", cat: &fakeCatalog{revision: "9", defs: login},
module: "shell", unread: true},
{name: "a marked event and a stream with a grant give their subjects and each .>", cat: &fakeCatalog{revision: "3",
byFp: map[string]catalogseat.CatalogEntry{"src2:m": mail}, newest: map[string]catalogseat.CatalogEntry{"mail": mail}},
module: "mail", fp: "src2:m", subjects: []string{"mesh.mod.mail.event.mail.received", "mesh.mod.mail.event.mail.received.>",
"mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}},
} {
t.Run(tc.name, func(t *testing.T) {
m := gate.Derive(tc.cat, tc.module, tc.fp)
if got := keysOf(m.Tools); !reflect.DeepEqual(got, tc.tools) {
t.Errorf("tools %v, want %v", got, tc.tools)
}
if tc.verbs == nil {
tc.verbs = map[string]string{}
}
if !reflect.DeepEqual(m.Verbs, tc.verbs) {
t.Errorf("verbs %v, want %v", m.Verbs, tc.verbs)
}
if got := keysOf(m.Subjects); !reflect.DeepEqual(got, tc.subjects) {
t.Errorf("subjects %v, want %v", got, tc.subjects)
}
if (m.Unread != "") != tc.unread || m.Revision != tc.cat.revision {
t.Errorf("unread %q, revision %q", m.Unread, m.Revision)
}
})
}
// §4: a bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised; a bundle that
// serves it clears it, and a catalog without the mark too.
t.Run("mark-unserved raised and cleared", func(t *testing.T) {
cat := &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login}
w := &findings{}
g := gate.New(gate.Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: Findings(w, "n1", "node-tools", nil)})
if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != gate.KindMarkUnserved {
t.Fatalf("unserved %v, findings %v", got, w.raised)
}
if got := g.Unserved("shell", "src2:1", []string{"wipe"}); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" {
t.Fatalf("a bundle serving the mark: unserved %v, findings %v", got, w.raised)
}
_ = g.Unserved("shell", "src2:1", nil)
cat.byFp["src2:1"], cat.newest["shell"] = entry("src2:1", nil, nil), entry("src2:1", nil, nil)
if got := g.Unserved("shell", "src2:1", nil); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" {
t.Fatalf("a catalog without the mark: unserved %v, findings %v", got, w.raised)
}
})
})
}
+100
View File
@@ -0,0 +1,100 @@
// Package approval is the tool runner's side of mesh-sdk's approval gate (go/approval; design 47 G lead answers A-224,
// A-228): it translates what the gate reads beyond itself from the generated contracts, the gate's findings into the
// contract's findings about the runner on its machine, the catalog's entries into the gate's, and the assigner's view of
// the seats' holders into its holder function. The gate itself is mesh-sdk's; none is written here.
package approval
import (
"context"
"encoding/json"
"fmt"
"time"
assignerseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
gate "git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/views"
)
// HeldWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding).
type HeldWriter interface {
Write(ctx context.Context, finding condseat.Finding) error
Clear(ctx context.Context, scope, id, kind, why string) error
}
// scopeMachine is the scope of the gate's findings on the tool runner: its machine (lead answer A-154).
const scopeMachine = "machine"
// Findings is the gate's finding writer over the runner's held writer: each finding about the runner's module on node, at
// scope machine, and its pending named in the writer's `findings-stated` where the writer keeps one (design 47 G §384).
func Findings(w HeldWriter, node, runner string, now func() time.Time) gate.FindingWriter {
if w == nil {
return nil
}
if now == nil {
now = time.Now
}
return findingWriter{w: w, node: node, runner: runner, now: now}
}
type findingWriter struct {
w HeldWriter
node, runner string
now func() time.Time
}
func (f findingWriter) Raise(ctx context.Context, kind, id, summary, headline string) error {
return f.w.Write(ctx, condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id, Severity: condseat.FindingSeverityWarning,
Source: "approval", Observer: f.runner, Subject: condseat.Subject{Scope: "module", ID: f.runner, Machine: f.node},
Summary: summary, Headline: headline, At: f.now().UTC()})
}
func (f findingWriter) Clear(ctx context.Context, kind, id, why string) error {
return f.w.Clear(ctx, scopeMachine, id, kind, why)
}
// pender is the held writer's pending (views.HeldWriter's Pend and Judged).
type pender interface {
Pend(views.PendingKey)
Judged(key string)
}
func (f findingWriter) Pend(kind, id string, since time.Time, bound time.Duration) {
if p, ok := f.w.(pender); ok {
p.Pend(views.PendingKey{Key: views.FindingKey(scopeMachine, id, kind), Since: since, Bound: bound.String()})
}
}
func (f findingWriter) Judged(kind, id string) {
if p, ok := f.w.(pender); ok {
p.Judged(views.FindingKey(scopeMachine, id, kind))
}
}
// Entry is a catalog entry as the gate reads its marks.
func Entry(e catalogseat.CatalogEntry) gate.Entry {
manifest, _ := json.Marshal(e.Manifest)
return gate.Entry{Module: e.Module, Fingerprint: e.Fp, Serves: e.Serves, Manifest: manifest}
}
// HoldersIn is the gate's holder function over the assigner's view of the seats' holders as the runner holds it (§19):
// view answers the view's values by key, an error while its first view is not answered; a seat is held when its key names
// a holder.
func HoldersIn(view func() (map[string][]byte, error)) func(seat string) (bool, error) {
return func(seat string) (bool, error) {
values, err := view()
if err != nil {
return false, err
}
raw, ok := values[assignerseat.SeatHoldersBySeatKey(seat)]
if !ok {
return false, nil
}
var h assignerseat.SeatHolders
if err := json.Unmarshal(raw, &h); err != nil {
return false, fmt.Errorf("the holders of %s cannot be read: %w", seat, err)
}
return len(h.Holders) > 0, nil
}
}
@@ -0,0 +1,83 @@
package approval
import (
"context"
"sort"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/views"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
gate "git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// fakeCatalog is a catalog half as a fixture: entries by fingerprint, the newest by module, the seat definitions.
type fakeCatalog struct {
ready error
revision string
byFp map[string]catalogseat.CatalogEntry
newest map[string]catalogseat.CatalogEntry
defs map[string]seatdef.Definition
}
func (c *fakeCatalog) Ready() error { return c.ready }
func (c *fakeCatalog) Revision() string { return c.revision }
func (c *fakeCatalog) Served(fp string) (gate.Entry, bool, string) {
e, ok := c.byFp[fp]
return Entry(e), ok, "not-found"
}
func (c *fakeCatalog) Newest(m string) (gate.Entry, bool) {
e, ok := c.newest[m]
return Entry(e), ok
}
func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs }
func (c *fakeCatalog) Entries() []gate.Entry {
var out []gate.Entry
for _, e := range c.newest {
out = append(out, Entry(e))
}
return out
}
func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry {
return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest}
}
// findings is the runner's held writer as a fixture: each finding raised now, by id.
type findings struct {
raised map[string]string
// pends are the keys pending in the held writer's statement; pended every key ever pended.
pends map[string]views.PendingKey
pended []string
}
func (f *findings) Pend(p views.PendingKey) {
if f.pends == nil {
f.pends = map[string]views.PendingKey{}
}
f.pends[p.Key] = p
f.pended = append(f.pended, p.Key)
}
func (f *findings) Judged(key string) { delete(f.pends, key) }
func (f *findings) Write(_ context.Context, x condseat.Finding) error {
if f.raised == nil {
f.raised = map[string]string{}
}
f.raised[x.ID] = x.Kind
return nil
}
func (f *findings) Clear(_ context.Context, _, id, _, _ string) error {
delete(f.raised, id)
return nil
}
func keysOf(set map[string]string) []string {
var out []string
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
+8 -7
View File
@@ -58,15 +58,16 @@ func (c *Conn) Bindings(module string) []membership.BusBinding {
return nil return nil
} }
// jetStream is a JetStream handle on this connection. // JetStream is a JetStream handle on this connection: its one accessor, for a part that hands it to a finding writer (design 47 G
func (c *Conn) jetStream() (jetstream.JetStream, error) { return jetstream.New(c.nc) } // A-16, A-27).
func (c *Conn) JetStream() (jetstream.JetStream, error) { return jetstream.New(c.nc) }
func (c *Conn) objectStore(ctx context.Context, module, local string) (jetstream.ObjectStore, membership.BusBinding, error) { func (c *Conn) objectStore(ctx context.Context, module, local string) (jetstream.ObjectStore, membership.BusBinding, error) {
b, err := c.Binding(module, local, catalogue.StorageObjectStore) b, err := c.Binding(module, local, catalogue.StorageObjectStore)
if err != nil { if err != nil {
return nil, b, err return nil, b, err
} }
js, err := c.jetStream() js, err := c.JetStream()
if err != nil { if err != nil {
return nil, b, err return nil, b, err
} }
@@ -238,7 +239,7 @@ func (c *Conn) ObjectsWatch(module string, r buscalls.ObjectsWatchRequest, deliv
func (c *Conn) streamFill(name string) (buscalls.StreamFilled, error) { func (c *Conn) streamFill(name string) (buscalls.StreamFilled, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel() defer cancel()
js, err := c.jetStream() js, err := c.JetStream()
if err != nil { if err != nil {
return buscalls.StreamFilled{}, err return buscalls.StreamFilled{}, err
} }
@@ -295,7 +296,7 @@ func (c *Conn) StreamPublish(module string, r buscalls.StreamPublishRequest) (bu
} }
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel() defer cancel()
js, err := c.jetStream() js, err := c.JetStream()
if err != nil { if err != nil {
return buscalls.StreamPublished{}, err return buscalls.StreamPublished{}, err
} }
@@ -336,7 +337,7 @@ func (c *Conn) readStream(name, filter string, from uint64, limit int) (buscalls
} }
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel() defer cancel()
js, err := c.jetStream() js, err := c.JetStream()
if err != nil { if err != nil {
return buscalls.StreamPage{}, err return buscalls.StreamPage{}, err
} }
@@ -396,7 +397,7 @@ func (c *Conn) WorkQueueDead(module string, r buscalls.WorkQueueDeadRequest) (bu
} }
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel() defer cancel()
js, err := c.jetStream() js, err := c.JetStream()
if err != nil { if err != nil {
return buscalls.WorkQueueDeadLetters{}, err return buscalls.WorkQueueDeadLetters{}, err
} }
+18
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"strings" "strings"
"testing" "testing"
@@ -71,6 +72,23 @@ func TestABundleReachesItsOwnBoundObjectsAndStreamsOnly(t *testing.T) {
if err != nil || got == nil || string(got.Data) != "what was put" || got.Info.Name != "a" { if err != nil || got == nil || string(got.Data) != "what was put" || got.Info.Name != "a" {
t.Fatalf("read back %+v, %v", got, err) t.Fatalf("read back %+v, %v", got, err)
} }
// Design 47 G §518: a value over one chunk is streamed into the store and read back out of it, its description kept.
big := strings.Repeat("0123456789", 120<<10)
info, err := conn.ObjectsPutStream(ctx, "m", buscalls.ObjectsNameRequest{Objects: "files", Name: "big"}, "streamed", strings.NewReader(big))
if err != nil || info.Size != uint64(len(big)) || info.Description != "streamed" {
t.Fatalf("streamed put %+v, %v", info, err)
}
opened, rd, found, err := conn.ObjectsOpen(ctx, "m", buscalls.ObjectsNameRequest{Objects: "files", Name: "big"})
if err != nil || !found || opened.Size != uint64(len(big)) {
t.Fatalf("opened %+v %v %v", opened, found, err)
}
if back, _ := io.ReadAll(rd); string(back) != big {
t.Fatalf("read back %d bytes, want %d", len(back), len(big))
}
_ = rd.Close()
if err := conn.ObjectsDelete("m", buscalls.ObjectsNameRequest{Objects: "files", Name: "big"}); err != nil {
t.Fatal(err)
}
if listed, err := conn.ObjectsList("m", buscalls.ObjectsRequest{Objects: "files"}); err != nil || len(listed.Objects) != 1 { if listed, err := conn.ObjectsList("m", buscalls.ObjectsRequest{Objects: "files"}); err != nil || len(listed.Objects) != 1 {
t.Fatalf("listed %+v, %v", listed, err) t.Fatalf("listed %+v, %v", listed, err)
} }
+11 -5
View File
@@ -75,9 +75,6 @@ type Membership struct {
// Hears are the module streams the module hears, each through a durable consumer on that stream the // Hears are the module streams the module hears, each through a durable consumer on that stream the
// runtime binds beside the one on EVENTS (ADR 0363 §8). // runtime binds beside the one on EVENTS (ADR 0363 §8).
Hears []HeardIssued `json:"hears,omitempty"` Hears []HeardIssued `json:"hears,omitempty"`
// Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2):
// the tool runner's alone, composed from its manifest with the grant that lets it subscribe them.
Waits []WaitIssued `json:"waits,omitempty"`
// Bindings are the module's bus state in the new form, by local name (novox/hq ADR 0595 §1, mesh-sdk // Bindings are the module's bus state in the new form, by local name (novox/hq ADR 0595 §1, mesh-sdk
// membership.BusBinding): its object stores, streams, work queues and ledgers, its own alone (bindings.go). // membership.BusBinding): its object stores, streams, work queues and ledgers, its own alone (bindings.go).
Bindings []membership.BusBinding `json:"bindings,omitempty"` Bindings []membership.BusBinding `json:"bindings,omitempty"`
@@ -852,6 +849,12 @@ func answerOf(data []byte) (Answered, error) {
// PublishAs emits an event as a module: published into JetStream and awaited, de-duplicated by its // PublishAs emits an event as a module: published into JetStream and awaited, de-duplicated by its
// own id (ADR 0042). The body is the payload; the metadata rides as headers. // own id (ADR 0042). The body is the payload; the metadata rides as headers.
func (c *Conn) PublishAs(module string, env Envelope) error { func (c *Conn) PublishAs(module string, env Envelope) error {
_, err := c.PublishAsAcked(module, env)
return err
}
// PublishAsAcked is PublishAs that also answers the sequence the bus acknowledged the event at.
func (c *Conn) PublishAsAcked(module string, env Envelope) (uint64, error) {
// On the module's own stream when its membership names one carrying the event (ADR 0363 §2), on EVENTS // On the module's own stream when its membership names one carrying the event (ADR 0363 §2), on EVENTS
// otherwise — and only there, as before, for a membership that names no stream. // otherwise — and only there, as before, for a membership that names no stream.
msg := nats.NewMsg(eventSubject(c.Membership(module), module, env.Key)) msg := nats.NewMsg(eventSubject(c.Membership(module), module, env.Key))
@@ -873,8 +876,11 @@ func (c *Conn) PublishAs(module string, env Envelope) error {
if id := env.Headers["x-event-id"]; id != "" { if id := env.Headers["x-event-id"]; id != "" {
opts = append(opts, nats.MsgId(id)) opts = append(opts, nats.MsgId(id))
} }
_, err := c.js.PublishMsg(msg, opts...) ack, err := c.js.PublishMsg(msg, opts...)
return err if err != nil {
return 0, err
}
return ack.Sequence, nil
} }
// Say publishes one message on core NATS, kept by nobody: a heartbeat, whose loss is the next one. // Say publishes one message on core NATS, kept by nobody: a heartbeat, whose loss is the next one.
+143
View File
@@ -0,0 +1,143 @@
package bus
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// FileField is the member of a chunked verb's answer that carries the file received: its whole bytes, base64 (design 47 G
// A-249).
const FileField = "file"
// AskAsChunked is AskAs of a seat's verb whose definition says `chunks` (design 47 G §681, A-237, A-249): it chooses the call
// token (the body's `call`), hears the chunk subjects of that call before it asks, receives the chunks the verb's answer
// counts with buscalls.SeatChunks, each acknowledged before the next, and answers the verb's own answer with the whole
// file added as FileField. The asking bundle receives no chunk itself.
func (c *Conn) AskAsChunked(module, key string, body any) (Answered, error) {
// The verb may be asked of the machine that ran the ask (`@<node>`, A-254): the chunk subjects name the seat, the verb and
// the call alone.
name, _, _ := strings.Cut(key, "@")
seat, verb, ok := strings.Cut(strings.TrimPrefix(name, "seat:"), ".")
if !strings.HasPrefix(key, "seat:") || !ok {
return Answered{}, fmt.Errorf("%q is no seat:<seat>.<verb>, which a chunked answer needs", key)
}
args := map[string]any{}
if raw, err := json.Marshal(body); err != nil {
return Answered{}, err
} else if string(raw) != "null" {
if err := json.Unmarshal(raw, &args); err != nil {
return Answered{}, fmt.Errorf("the arguments of %s are not an object: %w", key, err)
}
}
var bits [16]byte
if _, err := rand.Read(bits[:]); err != nil {
return Answered{}, err
}
send := buscalls.SeatChunks{Seat: seat, Verb: verb, Call: hex.EncodeToString(bits[:])}
args["call"] = send.Call
chunks := make(chan *nats.Msg, 4)
sub, err := c.nc.ChanSubscribe(send.Chunks(), chunks)
if err != nil {
return Answered{}, fmt.Errorf("the chunks of %s cannot be heard: %w", key, err)
}
defer func() { _ = sub.Unsubscribe() }()
answered, err := c.AskAs(module, key, args)
if err != nil {
return answered, err
}
fields := map[string]json.RawMessage{}
if err := json.Unmarshal(answered.Result, &fields); err != nil {
return answered, fmt.Errorf("%s answered what is no object: %w", key, err)
}
var count int64
if raw, has := fields["chunks"]; has {
if err := json.Unmarshal(raw, &count); err != nil {
return answered, fmt.Errorf("%s answered chunks that is no count: %w", key, err)
}
}
var file bytes.Buffer
if count > 0 {
next := func() (string, []byte, error) {
select {
case m := <-chunks:
return m.Subject, m.Data, nil
case <-time.After(buscalls.SeatAckWithin):
return "", nil, buscalls.ErrSeatChunkMissed
}
}
ack := func(subject string) error {
_, err := c.nc.Request(subject, []byte("{}"), buscalls.SeatAckWithin)
return err
}
if _, err := send.Receive(count, next, ack, &file); err != nil {
return answered, fmt.Errorf("the chunks of %s: %w", key, err)
}
}
if _, taken := fields[FileField]; taken {
return answered, errors.New(key + " answers a member named " + FileField + " itself")
}
enc, _ := json.Marshal(base64.StdEncoding.EncodeToString(file.Bytes()))
fields[FileField] = enc
out, err := json.Marshal(fields)
if err != nil {
return answered, err
}
answered.Result = out
return answered, nil
}
// AnswerChunked is a seat verb's answer as its definition says `chunks` (design 47 G A-257, §681): a bundle answers the whole file
// as the member FileField (base64), and the runner that serves the verb sends it in chunks with buscalls.SeatChunks on the call
// token the body names, each acknowledged by the caller before the next, and answers the verb's own members with `chunks`, the
// count, in place of the file. The bundle sends no chunk itself. An answer with no file is answered as it is. The send runs after
// the answer is returned: the caller hears the chunks before it asks (AskAsChunked) and acknowledges each once it has the answer.
func (c *Conn) AnswerChunked(seat, verb string, body json.RawMessage, answer any) (any, error) {
raw, err := json.Marshal(answer)
if err != nil {
return nil, err
}
fields := map[string]json.RawMessage{}
if err := json.Unmarshal(raw, &fields); err != nil {
return answer, nil
}
enc, has := fields[FileField]
if !has {
return answer, nil
}
var text string
if err := json.Unmarshal(enc, &text); err != nil {
return nil, fmt.Errorf("%s.%s answers a %s that is no string: %w", seat, verb, FileField, err)
}
file, err := base64.StdEncoding.DecodeString(text)
if err != nil {
return nil, fmt.Errorf("%s.%s answers a %s in no base64: %w", seat, verb, FileField, err)
}
var asked struct {
Call string `json:"call"`
}
if err := json.Unmarshal(body, &asked); err != nil || asked.Call == "" {
return nil, fmt.Errorf("%s.%s is asked in chunks with no call token", seat, verb)
}
delete(fields, FileField)
count := int64((len(file) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes)
fields["chunks"], _ = json.Marshal(count)
send := buscalls.SeatChunks{Seat: seat, Verb: verb, Call: asked.Call}
go func() { _ = send.Send(c.nc, file, buscalls.SeatAckWithin, c.Logf) }()
return json.RawMessage(mustMarshal(fields)), nil
}
func mustMarshal(v any) []byte {
b, _ := json.Marshal(v)
return b
}
+117
View File
@@ -0,0 +1,117 @@
package bus_test
import (
"encoding/base64"
"encoding/json"
"testing"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// Design 47 G A-249, §681: the runner asks a chunked verb with a call token of its own, receives the chunks the answer counts
// (each acknowledged before the next) and answers the verb's own answer with the whole file, so the asker receives none.
func TestAChunkedSeatAnswerIsReceivedByTheRunnerAndHandedWhole(t *testing.T) {
url := mt.URL(t)
holder := dial(t, url, "mesh-delivery-indexer", "anchor", "")
asker := dial(t, url, "mesh-build-go", "anchor", "")
raw, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(raw.Close)
file := make([]byte, buscalls.SeatChunkBytes*2+5)
for i := range file {
file[i] = byte(i)
}
// the holder's process: answers the count, then sends each chunk and waits for its acknowledgement
stop, err := holder.Handle("seat:mesh-catalog.input", func(body json.RawMessage) (any, error) {
var in struct{ Call, Digest string }
if err := json.Unmarshal(body, &in); err != nil || in.Call == "" {
t.Errorf("the verb was asked with no call token: %s (%v)", body, err)
return nil, err
}
send := buscalls.SeatChunks{Seat: "mesh-catalog", Verb: "input", Call: in.Call}
go func() {
for n := int64(1); n <= 3; n++ {
end := min(int64(len(file)), n*buscalls.SeatChunkBytes)
data, _ := json.Marshal(buscalls.SeatChunk{N: n, Of: 3, Data: file[(n-1)*buscalls.SeatChunkBytes : end]})
ackSub, _ := raw.SubscribeSync(send.Ack(n))
_ = raw.Flush()
if err := raw.Publish(send.Chunk(n), data); err != nil {
return
}
m, err := ackSub.NextMsg(buscalls.SeatAckWithin)
if err != nil {
t.Errorf("chunk %d not acknowledged: %v", n, err)
return
}
_ = m.Respond([]byte("{}"))
}
}()
return map[string]any{"digest": in.Digest, "chunks": 3}, nil
})
if err != nil {
t.Fatal(err)
}
defer stop()
holder.Flush() // the bus holds the verb's subscription before it is asked: the asker is another connection
got, err := asker.AskAsChunked("mesh-build-go", "seat:mesh-catalog.input", map[string]any{"digest": "d1"})
if err != nil {
t.Fatal(err)
}
var ans struct {
Digest string `json:"digest"`
Chunks int `json:"chunks"`
File string `json:"file"`
}
if err := json.Unmarshal(got.Result, &ans); err != nil || ans.Digest != "d1" || ans.Chunks != 3 {
t.Fatalf("the answer %s: %v", got.Result, err)
}
if body, err := base64.StdEncoding.DecodeString(ans.File); err != nil || string(body) != string(file) {
t.Errorf("the file handed is %d bytes, want the %d sent: %v", len(body), len(file), err)
}
// Design 47 G A-257, A-254: a bundle answers a chunked verb with the whole file in the member `file`; the runner that serves the
// verb sends the file in chunks (each acknowledged before the next) and answers the count, so the bundle sends none, and the asker
// of one node's verb receives it whole.
t.Run("ABundlesChunkedAnswerIsSentByTheRunner", func(t *testing.T) {
url := mt.URL(t)
holder := dial(t, url, "mesh-build-go", "anchor", "")
asker := dial(t, url, "mesh-delivery-indexer", "anchor", "")
file := make([]byte, buscalls.SeatChunkBytes*2+7)
for i := range file {
file[i] = byte(i * 7)
}
stop, err := holder.Handle("seat:node-build-go.output@anchor", func(body json.RawMessage) (any, error) {
answer := map[string]any{"ask": "a1", "file": base64.StdEncoding.EncodeToString(file)}
return holder.AnswerChunked("node-build-go", "output", body, answer)
})
if err != nil {
t.Fatal(err)
}
defer stop()
holder.Flush() // the bus holds the verb's subscription before it is asked: the asker is another connection
got, err := asker.AskAsChunked("mesh-delivery-indexer", "seat:node-build-go.output@anchor", map[string]any{"ask": "a1"})
if err != nil {
t.Fatal(err)
}
var ans struct {
Ask string `json:"ask"`
Chunks int `json:"chunks"`
File string `json:"file"`
}
if err := json.Unmarshal(got.Result, &ans); err != nil || ans.Ask != "a1" || ans.Chunks != 3 {
t.Fatalf("the answer %s: %v", got.Result, err)
}
if body, err := base64.StdEncoding.DecodeString(ans.File); err != nil || string(body) != string(file) {
t.Errorf("the file handed is %d bytes, want the %d sent: %v", len(body), len(file), err)
}
// an answer with no file is the verb's own: no chunks are sent
plain, err := holder.AnswerChunked("node-build-go", "output", json.RawMessage(`{"call":"c"}`), map[string]any{"ask": "x", "chunks": 0})
if m, _ := plain.(map[string]any); err != nil || m["chunks"] != 0 {
t.Fatalf("an answer with no file was %v, %v", plain, err)
}
})
}
+78 -2
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/membership"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
@@ -82,7 +83,7 @@ func publishExpecting(c *Conn, subject string, body []byte, expect uint64) (uint
func lastOn(c *Conn, stream, subject string) ([]byte, uint64, error) { func lastOn(c *Conn, stream, subject string) ([]byte, uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel() defer cancel()
js, err := c.jetStream() js, err := c.JetStream()
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -143,13 +144,83 @@ func (h *busHistory) Read(keys string, from uint64, limit int) ([]ledger.Kept, e
} }
} }
// Purge removes every operation of one instance from the history stream, on the module's ledger connection, the one
// granted the purge of its own ledger's streams.
func (h *busHistory) Purge(key string) error {
return purgeSubject(h.read, h.write, h.stream, h.prefix+"."+key)
}
// Fill is the history stream's fill against its cap, the cap the manifest gives the ledger.
func (h *busHistory) Fill() (buscalls.Fill, error) {
name := h.stream
if name == "" {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
js, err := h.read.JetStream()
if err != nil {
return buscalls.Fill{}, err
}
if name, err = js.StreamNameBySubject(ctx, h.prefix+".>"); err != nil {
return buscalls.Fill{}, fmt.Errorf("no stream on the bus keeps %s: %w", h.prefix, err)
}
}
f, err := h.read.streamFill(name)
return f.Fill, err
}
// purgeSubject purges one subject's messages from the stream that keeps it: the stream named, or found on the runner's
// own connection by its subject, purged on the ledger connection.
func purgeSubject(read, write *Conn, stream, subject string) error {
if write == nil {
return errors.New("a ledger's streams are purged only on its module's ledger connection, and none is held")
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
if stream == "" {
js, err := read.JetStream()
if err != nil {
return err
}
if stream, err = js.StreamNameBySubject(ctx, subject); err != nil {
return fmt.Errorf("no stream on the bus keeps %s: %w", subject, err)
}
}
// The purge is asked of the stream by its name, with no read of its info first: the ledger account is granted the
// purge of its own ledger's two streams and nothing else (PurgeAPI).
body, err := json.Marshal(map[string]string{"filter": subject})
if err != nil {
return err
}
reply, err := write.nc.RequestWithContext(ctx, PurgeAPI(stream), body)
if err != nil {
return fmt.Errorf("purging %s from %s: %w", subject, stream, err)
}
var ack struct {
Success bool `json:"success"`
Error *struct {
Description string `json:"description"`
} `json:"error"`
}
if err := json.Unmarshal(reply.Data, &ack); err != nil || !ack.Success {
why := string(reply.Data)
if ack.Error != nil {
why = ack.Error.Description
}
return fmt.Errorf("the bus refused the purge of %s from %s: %s", subject, stream, why)
}
return nil
}
// PurgeAPI is the bus's subject that purges a stream, the one a ledger account is granted for its own ledger's streams.
func PurgeAPI(stream string) string { return "$JS.API.STREAM.PURGE." + stream }
type busState struct { type busState struct {
c *Conn c *Conn
bucket string bucket string
} }
func (s *busState) kv(ctx context.Context) (jetstream.KeyValue, error) { func (s *busState) kv(ctx context.Context) (jetstream.KeyValue, error) {
js, err := s.c.jetStream() js, err := s.c.JetStream()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -231,6 +302,11 @@ func (s *busSnapshots) Get(key string) ([]byte, uint64, error) {
return lastOn(s.read, "", s.prefix+"."+key) return lastOn(s.read, "", s.prefix+"."+key)
} }
// Purge removes one instance's snapshot, on the module's ledger connection.
func (s *busSnapshots) Purge(key string) error {
return purgeSubject(s.read, s.write, "", s.prefix+"."+key)
}
func (s *busSnapshots) Put(key string, value []byte, expect uint64) error { func (s *busSnapshots) Put(key string, value []byte, expect uint64) error {
if s.write == nil { if s.write == nil {
return errors.New("a ledger's snapshot is published only on its module's ledger connection, and none is held") return errors.New("a ledger's snapshot is published only on its module's ledger connection, and none is held")
+60
View File
@@ -0,0 +1,60 @@
package bus
import (
"context"
"errors"
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"github.com/nats-io/nats.go/jetstream"
)
// SentGet reads one object of a sender's store of sent objects by the reference that carried it (ADR 0666 §16): the
// store is the sender's `<module>_sent-objects`, and no other — a state's objects are reached through their owner's
// bindings alone. It reads as this connection's account, which the bus lets read a sender's sent store only where the
// grants part granted it (a bundle is held to what its runtime channel carries by the runtime call, not here), from the manifests' callers and consumers; a refusal says the store.
func (c *Conn) SentGet(r buscalls.ObjectsSentGetRequest) (*buscalls.ObjectData, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
obs, err := c.sentStore(ctx, r)
if err != nil {
return nil, err
}
info, err := obs.GetInfo(ctx, r.Object)
if errors.Is(err, jetstream.ErrObjectNotFound) {
return nil, fmt.Errorf("the sent object %s is not in %s: it is kept 7 days, then expires", r.Object, r.Store)
}
if err != nil {
return nil, fmt.Errorf("the sent object %s of %s: %w", r.Object, r.Store, err)
}
data, err := obs.GetBytes(ctx, r.Object)
if err != nil {
return nil, fmt.Errorf("the sent object %s of %s: %w", r.Object, r.Store, err)
}
return &buscalls.ObjectData{Info: objectInfoOf(info), Data: data}, nil
}
// sentStore checks the reference r and opens the sender's sent store it names.
func (c *Conn) sentStore(ctx context.Context, r buscalls.ObjectsSentGetRequest) (jetstream.ObjectStore, error) {
ref := stateobjects.Ref{Store: r.Store, Object: r.Object, Size: r.Size, Digest: r.Digest, Form: stateobjects.FormVersion}
if _, ok := stateobjects.ReadRef(stateobjects.Wrap(ref)); !ok {
return nil, errors.New("a sent object is named by the reference's store, object, size and sha256, as the reply or event carried them")
}
if !strings.HasSuffix(r.Store, catalogue.BusNameSeparator+catalogue.SentObjectsLocal) {
return nil, fmt.Errorf("%s is no sent store (a module's is `<module>%s%s`): only a sent object is read by its reference",
r.Store, catalogue.BusNameSeparator, catalogue.SentObjectsLocal)
}
js, err := c.JetStream()
if err != nil {
return nil, err
}
obs, err := js.ObjectStore(ctx, r.Store)
if err != nil {
return nil, fmt.Errorf("the sent store %s cannot be read as %s: it is not on the bus, or the bus grants this account no "+
"read of it (the manifests' callers and consumers are granted): %w", r.Store, c.cred.Module, err)
}
return obs, nil
}
+76
View File
@@ -0,0 +1,76 @@
package bus
import (
"context"
"errors"
"fmt"
"io"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"github.com/nats-io/nats.go/jetstream"
)
// The bus side of a bundle's put and get sessions (design 47 G §518, A-21, A-25): a put written into the module's object
// store as its chunks arrive and a get read out of it as they are asked, through the store's own chunking, so no more than
// one chunk is held in memory. The session itself is the runtime's (internal/runtime/sessions.go).
// ObjectsPutStream stores the object r.Name of the module's object store r.Objects from in, read to its end, with the
// put's description (A-25); it answers once the object is sealed. A read error of in drops the object: nothing is left.
func (c *Conn) ObjectsPutStream(ctx context.Context, module string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error) {
if r.Name == "" {
return buscalls.ObjectInfo{}, errors.New("an object is put under a name")
}
obs, _, err := c.objectStore(ctx, module, r.Objects)
if err != nil {
return buscalls.ObjectInfo{}, err
}
info, err := obs.Put(ctx, jetstream.ObjectMeta{Name: r.Name, Description: description}, in)
if err != nil {
_ = obs.Delete(context.WithoutCancel(ctx), r.Name)
return buscalls.ObjectInfo{}, err
}
return objectInfoOf(info), nil
}
// ObjectsOpen is one object of the module's object store as a reader of its bytes beside its info; found is false when there
// is none. The caller closes the reader.
func (c *Conn) ObjectsOpen(ctx context.Context, module string, r buscalls.ObjectsNameRequest) (info buscalls.ObjectInfo, rd io.ReadCloser, found bool, err error) {
obs, _, err := c.objectStore(ctx, module, r.Objects)
if err != nil {
return info, nil, false, err
}
res, err := obs.Get(ctx, r.Name)
if errors.Is(err, jetstream.ErrObjectNotFound) {
return info, nil, false, nil
}
if err != nil {
return info, nil, false, err
}
i, err := res.Info()
if err != nil {
res.Close()
return info, nil, false, err
}
return objectInfoOf(i), res, true, nil
}
// SentOpen is a sent object, read by its reference as SentGet reads it, as a reader of its bytes beside its info.
func (c *Conn) SentOpen(ctx context.Context, r buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error) {
obs, err := c.sentStore(ctx, r)
if err != nil {
return buscalls.ObjectInfo{}, nil, err
}
res, err := obs.Get(ctx, r.Object)
if errors.Is(err, jetstream.ErrObjectNotFound) {
return buscalls.ObjectInfo{}, nil, fmt.Errorf("the sent object %s is not in %s: it is kept 7 days, then expires", r.Object, r.Store)
}
if err != nil {
return buscalls.ObjectInfo{}, nil, fmt.Errorf("the sent object %s of %s: %w", r.Object, r.Store, err)
}
i, err := res.Info()
if err != nil {
res.Close()
return buscalls.ObjectInfo{}, nil, err
}
return objectInfoOf(i), res, nil
}
+68 -69
View File
@@ -87,6 +87,74 @@ func TestAWatchHoldsWhileItsCurrentValuesKeepArriving(t *testing.T) {
} }
return nil return nil
}) })
// **A watch given up, or stopped, hands over nothing more** (issue 774). The runtime's watch stopped its watcher
// when the current values did not arrive within the bound, but the watcher still held values, and its goroutine
// went on handing them over: to a watch id the bundle no longer held (on the provisioner, 51 failed watches were
// followed by 1257 orphan changes). Each row blocks the bundle on its first value, gives the watch up, lets the
// bundle go and counts what it is still handed.
t.Run("AWatchGivenUpHandsOverNothingMore", func(t *testing.T) {
for _, tc := range []struct {
name string
refused bool // the bound gives it up, or the caller stops it
}{
{"refused at the bound", true},
{"stopped by its caller", false},
} {
t.Run(tc.name, func(t *testing.T) {
was := bus.StateTimeout
bus.StateTimeout = 300 * time.Millisecond
t.Cleanup(func() { bus.StateTimeout = was })
conn, kv := owedWatcher(t)
put := func(i int) {
if _, err := kv.Put(fmt.Sprintf("novox/hq.%d.%040x.status=mesh/check", i+1, i), []byte(`{"v":1}`)); err != nil {
t.Fatal(err)
}
}
if tc.refused {
for i := 0; i < 4; i++ {
put(i)
}
}
var calls atomic.Int32
first := make(chan struct{}, 1)
release := make(chan struct{})
deliver := func(ch bus.StateChange) error {
if calls.Add(1) == 1 {
first <- struct{}{}
<-release
}
return nil
}
var stop func()
if tc.refused {
if _, err := conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err == nil {
t.Fatal("the watch whose bundle took nothing was not refused")
}
} else {
var err error
if stop, err = conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
put(i)
}
select {
case <-first:
case <-time.After(5 * time.Second):
t.Fatal("the bundle was handed no change")
}
stop()
}
// Let the watcher's server side deliver what it held, then free the bundle.
time.Sleep(300 * time.Millisecond)
close(release)
time.Sleep(500 * time.Millisecond)
if n := calls.Load(); n != 1 {
t.Fatalf("the bundle was handed %d changes of a watch given up, want 1 (the one in hand)", n)
}
})
}
})
} }
// A watch whose bundle takes nothing for longer than the bound is still refused, so a bundle that hangs // A watch whose bundle takes nothing for longer than the bound is still refused, so a bundle that hangs
@@ -187,72 +255,3 @@ func owedWatcher(t *testing.T) (*bus.Conn, interface {
}) })
return conn, kv return conn, kv
} }
// **A watch given up, or stopped, hands over nothing more** (issue 774). The runtime's watch stopped its watcher
// when the current values did not arrive within the bound, but the watcher still held values, and its goroutine
// went on handing them over: to a watch id the bundle no longer held (on the provisioner, 51 failed watches were
// followed by 1257 orphan changes). Each row blocks the bundle on its first value, gives the watch up, lets the
// bundle go and counts what it is still handed.
func TestAWatchGivenUpHandsOverNothingMore(t *testing.T) {
for _, tc := range []struct {
name string
refused bool // the bound gives it up, or the caller stops it
}{
{"refused at the bound", true},
{"stopped by its caller", false},
} {
t.Run(tc.name, func(t *testing.T) {
was := bus.StateTimeout
bus.StateTimeout = 300 * time.Millisecond
t.Cleanup(func() { bus.StateTimeout = was })
conn, kv := owedWatcher(t)
put := func(i int) {
if _, err := kv.Put(fmt.Sprintf("novox/hq.%d.%040x.status=mesh/check", i+1, i), []byte(`{"v":1}`)); err != nil {
t.Fatal(err)
}
}
if tc.refused {
for i := 0; i < 4; i++ {
put(i)
}
}
var calls atomic.Int32
first := make(chan struct{}, 1)
release := make(chan struct{})
deliver := func(ch bus.StateChange) error {
if calls.Add(1) == 1 {
first <- struct{}{}
<-release
}
return nil
}
var stop func()
if tc.refused {
if _, err := conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err == nil {
t.Fatal("the watch whose bundle took nothing was not refused")
}
} else {
var err error
if stop, err = conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
put(i)
}
select {
case <-first:
case <-time.After(5 * time.Second):
t.Fatal("the bundle was handed no change")
}
stop()
}
// Let the watcher's server side deliver what it held, then free the bundle.
time.Sleep(300 * time.Millisecond)
close(release)
time.Sleep(500 * time.Millisecond)
if n := calls.Load(); n != 1 {
t.Fatalf("the bundle was handed %d changes of a watch given up, want 1 (the one in hand)", n)
}
})
}
}
@@ -1,6 +1,11 @@
package bus package bus
import "testing" import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// novox/hq ADR 0363 §2, §3: an event goes to the stream whose pattern carries it, as the bus matches subjects. // novox/hq ADR 0363 §2, §3: an event goes to the stream whose pattern carries it, as the bus matches subjects.
func TestAnEventFallsUnderAStreamsPatternAsTheBusMatchesIt(t *testing.T) { func TestAnEventFallsUnderAStreamsPatternAsTheBusMatchesIt(t *testing.T) {
@@ -22,6 +27,24 @@ func TestAnEventFallsUnderAStreamsPatternAsTheBusMatchesIt(t *testing.T) {
t.Errorf("%q under %q: %v, want %v", c.event, c.pattern, got, c.want) t.Errorf("%q under %q: %v, want %v", c.event, c.pattern, got, c.want)
} }
} }
// Design 47 G §347, §59, §462: a sent object is read by the reference that carried it, from a sender's sent store only; a
// state's objects, and a reference that is none are refused before the bus is asked.
t.Run("SentGetRefusesWhatIsNoSentObjectsReference", func(t *testing.T) {
digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
for _, c := range []struct {
name string
req buscalls.ObjectsSentGetRequest
want string
}{
{"no digest", buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: 3}, "named by the reference"},
{"a state's objects", buscalls.ObjectsSentGetRequest{Store: "m_asks-objects", Object: "o", Size: 3, Digest: digest}, "no sent store"},
} {
_, err := (&Conn{}).SentGet(c.req)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: got %v, want an error containing %q", c.name, err, c.want)
}
}
})
} }
// A membership naming no stream publishes every event on EVENTS, exactly as before (issue 725). // A membership naming no stream publishes every event on EVENTS, exactly as before (issue 725).
+87
View File
@@ -0,0 +1,87 @@
package bus
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"github.com/nats-io/nats.go/jetstream"
)
// SubscribeNew hears one event, named `<emitter>.<event>`, from the next message on, on an ordered consumer of its own on each
// stream that carries it, and hands every message to deliver as the envelope a module sees. The subjects the name lands on are
// mesh-sdk's one rule (buscompose.EventSubjects, design 47 G §559, A-153), so a name with a wildcard is refused, naming the rule;
// a name no stream on the bus carries is an error, never a subscription that hears nothing. stop ends every consumer.
func (c *Conn) SubscribeNew(name string, deliver func(json.RawMessage) error) (func(), error) {
subjects, err := buscompose.EventSubjects(name, "")
if err != nil {
return nil, err
}
js, err := c.JetStream()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
// The streams that carry each subject, found by the bus: a namespace no stream carries hears nothing and is skipped.
byStream := map[string][]string{}
for _, subject := range subjects {
if strings.HasSuffix(subject, ".>") {
continue
}
stream, err := js.StreamNameBySubject(ctx, subject)
if err != nil || stream == "" {
continue
}
byStream[stream] = append(byStream[stream], subject, subject+".>")
}
if len(byStream) == 0 {
return nil, fmt.Errorf("no stream on the bus carries %s (%s): nothing would be heard", name, strings.Join(subjects, ", "))
}
var stops []func()
stopAll := func() {
for _, s := range stops {
s()
}
}
for _, stream := range slices.Sorted(mapKeys(byStream)) {
cons, err := js.OrderedConsumer(ctx, stream, jetstream.OrderedConsumerConfig{FilterSubjects: byStream[stream],
DeliverPolicy: jetstream.DeliverNewPolicy})
if err != nil {
stopAll()
return nil, fmt.Errorf("an ordered consumer of %s on %s: %w", name, stream, err)
}
cc, err := cons.Consume(func(m jetstream.Msg) {
env, ok := envelopeOf(m.Subject(), m.Headers(), m.Data())
if !ok {
return
}
raw, err := json.Marshal(env)
if err != nil {
return
}
if err := deliver(raw); err != nil && c.Logf != nil {
c.Logf("[mesh-tools] a subscription for new %s messages did not take one: %v", name, err)
}
})
if err != nil {
stopAll()
return nil, fmt.Errorf("consuming %s on %s: %w", name, stream, err)
}
stops = append(stops, cc.Stop)
}
return stopAll, nil
}
func mapKeys(m map[string][]string) func(func(string) bool) {
return func(yield func(string) bool) {
for k := range m {
if !yield(k) {
return
}
}
}
}
+47 -34
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -16,38 +17,42 @@ import (
// subscribes the subjects when called and unsubscribes them before it answers, so an event published // subscribes the subjects when called and unsubscribes them before it answers, so an event published
// between two calls is not heard — a session reads the state from its owner after every wake (§4). // between two calls is not heard — a session reads the state from its owner after every wake (§4).
// WaitIssued is one event family the mesh issued this account to wait on (ADR 0367 §2): the name a session // Heard is one event a wait heard: the subject it arrived on, its body, when it arrived, and when it was published, the
// asks for, and the subject it lands on. Only the tool runner's membership carries any. // event's own header `x-time` as its emitter wrote it, empty for an event that carries none (design 34 every-event §12).
type WaitIssued struct {
Event string `json:"event"`
Subject string `json:"subject"`
}
// Heard is one event a wait heard: the subject it arrived on, its body and when it arrived.
type Heard struct { type Heard struct {
Subject string Subject string
Body json.RawMessage Body json.RawMessage
At time.Time At time.Time
Published string
} }
// HearFirst subscribes the subjects for this call alone and answers the first message whose body match // HearFirst subscribes the subjects for this call alone and answers the first message whose body match
// accepts (every one, when match is nil), or ok false once `within` has passed. Every subscription is // accepts (every one, when match is nil), or ok false once `within` has passed. Every subscription is
// closed before it returns. A subscription the bus refuses is said as an error naming the subject, at once; // closed before it returns. A subscription the bus refuses is noted and the others go on waiting; refused lists
// a caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for nobody. // those noted by the time it answers (design 34 every-event §16). When the bus refused every subscription of the
func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, err error) { // call, it still waits out `within` and then answers the refusal, so a caller looping on it asks once a timeout and
// never at once again. A caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for
// nobody.
func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, refused []string, err error) {
if len(subjects) == 0 { if len(subjects) == 0 {
return Heard{}, false, fmt.Errorf("a wait names at least one subject") return Heard{}, false, nil, fmt.Errorf("a wait names at least one subject")
}
var unique []string
for _, subject := range subjects {
if !slices.Contains(unique, subject) {
unique = append(unique, subject)
}
} }
got := make(chan Heard, 1) got := make(chan Heard, 1)
refused := make(chan string, len(subjects)) refusals := make(chan string, len(unique))
var subs []*nats.Subscription var subs []*nats.Subscription
defer func() { defer func() {
for _, s := range subs { for _, s := range subs {
_ = s.Unsubscribe() _ = s.Unsubscribe()
} }
c.mu.Lock() c.mu.Lock()
for _, subject := range subjects { for _, subject := range unique {
delete(c.waits[subject], refused) delete(c.waits[subject], refusals)
if len(c.waits[subject]) == 0 { if len(c.waits[subject]) == 0 {
delete(c.waits, subject) delete(c.waits, subject)
} }
@@ -58,14 +63,14 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur
if c.waits == nil { if c.waits == nil {
c.waits = map[string]map[chan string]bool{} c.waits = map[string]map[chan string]bool{}
} }
for _, subject := range subjects { for _, subject := range unique {
if c.waits[subject] == nil { if c.waits[subject] == nil {
c.waits[subject] = map[chan string]bool{} c.waits[subject] = map[chan string]bool{}
} }
c.waits[subject][refused] = true c.waits[subject][refusals] = true
} }
c.mu.Unlock() c.mu.Unlock()
for _, subject := range subjects { for _, subject := range unique {
sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) { sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) {
body := json.RawMessage(msg.Data) body := json.RawMessage(msg.Data)
if !json.Valid(body) { if !json.Valid(body) {
@@ -76,31 +81,39 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur
return return
} }
select { select {
case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC()}: case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC(), Published: msg.Header.Get("x-time")}:
default: default:
} }
}) })
if err != nil { if err != nil {
return Heard{}, false, fmt.Errorf("could not wait on %s: %w", subject, err) return Heard{}, false, nil, fmt.Errorf("could not wait on %s: %w", subject, err)
} }
subs = append(subs, sub) subs = append(subs, sub)
} }
// The bus has every subscription — or has said which it refused — once this returns. // The bus has every subscription — or has said which it refused — once this returns.
if err := c.nc.FlushTimeout(5 * time.Second); err != nil { if err := c.nc.FlushTimeout(5 * time.Second); err != nil {
return Heard{}, false, fmt.Errorf("the bus did not take the wait: %w", err) return Heard{}, false, nil, fmt.Errorf("the bus did not take the wait: %w", err)
} }
timer := time.NewTimer(within) timer := time.NewTimer(within)
defer timer.Stop() defer timer.Stop()
select { for {
case h := <-got: select {
return h, true, nil case h := <-got:
case subject := <-refused: return h, true, refused, nil
return Heard{}, false, fmt.Errorf("the bus refused this account a subscription on %s: it is not in the "+ case subject := <-refusals:
"account's wait grant (novox/hq ADR 0367 §2)", subject) if !slices.Contains(refused, subject) {
case <-timer.C: refused = append(refused, subject)
return Heard{}, false, nil }
case <-ctx.Done(): case <-timer.C:
return Heard{}, false, ctx.Err() if len(refused) == len(unique) {
return Heard{}, false, refused, fmt.Errorf("the bus refused this account a subscription on %s: the "+
"grants part grants this machine's tool runner every event subject (novox/hq ADR 0670 §1), so its "+
"grant is not composed yet; read the state from its owner meanwhile", strings.Join(refused, ", "))
}
return Heard{}, false, refused, nil
case <-ctx.Done():
return Heard{}, false, refused, ctx.Err()
}
} }
} }
+58 -24
View File
@@ -2,22 +2,24 @@ package bus_test
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
// novox/hq ADR 0367 §2, §5: the bus refuses the tool runner's account any subject its wait grant does not // novox/hq ADR 0670 §1 (design 34 every-event §12, §16): a subscription the bus refuses is noted and the others go on
// name — another asker's `decided` among them — and a wait on one says the bus refused it, at once. // waiting, a heard event carries its own time; a wait whose every subscription is refused answers only after its
// timeout, in words naming the grants part's grant of every event subject, never a wait grant (E-2).
func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) { func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) {
granted := "mesh.seat.operator-channel.event.decided.claude-code" granted := "mesh.seat.operator-channel.event.decided.claude-code"
other := "mesh.seat.operator-channel.event.decided.shop"
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true, s, 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{ Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{
Publish: &server.SubjectPermission{Allow: []string{"$JS.API.DIRECT.GET.>", "mesh.>"}}, Publish: &server.SubjectPermission{Allow: []string{"$JS.API.DIRECT.GET.>", "mesh.>"}},
@@ -33,33 +35,65 @@ func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) {
t.Cleanup(s.Shutdown) t.Cleanup(s.Shutdown)
c := dial(t, s.ClientURL(), "node-tools", "desk", "desk.node-tools") c := dial(t, s.ClientURL(), "node-tools", "desk", "desk.node-tools")
// Every subscription refused: answered after the timeout, never at once, in §16's words.
start := time.Now() start := time.Now()
_, ok, err := c.HearFirst(context.Background(), []string{"mesh.seat.operator-channel.event.decided.shop"}, 5*time.Second, nil) _, ok, refused, err := c.HearFirst(context.Background(), []string{other}, 300*time.Millisecond, nil)
if ok || err == nil || !strings.Contains(err.Error(), "refused") { if took := time.Since(start); took < 300*time.Millisecond || took > 3*time.Second {
t.Errorf("a wait on another asker's decided: heard %v, %v", ok, err) t.Errorf("a wait refused whole answered after %s, want its timeout of 300ms", took)
} }
if time.Since(start) > 3*time.Second { if ok || err == nil || !strings.Contains(err.Error(), "the bus refused this account a subscription on "+other) ||
t.Errorf("the refusal took %s to be said", time.Since(start)) !strings.Contains(err.Error(), "every event subject (novox/hq ADR 0670 §1)") ||
strings.Contains(err.Error(), "wait grant") || strings.Contains(err.Error(), "0367") ||
len(refused) != 1 || refused[0] != other {
t.Errorf("a wait on another asker's decided: heard %v, refused %v, %v", ok, refused, err)
} }
done := make(chan bus.Heard, 1) // One refused beside one granted: the granted one hears, with the event's own time, and the refused one is listed.
go func() { pub, err := nats.Connect(s.ClientURL(), nats.UserInfo("desk.node-tools", "pw"))
h, ok, err := c.HearFirst(context.Background(), []string{granted}, 5*time.Second, func(body json.RawMessage) bool { return true }) if err != nil {
if ok && err == nil {
done <- h
}
}()
time.Sleep(300 * time.Millisecond)
if err := c.Say(granted, []byte(`{"ask":"a1"}`)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
select { t.Cleanup(pub.Close)
case h := <-done: for _, published := range []string{"2026-11-12T03:00:00.5Z", ""} {
if h.Subject != granted || string(h.Body) != `{"ask":"a1"}` { type result struct {
t.Errorf("heard %+v", h) h bus.Heard
refused []string
err error
}
done := make(chan result, 1)
go func() {
h, ok, refused, err := c.HearFirst(context.Background(), []string{other, granted}, 5*time.Second, nil)
if !ok && err == nil {
err = errors.New("timed out")
}
done <- result{h, refused, err}
}()
// The event is said again until the wait, subscribed in its own time, hears it.
say := time.NewTicker(20 * time.Millisecond)
var r result
heard:
for {
select {
case r = <-done:
break heard
case <-say.C:
msg := nats.NewMsg(granted)
msg.Data = []byte(`{"ask":"a1"}`)
if published != "" {
msg.Header.Set("x-time", published)
}
if err := pub.PublishMsg(msg); err != nil {
t.Fatal(err)
}
}
}
say.Stop()
if r.err != nil || r.h.Subject != granted || string(r.h.Body) != `{"ask":"a1"}` || r.h.Published != published {
t.Errorf("heard %+v, %v; want published %q", r.h, r.err, published)
}
if len(r.refused) > 1 || len(r.refused) == 1 && r.refused[0] != other {
t.Errorf("refused %v, want at most %s", r.refused, other)
} }
case <-time.After(5 * time.Second):
t.Fatal("a wait on the granted subject heard nothing")
} }
} }
@@ -70,7 +104,7 @@ func TestAWaitEndsWhenItsCallerIsGone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(200*time.Millisecond, cancel) time.AfterFunc(200*time.Millisecond, cancel)
start := time.Now() start := time.Now()
_, ok, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil) _, ok, _, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil)
if ok || !errors.Is(err, context.Canceled) { if ok || !errors.Is(err, context.Canceled) {
t.Errorf("a cancelled wait answered %v, %v", ok, err) t.Errorf("a cancelled wait answered %v, %v", ok, err)
} }
+51 -63
View File
@@ -19,11 +19,12 @@ package console
import ( import (
assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
meshcatalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@@ -95,8 +96,7 @@ func discovery() []map[string]any {
"bus, and when it was last heard — and every runtime or machine expected that was not heard. Read-only; " + "bus, and when it was last heard — and every runtime or machine expected that was not heard. Read-only; " +
"for when an address is said to be missing or on another machine."}, "for when an address is said to be missing or on another machine."},
{"name": verbFirstViews, "inputSchema": obj(map[string]any{}), {"name": verbFirstViews, "inputSchema": obj(map[string]any{}),
"description": "The tool runner's first views of the four states it reads of the indexer and the assigner (the " + "description": "The tool runner's first views: the assigner's assignments and seat-holders, and the catalog half's registrations, the gate's source and the view of each watched branch (which modules exist, each with its manifest): for each, the owner, the tool " +
"assigner's assignments and seat-holders, the indexer's manifests and modules): for each, the owner, the tool " +
"asked, the revision held and when it was made, how many keys, when it was asked and answered, how often " + "asked, the revision held and when it was made, how many keys, when it was asked and answered, how often " +
"it was asked and how often by hand, whether its condition is raised, and why it was last asked. Read-only."}, "it was asked and how often by hand, whether its condition is raised, and why it was last asked. Read-only."},
{"name": verbFirstAgain, "inputSchema": obj(map[string]any{ {"name": verbFirstAgain, "inputSchema": obj(map[string]any{
@@ -104,12 +104,13 @@ func discovery() []map[string]any {
}), "description": "Ask the tool runner's four first views again, now, and say what each answers. A hand act, recorded in " + }), "description": "Ask the tool runner's four first views again, now, and say what each answers. A hand act, recorded in " +
"the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."}, "the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."},
waitTool(), waitTool(),
approvalsTool(),
} }
} }
func isDiscovery(name string) bool { func isDiscovery(name string) bool {
switch name { switch name {
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait: case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait, verbApprovals:
return true return true
} }
return false return false
@@ -359,14 +360,14 @@ func indexOn(conn *bus.Conn, src Source, held announce.Known, before func(announ
return x, nil return x, nil
} }
// Source is where the console reads the four states of the indexer and the assigner it indexes the mesh by: the assigner's // Source is where the console reads what it indexes the mesh by: the assigner's assignments and seat-holders, by
// assignments and seat-holders, the indexer's modules and manifests, by `<owner>.<state>` (firstview.AssignmentsRead and the // `<owner>.<state>` (firstview.AssignmentsRead and SeatHoldersRead), and the modules of the catalog half with their entries.
// rest). The tool runner's is its first views (firstview.Views), never the owners' state (novox/hq ADR 0615 §1). // The tool runner's is its first views (firstview.Views), never the owners' state (novox/hq ADR 0615 §1; design 47 G §196, §594).
type Source interface { type Source interface {
// State is every current value of a state by key; an error says why it cannot be told yet. // State is every current value of a state by key; an error says why it cannot be told yet.
State(name string) (map[string][]byte, error) State(name string) (map[string][]byte, error)
// Entry is one value of a state, nil when it holds none. // Modules is every module the catalog half holds, by name, with its entry; an error says why it cannot be told yet.
Entry(name, key string) ([]byte, error) Modules() (map[string]meshcatalog.CatalogEntry, error)
} }
// seatHoldersOn reads the state seat-holders whole (novox/hq ADR 0571 clause 4), as firstview.SeatHoldersRead. // seatHoldersOn reads the state seat-holders whole (novox/hq ADR 0571 clause 4), as firstview.SeatHoldersRead.
@@ -374,22 +375,12 @@ func seatHoldersOn(src Source) (map[string][]byte, error) {
return src.State(firstview.SeatHoldersRead) return src.State(firstview.SeatHoldersRead)
} }
// The indexer's states the console reads (novox/hq ADR 0571 clauses 4 and 5): every module, and each manifest by its // manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces.
// digest, by the names a reader's manifest declares.
var (
modulesRead = firstview.ModulesRead
manifestsRead = firstview.ManifestsRead
)
// manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces. A manifest's
// digest never names another, so what is read once is kept.
type manifestFacts struct { type manifestFacts struct {
Tools []string `json:"tools"` Tools []string `json:"tools"`
Replaces map[string][]string `json:"replaces"` Replaces map[string][]string `json:"replaces"`
} }
var manifestsKept sync.Map // digest → manifestFacts
// recordsOn is every module the indexer holds, with where the assigner seat places it and whether it declares tools, // recordsOn is every module the indexer holds, with where the assigner seat places it and whether it declares tools,
// and every machine the assignments name (moduleNames, assigner.MachineNames). What cannot be read is said, one line // and every machine the assignments name (moduleNames, assigner.MachineNames). What cannot be read is said, one line
// per state, for the listing's not answering. // per state, for the listing's not answering.
@@ -411,18 +402,19 @@ func recordsOn(src Source) (recorded []recordedModule, machines []string, why []
on[m.Module] = append(on[m.Module], node) on[m.Module] = append(on[m.Module], node)
} }
} }
modules, err := src.State(modulesRead) entries, err := src.Modules()
if err != nil { if err != nil {
return nil, machines, append(why, modulesRead+" (which modules there are could not be read: "+err.Error()+")") return nil, machines, append(why, "the catalog half's modules (which modules there are could not be read: "+err.Error()+")")
} }
names, err := moduleNames(modules) names := make([]string, 0, len(entries))
if err != nil { for name := range entries {
return nil, machines, append(why, modulesRead+" ("+err.Error()+")") names = append(names, name)
} }
sort.Strings(names)
for _, name := range names { for _, name := range names {
r := recordedModule{Module: name, On: on[name]} r := recordedModule{Module: name, On: on[name]}
if m, err := meshcatalogclient.DecodeModule(modules[name]); err == nil && m.Manifest != "" { if m := entries[name].Manifest; len(m) > 0 {
facts, err := manifestOf(src, m.Manifest) facts, err := manifestOf(m)
if err != nil { if err != nil {
why = append(why, name+" (its manifest could not be read, so whether it answers tools is not known: "+err.Error()+")") why = append(why, name+" (its manifest could not be read, so whether it answers tools is not known: "+err.Error()+")")
} else { } else {
@@ -434,42 +426,16 @@ func recordsOn(src Source) (recorded []recordedModule, machines []string, why []
return recorded, machines, why return recorded, machines, why
} }
// moduleNames is every module the indexer holds, sorted, from its state `modules` as a reader holds its keys (key to // manifestOf is what the console takes of the manifest an entry of the catalog half carries whole (design 47 G §443).
// value; a deleted key absent). A key that is not a module's is refused, naming it. func manifestOf(manifest map[string]any) (manifestFacts, error) {
func moduleNames(keys map[string][]byte) ([]string, error) { raw, err := json.Marshal(manifest)
out := make([]string, 0, len(keys))
for k := range keys {
module, err := meshcatalogclient.ParseModulesKey(k)
if err != nil {
return nil, err
}
out = append(out, module)
}
sort.Strings(out)
return out, nil
}
// manifestOf is what the console takes of the manifest of a digest, read from the indexer's manifests once.
func manifestOf(src Source, digest string) (manifestFacts, error) {
if kept, ok := manifestsKept.Load(digest); ok {
return kept.(manifestFacts), nil
}
entry, err := src.Entry(manifestsRead, digest)
if err != nil {
return manifestFacts{}, err
}
if entry == nil {
return manifestFacts{}, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := meshcatalogclient.DecodeManifest(entry)
if err != nil { if err != nil {
return manifestFacts{}, err return manifestFacts{}, err
} }
var facts manifestFacts var facts manifestFacts
if err := json.Unmarshal([]byte(m.Manifest), &facts); err != nil { if err := json.Unmarshal(raw, &facts); err != nil {
return manifestFacts{}, err return manifestFacts{}, err
} }
manifestsKept.Store(digest, facts)
return facts, nil return facts, nil
} }
@@ -961,6 +927,21 @@ func schemaWithoutNode(raw json.RawMessage) map[string]any {
} }
// answerText is a discovery verb's answer as MCP content: JSON, indented. // answerText is a discovery verb's answer as MCP content: JSON, indented.
// callFailed is why a call failed, as mesh_call says it: a marked tool or verb that reaches no responder names `withheld`
// and the grants part's read (design 46 7-approval-mark §6), and a target known only from an earlier round says so.
func (s *Surface) callFailed(t target, err error) string {
why := whyItFailed(t.Address, err)
if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) {
// A marked subject withheld from a machine whose tool runner does not honour the mark (§6).
why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " +
"approval-mark/1: mesh-grants.grants_user_list names it under withheld"
}
if t.Stale != nil {
why += " (" + t.Stale.say() + ")"
}
return why
}
func answerText(v any) map[string]any { func answerText(v any) map[string]any {
var b strings.Builder var b strings.Builder
enc := json.NewEncoder(&b) enc := json.NewEncoder(&b)
@@ -985,6 +966,9 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
if name == verbWait { if name == verbWait {
return s.waitAnswer(ctx, args) return s.waitAnswer(ctx, args)
} }
if name == verbApprovals {
return s.approvalsAnswer(args)
}
x, err := s.index() x, err := s.index()
if err != nil { if err != nil {
return failure("the mesh's discovery failed: " + err.Error()) return failure("the mesh's discovery failed: " + err.Error())
@@ -1145,6 +1129,10 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
if t.Stale != nil { if t.Stale != nil {
described["found"] = t.Stale.say() described["found"] = t.Stale.say()
} }
if ok, source := s.markedTool(t); ok {
described["needs-approval"] = map[string]any{"source": source,
"says": "the first call asks the operator and runs nothing; the same call again within 30 minutes of the Approve runs once"}
}
return answerText(described) return answerText(described)
case verbCall: case verbCall:
@@ -1164,13 +1152,13 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing) got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing)
} }
if err != nil { if err != nil {
why := whyItFailed(t.Address, err) return failure(s.callFailed(t, err))
if t.Stale != nil { }
why += " (" + t.Stale.say() + ")" result, unread := resolveSent(s.conn, got.Result)
} content := []map[string]any{{"type": "text", "text": pretty(result)}}
return failure(why) if unread != "" {
content = append(content, map[string]any{"type": "text", "text": unread})
} }
content := []map[string]any{{"type": "text", "text": pretty(got.Result)}}
if got.Node != "" { if got.Node != "" {
content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node}) content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node})
} }
+99
View File
@@ -0,0 +1,99 @@
package console
import (
"log"
"strings"
"git.novox.be/novox/mesh-sdk/go/approval"
)
// verbApprovals is the mesh MCP server's tool that reads and steers what the gate asked on this machine (design 46
// 7-approval-mark §18).
const verbApprovals = "mesh_approvals"
// Approvals is the gate of this machine's tool runner, as mesh_approvals and mesh_describe read it.
type Approvals interface {
List() ([]approval.Listed, error)
Read(id string) (approval.Whole, error)
Applied() []approval.Marks
Describe(module, seat, name string) (bool, string)
Again(id string) error
Cancel(id string) error
// Fill is the state approvals' bytes against its cap, every machine's keys together.
Fill() (bytes uint64, max int64, err error)
}
func approvalsTool() map[string]any {
str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
return map[string]any{"name": verbApprovals, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{
"ask": str("an ask's id: its whole arguments and the act's digest"),
"record": str("an ask's id: ask mesh-hand-acts.warranted again for its run, recorded in the hand-act log first"),
"cancel": str("an ask's id: take that open ask back, recorded in the hand-act log first"),
"why": str("why, for record and cancel, kept in the hand-act log"),
}}, "description": "The calls that needed the operator's approval on this machine: with no argument, those asked in " +
"the last day, newest first, with their state and fingerprint, the marks the tool runner applies (each module's " +
"marked tools and verbs with their source and the catalog revision read) and its state's fill; `ask` reads one " +
"whole, `record` asks its hand-act again, `cancel` takes an open ask back."}
}
// approvalsAnswer is mesh_approvals.
func (s *Surface) approvalsAnswer(args map[string]any) map[string]any {
if s.Approvals == nil {
return failure("this tool runner has no approval gate")
}
str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) }
why := str("why")
if why == "" {
why = "asked by hand with no reason given"
}
act := func(id, what, cause string, do func(string) error) map[string]any {
// A hand act, recorded through the seat mesh-hand-acts before it is done (§18); one that cannot be recorded is refused.
log.Printf("[mesh-tools] hand act: %s %s ask %s: %s", s.who, what, id, why)
if err := s.recordAct(map[string]any{"what": what + " ask " + id, "why": why, "cause": cause, "verb": verbApprovals}); err != nil {
return failure("the hand act was not recorded through mesh-hand-acts, so nothing was done: " + err.Error())
}
if err := do(id); err != nil {
return failure(err.Error())
}
return answerText(map[string]any{"done": what + " ask " + id, "recorded": "mesh-hand-acts.hand-act"})
}
switch {
case str("record") != "":
return act(str("record"), "asked the hand-act of", "approval-record", s.Approvals.Again)
case str("cancel") != "":
return act(str("cancel"), "cancelled", "approval-cancel", s.Approvals.Cancel)
case str("ask") != "":
w, err := s.Approvals.Read(str("ask"))
if err != nil {
return failure(err.Error())
}
return answerText(w)
}
asks, err := s.Approvals.List()
out := map[string]any{"asks": asks, "marks": s.Approvals.Applied()}
if err != nil {
out["asks"], out["asks_why"] = []approval.Listed{}, "the state approvals cannot be read: "+err.Error()
}
if b, max, err := s.Approvals.Fill(); err != nil {
out["fill_why"] = err.Error()
} else {
out["fill"] = map[string]any{"bytes": b, "max_bytes": max, "of": "every machine's keys together"}
}
return answerText(out)
}
// marked is what mesh_describe and a call that reached no one say of a marked tool or verb (§6, §18).
func (s *Surface) marked(module, seat, name string) (bool, string) {
if s.Approvals == nil {
return false, ""
}
return s.Approvals.Describe(module, seat, name)
}
// markedTool is marked for a resolved address.
func (s *Surface) markedTool(t target) (bool, string) {
if t.Seat {
return s.marked("", t.Tool.Module, t.Tool.Name)
}
return s.marked(t.Tool.Module, "", t.Tool.Name)
}
+19 -3
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-sdk/go/views"
) )
@@ -16,6 +17,14 @@ type FirstViewer interface {
Again(ctx context.Context, why string) []error Again(ctx context.Context, why string) []error
} }
// entryLister is a FirstViewer that also keeps entries by fingerprint (design 47 G A-103): mesh_first_views lists them with their
// state and since when.
type entryLister interface{ Entries() any }
// ownBuilder is a FirstViewer that also says the build the tool runner itself was started with (design 47 G §205, §424):
// mesh_first_views names it, or says `own build unknown` and why.
type ownBuilder interface{ OwnBuild() process.Build }
// firstViewsAnswer is mesh_first_views and mesh_first_view_again. // firstViewsAnswer is mesh_first_views and mesh_first_view_again.
func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[string]any) map[string]any { func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[string]any) map[string]any {
fv, ok := s.src.(FirstViewer) fv, ok := s.src.(FirstViewer)
@@ -24,7 +33,14 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st
"another source") "another source")
} }
if name == verbFirstViews { if name == verbFirstViews {
return answerText(map[string]any{"first views": fv.Status()}) out := map[string]any{"first views": fv.Status()}
if ob, ok := fv.(ownBuilder); ok {
out["own build"] = ob.OwnBuild().String()
}
if el, ok := fv.(entryLister); ok {
out["entries"] = el.Entries()
}
return answerText(out)
} }
why, _ := args["why"].(string) why, _ := args["why"].(string)
if why = strings.TrimSpace(why); why == "" { if why = strings.TrimSpace(why); why == "" {
@@ -32,8 +48,8 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st
} }
// A hand act, recorded through the seat mesh-hand-acts (novox/hq ADR 0615 §6, ADR 0565 §10) before it is done, and in the // A hand act, recorded through the seat mesh-hand-acts (novox/hq ADR 0615 §6, ADR 0565 §10) before it is done, and in the
// runner's log. One that cannot be recorded is said, and the ask is not made. // runner's log. One that cannot be recorded is said, and the ask is not made.
log.Printf("[mesh-tools] hand act: %s asked the four first views again: %s", s.who, why) log.Printf("[mesh-tools] hand act: %s asked the first views again: %s", s.who, why)
if err := s.recordAct(map[string]any{"what": "asked the tool runner's four first views again", "why": why, if err := s.recordAct(map[string]any{"what": "asked the tool runner's first views again", "why": why,
"cause": "first-view-again", "verb": verbFirstAgain}); err != nil { "cause": "first-view-again", "verb": verbFirstAgain}); err != nil {
return failure("the hand act was not recorded through mesh-hand-acts, so the first views were not asked again: " + err.Error()) return failure("the hand act was not recorded through mesh-hand-acts, so the first views were not asked again: " + err.Error())
} }
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-sdk/go/views"
) )
@@ -14,12 +15,19 @@ import (
type viewer struct { type viewer struct {
busSource busSource
asked []string asked []string
build process.Build
} }
func (v *viewer) Status() []views.Status { func (v *viewer) Status() []views.Status {
return []views.Status{{Owner: "mesh-delivery-assigner", Tool: "assignments", State: "assignments", Revision: 4, Asks: 1}} return []views.Status{{Owner: "mesh-delivery-assigner", Tool: "assignments", State: "assignments", Revision: 4, Asks: 1}}
} }
// OwnBuild is the build the viewer was started with.
func (v *viewer) OwnBuild() process.Build { return v.build }
// Entries are the entries by fingerprint the viewer keeps (design 47 G A-103).
func (v *viewer) Entries() any { return []string{"fp:a asked"} }
func (v *viewer) Again(_ context.Context, why string) []error { func (v *viewer) Again(_ context.Context, why string) []error {
v.asked = append(v.asked, why) v.asked = append(v.asked, why)
return nil return nil
@@ -35,9 +43,16 @@ func TestTheConsoleShowsAndAsksAgainTheRunnersFirstViews(t *testing.T) {
v := &viewer{} v := &viewer{}
var recorded []map[string]any var recorded []map[string]any
s := &Surface{src: v, who: "desk.node-tools", Record: func(a map[string]any) error { recorded = append(recorded, a); return nil }} s := &Surface{src: v, who: "desk.node-tools", Record: func(a map[string]any) error { recorded = append(recorded, a); return nil }}
if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, "mesh-delivery-assigner") || !strings.Contains(got, "assignments") { if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, "mesh-delivery-assigner") || !strings.Contains(got, "assignments") || !strings.Contains(got, "fp:a asked") {
t.Errorf("mesh_first_views shows %s", got) t.Errorf("mesh_first_views shows %s", got)
} }
// The tool runner's own build is named, or said unknown with the reason (design 47 G §205, §424).
for build, want := range map[string]string{"abcd1234": "abcd1234", "": "own build unknown"} {
v.build = process.ReadOwnBuild(func(string) string { return build })
if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, `own build`) || !strings.Contains(got, want) {
t.Errorf("mesh_first_views with the build %q shows %s", build, got)
}
}
got := s.discover(context.Background(), "mesh_first_view_again", map[string]any{"why": "doubted"}) got := s.discover(context.Background(), "mesh_first_view_again", map[string]any{"why": "doubted"})
if len(v.asked) != 1 || v.asked[0] != "doubted" || !strings.Contains(text(got), "asked again") { if len(v.asked) != 1 || v.asked[0] != "doubted" || !strings.Contains(text(got), "asked again") {
t.Errorf("mesh_first_view_again asked %v and answered %s", v.asked, text(got)) t.Errorf("mesh_first_view_again asked %v and answered %s", v.asked, text(got))
@@ -8,37 +8,70 @@ import (
"testing" "testing"
) )
// novox/hq ADR 0367 §5: the tool runner's manifest names the families a session waits on today, one by one, // The tool runner's manifest names no events to wait on (novox/hq ADR 0670 §2): a session waits on any event by its
// and the asker's `decided` for the claude-code module alone. Adding one is a reviewed change to this file // name. It holds each change of design 34 every-event §11 (E-1): no `waits`, `operator-channel` in `uses` for the asks of
// (§6), so this list changes with it. The delivery's transitions are waited on under the gate's name alone (ADR 0450 §8, issues 1350 // marked waits, the per-machine state `approvals` in the old `state` section with its 48 hours and none in `requires`
// and 1406): today's module is forgotten. // (design 46 7-approval-mark §13), and the finding stream (design 47 G §387).
func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { func TestTheManifestNamesNoWaitsAndKeepsItsApprovals(t *testing.T) {
raw, err := os.ReadFile("../../module.json") raw, err := os.ReadFile("../../module.json")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
t.Fatal(err)
}
if _, ok := keys["waits"]; ok {
t.Error("the manifest still carries waits (novox/hq ADR 0670 §2)")
}
var m struct { var m struct {
Module string `json:"module"` Module string `json:"module"`
Waits []string `json:"waits"` Uses []string `json:"uses"`
Consumes []string `json:"consumes"` State []json.RawMessage `json:"state"`
Requires []json.RawMessage `json:"requires"`
Consumes []string `json:"consumes"`
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
} `json:"streams"`
} }
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status", if m.Module != "node-tools" || !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
"mesh-self-check.checked", "operator-channel.decided.claude-code"} t.Errorf("%s uses %v, want operator-channel (design 46 7-approval-mark §7)", m.Module, m.Uses)
if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) {
t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want)
} }
for _, w := range m.Waits { type state struct {
if strings.ContainsAny(w, "*>") { Name string `json:"name"`
t.Errorf("a family named by a wildcard: %s", w) PerMachine bool `json:"per-machine"`
TTLSeconds int `json:"ttl-seconds"`
}
var approvals []state
for _, r := range m.State {
var s state
if json.Unmarshal(r, &s) == nil && s.Name == "approvals" {
approvals = append(approvals, s)
} }
} }
// A wait is no consumer (ADR 0367 §2): what it consumes is the four views families of its first views (ADR 0615 §5, issue if want := []state{{Name: "approvals", PerMachine: true, TTLSeconds: 172800}}; !reflect.DeepEqual(approvals, want) {
// 1966 TLS-5) and the two events of the seat that serves the seat list (ADR 0636 §4), and nothing else. t.Errorf("the old state section's approvals is %+v, want %+v (design 46 7-approval-mark §13)", approvals, want)
wantConsumes := []string{"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.seat-holders", }
"mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"} for _, r := range m.Requires {
if strings.Contains(string(r), `"approvals"`) {
t.Errorf("approvals is asked in requires too: %s", r)
}
}
if len(m.Streams) != 1 || m.Streams[0].Name != "node-tools-findings" ||
!reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) {
t.Errorf("the finding stream is %+v, want node-tools-findings with the three finding events (design 47 G §387)", m.Streams)
}
// A wait is no consumer (ADR 0367 §2): what it consumes is the assigner's two views families (ADR 0615 §5, issue 1966
// TLS-5), the events of the catalog half's branch follower (design 47 G §196, §614: `catalog-revision`,
// `registration-changed`, and the gate's `source-moved`; never the indexer's `views.modules` or `views.manifests`), the two
// events of the seat that serves the seat list (ADR 0636 §4) and the keeper's `findings-asked`, and nothing else.
wantConsumes := []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "seat:mesh-module-assigner.views.assignments",
"seat:mesh-module-assigner.views.seat-holders", "mesh-delivery.source-moved"}
if !reflect.DeepEqual(m.Consumes, wantConsumes) { if !reflect.DeepEqual(m.Consumes, wantConsumes) {
t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes) t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes)
} }
+2
View File
@@ -63,6 +63,8 @@ type Surface struct {
Flat bool Flat bool
// Record, when set, records a hand act in place of the seat mesh-hand-acts' verb (a test's). // Record, when set, records a hand act in place of the seat mesh-hand-acts' verb (a test's).
Record func(args map[string]any) error Record func(args map[string]any) error
// Approvals is this machine's approval gate, read by mesh_approvals and mesh_describe; nil when the runner has none.
Approvals Approvals
} }
// MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape // MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape
@@ -11,18 +11,33 @@ import (
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
) )
// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529). // A marked tool or verb that reaches no responder names withheld and the grants part's read (design 46 7-approval-mark §6);
func TestAToolsOwn503IsNotNothingServes(t *testing.T) { // an unmarked one, or a marked one failing otherwise, does not. A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says
// nothing serves.
func TestARealNoRespondersStillSaysNothingServes(t *testing.T) {
got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable")) got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable"))
if strings.Contains(got, "nothing serves") || !strings.HasPrefix(got, "scanner.scan failed: ") || !strings.Contains(got, "503") { if strings.Contains(got, "nothing serves") || !strings.HasPrefix(got, "scanner.scan failed: ") || !strings.Contains(got, "503") {
t.Fatalf("got %q", got) t.Fatalf("got %q", got)
} }
}
func TestARealNoRespondersStillSaysNothingServes(t *testing.T) {
for _, err := range []error{bus.ErrNoResponders, fmt.Errorf("ask: %w", bus.ErrNoResponders), nats.ErrNoResponders} { for _, err := range []error{bus.ErrNoResponders, fmt.Errorf("ask: %w", bus.ErrNoResponders), nats.ErrNoResponders} {
if got := whyItFailed("a.b", err); !strings.HasPrefix(got, "nothing serves a.b") { if got := whyItFailed("a.b", err); !strings.HasPrefix(got, "nothing serves a.b") {
t.Fatalf("%v: got %q", err, got) t.Fatalf("%v: got %q", err, got)
} }
} }
s := &Surface{Approvals: &fakeApprovals{marked: "shell.wipe"}}
for _, c := range []struct {
target target
err error
hinted bool
}{
{target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, bus.ErrNoResponders, true},
{target{Address: "node-login-shell.execute", Seat: true, Tool: Tool{Module: "node-login-shell", Name: "execute"}}, bus.ErrNoResponders, false},
{target{Address: "shell.tidy", Tool: Tool{Module: "shell", Name: "tidy"}}, bus.ErrNoResponders, false},
{target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, errors.New("its own failure"), false},
} {
got := s.callFailed(c.target, c.err)
if hinted := strings.Contains(got, "approval-mark/1: mesh-grants.grants_user_list names it under withheld"); hinted != c.hinted {
t.Errorf("%s (%v): %q", c.target.Address, c.err, got)
}
}
} }
@@ -1,9 +1,18 @@
package console package console
import ( import (
"context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
// A refusal is said only for the bus's own refusals; a tool's answer that mentions authorization is the // A refusal is said only for the bus's own refusals; a tool's answer that mentions authorization is the
@@ -19,6 +28,45 @@ func TestARefusalIsSaidOnlyForTheBussOwn(t *testing.T) {
t.Errorf("%q read as a refusal: %v, want %v", msg, got, refusal) t.Errorf("%q read as a refusal: %v, want %v", msg, got, refusal)
} }
} }
// ADR 0666 §16: an answer a module sent as a reference is read from its sent store for the person asking; one the bus does
// not let the runner read is shown as its reference with its size and why it was not read.
t.Run("AnAnswerSentAsAReferenceIsReadForThePersonAsking", func(t *testing.T) {
nc, err := nats.Connect(meshtest.URL(t))
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, _ := jetstream.New(nc)
obs, err := js.CreateObjectStore(context.Background(), jetstream.ObjectStoreConfig{Bucket: "m_sent-objects"})
if err != nil {
t.Fatal(err)
}
conn, err := bus.Connect(bus.Credential{URL: meshtest.URL(t), Module: "console", Node: "anchor"})
if err != nil {
t.Fatal(err)
}
defer conn.Close()
body := []byte(`{"lines":"` + strings.Repeat("x", 9<<20) + `"}`)
sum := sha256.Sum256(body)
ref := stateobjects.Ref{Store: "m_sent-objects", Object: "tool.lines." + hex.EncodeToString(sum[:]), Size: int64(len(body)),
Digest: hex.EncodeToString(sum[:]), Form: stateobjects.FormVersion}
if _, err := obs.PutBytes(context.Background(), ref.Object, body); err != nil {
t.Fatal(err)
}
if got, unread := resolveSent(conn, stateobjects.Wrap(ref)); unread != "" || string(got) != string(body) {
t.Fatalf("read %d bytes of %d, %q", len(got), len(body), unread)
}
missing := ref
missing.Store = "gone_sent-objects"
wrapped := stateobjects.Wrap(missing)
got, unread := resolveSent(conn, wrapped)
if string(got) != string(wrapped) || !strings.Contains(unread, "9437") || !strings.Contains(unread, "gone_sent-objects") {
t.Fatalf("an unread answer showed %q and %q", got, unread)
}
if got, unread := resolveSent(conn, []byte(`{"a":1}`)); unread != "" || string(got) != `{"a":1}` {
t.Fatalf("a plain answer showed %q, %q", got, unread)
}
})
} }
// **A timeout says it is not a failure** (novox/hq issue 265): a push that outlasted the wait had // **A timeout says it is not a failure** (novox/hq issue 265): a push that outlasted the wait had
+31
View File
@@ -0,0 +1,31 @@
package console
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
"github.com/novox/mesh-tools/node-tools/internal/bus"
)
// resolveSent is the answer a person reads (ADR 0666 §16): an answer a module sent as a reference to an object of its
// sent store is read from it when the bus grants this runner's account the read (the standing grant the manifests give
// the callers of the verb), else shown as its reference with its size and why it was not read.
func resolveSent(conn *bus.Conn, result json.RawMessage) (json.RawMessage, string) {
ref, ok := stateobjects.ReadRef(result)
if !ok {
return result, ""
}
whole, err := stateobjects.Receive(result, func(r stateobjects.Ref) ([]byte, error) {
got, err := conn.SentGet(buscalls.ObjectsSentGetRequest{Store: r.Store, Object: r.Object, Size: r.Size, Digest: r.Digest})
if err != nil {
return nil, err
}
return got.Data, nil
})
if err != nil {
return result, fmt.Sprintf("the answer is %d bytes, held as the object %s of %s, and was not read: %v", ref.Size, ref.Object, ref.Store, err)
}
return whole, ""
}
@@ -1,9 +1,13 @@
package console package console
import ( import (
"encoding/json"
"sync" "sync"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
// busSource is a Source over a bus connection's own states, which a test's fixture mesh fills: the console's tests stand a // busSource is a Source over a bus connection's own states, which a test's fixture mesh fills: the console's tests stand a
@@ -37,3 +41,20 @@ func (b busSource) Entry(name, key string) ([]byte, error) {
} }
return entry.Value, nil return entry.Value, nil
} }
// Modules reads the test bed's entries of the catalog half (meshtest.ModulesRead), as the tool runner's first views hold them.
func (b busSource) Modules() (map[string]meshcatalog.CatalogEntry, error) {
raw, err := b.State(mt.ModulesRead)
if err != nil {
return nil, err
}
out := map[string]meshcatalog.CatalogEntry{}
for module, value := range raw {
var e meshcatalog.CatalogEntry
if err := json.Unmarshal(value, &e); err != nil {
return nil, err
}
out[module] = e
}
return out, nil
}
+206 -47
View File
@@ -2,9 +2,9 @@ package console
// mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367). // mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367).
// //
// **Only the families the tool runner was issued** (§2, §6): its membership names them, composed by the // **Any event, by its name** (novox/hq ADR 0670 §1, ADR 0671 §1; design 34 every-event §13, §26): the grants part
// mesh from the tool runner's manifest together with the grant that lets this account subscribe them. // grants this machine's tool runner every event subject, a seat's, a module's and a module stream's, and a name
// A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one. // becomes its subjects through mesh-sdk's buscompose.EventSubjects; no membership and no manifest names a list.
// **An event only wakes the session** (§4): what it says is read again from the verb that owns it. // **An event only wakes the session** (§4): what it says is read again from the verb that owns it.
import ( import (
@@ -13,9 +13,15 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"sort" "slices"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/approval"
) )
// WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last
@@ -25,20 +31,29 @@ var (
WaitLongest = 10 * time.Minute WaitLongest = 10 * time.Minute
) )
// waitMostNames is the most event names one call gives (design 34 every-event §13, §26): each name is six
// subscriptions for the call, so 16 names are 96, far above what a watcher names.
const waitMostNames = 16
func waitTool() map[string]any { func waitTool() map[string]any {
return map[string]any{"name": verbWait, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{ return map[string]any{"name": verbWait, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{
"subjects": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "subjects": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "one or more event families to wait on, by name, e.g. `mesh-knowledge.reviewed`; only " + "description": fmt.Sprintf("one or more event names, `<seat or module>.<event>`, such as "+
"those the mesh granted this machine's tool runner — a refusal names them"}, "`mesh-delivery-view.delivered`, at most %d; an event whose subject goes on after the name (an asker, a "+
"kind, a module) is heard by the name too", waitMostNames)},
"from": map[string]any{"type": "string", "enum": []string{"seat", "module", "stream"},
"description": "narrows every name to a seat's events, a module's or a module stream's, for an emitter " +
"whose name is more than one of them (optional)"},
"filter": map[string]any{"type": "object", "filter": map[string]any{"type": "object",
"description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367} (optional)"}, "description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367}: the filter narrows by the body's fields (optional)"},
"timeout": map[string]any{"type": "number", "timeout": map[string]any{"type": "number",
"description": fmt.Sprintf("seconds to wait: %d when absent, %d at most", int(WaitDefault.Seconds()), int(WaitLongest.Seconds()))}, "description": fmt.Sprintf("seconds to wait: %d when absent, %d at most", int(WaitDefault.Seconds()), int(WaitLongest.Seconds()))},
}, "required": []string{"subjects"}}, }, "required": []string{"subjects"}},
"description": "Wait for the first event of the named families that matches the filter, and answer it with its " + "description": "Wait for the first event of the named events that matches the filter, and answer it with its " +
"family, body and time — or say it timed out. Call it in a loop from a background command, and after every " + "name, where it came from, body and times — or say it timed out. Call it in a loop from a background command, " +
"answer read the state from the verb that owns it: an event only wakes you, and one published between two " + "and after every answer read the state from the verb that owns it: an event only wakes you, and one " +
"waits is not heard (novox/hq ADR 0367)."} "published between two waits is not heard (novox/hq ADR 0367). An operator's answer is read only with " +
"claude_code_answer, never from the event."}
} }
// waitAnswer answers one mesh_wait call. // waitAnswer answers one mesh_wait call.
@@ -56,51 +71,195 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
return failure("mesh_wait's filter is an object of body fields to the values they must equal") return failure("mesh_wait's filter is an object of body fields to the values they must equal")
} }
issued := map[string]string{} // name → subject from, _ := args["from"].(string)
if m := s.conn.Membership(s.conn.Module()); m != nil { if args["from"] != nil && from != "seat" && from != "module" && from != "stream" {
for _, w := range m.Waits { return failure(fmt.Sprintf("mesh_wait's from is seat, module or stream, not %v", args["from"]))
issued[w.Event] = w.Subject
}
} }
if len(issued) == 0 { if len(names) > waitMostNames {
return failure("this machine's tool runner was issued no event families to wait on: the mesh grants them from " + return failure(fmt.Sprintf("mesh_wait names at most %d events in one call, not %d: each name is six "+
"the tool runner's manifest (novox/hq ADR 0367 §2), and its membership names none yet") "subscriptions for the call (design 34 every-event §13)", waitMostNames, len(names)))
} }
byName := make([]string, 0, len(issued)) known, unchecked := s.emitters()
for name := range issued { var subjects, undeclared []string
byName = append(byName, name)
}
sort.Strings(byName)
family := map[string]string{} // subject → name
var subjects []string
for _, name := range names { for _, name := range names {
subject, granted := issued[name] subs, err := buscompose.EventSubjects(name, from)
if !granted { if err != nil {
return failure(fmt.Sprintf("mesh_wait may not wait on %q: this machine's tool runner is granted %s alone. "+ return failure("mesh_wait may not wait on it: " + err.Error())
"A family is added only by a reviewed change to the tool runner's manifest (node-tools' module.json, "+
"novox/hq ADR 0367 §6); no argument, call or setting adds one", name, strings.Join(byName, ", ")))
} }
if _, twice := family[subject]; !twice { // A misspelt emitter is refused, never waited on for ever; a known emitter's undeclared event is waited on and
subjects = append(subjects, subject) // its timed-out answer says so (design 34 every-event §14).
if unchecked == "" {
emitter, event, _ := strings.Cut(name, ".")
seatEvents, isSeat := known.seats[emitter]
moduleEvents, isModule := known.modules[emitter]
isSeat = isSeat && from != "module" && from != "stream"
isModule = isModule && from != "seat"
if !isSeat && !isModule {
return failure(fmt.Sprintf("mesh_wait may not wait on %q: %s is neither a seat nor a module of %s",
name, emitter, known.source))
}
if !(isSeat && declares(seatEvents, event)) && !(isModule && declares(moduleEvents, event)) {
undeclared = append(undeclared, name)
}
}
for _, subject := range subs {
if !slices.Contains(subjects, subject) {
subjects = append(subjects, subject)
}
} }
family[subject] = name
} }
heard, ok, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) 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 { if err != nil {
return failure("mesh_wait could not wait: " + err.Error()) return failure(err.Error())
} }
if !ok { return answerText(answer)
return answerText(map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names,
"read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"})
}
return answerText(map[string]any{
"heard": map[string]any{"subject": family[heard.Subject], "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)},
"read": "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)",
})
} }
// waitNames is the families a call names: a list of names, or one name. // 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 nil, fmt.Errorf("mesh_wait could not wait: %w", err)
}
var answer map[string]any
if !ok {
answer = map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names,
"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, ", "), source)
}
} else {
name, from, _ := buscompose.EventName(heard.Subject)
h := map[string]any{"subject": name, "from": from, "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)}
if heard.Published != "" {
h["published"] = heard.Published
}
read := "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)"
if strings.HasPrefix(name, "operator-channel.decided") {
read = "an event only wakes you: the operator's answer is read only with claude_code_answer, never from " +
"the event's body (novox/hq ADR 0670 §5)"
}
answer = map[string]any{"heard": h, "read": read}
}
if unchecked != "" {
answer["unchecked"] = "the names were not checked: " + unchecked
}
// A subscription the bus refused while others stood is said (design 34 every-event §16).
if len(refused) > 0 {
answer["refused"] = refused
}
return answer, nil
}
// halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait
// checks an event name's emitter against it (design 34 every-event §14).
type halfReader interface {
Halves() (map[string]meshcatalogclient.Half, error)
}
// knownEmitters is what the catalog half says emits what: each seat's declared events and each module's, and the
// source a refusal names, with its revisions.
type knownEmitters struct {
seats, modules map[string][]string
source string
}
// emitters is the catalog half's emitters, or why they are not checked: the half not yet read (its first view,
// mesh_first_views), or no half held at all.
func (s *Surface) emitters() (known knownEmitters, unchecked string) {
hr, ok := s.src.(halfReader)
if !ok {
return known, "this tool runner holds no catalog half"
}
halves, err := hr.Halves()
if err != nil {
return known, "the catalog half is not yet read (" + err.Error() + "); its first view is in mesh_first_views"
}
seatEvents := map[string][]string{} // a seat's events, as its definition in the seat list declares them
for _, st := range catalogue.Seats() {
seatEvents[st.Name] = st.Emits
}
known.seats, known.modules = map[string][]string{}, map[string][]string{}
branches := make([]string, 0, len(halves))
for b := range halves {
branches = append(branches, b)
}
slices.Sort(branches)
var revisions []string
for _, b := range branches {
half := halves[b]
revisions = append(revisions, fmt.Sprintf("revision %d of %s", half.Revision.Revision, b))
for _, d := range half.Revision.Definitions {
known.seats[d.Seat] = seatEvents[d.Seat]
}
for m, entry := range half.Entries {
if _, taken := known.modules[m]; !taken {
known.modules[m] = eventsDeclared(entry.Manifest)
}
}
}
known.source = "the catalog half (mesh-catalog.revision, " + strings.Join(revisions, "; ") + ")"
return known, ""
}
// eventsDeclared is the events a module's manifest declares: its `emits` and its streams' `events`.
func eventsDeclared(manifest map[string]any) []string {
var out []string
add := func(list any) {
items, _ := list.([]any)
for _, x := range items {
if e, ok := x.(string); ok {
out = append(out, e)
}
}
}
add(manifest["emits"])
streams, _ := manifest["streams"].([]any)
for _, st := range streams {
if m, ok := st.(map[string]any); ok {
add(m["events"])
}
}
return out
}
// declares says whether one of the declared events is the event a name gives, or one its name is followed by.
func declares(declared []string, event string) bool {
for _, d := range declared {
if buscompose.Overlaps(d, event) || buscompose.Overlaps(d, event+".>") {
return true
}
}
return false
}
// waitNames is the event names a call gives: a list of names, or one name.
func waitNames(given any) ([]string, error) { func waitNames(given any) ([]string, error) {
var names []string var names []string
switch v := given.(type) { switch v := given.(type) {
@@ -110,7 +269,7 @@ func waitNames(given any) ([]string, error) {
for _, x := range v { for _, x := range v {
name, ok := x.(string) name, ok := x.(string)
if !ok { if !ok {
return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-knowledge.reviewed; %v is not one", x) return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-delivery-view.delivered; %v is not one", x)
} }
names = append(names, name) names = append(names, name)
} }
@@ -119,7 +278,7 @@ func waitNames(given any) ([]string, error) {
names[i] = strings.TrimSpace(names[i]) names[i] = strings.TrimSpace(names[i])
} }
if len(names) == 0 { if len(names) == 0 {
return nil, fmt.Errorf("mesh_wait names at least one event family in `subjects`, such as mesh-knowledge.reviewed") return nil, fmt.Errorf("mesh_wait names at least one event in `subjects`, such as mesh-delivery-view.delivered")
} }
return names, nil return names, nil
} }
+468 -58
View File
@@ -3,32 +3,41 @@ package console
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
"sort" "sort"
"strings" "strings"
"sync"
"testing" "testing"
"time" "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/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
runnerapproval "github.com/novox/mesh-tools/node-tools/internal/approval"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" 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 // waitingRuntime is the tool runner on a machine, issued no waits (novox/hq ADR 0670 §2): the surface over its
// composes them from its manifest: the surface over its connection, and the mesh to publish on. // connection, and the mesh to publish on.
func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) { func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
t.Helper() t.Helper()
mesh := mt.New(t) mesh := mt.New(t)
m := mt.MembershipOf("node-tools", "desk", false, nil) mesh.Issue(t, 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") conn := connect(t, "node-tools", "desk")
mt.Until(t, func() error { mt.Until(t, func() error {
if got := conn.Membership("node-tools"); got == nil || len(got.Waits) == 0 { if conn.Membership("node-tools") == nil {
return os.ErrNotExist return os.ErrNotExist
} }
return nil return nil
@@ -36,6 +45,25 @@ func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh 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. // 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) { func callWait(s *Surface, args map[string]any) (text string, isError bool) {
raw, _ := json.Marshal(map[string]any{"name": verbWait, "arguments": args}) raw, _ := json.Marshal(map[string]any{"name": verbWait, "arguments": args})
@@ -50,8 +78,9 @@ func callWait(s *Surface, args map[string]any) (text string, isError bool) {
return strings.Join(parts, "\n"), isErr 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 // novox/hq ADR 0367 §1, ADR 0670 §1 (design 34 every-event §13, §15, §19): mesh_wait answers the first event that
// its subject, body and time, within a second of its publication, and ignores the others. // 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) { func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
s, mesh := waitingRuntime(t) s, mesh := waitingRuntime(t)
type answer struct { type answer struct {
@@ -59,18 +88,39 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
isErr bool isErr bool
at time.Time 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) done := make(chan answer, 1)
before := s.conn.Subscriptions()
go func() { go func() {
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-delivery-view.delivered"},
"filter": map[string]any{"kind": "decision", "number": 367}, "timeout": 20}) "filter": map[string]any{"module": "netcheck"}, "timeout": 20})
done <- answer{text, isErr, time.Now()} done <- answer{text, isErr, time.Now()}
}() }()
// Once it listens: another record's verdict, another family's event, then the one it waits for. // Once it listens: another module's delivery, another event, then the one it waits for, with its own time.
time.Sleep(300 * time.Millisecond) listening(t, s, before, 6)
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 366, "what": "approved"}) mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.delivered", map[string]any{"module": "grafana"})
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.proposed", map[string]any{"kind": "decision", "number": 367}) 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() published := time.Now()
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 367, "what": "approved"}) if err := nc.PublishMsg(msg); err != nil {
t.Fatal(err)
}
var got answer var got answer
select { select {
case got = <-done: case got = <-done:
@@ -83,32 +133,32 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
if got.at.Sub(published) > time.Second { if got.at.Sub(published) > time.Second {
t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published)) t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published))
} }
var heard struct { var heard heardAnswer
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 { if err := json.Unmarshal([]byte(got.text), &heard); err != nil {
t.Fatalf("not an answer: %s", got.text) 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() { if h := heard.Heard; h.Subject != "mesh-delivery-view.delivered" || h.From != "module" || h.Body["module"] != "netcheck" ||
t.Errorf("answered %+v, want record 367's verdict with its time", heard.Heard) 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 the subject it is granted on the asker's own `decided`, by the name a session asks for. // 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() { go func() {
text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided.claude-code", "timeout": 20}) text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided", "timeout": 20})
done <- answer{text, isErr, time.Now()} done <- answer{text, isErr, time.Now()}
}() }()
time.Sleep(300 * time.Millisecond) 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. // 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 { if err := connect(t, "messenger", "").Say("mesh.seat.operator-channel.event.decided.claude-code", []byte(`{"ask":"a1"}`)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
select { select {
case got = <-done: case got = <-done:
if got.isErr || !strings.Contains(got.text, `"subject": "operator-channel.decided.claude-code"`) { 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) t.Errorf("the asker's decided: %s", got.text)
} }
case <-time.After(10 * time.Second): case <-time.After(10 * time.Second):
@@ -125,7 +175,8 @@ func TestMeshWaitSaysItTimedOutAndKeepsNothing(t *testing.T) {
if took := time.Since(start); took < time.Second || took > 3*time.Second { if took := time.Since(start); took < time.Second || took > 3*time.Second {
t.Errorf("a one-second wait took %s", took) t.Errorf("a one-second wait took %s", took)
} }
if isErr || !strings.Contains(text, `"timed-out": true`) { // 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) t.Errorf("a wait that heard nothing: %s", text)
} }
if after := s.conn.Subscriptions(); after != before { if after := s.conn.Subscriptions(); after != before {
@@ -148,39 +199,357 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) {
} }
} }
// novox/hq ADR 0367 §2 and §6: an event outside the granted families is refused, naming the families and how // novox/hq ADR 0670 §1, §2 (design 34 every-event §13, §16, §19): mesh_wait refuses loudly and never loops: a name with
// one is added; no argument — a wildcard, another asker's `decided`, a subject spelled out — widens it. // a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a
func TestMeshWaitRefusesAnEventOutsideTheGrantedFamilies(t *testing.T) { // 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, _ := waitingRuntime(t)
for _, subjects := range []any{ var seventeen []any
[]any{"mesh-knowledge.proposed"}, for i := 0; i < 17; i++ {
[]any{"mesh-knowledge.reviewed", "gitea.forge.push"}, seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i))
[]any{"mesh-knowledge.*"}, }
[]any{"operator-channel.decided.shop"}, for _, c := range []struct {
[]any{"operator-channel.decided.*"}, args map[string]any
[]any{"mesh.mod.mesh-knowledge.event.reviewed"}, says string
[]any{">"}, }{
[]any{}, {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"},
} { } {
text, isErr := callWait(s, map[string]any{"subjects": subjects, "timeout": 1}) c.args["timeout"] = 1
if !isErr { start := time.Now()
t.Errorf("%v was waited on: %s", subjects, text) text, isErr := callWait(s, c.args)
continue 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 len(subjects.([]any)) > 0 && (!strings.Contains(text, "mesh-knowledge.reviewed") || !strings.Contains(text, "manifest")) { if took := time.Since(start); took > 500*time.Millisecond {
t.Errorf("%v refused without naming the families and how one is added: %s", subjects, text) t.Errorf("%v was refused after %s, want at once", c.args, took)
}
}
t.Run("the catalog half", func(t *testing.T) {
t.Parallel()
// 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)
}
}
})
t.Run("a bus that refuses every subscription", func(t *testing.T) {
t.Parallel()
// 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.
// Each group of rows has a runtime of its own, so the waits of the groups run side by side (A-311: the one-second floor of
// a wait's timeout stands, and no clock is injected).
for name, group := range map[string]func(*testing.T, *Surface, *mt.Mesh){
"no warrant": markedWaitsWithNoWarrant, "a warrant": markedWaitsOnAWarrant, "the catalog half unread": markedWaitsBeforeTheHalfIsRead,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
s, mesh := waitingRuntime(t)
group(t, s, mesh)
})
}
}
// markedWaitsWithNoWarrant are §37's rows of a wait with no warrant, against a gate on the test bus, the router's records written there as the router writes them.
func markedWaitsWithNoWarrant(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())
}
}
// markedWaitsOnAWarrant are §37's rows of a wait chosen while it waits and of one on a warrant.
func markedWaitsOnAWarrant(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)
}
// 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() < 1 {
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)
} }
} }
} }
// A tool runner issued no families waits on nothing, and says so. // markedWaitsBeforeTheHalfIsRead are §37's rows of a wait before the catalog half is read, and of one whose runner build is unknown.
func TestMeshWaitWithNoFamiliesIssuedWaitsOnNothing(t *testing.T) { func markedWaitsBeforeTheHalfIsRead(t *testing.T, s *Surface, mesh *mt.Mesh) {
mt.New(t) gw := newGatedWaits(t, s, mesh, "src2:runner")
s := NewSurface(connect(t, "node-tools", "desk"), nil, "desk.node-tools") g := gw.surface
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1}) took := func(args map[string]any) (string, bool, time.Duration) {
if !isErr || !strings.Contains(text, "no event families") { start := time.Now()
t.Errorf("a runtime issued no families: %s", text) text, isErr := callWait(g, args)
return text, isErr, time.Since(start)
} }
// 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", "")}
state := mt.NewState()
gate := approval.New(approval.Deps{Node: "desk", Runner: "node-tools", Build: build, Catalog: gw.cat, Wait: gw.cat.wait,
State: state, 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
},
Channel: &operatorchannelclient.GateChannel{State: state, KeyPrefix: "desk.", 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
},
ReadRecord: 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) (approval.Entry, bool, string) {
return approval.Entry{}, false, "not-found"
}
func (c *waitCatalog) Newest(string) (approval.Entry, bool) {
return approval.Entry{}, false
}
func (c *waitCatalog) Definitions() map[string]seatdef.Definition { return nil }
func (c *waitCatalog) Entries() []approval.Entry {
var out []approval.Entry
for _, e := range c.entries {
out = append(out, runnerapproval.Entry(e))
}
return out
} }
// The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded. // The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded.
@@ -194,7 +563,7 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
} }
} }
sort.Strings(names) sort.Strings(names)
want := []string{"mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"} 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) { if !reflect.DeepEqual(names, want) {
t.Errorf("announced %v, want %v", names, want) t.Errorf("announced %v, want %v", names, want)
} }
@@ -207,8 +576,49 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
t.Errorf("mesh_wait's schema has no %s: %s", word, schema) 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
// marked is the one `<module>.<tool>` its Describe says is marked.
marked 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(module, _, name string) (bool, string) {
return f.marked != "" && f.marked == module+"."+name, "entry " + module
}
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 // 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. // 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) { func TestMeshWaitEndsWhenItsClientHangsUp(t *testing.T) {
+196
View File
@@ -0,0 +1,196 @@
package firstview
import (
"context"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
runnerapproval "github.com/novox/mesh-tools/node-tools/internal/approval"
)
// Findings is the runner's held finding writer, the one the gate writes its findings through too (design 46 7-approval-mark
// §4, §16, §19); nil when it was not made.
func (v *Views) Findings() runnerapproval.HeldWriter {
if v.deps.Findings == nil {
return nil
}
return v.deps.Findings
}
// ApprovalCatalog is the catalog half as the gate reads its marks (design 46 7-approval-mark §5): the entries the branch
// follower holds, an entry by fingerprint asked of `mesh-catalog.entry` when no branch lists it, and the seat definitions
// the seat list holds (defs).
func (v *Views) ApprovalCatalog(defs func() map[string]seatdef.Definition) approval.Catalog {
return approvalCatalog{v: v, defs: defs}
}
type approvalCatalog struct {
v *Views
defs func() map[string]seatdef.Definition
}
func (c approvalCatalog) Ready() error {
if c.v.branches == nil {
return errNotFollowed
}
return c.v.branches.Ready()
}
func (c approvalCatalog) Revision() string {
if c.v.branches == nil {
return ""
}
for _, branch := range c.v.branches.Followed() {
if cat, ok := c.v.branches.Catalog(branch); ok {
if half, ok := cat.View(); ok {
return strconv.FormatInt(half.Revision.Revision, 10)
}
}
}
return ""
}
func (c approvalCatalog) Served(fingerprint string) (approval.Entry, bool, string) {
if c.v.branches == nil {
return approval.Entry{}, false, errNotFollowed.Error()
}
e, state := c.v.branches.Resolve(context.Background(), fingerprint)
return runnerapproval.Entry(e), state == meshcatalogclient.EntryAnswered, string(state)
}
func (c approvalCatalog) Newest(module string) (approval.Entry, bool) {
if c.v.branches == nil {
return approval.Entry{}, false
}
e, ok, err := c.v.branches.Entry(module)
return runnerapproval.Entry(e), ok && err == nil
}
func (c approvalCatalog) Definitions() map[string]seatdef.Definition {
if c.defs == nil {
return nil
}
return c.defs()
}
func (c approvalCatalog) Entries() []approval.Entry {
halves, err := c.v.Halves()
if err != nil {
return nil
}
var out []approval.Entry
for _, half := range halves {
for _, e := range half.Entries {
out = append(out, runnerapproval.Entry(e))
}
}
return out
}
type notFollowed struct{}
func (notFollowed) Error() string { return "the catalog half is not followed by this runner" }
var errNotFollowed error = notFollowed{}
// heardOne wakes every wait on the picture.
func (v *Views) heardOne() {
v.mu.Lock()
defer v.mu.Unlock()
if v.heard != nil {
close(v.heard)
}
v.heard = make(chan struct{})
}
// heardNext is closed at the next event heard.
func (v *Views) heardNext() <-chan struct{} {
v.mu.Lock()
defer v.mu.Unlock()
if v.heard == nil {
v.heard = make(chan struct{})
}
return v.heard
}
// WaitCatalog waits within the bound for the catalog half's first view of every watched branch (design 46 7-approval-mark
// §19), waking at each event the runner hears and at each first view answered; nil once it is read, else why not.
func (v *Views) WaitCatalog(within time.Duration) error {
c := approvalCatalog{v: v}
timer := time.NewTimer(within)
defer timer.Stop()
for {
next := v.heardNext()
err := c.Ready()
if err == nil {
return nil
}
var ready []<-chan struct{}
if v.branches != nil {
for _, branch := range v.branches.Followed() {
if cat, ok := v.branches.Catalog(branch); ok {
select {
case <-cat.Ready(): // read already: waits on nothing
default:
ready = append(ready, cat.Ready())
}
}
}
}
if !waitAny(next, timer.C, ready) {
return c.Ready()
}
}
}
// waitAny waits for next or one of ready, true, or for the bound, false.
func waitAny(next <-chan struct{}, bound <-chan time.Time, ready []<-chan struct{}) bool {
woke := make(chan struct{}, 1)
stop := make(chan struct{})
defer close(stop)
for _, r := range ready {
go func(r <-chan struct{}) {
select {
case <-r:
woke <- struct{}{}
case <-stop:
}
}(r)
}
select {
case <-next:
case <-woke:
case <-bound:
return false
}
return true
}
// OnSeatHolder calls f whenever the assigner's seat-holders view changes for the seat (lead answer A-157: the gate asks
// mesh-hand-acts.warranted again when the hand-act seat's holder is heard again).
func (v *Views) OnSeatHolder(seat string, f func()) {
v.mu.Lock()
defer v.mu.Unlock()
if v.onKey == nil {
v.onKey = map[string][]func(){}
}
k := "seat-holders " + seat
v.onKey[k] = append(v.onKey[k], f)
}
// heardKey calls the hearers of a key of an owner's state just changed: a seat's holders, under the seat's own key and
// its `<seat>.<node>` keys.
func (v *Views) heardKey(state, key string) {
seat, _, _ := strings.Cut(key, ".")
v.mu.Lock()
fs := append([]func(){}, v.onKey[state+" "+seat]...)
v.mu.Unlock()
for _, f := range fs {
go f()
}
}
+422 -80
View File
@@ -1,66 +1,79 @@
// Package firstview is the tool runner's picture of the four states it reads of the indexer and the assigner: the assigner's // Package firstview is the tool runner's picture of what it reads of the assigner and the catalog half: the assigner's
// assignments and seat-holders, the indexer's manifests and modules (novox/hq ADR 0615 §1, §3; ADR 0621 §2; issue 1966 TLS-5). // assignments and seat-holders (novox/hq ADR 0615 §1, §3; ADR 0621 §2; issue 1966 TLS-5), and which modules exist with the
// manifest each entry carries, followed per watched branch through meshcatalogclient's branch follower on this process's own
// consumer (design 47 G §196, §594, §614; lead answers A-5, A-27, A-103), never the indexer's state `modules` or `manifests`.
// //
// **It never reads the owners' state.** At start it asks each owner's first-view tool until answered (the owner answers in a // **It never reads the owners' state.** At start it asks each owner's first-view tool until answered (the owner answers in a
// runtime of its own, never through this one: ADR 0621 §1), then follows the owner's views family, one event per changed key; // runtime of its own, never through this one: ADR 0621 §1), then follows the owner's views family, one event per changed key;
// a gap in the revisions, or an owner whose state was made again, asks the first view once more, said in the log. Past two // a gap in the revisions, or an owner whose state was made again, asks the first view once more, said in the log. Past two
// minutes without an answer it writes a finding in its own state `findings`, which the condition keeper raises and clears. // minutes without an answer it writes a finding through its held finding writer (design 47 G A-27, A-28), which keeps it in its
// own state `findings` and says it; the answer clears it.
package firstview package firstview
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-sdk/go/views"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
) )
// The owners and their first-view tools (the tools' addresses are `<owner>.<tool>`). // AssignerSeat is the seat whose holder's views the runner follows: the holder is read from the catalog half's claims of the
// seat, never named in code (design 47 G §616, §428), and the names the console reads the two states by are the seat's
// (`<seat>.<state>`; the manifest's `consumes` names their views families by the seat, as `seat:<seat>.views.<state>`).
const AssignerSeat = meshmoduleassigner.Seat
const ( const (
Indexer = "mesh-delivery-indexer" AssignmentsRead = AssignerSeat + ".assignments"
Assigner = "mesh-delivery-assigner" SeatHoldersRead = AssignerSeat + ".seat-holders"
) )
// The names the console reads the four states by (`<owner>.<state>`), as the manifest's `consumes` names their views // Spec is one first view the runner holds of the seat's holder: its first-view tool and the state.
// families (no seat's client names a holder, novox/hq ADR 0625 §4; TestTheFirstViewsAreTheManifestsViews). type Spec struct{ Tool, State string }
const (
AssignmentsRead = Assigner + ".assignments"
SeatHoldersRead = Assigner + ".seat-holders"
ModulesRead = Indexer + ".modules"
ManifestsRead = Indexer + ".manifests"
)
// Spec is one first view the runner holds. // Specs are the two states asked of the holder's tool; the catalog half's views are adopted by the branch follower.
type Spec struct{ Owner, Tool, State string }
// Specs are the four.
var Specs = []Spec{ var Specs = []Spec{
{Assigner, "assignments", "assignments"}, {"assignments", "assignments"},
{Assigner, "seat-holders", "seat-holders"}, {"seat-holders", "seat-holders"},
{Indexer, "manifests", "manifests"},
{Indexer, "modules", "modules"},
} }
// Name is the name a state is read by, `<owner>.<state>`: AssignmentsRead, SeatHoldersRead and the indexer's. // Name is the name a state is read by, `<seat>.<state>`: AssignmentsRead and SeatHoldersRead.
func (s Spec) Name() string { return s.Owner + "." + s.State } func (s Spec) Name() string { return AssignerSeat + "." + s.State }
// Views is the four followers. // Views is the first views: the assigner's two, and the catalog half's per watched branch.
type Views struct { type Views struct {
logf func(string, ...any) logf func(string, ...any)
followers map[string]*views.Follower // by Spec.Name() mu sync.Mutex
byState map[string]*views.Follower // by the state an event names followers map[string]*views.Follower // by `<owner>.<state>`
byState map[string]*views.Follower // by the state an event names, the owners' views families
adopted map[string]*adoption // by `<owner>.<state>`, the views the branch follower adopted
hearers map[string][]*adoption // event key `<seat>.<event>` -> the adopted views that hear it
branches *meshcatalogclient.Branches
holder *holding // the module that claims the assigner seat, once the catalog half names it
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
deps Deps deps Deps
// heard is closed and made again at every event heard, so a wait for the picture wakes on events, never on a timer.
heard chan struct{}
// onKey are the hearers of one key of an owner's state, by `<state> <key>` (OnSeatHolder).
onKey map[string][]func()
} }
// FindingsState is the runner's own state the findings are written in. // FindingsState is the runner's own state the findings are written in.
@@ -74,70 +87,147 @@ type Deps struct {
Ask func(address string, body any) (json.RawMessage, error) Ask func(address string, body any) (json.RawMessage, error)
// Consume hands each event of the owners' views families to deliver, until the stop is called. // Consume hands each event of the owners' views families to deliver, until the stop is called.
Consume func(deliver func(bus.Envelope) error) (stop func(), err error) Consume func(deliver func(bus.Envelope) error) (stop func(), err error)
// PutFinding and DeleteFinding keep and remove a finding in the runner's own state. // Findings is the runner's held finding writer (views.HeldWriter): a follower's unanswered first view is written and cleared
PutFinding func(key string, value json.RawMessage) error // through it with the contract's Finding (design 47 G A-28). Nil says it in the log only.
DeleteFinding func(key string) error Findings FindingWriter
// StartFindings starts that writer, and its stop ends it; nil starts none.
StartFindings func(ctx context.Context) (stop func(), err error)
// Build is the build this process was started with (process.OwnBuild, read once at the start): its fingerprint, or
// why it is unknown (design 47 G §205, §424).
Build process.Build
// Node is the machine the runner is on, named in its findings' subject.
Node string
// Bound and Wait are the follower's (zero: two minutes, five seconds). // Bound and Wait are the follower's (zero: two minutes, five seconds).
Bound, Wait time.Duration Bound, Wait time.Duration
} }
// Start asks the four first views, each until answered, and follows the owners' views families on the runner's own // FindingWriter is the writer the runner's findings go through: views.HeldWriter over the contract's Finding.
// consumers. It returns at once: the picture fills as the owners answer. type FindingWriter interface {
func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (*Views, func()) { Write(ctx context.Context, finding condseat.Finding) error
return StartWith(parent, DepsOf(conn), logf) Clear(ctx context.Context, scope, id, kind, why string) error
} }
// DepsOf is the views' reach through the runner's own connection: its tool calls, its consumer and its findings state. // Start asks the first views, each until answered, and follows the owners' views families on the runner's own
func DepsOf(conn *bus.Conn) Deps { // consumers. It returns at once: the picture fills as the owners answer.
func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (*Views, func()) {
return StartWith(parent, DepsOf(conn, logf), logf)
}
// DepsOf is the views' reach through the runner's own connection: its tool calls, its consumer and its held finding writer, whose
// state adapter, JetStream and node are this connection's, in this one place (design 47 G A-16, A-27).
func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps {
me := conn.Module() me := conn.Module()
return Deps{Module: me, d := Deps{Module: me, Node: conn.Node(), Build: process.OwnBuild(),
Ask: func(address string, body any) (json.RawMessage, error) { Ask: func(address string, body any) (json.RawMessage, error) {
got, err := conn.Ask(address, body, "") got, err := conn.Ask(address, body, "")
return got.Result, err return got.Result, err
}, },
Consume: func(deliver func(bus.Envelope) error) (func(), error) { return conn.ConsumeAs(me, deliver) }, Consume: func(deliver func(bus.Envelope) error) (func(), error) { return conn.ConsumeAs(me, deliver) },
PutFinding: func(key string, value json.RawMessage) error {
_, err := conn.StatePut(me, FindingsState, key, value)
return err
},
DeleteFinding: func(key string) error { return conn.StateDelete(me, FindingsState, key) },
} }
js, err := conn.JetStream()
if err != nil {
logf("[mesh-tools] the runner's findings are not written (%v): the first views' findings are in the log only", err)
return d
}
w, err := views.NewHeldWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{
Asked: condseat.SubjectFindingsAsked, Module: me, Node: conn.Node(), State: findingsState{conn: conn, module: me}, JS: js, Logf: logf,
Kinds: append([]string{views.UnansweredKind}, approval.Kinds...)})
if err != nil {
logf("[mesh-tools] the runner's findings are not written (%v): the first views' findings are in the log only", err)
return d
}
d.Findings = w
d.StartFindings = func(ctx context.Context) (func(), error) { return w.Close, w.Start(ctx) }
return d
} }
// findingsState is the runner's own state `findings` (views.FindingState) over its connection.
type findingsState struct {
conn *bus.Conn
module string
}
func (s findingsState) Get(key string) (*stdio.StateEntry, error) {
e, err := s.conn.StateGet(s.module, FindingsState, key)
if err != nil || e == nil {
return nil, err
}
return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil
}
func (s findingsState) Put(key string, value any) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
return s.conn.StatePut(s.module, FindingsState, key, raw)
}
func (s findingsState) Delete(key string) error {
return s.conn.StateDelete(s.module, FindingsState, key)
}
func (s findingsState) Keys() ([]string, error) { return s.conn.StateKeys(s.module, FindingsState) }
// StartWith is Start over deps. // StartWith is Start over deps.
func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*Views, func()) { func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*Views, func()) {
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{}, deps: d} v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{},
for _, s := range Specs { adopted: map[string]*adoption{}, hearers: map[string][]*adoption{}, deps: d}
s := s stopFindings := func() {}
call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(s.Owner+"."+tool, body) } if d.StartFindings != nil {
f := &views.Follower{Owner: s.Owner, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: logf, if stop, err := d.StartFindings(ctx); err != nil {
Bound: d.Bound, Wait: d.Wait, logf("[mesh-tools] the runner's finding writer does not start (%v): the first views' findings are in the log only", err)
Raise: func(st views.Status) { v.finding(st, true) }, } else {
Clear: func(st views.Status) { v.finding(st, false) }} stopFindings = stop
v.followers[s.Name()] = f }
v.byState[s.State] = f }
v.wg.Add(1) // The catalog half is followed by the shared branch follower on this process's own wiring (design 47 G §614, A-103): the
go func() { defer v.wg.Done(); _ = f.Start(ctx) }() // registrations, the gate's source and a view per watched branch, each heard on this consumer.
branches, err := meshcatalogclient.NewBranches(wiring{v}, func(key string, body any) (json.RawMessage, error) { return d.Ask(key, body) })
if err != nil {
logf("[mesh-tools] the catalog half is not followed (%v): the console cannot say which modules exist", err)
} else {
branches.Logf = func(format string, args ...any) { logf("[mesh-tools] "+format, args...) }
branches.Changed = func(_, module string, entry json.RawMessage, deleted bool, _ time.Time) {
v.claimed(module, entry, deleted)
}
v.branches = branches
} }
stop, err := d.Consume(v.hear) stop, err := d.Consume(v.hear)
if err != nil { if err != nil {
logf("[mesh-tools] the owners' views are not followed (%v): the pictures are the first views alone until the runner starts again", err) logf("[mesh-tools] the owners' views are not followed (%v): the pictures are the first views alone until the runner starts again", err)
stop = func() {} stop = func() {}
} }
return v, func() { stop(); cancel(); v.wg.Wait() } return v, func() { stop(); cancel(); v.wg.Wait(); stopFindings() }
} }
// hear applies one views event; a gap asks the first view once more. // hear applies one views event; a gap asks the first view once more. Every event heard wakes what waits on the picture
// (heardOne).
func (v *Views) hear(env bus.Envelope) error { func (v *Views) hear(env bus.Envelope) error {
defer v.heardOne()
v.mu.Lock()
hs := append([]*adoption(nil), v.hearers[env.Key]...)
v.mu.Unlock()
if len(hs) > 0 { // an event of the catalog half or the gate's source, heard by the views the branch follower adopted
for _, a := range hs {
if err := a.hear(v.ctx, env.Body); err != nil {
v.logf("[mesh-tools] %v", err)
}
}
return nil
}
ev, err := views.DecodeEvent(env.Body) ev, err := views.DecodeEvent(env.Body)
if err != nil { if err != nil {
return nil // not a views event: nothing to hold, nothing to retry return nil // not a views event: nothing to hold, nothing to retry
} }
v.mu.Lock()
f := v.byState[ev.State] f := v.byState[ev.State]
v.mu.Unlock()
if f == nil || f.Owner != ev.Owner { if f == nil || f.Owner != ev.Owner {
return nil return nil
} }
defer v.heardKey(ev.State, ev.Key)
if gap, why := f.Apply(ev); gap { if gap, why := f.Apply(ev); gap {
v.logf("[mesh-tools] a gap in %s's %s (%s): its first view %s is asked once more", f.Owner, f.State, why, f.Tool) v.logf("[mesh-tools] a gap in %s's %s (%s): its first view %s is asked once more", f.Owner, f.State, why, f.Tool)
go func() { _ = f.Again(v.ctx, "a gap: "+why) }() go func() { _ = f.Again(v.ctx, "a gap: "+why) }()
@@ -145,27 +235,36 @@ func (v *Views) hear(env bus.Envelope) error {
return nil return nil
} }
// finding writes or removes the runner's finding of a first view unanswered past its bound, in its own state. // finding writes or clears the runner's finding of a first view unanswered past its bound, through its writer (design 47 G A-28).
func (v *Views) finding(st views.Status, raised bool) { func (v *Views) finding(f condseat.Finding, tool string, raised bool) {
key, value, err := conditionOf(views.UnansweredFinding(v.deps.Module, st, time.Now())) if v.deps.Findings == nil {
if err != nil { v.logf("[mesh-tools] %s", f.Summary)
v.logf("[mesh-tools] the finding of the first view %s is not made: %v", st.Tool, err)
return return
} }
var err error
if raised { if raised {
err = v.deps.PutFinding(key, value) err = v.deps.Findings.Write(context.Background(), f)
} else { } else {
err = v.deps.DeleteFinding(key) err = v.deps.Findings.Clear(context.Background(), f.Scope, f.ID, f.Kind, "the owner answered")
} }
if err != nil { if err != nil {
v.logf("[mesh-tools] the finding of the first view %s is not kept: %v", st.Tool, err) v.logf("[mesh-tools] the finding of the first view %s is not kept: %v", tool, err)
} }
} }
// State is the picture of the state spec names (`<owner>.<state>`), every current value by key; an error says the owner has // State is the picture of the state spec names (`<owner>.<state>`), every current value by key; an error says the owner has
// not answered yet, so nothing is claimed of it. // not answered yet, so nothing is claimed of it.
func (v *Views) State(name string) (map[string][]byte, error) { func (v *Views) State(name string) (map[string][]byte, error) {
v.mu.Lock()
if state, ok := strings.CutPrefix(name, AssignerSeat+"."); ok { // the seat's name stands for its holder's
if v.holder == nil {
v.mu.Unlock()
return nil, fmt.Errorf("the catalog half names no holder of the seat %s yet, so %s has no first view", AssignerSeat, name)
}
name = v.holder.module + "." + state
}
f := v.followers[name] f := v.followers[name]
v.mu.Unlock()
if f == nil { if f == nil {
return nil, fmt.Errorf("the tool runner holds no first view of %s", name) return nil, fmt.Errorf("the tool runner holds no first view of %s", name)
} }
@@ -193,17 +292,40 @@ func (v *Views) Entry(name, key string) ([]byte, error) {
// Status is each first view as it stands, by `<owner>.<state>`, sorted. // Status is each first view as it stands, by `<owner>.<state>`, sorted.
func (v *Views) Status() []views.Status { func (v *Views) Status() []views.Status {
var out []views.Status var out []views.Status
for _, f := range v.followers { for _, f := range v.all() {
out = append(out, f.Status()) out = append(out, f.Status())
} }
sort.Slice(out, func(i, j int) bool { return out[i].Owner+out[i].State < out[j].Owner+out[j].State }) sort.Slice(out, func(i, j int) bool { return out[i].Owner+out[i].State < out[j].Owner+out[j].State })
return out return out
} }
// OwnBuild is the build the tool runner was started with: the fingerprint of the want its node-engine applied, or why it is
// unknown (`own build unknown: started without MESH_FINGERPRINT`).
func (v *Views) OwnBuild() process.Build { return v.deps.Build }
// Entries are the entries by fingerprint the catalog half's follower keeps, with their state and since when (design 47 G A-103).
func (v *Views) Entries() any {
if v.branches == nil {
return []meshcatalogclient.EntryStatus{}
}
return v.branches.EntryStatuses()
}
// all is every first view held now: the owners', then the adopted ones.
func (v *Views) all() []*views.Follower {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*views.Follower, 0, len(v.followers))
for _, f := range v.followers {
out = append(out, f)
}
return out
}
// Again asks every first view once more, by hand, for why. // Again asks every first view once more, by hand, for why.
func (v *Views) Again(ctx context.Context, why string) []error { func (v *Views) Again(ctx context.Context, why string) []error {
var errs []error var errs []error
for _, f := range v.followers { for _, f := range v.all() {
if err := f.Again(ctx, why); err != nil { if err := f.Again(ctx, why); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
@@ -211,20 +333,240 @@ func (v *Views) Again(ctx context.Context, why string) []error {
return errs return errs
} }
// conditionOf is a finding in the layout of the seat mesh-conditions' state findings (its contract, ADR 0634 §1), about a // unansweredFinding is the finding of a first view unanswered past its bound, in the contract's form (design 47 G A-28): about the
// module, a warning. // runner's module, a warning, its id the view's name escaped as a key's name is.
func conditionOf(f views.Finding) (string, []byte, error) { func unansweredFinding(module, node string, st views.Status, at time.Time) condseat.Finding {
key := conditions.Key(scopeModule, f.ID, f.Kind) return condseat.Finding{Kind: views.UnansweredKind, Scope: scopeModule, ID: keys.EscapeName(module + "." + st.Owner + "." + st.State),
v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning, Severity: condseat.FindingSeverityWarning, Source: "views", Observer: module,
Subject: condseat.Subject{Scope: scopeModule, ID: f.ID}, Summary: f.Summary, Headline: f.Headline, Subject: condseat.Subject{Scope: scopeModule, ID: module, Machine: node},
Explanation: f.Explanation, Source: f.Observer + "." + f.Kind, At: f.At.UTC(), Summary: fmt.Sprintf("%s has no first view of %s's %s: %s has not answered since %s (%s)", module, st.Owner, st.State, st.Tool,
Evidence: []condseat.Evidence{{At: f.At.UTC(), Said: f.Summary}}} st.Asked.Format(time.RFC3339), strings.TrimSpace(st.Error)),
if err := conditions.CheckFinding(v); err != nil { Headline: fmt.Sprintf("%s waits for %s", module, st.Owner), At: at.UTC()}
return "", nil, err
}
raw, err := json.Marshal(v)
return key, raw, err
} }
// scopeModule is the scope of a finding about a module. // scopeModule is the scope of a finding about a module.
const scopeModule = "module" const scopeModule = "module"
// viewWaitsFinding is the finding of a view waited for past views.WaitsBound, in the contract's form: about the runner's module,
// a warning, its id the view's name escaped as a key's name is.
func viewWaitsFinding(module, node string, st views.Status, at time.Time) condseat.Finding {
return condseat.Finding{Kind: views.ViewWaitsKind, Scope: scopeModule, ID: keys.EscapeName(module + "." + st.Owner + "." + st.State),
Severity: condseat.FindingSeverityWarning, Source: "views", Observer: module,
Subject: condseat.Subject{Scope: scopeModule, ID: module, Machine: node},
Summary: fmt.Sprintf("%s waits for the view %s.%s at revision %s since %s and acts on nothing new until it holds it; it holds %s",
module, st.Owner, st.State, st.WaitsFor, st.WaitingSince.UTC().Format(time.RFC3339), orNone(st.View)),
Headline: fmt.Sprintf("%s waits for a view of %s", module, st.Owner), At: at.UTC()}
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
// adoption is a view the branch follower put on this process's consumer: its follower, the event it hears and its handler.
type adoption struct {
name, key string
f *views.Follower
hear func(context.Context, []byte) error
cancel context.CancelFunc
}
// wiring is the branch follower's wiring over this process's own consumer (meshcatalogclient.Wiring; design 47 G §614, A-103):
// an adopted view is held with the owners', takes its first view at once, hears its seat's event on the runner's consumer,
// writes its findings through the runner's held writer, and a dropped one clears them.
type wiring struct{ v *Views }
func (w wiring) Adopt(f *views.Follower, seat, event string, hear func(context.Context, []byte) error) error {
v := w.v
if f == nil || f.Owner == "" || f.State == "" || seat == "" || event == "" || hear == nil {
return fmt.Errorf("the tool runner adopts a view that names its owner, state, seat's event and handler")
}
name := f.Owner + "." + f.State
key := seat + "." + event
v.mu.Lock()
if v.followers[name] != nil {
v.mu.Unlock()
return fmt.Errorf("%s is followed already", name)
}
if f.Logf == nil {
f.Logf = v.logf
}
if f.Bound == 0 {
f.Bound = v.deps.Bound
}
if f.Wait == 0 {
f.Wait = v.deps.Wait
}
held := func() bool { v.mu.Lock(); defer v.mu.Unlock(); return v.followers[name] == f }
f.Raise = func(st views.Status) {
if held() {
v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true)
}
}
f.Clear = func(st views.Status) {
v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
}
f.RaiseWaits = func(st views.Status) {
if held() {
v.finding(viewWaitsFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true)
}
}
f.ClearWaits = func(st views.Status) {
v.finding(viewWaitsFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
}
ctx, cancel := context.WithCancel(v.ctx)
a := &adoption{name: name, key: key, f: f, hear: hear, cancel: cancel}
v.followers[name] = f
v.adopted[name] = a
v.hearers[key] = append(v.hearers[key], a)
v.mu.Unlock()
v.wg.Add(1)
go func() { defer v.wg.Done(); _ = f.Start(ctx) }()
return nil
}
func (w wiring) Drop(owner, state string) error {
v := w.v
name := owner + "." + state
v.mu.Lock()
a := v.adopted[name]
if a == nil {
v.mu.Unlock()
return fmt.Errorf("%s is not a view this tool runner adopted", name)
}
delete(v.followers, name)
delete(v.adopted, name)
v.hearers[a.key] = slices.DeleteFunc(v.hearers[a.key], func(o *adoption) bool { return o == a })
v.mu.Unlock()
a.cancel()
st := a.f.Status()
if st.Raised {
v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
}
if st.WaitsRaised {
v.finding(viewWaitsFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
}
return nil
}
// Halves is the catalog half of each followed branch, by branch: its revision, with the seat definitions it was judged
// with, and its entries; mesh_wait checks an event name's emitter against them (design 34 every-event §14). An error says
// the half has no first view yet, never that no module exists.
func (v *Views) Halves() (map[string]meshcatalogclient.Half, error) {
if v.branches == nil {
return nil, fmt.Errorf("the tool runner does not follow the catalog half")
}
if err := v.branches.Ready(); err != nil {
return nil, err
}
out := map[string]meshcatalogclient.Half{}
for _, branch := range v.branches.Followed() {
if c, ok := v.branches.Catalog(branch); ok {
out[branch], _ = c.View() // a branch at none-yet is ready and empty (§614)
}
}
return out, nil
}
// Modules is every module the catalog half holds, by name, with its entry: the entries of the watched branches' newest trunk
// revisions, the branch followed first naming a module more than one hold (design 47 G §196, §594). An error says the half has
// no first view yet, never that no module exists.
func (v *Views) Modules() (map[string]catalogseat.CatalogEntry, error) {
if v.branches == nil {
return nil, fmt.Errorf("the tool runner does not follow the catalog half")
}
if err := v.branches.Ready(); err != nil {
return nil, err
}
out := map[string]catalogseat.CatalogEntry{}
for _, branch := range v.branches.Followed() {
c, ok := v.branches.Catalog(branch)
if !ok {
continue
}
half, _ := c.View() // a branch at none-yet is ready and empty (§614)
for m, e := range half.Entries {
if _, taken := out[m]; !taken {
out[m] = e
}
}
}
return out, nil
}
// holding is the views of the module that claims the assigner seat, and what stops them.
type holding struct {
module string
cancel context.CancelFunc
views []*views.Follower
}
// claimed is the branch follower's word of one entry changed: an entry that serves a verb of the assigner seat makes its module
// the holder whose views the runner follows (a holder before it is dropped), the deletion of the holder's entry drops them
// (design 47 G §616, §428). It acts on the event alone; nothing waits on a timer for the claim.
func (v *Views) claimed(module string, entry json.RawMessage, deleted bool) {
claims := false
if !deleted {
var e catalogseat.CatalogEntry
if json.Unmarshal(entry, &e) == nil {
claims = slices.ContainsFunc(e.Serves, func(s string) bool { return strings.HasPrefix(s, AssignerSeat+".") })
}
}
v.mu.Lock()
cur := v.holder
switch {
case claims && cur != nil && cur.module == module, !claims && (cur == nil || cur.module != module), v.ctx.Err() != nil:
v.mu.Unlock()
return
}
if cur != nil {
v.dropHolderLocked()
}
if claims {
v.startHolderLocked(module)
}
v.mu.Unlock()
}
// startHolderLocked follows the first views of module, the holder of the seat, on the runner's own consumer.
func (v *Views) startHolderLocked(module string) {
ctx, cancel := context.WithCancel(v.ctx)
h := &holding{module: module, cancel: cancel}
d := v.deps
for _, s := range Specs {
s := s
call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(module+"."+tool, body) }
f := &views.Follower{Owner: module, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: v.logf,
Bound: d.Bound, Wait: d.Wait,
Raise: func(st views.Status) {
v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, true)
},
Clear: func(st views.Status) {
v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, false)
}}
v.followers[module+"."+s.State] = f
v.byState[s.State] = f
h.views = append(h.views, f)
v.wg.Add(1)
go func() { defer v.wg.Done(); _ = f.Start(ctx) }()
}
v.holder = h
v.logf("[mesh-tools] the seat %s is held by %s: its views are followed", AssignerSeat, module)
}
// dropHolderLocked stops the views of the holder and clears the findings they raised.
func (v *Views) dropHolderLocked() {
h := v.holder
v.holder = nil
h.cancel()
for _, f := range h.views {
delete(v.followers, f.Owner+"."+f.State)
delete(v.byState, f.State)
if st := f.Status(); st.Raised {
go v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
}
}
v.logf("[mesh-tools] %s no longer holds the seat %s: its views are no longer followed", h.module, AssignerSeat)
}
@@ -11,11 +11,16 @@ import (
"testing" "testing"
"time" "time"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-sdk/go/views"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
@@ -40,28 +45,30 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
if err := json.Unmarshal(raw, &manifest); err != nil { if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err) t.Fatal(err)
} }
streamOf := map[string]string{Assigner: catalogue.StreamBusName("assigner-views"), Indexer: catalogue.StreamBusName("indexer-views")} // the manifest names the assigner's views by the seat; the module the catalog half says claims it is the fixture's
const holder = "assigner-fixture"
streamOf := map[string]string{"seat:" + AssignerSeat: catalogue.StreamBusName("assigner-views")}
filters := map[string][]string{} filters := map[string][]string{}
families := 0 families := 0
for _, c := range manifest.Consumes { for _, c := range manifest.Consumes {
emitter, event, isView := strings.Cut(c, ".views.") emitter, event, isView := strings.Cut(c, ".views.")
if !isView { // the seat mesh-catalog's events: the seat list's, on EVENTS (cmd/node-tools/seatlist.go) if !isView { // the seats' events (the seat list's, the catalog half's), on EVENTS (cmd/node-tools/seatlist.go)
continue continue
} }
stream := streamOf[emitter] stream := streamOf[emitter]
if stream == "" { if stream == "" {
t.Fatalf("the manifest consumes %s, whose views stream the test does not know", c) t.Fatalf("the manifest consumes %s, whose views stream the test does not know", c)
} }
filters[stream] = append(filters[stream], process.ModuleStreamSubject(emitter, views.EventPrefix+event)) filters[stream] = append(filters[stream], process.ModuleStreamSubject(holder, views.EventPrefix+event))
families++ families++
} }
if families != len(Specs) { if families != len(Specs) {
t.Fatalf("the manifest consumes %d families, the runner holds %d first views", families, len(Specs)) t.Fatalf("the manifest consumes %d families, the runner holds %d first views", families, len(Specs))
} }
runner := mt.MembershipOf("node-tools", "anchor", false, nil) runner := mt.MembershipOf("node-tools", "anchor", false, nil)
for _, owner := range []string{Assigner, Indexer} { for _, seat := range []string{"seat:" + AssignerSeat} {
stream := streamOf[owner] stream := streamOf[seat]
mesh.ModuleStream(t, owner, stream) mesh.ModuleStream(t, holder, stream)
mesh.ConsumerOn(t, stream, "anchor", "node-tools", filters[stream], 2*time.Second) mesh.ConsumerOn(t, stream, "anchor", "node-tools", filters[stream], 2*time.Second)
runner.Hears = append(runner.Hears, bus.HeardIssued{Stream: stream, Consumer: "anchor_node-tools"}) runner.Hears = append(runner.Hears, bus.HeardIssued{Stream: stream, Consumer: "anchor_node-tools"})
} }
@@ -84,21 +91,21 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
var mu sync.Mutex var mu sync.Mutex
findings := map[string]bool{} findings := map[string]bool{}
deps := DepsOf(conn) deps := DepsOf(conn, t.Logf)
deps.Bound, deps.Wait = 100*time.Millisecond, 20*time.Millisecond deps.Bound, deps.Wait = 100*time.Millisecond, 20*time.Millisecond
deps.PutFinding = func(key string, _ json.RawMessage) error { mu.Lock(); findings[key] = true; mu.Unlock(); return nil } deps.Findings = writes{&mu, findings}
deps.DeleteFinding = func(key string) error { mu.Lock(); delete(findings, key); mu.Unlock(); return nil } deps.StartFindings = nil
v, stop := StartWith(context.Background(), deps, func(string, ...any) {}) v, stop := StartWith(context.Background(), deps, func(string, ...any) {})
t.Cleanup(stop) t.Cleanup(stop)
// The owners are down: no tool answers, nothing is claimed, and past the bound each view is a finding. // The owners are down: no tool answers, nothing is claimed, and past the bound each view is a finding.
if _, err := v.State(Assigner + ".assignments"); err == nil { if _, err := v.State(AssignmentsRead); err == nil {
t.Fatal("a state its owner has not answered is claimed") t.Fatal("a state its owner has not answered is claimed")
} }
mt.Until(t, func() error { mt.Until(t, func() error {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if len(findings) != len(Specs) { if len(findings) != catalogViews {
return errFindings(len(findings)) return errFindings(len(findings))
} }
return nil return nil
@@ -110,10 +117,25 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(nc.Close) t.Cleanup(nc.Close)
respond := func(subject string, answer any) {
if _, err := nc.Subscribe(subject, func(m *nats.Msg) {
raw, _ := json.Marshal(answer)
body, _ := json.Marshal(map[string]json.RawMessage{"result": raw})
_ = m.Respond(body)
}); err != nil {
t.Fatal(err)
}
}
// the catalog half answers: the gate's source names main, whose entries say the fixture claims the assigner seat
respond("mesh.seat.mesh-delivery.tool.source", delivery.SourceAnswer{Branches: []string{"main"}})
respond("mesh.seat.mesh-catalog.tool.registrations", catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}})
respond("mesh.seat.mesh-catalog.tool.revision", catalogseat.RevisionAnswer{
Revision: &catalogseat.CatalogRevision{Revision: 3, Kind: meshcatalogclient.KindTrunk, Branch: "main"},
Entries: []catalogseat.CatalogEntry{{Module: holder, Serves: []string{AssignerSeat + ".assignments"}}}})
for _, s := range Specs { for _, s := range Specs {
s := s s := s
if _, err := nc.Subscribe("mesh.mod."+s.Owner+".tool."+s.Tool, func(m *nats.Msg) { if _, err := nc.Subscribe("mesh.mod."+holder+".tool."+s.Tool, func(m *nats.Msg) {
answer, _ := json.Marshal(views.Answer{V: 1, Owner: s.Owner, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(), answer, _ := json.Marshal(views.Answer{V: 1, Owner: holder, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(),
Values: map[string]json.RawMessage{"first": json.RawMessage(`{"n":1}`)}}) Values: map[string]json.RawMessage{"first": json.RawMessage(`{"n":1}`)}})
body, _ := json.Marshal(map[string]json.RawMessage{"result": answer}) body, _ := json.Marshal(map[string]json.RawMessage{"result": answer})
_ = m.Respond(body) _ = m.Respond(body)
@@ -129,7 +151,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
mt.Until(t, func() error { mt.Until(t, func() error {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if len(findings) != 0 { if len(findings) != 0 { // every view answered clears its finding
return errFindings(len(findings)) return errFindings(len(findings))
} }
return nil return nil
@@ -137,9 +159,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
// Each owner's next event arrives on the runner's consumer: all four families are bound. // Each owner's next event arrives on the runner's consumer: all four families are bound.
for _, s := range Specs { for _, s := range Specs {
ev, _ := json.Marshal(views.Event{V: 1, Owner: s.Owner, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(), ev, _ := json.Marshal(views.Event{V: 1, Owner: holder, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(),
Value: json.RawMessage(`{"n":2}`)}) Value: json.RawMessage(`{"n":2}`)})
mesh.Emit(t, process.ModuleStreamSubject(s.Owner, views.EventPrefix+s.State), json.RawMessage(ev)) mesh.Emit(t, process.ModuleStreamSubject(holder, views.EventPrefix+s.State), json.RawMessage(ev))
} }
for _, s := range Specs { for _, s := range Specs {
name := s.Name() name := s.Name()
@@ -153,6 +175,30 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
} }
} }
// catalogViews is the first views the branch follower adopts before any branch is known: the registrations and the gate's source
// (design 47 G §614), each a finding while the seats' verbs go unanswered.
const catalogViews = 2
type errFindings int type errFindings int
func (e errFindings) Error() string { return "not yet" } func (e errFindings) Error() string { return "not yet" }
// writes is the runner's finding writer in this test: the findings written and not cleared.
type writes struct {
mu *sync.Mutex
by map[string]bool
}
func (w writes) Write(_ context.Context, f condseat.Finding) error {
w.mu.Lock()
defer w.mu.Unlock()
w.by[views.FindingKey(f.Scope, f.ID, f.Kind)] = true
return nil
}
func (w writes) Clear(_ context.Context, scope, id, kind, _ string) error {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.by, views.FindingKey(scope, id, kind))
return nil
}
+138 -40
View File
@@ -11,6 +11,10 @@ import (
"testing" "testing"
"time" "time"
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-sdk/go/views"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
@@ -25,12 +29,32 @@ type owners struct {
asked map[string]int asked map[string]int
deliver func(bus.Envelope) error deliver func(bus.Envelope) error
finds map[string]bool finds map[string]bool
// catalog is the revision of the watched branch main the seat mesh-catalog answers, and modules its entries
catalog int64
modules []string
// holder is the module among them that claims the assigner seat
holder string
} }
func newOwners() *owners { func newOwners() *owners {
return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}, return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}},
"manifests": {"d1": `{"m":1}`}, "modules": {"web": `{"w":1}`}}, rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, asked: map[string]int{}, finds: map[string]bool{},
rev: map[string]uint64{"assignments": 4, "seat-holders": 2, "manifests": 7, "modules": 3}, asked: map[string]int{}, finds: map[string]bool{}} catalog: 3, modules: []string{"web", fixtureHolder}, holder: fixtureHolder}
}
// Write and Clear are the runner's finding writer in a test: the findings written and not cleared, by their key.
func (o *owners) Write(_ context.Context, f condseat.Finding) error {
o.mu.Lock()
defer o.mu.Unlock()
o.finds[views.FindingKey(f.Scope, f.ID, f.Kind)] = true
return nil
}
func (o *owners) Clear(_ context.Context, scope, id, kind, _ string) error {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.finds, views.FindingKey(scope, id, kind))
return nil
} }
func (o *owners) ask(address string, _ any) (json.RawMessage, error) { func (o *owners) ask(address string, _ any) (json.RawMessage, error) {
@@ -41,6 +65,22 @@ func (o *owners) ask(address string, _ any) (json.RawMessage, error) {
if o.down { if o.down {
return nil, errors.New("503 no responders") return nil, errors.New("503 no responders")
} }
switch address {
case "seat:mesh-delivery.source":
return json.Marshal(delivery.SourceAnswer{Branches: []string{"main"}})
case "seat:mesh-catalog.registrations":
return json.Marshal(catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}})
case "seat:mesh-catalog.revision":
a := catalogseat.RevisionAnswer{Revision: &catalogseat.CatalogRevision{Revision: o.catalog, Kind: meshcatalogclient.KindTrunk, Branch: "main"}}
for _, m := range o.modules {
e := catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}}
if m == o.holder {
e.Serves = []string{AssignerSeat + ".assignments", AssignerSeat + ".seat-holders"}
}
a.Entries = append(a.Entries, e)
}
return json.Marshal(a)
}
vals := map[string]json.RawMessage{} vals := map[string]json.RawMessage{}
for k, v := range o.state[tool] { for k, v := range o.state[tool] {
vals[k] = json.RawMessage(v) vals[k] = json.RawMessage(v)
@@ -60,14 +100,10 @@ func (o *owners) say(t *testing.T, state, key, value string, revision uint64, ga
} }
} }
func owner(state string) string { // fixtureHolder is the module the fixture catalog half says claims the assigner seat: no name is in the code.
for _, s := range Specs { const fixtureHolder = "assigner-fixture"
if s.State == state {
return s.Owner func owner(state string) string { return fixtureHolder }
}
}
return ""
}
func eventually(t *testing.T, what string, ok func() bool) { func eventually(t *testing.T, what string, ok func() bool) {
t.Helper() t.Helper()
@@ -87,41 +123,48 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
var logs []string var logs []string
var lmu sync.Mutex var lmu sync.Mutex
deps := Deps{Module: "node-tools", Ask: o.ask, Bound: 30 * time.Millisecond, Wait: 10 * time.Millisecond, deps := Deps{Module: "node-tools", Ask: o.ask, Bound: 30 * time.Millisecond, Wait: 10 * time.Millisecond,
Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil },
PutFinding: func(key string, _ json.RawMessage) error { Findings: o}
o.mu.Lock()
o.finds[key] = true
o.mu.Unlock()
return nil
},
DeleteFinding: func(key string) error {
o.mu.Lock()
delete(o.finds, key)
o.mu.Unlock()
return nil
}}
v, stop := StartWith(context.Background(), deps, func(f string, a ...any) { lmu.Lock(); logs = append(logs, f); lmu.Unlock() }) v, stop := StartWith(context.Background(), deps, func(f string, a ...any) { lmu.Lock(); logs = append(logs, f); lmu.Unlock() })
defer stop() defer stop()
// Down: nothing is claimed, and past the bound each unanswered view is a finding. // Down: nothing is claimed, and past the bound each unanswered view is a finding.
if _, err := v.State("mesh-delivery-assigner.assignments"); err == nil || !strings.Contains(err.Error(), "not been answered") { if _, err := v.State(AssignmentsRead); err == nil || !strings.Contains(err.Error(), "names no holder") {
t.Fatalf("a state its owner has not answered is claimed: %v", err) t.Fatalf("a state whose holder is not known is claimed: %v", err)
} }
eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 4 }) eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 2 })
// Up: answered, the findings are cleared, and the pictures hold the owners' values. // Up: answered, the findings are cleared, and the pictures hold the owners' values.
o.mu.Lock() o.mu.Lock()
o.down = false o.down = false
o.mu.Unlock() o.mu.Unlock()
eventually(t, "not answered", func() bool { _, err := v.State("mesh-delivery-indexer.manifests"); return err == nil }) eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil })
eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 }) eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 })
if got, _ := v.Entry("mesh-delivery-indexer.modules", "web"); string(got) != `{"w":1}` { if got, _ := v.Entry(AssignerSeat+".seat-holders", "none"); got != nil {
t.Errorf("the modules picture holds %q", got)
}
if got, _ := v.Entry("mesh-delivery-indexer.modules", "none"); got != nil {
t.Errorf("an absent key is %q", got) t.Errorf("an absent key is %q", got)
} }
// The catalog half is followed by the shared branch follower on the runner's own consumer: the gate's source names main,
// main's view is adopted, and its entries are the modules, each with its manifest (design 47 G §196, §614).
eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 })
m, _ := v.Modules()
if m["web"].Manifest["module"] != "web" {
t.Errorf("the module web is %+v", m["web"])
}
// A catalog-revision of the branch is heard on the same consumer and asks the view once more.
o.mu.Lock()
o.catalog, o.modules = 4, []string{"web", "db", fixtureHolder}
o.mu.Unlock()
ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: 4})
if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil {
t.Fatal(err)
}
eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 3 })
// And the halves by branch, with their revision, which mesh_wait names checking an emitter (design 34 every-event §14).
if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 3 {
t.Errorf("the halves: %+v, %v", h, err)
}
// Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again. // Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again.
asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] } asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] }
before := asked("assignments") before := asked("assignments")
@@ -129,7 +172,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
o.say(t, "assignments", "laptop", `{"p":0}`, 5, false) o.say(t, "assignments", "laptop", `{"p":0}`, 5, false)
o.say(t, "assignments", "anchor", "", 6, false) o.say(t, "assignments", "anchor", "", 6, false)
// (a deleted mark carries no value: Deleted is the encoding's, set below) // (a deleted mark carries no value: Deleted is the encoding's, set below)
if got, _ := v.Entry("mesh-delivery-assigner.assignments", "laptop"); string(got) != `{"p":2}` { if got, _ := v.Entry(AssignerSeat+".assignments", "laptop"); string(got) != `{"p":2}` {
t.Errorf("the next revision is not applied: %q", got) t.Errorf("the next revision is not applied: %q", got)
} }
if n := asked("assignments"); n != before { if n := asked("assignments"); n != before {
@@ -147,18 +190,18 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
assignments = s assignments = s
} }
} }
if assignments.Owner != "mesh-delivery-assigner" || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() { if assignments.Owner != fixtureHolder || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() {
t.Errorf("the status of assignments is %+v", assignments) t.Errorf("the status of assignments is %+v", assignments)
} }
manifests := asked("manifests") manifests := asked("seat-holders")
if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 { if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 {
t.Fatalf("the hand ask failed: %v", errs) t.Fatalf("the hand ask failed: %v", errs)
} }
if asked("manifests") != manifests+1 { if asked("seat-holders") != manifests+1 {
t.Errorf("the hand ask did not ask the manifests again") t.Errorf("the hand ask did not ask the seat-holders again")
} }
for _, st := range v.Status() { for _, st := range v.Status() {
if st.State == "manifests" && st.ByHand != 1 { if st.State == "seat-holders" && st.ByHand != 1 {
t.Errorf("the hand ask is not counted: %+v", st) t.Errorf("the hand ask is not counted: %+v", st)
} }
} }
@@ -181,17 +224,72 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := map[string]bool{AssignmentsRead: true, SeatHoldersRead: true, ModulesRead: true, ManifestsRead: true} want := map[string]bool{AssignmentsRead: true, SeatHoldersRead: true}
for _, s := range Specs { for _, s := range Specs {
if !want[s.Name()] { if !want[s.Name()] {
t.Errorf("the first view %s is read by no name here", s.Name()) t.Errorf("the first view %s is read by no name here", s.Name())
} }
delete(want, s.Name()) delete(want, s.Name())
if view := s.Owner + ".views." + s.Tool; !slices.Contains(m.Consumes, view) { // the family is named by the seat, never by its holder (design 47 G §616)
if view := "seat:" + AssignerSeat + ".views." + s.Tool; !slices.Contains(m.Consumes, view) {
t.Errorf("the first view %s has no family in the manifest's consumes %v", view, m.Consumes) t.Errorf("the first view %s has no family in the manifest's consumes %v", view, m.Consumes)
} }
} }
if len(want) > 0 { if len(want) > 0 {
t.Errorf("no first view reads %v", want) t.Errorf("no first view reads %v", want)
} }
for _, c := range m.Consumes {
if strings.Contains(c, "assigner") && !strings.HasPrefix(c, "seat:") {
t.Errorf("the manifest names the assigner by a module: %s", c)
}
}
// the catalog half's follower hears these events and asks these verbs, and the manifest says so (design 47 G §614)
for _, ev := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"} {
if !slices.Contains(m.Consumes, ev) {
t.Errorf("the catalog half's event %s is not in the manifest's consumes %v", ev, m.Consumes)
}
}
for _, ev := range []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests"} {
if slices.Contains(m.Consumes, ev) {
t.Errorf("the manifest still consumes the indexer's %s", ev)
}
}
// design 47 G §616: the holder of the assigner seat is whichever module the catalog half says serves it; when another one
// claims it the views are of the new holder, and when none does the seat's names answer that the catalog half names no holder.
t.Run("TheAssignersViewsFollowTheModuleThatClaimsTheSeat", func(t *testing.T) {
o := newOwners()
deps := Deps{Module: "node-tools", Ask: o.ask, Bound: time.Second, Wait: 10 * time.Millisecond,
Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, Findings: o}
v, stop := StartWith(context.Background(), deps, func(string, ...any) {})
defer stop()
eventually(t, "not answered", func() bool { _, err := v.State(AssignmentsRead); return err == nil })
ownerOf := func() string {
for _, s := range v.Status() {
if s.State == "assignments" {
return s.Owner
}
}
return ""
}
if ownerOf() != fixtureHolder {
t.Fatalf("the views are of %q", ownerOf())
}
revise := func(rev int64, holder string, modules ...string) {
o.mu.Lock()
o.catalog, o.modules, o.holder = rev, modules, holder
o.mu.Unlock()
ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: rev})
if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil {
t.Fatal(err)
}
}
revise(4, "other-holder", "web", "other-holder")
eventually(t, "the views did not move to the new holder", func() bool { return ownerOf() == "other-holder" })
eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil })
revise(5, "", "web")
eventually(t, "the views stayed after the claim went", func() bool {
_, err := v.State(AssignmentsRead)
return err != nil && strings.Contains(err.Error(), "names no holder")
})
})
} }
+71 -2
View File
@@ -19,6 +19,7 @@ import (
"syscall" "syscall"
"time" "time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/buscalls"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
@@ -123,7 +124,7 @@ var busStateCalls = map[string]bool{
buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: true, buscalls.StreamPublish: true, buscalls.WorkQueueSize: true, buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: true, buscalls.StreamPublish: true, buscalls.WorkQueueSize: true,
buscalls.WorkQueueDead: true, buscalls.LedgerApply: true, buscalls.LedgerGet: true, buscalls.LedgerHistory: true, buscalls.WorkQueueDead: true, buscalls.LedgerApply: true, buscalls.LedgerGet: true, buscalls.LedgerHistory: true,
buscalls.LedgerQuery: true, buscalls.LedgerRebuild: true, buscalls.LedgerReindex: true, buscalls.LedgerSnapshot: true, buscalls.LedgerQuery: true, buscalls.LedgerRebuild: true, buscalls.LedgerReindex: true, buscalls.LedgerSnapshot: true,
buscalls.LedgerLag: true, buscalls.LedgerLag: true, buscalls.ObjectsSentGet: true, buscalls.ObjectsPutChunk: true, buscalls.ObjectsGetChunk: true,
} }
// busStateWatches are the watches of mesh-sdk buscalls, each with the method its changes are handed to the bundle by. // busStateWatches are the watches of mesh-sdk buscalls, each with the method its changes are handed to the bundle by.
@@ -386,13 +387,77 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
asked := time.Now() asked := time.Now()
switch m.Method { switch m.Method {
case "mesh/publish": case "mesh/publish":
err = mesh.Publish(m.Params) var said struct {
Ack bool `json:"ack"`
}
_ = json.Unmarshal(m.Params, &said)
if !said.Ack {
err = mesh.Publish(m.Params)
break
}
// With ack the runtime answers only once the bus acknowledged the event: its sequence, or {error}
// (design 47 G §559). A refusal is an answer, so a writer says it as a finding's resend.
acker, ok := mesh.(interface {
PublishAcked(params json.RawMessage) (uint64, error)
})
switch {
case !ok:
result = map[string]any{"error": "this tool runner's bus publishes no event with an acknowledgement"}
default:
if seq, perr := acker.PublishAcked(m.Params); perr != nil {
result = map[string]any{"error": perr.Error()}
} else {
result = map[string]any{"sequence": seq}
}
}
case "mesh/ask": case "mesh/ask":
var asked json.RawMessage var asked json.RawMessage
if asked, err = mesh.Ask(m.Params); err == nil { if asked, err = mesh.Ask(m.Params); err == nil {
result = asked result = asked
} }
case stdio.CapabilitiesCall:
// The capability words this runner states (design 47 G §576); a Bus that states none answers none.
words := []string{}
if w, ok := mesh.(interface{ CapabilityWords() []string }); ok {
words = w.CapabilityWords()
}
var raw []byte
if raw, err = json.Marshal(map[string]any{"words": words}); err == nil {
result = json.RawMessage(raw)
}
case "mesh/subscribe": case "mesh/subscribe":
var said struct {
Pattern string `json:"pattern"`
Deliver string `json:"deliver"`
}
_ = json.Unmarshal(m.Params, &said)
if said.Deliver == "new" {
// An ordered consumer of its own from the next message on (design 47 G §559, A-153): each message
// is handed to this child as an event is, and the consumer ends with the child.
newer, ok := mesh.(interface {
SubscribeNew(pattern string, deliver func(json.RawMessage) error) (func(), error)
})
if !ok {
err = errors.New("this tool runner's bus makes no subscription for new messages")
break
}
var stop func()
stop, err = newer.SubscribeNew(said.Pattern, func(envelope json.RawMessage) error {
_, err := c.ask(module, "mesh/event", map[string]any{"envelope": envelope}, EventTimeout)
return err
})
if err == nil {
c.mu.Lock()
if c.why != nil {
c.mu.Unlock()
stop()
} else {
c.watches = append(c.watches, stop)
c.mu.Unlock()
}
}
break
}
mu.Lock() mu.Lock()
subscriber = c subscriber = c
mu.Unlock() mu.Unlock()
@@ -577,6 +642,10 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
for _, stop := range watches { for _, stop := range watches {
stop() stop()
} }
// A session of the bundle's put or get ends with its channel (design 47 G §518).
if d, ok := mesh.(interface{ DropSessions() }); ok {
d.DropSessions()
}
close(c.dead) close(c.dead)
mu.Lock() mu.Lock()
// Only a child that was serving is brought back; one that died in its own handshake was // Only a child that was serving is brought back; one that died in its own handshake was
+60
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -68,6 +69,65 @@ func TestABundleTakesASeatsWorkThroughItsOwnChannel(t *testing.T) {
if err != nil || string(got) != `{"taken":true}` { if err != nil || string(got) != `{"taken":true}` {
t.Fatalf("the work was not handed to the bundle: %s %v", got, err) t.Fatalf("the work was not handed to the bundle: %s %v", got, err)
} }
// Design 47 G §576, §518: a bundle reads the runner's capability words with mesh/capabilities, then runs its put session
// through the runner's put-chunk, and the sessions of its channel are dropped when the channel closes.
t.Run("ABundleReadsTheCapabilityWordsRunsAPutSessionAndItsChannelsClosingDropsThem", func(t *testing.T) {
dir := t.TempDir()
entry := filepath.Join(dir, "bundle")
if err := os.WriteFile(entry, []byte(puttingBundle), 0o755); err != nil {
t.Fatal(err)
}
b := &sessionBus{dropped: make(chan struct{})}
l, err := Start("putter", entry, os.Environ(), b, func(string, ...any) {})
if err != nil {
t.Fatal(err)
}
t.Cleanup(l.Stop)
select {
case <-b.dropped:
case <-time.After(5 * time.Second):
t.Fatal("the bundle's sessions were not dropped when its channel closed")
}
b.mu.Lock()
defer b.mu.Unlock()
if len(b.chunks) != 1 || !strings.HasPrefix(b.chunks[0], "mesh/objects.put-chunk ") || !strings.Contains(b.chunks[0], `"seq":1`) {
t.Fatalf("the runner took %v", b.chunks)
}
})
// Design 47 G §559: `mesh/publish` with `ack` is answered with its sequence once the bus acknowledged it, or `{error}`;
// `mesh/subscribe` with `deliver: new` makes a subscription for new messages that ends with the bundle's channel.
t.Run("APublishWithAckIsAnsweredWithItsSequenceAndASubscriptionForNewMessagesEndsWithTheChannel", func(t *testing.T) {
dir := t.TempDir()
entry, out := filepath.Join(dir, "bundle"), filepath.Join(dir, "out")
if err := os.WriteFile(entry, []byte(findingBundle), 0o755); err != nil {
t.Fatal(err)
}
b := &findingBus{stopped: make(chan struct{})}
l, err := Start("writer", entry, append(os.Environ(), "FINDING_OUT="+out), b, func(string, ...any) {})
if err != nil {
t.Fatal(err)
}
t.Cleanup(l.Stop)
select {
case <-b.stopped:
case <-time.After(5 * time.Second):
t.Fatal("the subscription for new messages was not ended with the bundle's channel")
}
got, _ := os.ReadFile(out)
for _, want := range []string{`"sequence":42`, `"error":"the bus refused it"`} {
if !strings.Contains(string(got), want) {
t.Errorf("the bundle was answered %s, want it to hold %s", got, want)
}
}
b.mu.Lock()
defer b.mu.Unlock()
if b.pattern != "mesh-conditions.findings-asked" {
t.Errorf("the subscription was made for %q", b.pattern)
}
if len(b.published) != 0 {
t.Errorf("a publish with ack went the unacknowledged way: %v", b.published)
}
})
} }
// A bundle that pulls one ask from its worker when it is listed. // A bundle that pulls one ask from its worker when it is listed.
@@ -0,0 +1,78 @@
package launch
import (
"encoding/json"
"errors"
"strings"
"sync"
)
// puttingBundle asks the capability words, and once they are answered with put-sessions sends the one chunk of a put
// session, as mesh-sdk's bundle does; it then ends, which closes its channel.
const puttingBundle = `#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
*'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id"
printf '{"jsonrpc":"2.0","id":"w1","method":"mesh/capabilities","params":{}}\n' ;;
*'"id":"w1"'**put-sessions*) printf '{"jsonrpc":"2.0","id":"p1","method":"mesh/objects.put-chunk","params":{"objects":"o","name":"n","call":"c","seq":1,"last":true,"data":"aGk="}}\n' ;;
*'"id":"p1"'*) exit 0 ;;
esac
done
`
type sessionBus struct {
subscribing
mu sync.Mutex
chunks []string
dropped chan struct{}
}
func (b *sessionBus) CapabilityWords() []string { return []string{"put-sessions"} }
func (b *sessionBus) BusState(method string, params json.RawMessage) (json.RawMessage, error) {
b.mu.Lock()
b.chunks = append(b.chunks, method+" "+string(params))
b.mu.Unlock()
return json.RawMessage(`{"name":"n"}`), nil
}
func (b *sessionBus) DropSessions() { close(b.dropped) }
// findingBundle asks, as mesh-sdk's finding writer does, a publish with ack that the bus takes, one it refuses, and a
// subscription for new messages; each answer it gets is written to $FINDING_OUT, and it ends after the subscription's.
const findingBundle = `#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
*'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id"
printf '{"jsonrpc":"2.0","id":"a1","method":"mesh/publish","params":{"key":"taken","ack":true,"body":{}}}\n' ;;
*'"id":"a1"'*) printf '%s\n' "$line" >>"$FINDING_OUT"
printf '{"jsonrpc":"2.0","id":"a2","method":"mesh/publish","params":{"key":"refused","ack":true,"body":{}}}\n' ;;
*'"id":"a2"'*) printf '%s\n' "$line" >>"$FINDING_OUT"
printf '{"jsonrpc":"2.0","id":"s1","method":"mesh/subscribe","params":{"pattern":"mesh-conditions.findings-asked","deliver":"new"}}\n' ;;
*'"id":"s1"'*) printf '%s\n' "$line" >>"$FINDING_OUT"; exit 0 ;;
esac
done
`
type findingBus struct {
subscribing
mu sync.Mutex
pattern string
stopped chan struct{}
}
func (b *findingBus) PublishAcked(params json.RawMessage) (uint64, error) {
if strings.Contains(string(params), `"refused"`) {
return 0, errors.New("the bus refused it")
}
return 42, nil
}
func (b *findingBus) SubscribeNew(pattern string, _ func(json.RawMessage) error) (func(), error) {
b.mu.Lock()
b.pattern = pattern
b.mu.Unlock()
return func() { close(b.stopped) }, nil
}
+79
View File
@@ -58,6 +58,10 @@ type History interface {
// Read is the operations of the instances keys names (a key, with `*` for one name and `>` for the rest) from a // Read is the operations of the instances keys names (a key, with `*` for one name and `>` for the rest) from a
// sequence on, oldest first, at most limit of them (every one when limit is 0). // sequence on, oldest first, at most limit of them (every one when limit is 0).
Read(keys string, from uint64, limit int) ([]Kept, error) Read(keys string, from uint64, limit int) ([]Kept, error)
// Purge removes every operation of the instance key names, and nothing of another (an instance's removal).
Purge(key string) error
// Fill is the history's fill against its cap, the ledger's cap (buscalls.LedgerFill; design 47 G §587).
Fill() (buscalls.Fill, error)
} }
// State is a ledger's state bucket: each instance's record under its key. // State is a ledger's state bucket: each instance's record under its key.
@@ -69,6 +73,8 @@ type State interface {
Write(key string, value []byte, revision uint64) (uint64, error) Write(key string, value []byte, revision uint64) (uint64, error)
// Keys is every key the bucket holds. // Keys is every key the bucket holds.
Keys() ([]string, error) Keys() ([]string, error)
// Delete removes the key; a key it does not hold is no fault.
Delete(key string) error
} }
// Snapshots are a ledger's snapshots: the latest record of each instance, on a subject of its own, never expiring. // Snapshots are a ledger's snapshots: the latest record of each instance, on a subject of its own, never expiring.
@@ -77,6 +83,8 @@ type Snapshots interface {
Get(key string) ([]byte, uint64, error) Get(key string) ([]byte, uint64, error)
// Put writes the key's snapshot, expecting its last to be at expect (0: none); ErrRaced when it is not. // Put writes the key's snapshot, expecting its last to be at expect (0: none); ErrRaced when it is not.
Put(key string, value []byte, expect uint64) error Put(key string, value []byte, expect uint64) error
// Purge removes the key's snapshot; one it does not hold is no fault.
Purge(key string) error
} }
// Ledger is one ledger of one module: its types and where it is kept. // Ledger is one ledger of one module: its types and where it is kept.
@@ -495,3 +503,74 @@ func (l *Ledger) Apply(r buscalls.ApplyRequest, author buscalls.Author) (buscall
} }
return buscalls.Applied{Taken: true, Sequence: seq, StateBehind: behind}, nil return buscalls.Applied{Taken: true, Sequence: seq, StateBehind: behind}, nil
} }
// Fill is the fill of the ledger's history against the ledger's cap (buscalls.LedgerFill; design 47 G §587): its messages,
// its bytes and the cap in bytes. The history holds every operation, and the cap is its alone: neither the fullest of the
// stores nor their sum is answered. A history that cannot say its fill is an error naming the ledger, never a fill of
// nothing.
func (l *Ledger) Fill(buscalls.LedgerRequest) (buscalls.Fill, error) {
f, err := l.History.Fill()
if err != nil {
return buscalls.Fill{}, fmt.Errorf("%s's ledger %s cannot say the fill of its history: %w", l.Module, l.Name, err)
}
return f, nil
}
// Remove removes one instance with everything it kept (buscalls.LedgerRemove; design 47 G §587, §155): its operations, its
// snapshot, its key in the state bucket and its keys in every declared index, and answers how many operations and how many
// bytes of the history that was. An instance the ledger does not hold answers 0 and 0, and a removal that left something
// (said, and answered as an error) is made again by calling it again, which removes the rest.
func (l *Ledger) Remove(r buscalls.LedgerRemoveRequest) (buscalls.LedgerRemoved, error) {
key, err := KeyOf(r.Instance)
if err != nil {
return buscalls.LedgerRemoved{}, err
}
out := buscalls.LedgerRemoved{Instance: r.Instance}
before, err := l.History.Fill()
if err != nil {
return out, fmt.Errorf("%s's ledger %s cannot say the fill of its history, so a removal is not made: %w", l.Module, l.Name, err)
}
ops, err := l.History.Read(key, 1, 0)
if err != nil {
return out, err
}
for _, step := range []struct {
what string
remove func(string) error
}{{"operations", l.History.Purge}, {"snapshot", l.Snapshots.Purge}, {"state", l.State.Delete}, {"index entries", l.dropFromIndexes}} {
if err := step.remove(key); err != nil {
l.say("[mesh-tools] %s's ledger %s removes %s and its %s could not be removed: %v; removing it again removes what "+
"is left", l.Module, l.Name, r.Instance, step.what, err)
return out, fmt.Errorf("%s's ledger %s removed part of %s and its %s are left: %w", l.Module, l.Name, r.Instance, step.what, err)
}
}
after, err := l.History.Fill()
if err != nil {
return out, fmt.Errorf("%s's ledger %s removed %s and cannot say the fill of its history after: %w", l.Module, l.Name, r.Instance, err)
}
out.Operations = uint64(len(ops))
if before.Bytes > after.Bytes {
out.Bytes = before.Bytes - after.Bytes
}
return out, nil
}
// dropFromIndexes removes the entries of the instance key names from every index bucket the ledger is bound to.
func (l *Ledger) dropFromIndexes(key string) error {
instance := InstanceOf(key)
for name, ix := range l.Indexes {
held, err := ix.Entries("", 0)
if err != nil {
return fmt.Errorf("the index %s: %w", name, err)
}
for _, e := range held {
var v buscalls.IndexEntry
if json.Unmarshal(e.Value, &v) == nil && v.Instance == instance {
if err := ix.Delete(e.Key); err != nil {
return fmt.Errorf("the index %s: %w", name, err)
}
}
}
}
return nil
}
+46
View File
@@ -99,4 +99,50 @@ func TestApplyAppendsDerivesAndSnapshots(t *testing.T) {
c.run(t, l, f) c.run(t, l, f)
}) })
} }
// design 47 G §587: a ledger's fill is its history's against the ledger's cap, a store that cannot say it is an error naming
// the ledger; a removal takes an instance's operations, snapshot, state and index entries and answers what that was, an
// unknown instance answers none, and nothing of another instance goes.
t.Run("TheLedgerSaysItsFillAndRemovesAnInstanceWhole", func(t *testing.T) {
l, f := newLedger()
f.history.MaxBytes = 1 << 20
for _, n := range []string{"a", "b"} {
applied(t, l, "decision/0290", "note", `{"says":"`+n+`"}`, owner)
}
applied(t, l, "decision/0291", "note", `{"says":"other"}`, owner)
entry, _ := json.Marshal(buscalls.IndexEntry{Instance: "decision/0290", Sequence: 2})
other, _ := json.Marshal(buscalls.IndexEntry{Instance: "decision/0291", Sequence: 1})
_ = f.index.Put("t.decision.0290", entry)
_ = f.index.Put("t.decision.0291", other)
fill, err := l.Fill(buscalls.LedgerRequest{Ledger: "records"})
if err != nil || fill.Count != 3 || fill.Bytes == 0 || fill.MaxBytes != 1<<20 {
t.Fatalf("the fill is %+v (%v), want 3 messages against 1 MiB", fill, err)
}
got, err := l.Remove(buscalls.LedgerRemoveRequest{Ledger: "records", Instance: "decision/0290"})
if err != nil || got.Instance != "decision/0290" || got.Operations != 2 || got.Bytes == 0 {
t.Fatalf("the removal answered %+v (%v), want 2 operations and their bytes", got, err)
}
if kept, _ := f.history.Read("decision.0290", 1, 0); len(kept) != 0 {
t.Errorf("operations are left: %d", len(kept))
}
if raw, _, _ := f.state.Get("decision.0290"); raw != nil {
t.Errorf("the state is left: %s", raw)
}
if raw, _, _ := f.snapshots.Get("decision.0290"); raw != nil {
t.Errorf("the snapshot is left: %s", raw)
}
if held, _ := f.index.Entries("", 0); len(held) != 1 || held[0].Key != "t.decision.0291" {
t.Errorf("the index holds %v, want the other instance's entry alone", held)
}
if kept, _ := f.history.Read("decision.0291", 1, 0); len(kept) != 1 {
t.Errorf("another instance lost operations: %d left", len(kept))
}
if again, err := l.Remove(buscalls.LedgerRemoveRequest{Ledger: "records", Instance: "decision/0290"}); err != nil || again.Operations != 0 || again.Bytes != 0 {
t.Errorf("a removal said twice answered %+v (%v), want none", again, err)
}
f.history.FillFails = true
if _, err := l.Fill(buscalls.LedgerRequest{}); err == nil || !strings.Contains(err.Error(), "ledger records cannot say the fill") {
t.Errorf("a history that cannot say its fill answered %v", err)
}
})
} }
+50 -3
View File
@@ -1,9 +1,12 @@
package ledger package ledger
import ( import (
"errors"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"git.novox.be/novox/mesh-sdk/go/buscalls"
) )
// The memory stores keep what the bus keeps, as the bus answers it, for a test of a ledger without a bus: a stream whose // The memory stores keep what the bus keeps, as the bus answers it, for a test of a ledger without a bus: a stream whose
@@ -12,9 +15,13 @@ import (
// MemoryHistory is a history in memory. // MemoryHistory is a history in memory.
type MemoryHistory struct { type MemoryHistory struct {
mu sync.Mutex // MaxBytes is the cap its fill is said against.
seq uint64 MaxBytes uint64
kept []struct { // FillFails makes Fill an error, as a store that cannot say its fill.
FillFails bool
mu sync.Mutex
seq uint64
kept []struct {
key string key string
Kept Kept
} }
@@ -73,6 +80,32 @@ func (h *MemoryHistory) Read(keys string, from uint64, limit int) ([]Kept, error
return out, nil return out, nil
} }
func (h *MemoryHistory) Purge(key string) error {
h.mu.Lock()
defer h.mu.Unlock()
kept := h.kept[:0]
for _, k := range h.kept {
if k.key != key {
kept = append(kept, k)
}
}
h.kept = kept
return nil
}
func (h *MemoryHistory) Fill() (buscalls.Fill, error) {
h.mu.Lock()
defer h.mu.Unlock()
if h.FillFails {
return buscalls.Fill{}, errors.New("the history does not say its fill")
}
f := buscalls.Fill{Count: uint64(len(h.kept)), MaxBytes: h.MaxBytes}
for _, k := range h.kept {
f.Bytes += uint64(len(k.Data))
}
return f, nil
}
// Expire drops every operation of the history, as a ledger's keeping days end them. // Expire drops every operation of the history, as a ledger's keeping days end them.
func (h *MemoryHistory) Expire() { func (h *MemoryHistory) Expire() {
h.mu.Lock() h.mu.Lock()
@@ -129,6 +162,13 @@ func (s *MemoryState) Keys() ([]string, error) {
return out, nil return out, nil
} }
func (s *MemoryState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.kv, key)
return nil
}
// MemorySnapshots are snapshots in memory. // MemorySnapshots are snapshots in memory.
type MemorySnapshots struct { type MemorySnapshots struct {
mu sync.Mutex mu sync.Mutex
@@ -157,6 +197,13 @@ func (s *MemorySnapshots) Put(key string, value []byte, expect uint64) error {
return nil return nil
} }
func (s *MemorySnapshots) Purge(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.kv, key)
return nil
}
// MemoryIndex is an index's bucket in memory. // MemoryIndex is an index's bucket in memory.
type MemoryIndex struct { type MemoryIndex struct {
mu sync.Mutex mu sync.Mutex
+14 -22
View File
@@ -23,8 +23,7 @@ import (
assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
seatassigner "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" seatassigner "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
meshcatalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" meshcatalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
meshcatalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/membership"
"github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats-server/v2/server"
@@ -153,25 +152,27 @@ func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership {
return out return out
} }
// The indexer's two states the console reads, and their buckets (the test bed's own names: the indexer's client names // The states the console reads, and their buckets (the test bed's own names: the indexer's client names
// no holder). // no holder).
const ( const (
indexer = "mesh-delivery-indexer" catalogHolder = "mesh-catalog"
assignerHolder = "mesh-delivery-assigner" assignerHolder = "mesh-module-assigner" // the seat's name stands for its holder in the console's state names (firstview.AssignmentsRead)
// AssignmentsBucket and SeatHoldersBucket are the assigner's states assignments and seat-holders. // AssignmentsBucket and SeatHoldersBucket are the assigner's states assignments and seat-holders.
AssignmentsBucket = assignerHolder + "_assignments" AssignmentsBucket = assignerHolder + "_assignments"
SeatHoldersBucket = assignerHolder + "_seat-holders" SeatHoldersBucket = assignerHolder + "_seat-holders"
ModulesBucket = indexer + "_modules" // ModulesBucket holds the catalog half's entries by module, the test bed's own stand-in for the runner's first views of it.
ManifestsBucket = indexer + "_manifests" ModulesBucket = catalogHolder + "_modules"
) )
// ModulesRead names the test bed's state of the catalog half's entries.
const ModulesRead = catalogHolder + ".modules"
// ConsoleReads are the states a console's runtime reads to say what the mesh holds (novox/hq ADR 0571 clauses 4, 5): // ConsoleReads are the states a console's runtime reads to say what the mesh holds (novox/hq ADR 0571 clauses 4, 5):
// assignments, the indexer's modules and manifests, and the seat holders. // assignments, the catalog half's entries, and the seat holders.
func ConsoleReads() []bus.StateIssued { func ConsoleReads() []bus.StateIssued {
return []bus.StateIssued{ return []bus.StateIssued{
{Name: assignerHolder + ".assignments", Bucket: AssignmentsBucket}, {Name: assignerHolder + ".assignments", Bucket: AssignmentsBucket},
{Name: indexer + ".modules", Bucket: ModulesBucket}, {Name: ModulesRead, Bucket: ModulesBucket},
{Name: indexer + ".manifests", Bucket: ManifestsBucket},
{Name: assignerHolder + ".seat-holders", Bucket: SeatHoldersBucket}, {Name: assignerHolder + ".seat-holders", Bucket: SeatHoldersBucket},
} }
} }
@@ -188,20 +189,11 @@ func (m *Mesh) Placed(t *testing.T, tools map[string][]string, on map[string][]s
} }
return b return b
} }
modules, manifests, assigned := kv(ModulesBucket), kv(ManifestsBucket), kv(AssignmentsBucket) modules, assigned := kv(ModulesBucket), kv(AssignmentsBucket)
kv(SeatHoldersBucket) kv(SeatHoldersBucket)
for module, declared := range tools { for module, declared := range tools {
manifest, _ := json.Marshal(map[string]any{"module": module, "tools": declared}) manifest := map[string]any{"module": module, "tools": declared}
digest := meshcatalogclient.ManifestDigest(manifest) raw, err := json.Marshal(meshcatalog.CatalogEntry{Module: module, Manifest: manifest, Commit: strings.Repeat("a", 40)})
raw, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: module, Manifest: string(manifest), V: 1})
if err != nil {
t.Fatal(err)
}
if _, err := manifests.Put(digest, raw); err != nil {
t.Fatal(err)
}
raw, err = meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{None: "not built here", Manifest: digest, Repository: "novox/" + module,
Commit: strings.Repeat("a", 40), V: 1})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+113
View File
@@ -0,0 +1,113 @@
package meshtest
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// State is a module's state as a fixture, the shape a gate's Store takes: Fail makes every read and write fail, as a bus
// refusing them; Vals are its values by key. A key the bus refuses (a name outside letters, digits and -/_=, in
// dot-separated names) is refused here too, so no test passes a key the live bus refuses (lead answer A-209).
type State struct {
mu sync.Mutex
Vals map[string]json.RawMessage
revs map[string]uint64
n uint64
Fail error
}
// checkKey refuses a key the bus would.
func checkKey(k string) error {
for _, part := range strings.Split(k, ".") {
if keys.CheckName("key", part) != nil {
return fmt.Errorf("%q is not a key the bus can hold: letters, digits and -/_=, in dot-separated names", k)
}
}
return nil
}
// NewState is an empty State.
func NewState() *State { return &State{Vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} }
func (m *State) Get(k string) (*stdio.StateEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := checkKey(k); err != nil {
return nil, err
}
if m.Fail != nil {
return nil, m.Fail
}
v, ok := m.Vals[k]
if !ok {
return nil, nil
}
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
}
func (m *State) Create(k string, v any) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := checkKey(k); err != nil {
return 0, err
}
if m.Fail != nil {
return 0, m.Fail
}
if _, ok := m.Vals[k]; ok {
return 0, stdio.ErrChanged
}
return m.put(k, v)
}
func (m *State) Update(k string, v any, rev uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := checkKey(k); err != nil {
return 0, err
}
if m.Fail != nil {
return 0, m.Fail
}
if rev == 0 || m.revs[k] != rev {
return 0, stdio.ErrChanged
}
return m.put(k, v)
}
func (m *State) Put(k string, v any) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := checkKey(k); err != nil {
return 0, err
}
if m.Fail != nil {
return 0, m.Fail
}
return m.put(k, v)
}
func (m *State) Keys() ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Fail != nil {
return nil, m.Fail
}
var out []string
for k := range m.Vals {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
func (m *State) put(k string, v any) (uint64, error) {
raw, ok := v.(json.RawMessage)
if !ok {
raw, _ = json.Marshal(v)
}
m.n++
m.Vals[k], m.revs[k] = raw, m.n
return m.n, nil
}
+11 -2
View File
@@ -126,7 +126,7 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
var m struct { var m struct {
Serves map[string]map[string]any `json:"serves"` Serves map[string]map[string]any `json:"serves"`
Moved bool `json:"moved"` Moved bool `json:"moved"`
State []string `json:"state"` State []json.RawMessage `json:"state"`
Reads []string `json:"reads"` Reads []string `json:"reads"`
Provides []struct { Provides []struct {
Name string `json:"name"` Name string `json:"name"`
@@ -172,9 +172,18 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
// Every bucket the sdk's helper reaches is a state the manifest keeps or reads, by the sdk's names: the provider's // Every bucket the sdk's helper reaches is a state the manifest keeps or reads, by the sdk's names: the provider's
// own and the seat mesh-provisioner's provisions and plans (issue 1954 L6-T1); the controller's assignments no more. // own and the seat mesh-provisioner's provisions and plans (issue 1954 L6-T1); the controller's assignments no more.
declared := map[string]bool{} declared := map[string]bool{}
for _, n := range append(m.State, m.Reads...) { for _, n := range m.Reads {
declared[n] = true declared[n] = true
} }
for _, r := range m.State { // an old state entry is its name, or an object naming it (approvals, per machine)
var e struct {
Name string `json:"name"`
}
if json.Unmarshal(r, &e.Name) != nil {
_ = json.Unmarshal(r, &e)
}
declared[e.Name] = true
}
for bucket, want := range map[string]string{ for bucket, want := range map[string]string{
provider.BucketOf(Module): "bound", provider.BucketOf(Module): "bound",
provisions.ProvisionsBucketOf(Provisioner): "mesh-provisioner.provisions", provisions.ProvisionsBucketOf(Provisioner): "mesh-provisioner.provisions",
+62 -1
View File
@@ -2,11 +2,15 @@ package runtime
import ( import (
"encoding/json" "encoding/json"
"os"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/micro" "github.com/nats-io/nats.go/micro"
@@ -121,6 +125,7 @@ func TestTheRuntimeAnnouncesWhatItServesInTheServicesProtocol(t *testing.T) {
// issue 1966 TLS-3 (ENG-2): the runner states the capabilities of the calls it answers in its PING, on its own instance, which // issue 1966 TLS-3 (ENG-2): the runner states the capabilities of the calls it answers in its PING, on its own instance, which
// is what its machine's node-engine asks and takes into the node's record; no bundle is needed for it. // is what its machine's node-engine asks and takes into the node's record; no bundle is needed for it.
func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) {
t.Setenv(FingerprintWord, "sha256:runner")
mesh := mt.New(t) mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil))
nodeTools := connect(t, "node-tools", "anchor") nodeTools := connect(t, "node-tools", "anchor")
@@ -139,7 +144,63 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) {
t.Fatalf("ping of the instance: %v", err) t.Fatalf("ping of the instance: %v", err)
} }
var ping micro.Ping var ping micro.Ping
if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,seat-settle-delay,stream-publish" { if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls,stream-publish" {
t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err)
} }
// Its own fingerprint beside them (design 46 approval mark §6).
if ping.Metadata[announce.Fingerprint] != "sha256:runner" {
t.Errorf("the runner states its fingerprint as %q", ping.Metadata[announce.Fingerprint])
}
// Design 47 G §462, §488, §622: each call is stated under the capability word of its own family, the words mesh-sdk's buscalls
// names: the read of a sent object under sent-objects-calls (no longer under bus-state-calls), the ledger's fill and removal
// under ledger-upkeep-calls, the others where they were.
t.Run("EachCallIsStatedUnderTheWordOfItsFamily", func(t *testing.T) {
for method, word := range map[string]string{
buscalls.ObjectsSentGet: buscalls.SentObjectsCalls,
buscalls.LedgerFill: buscalls.LedgerUpkeepCalls,
buscalls.LedgerRemove: buscalls.LedgerUpkeepCalls,
buscalls.LedgerApply: buscalls.LedgerCalls,
buscalls.ObjectsPut: buscalls.BusStateCalls,
buscalls.StreamPublish: buscalls.StreamPublishWord,
} {
var in []string
for c, methods := range callFamilies {
if slices.Contains(methods, method) {
in = append(in, c)
}
}
if len(in) != 1 || in[0] != word {
t.Errorf("%s is stated under %v, want %s alone", method, in, word)
}
}
})
// Design 47 G §545: the words the tool runner states are exactly its manifest's artifact `states`, so the delivery plan's edge
// of §605 runs to it for each word a module's artifact `calls`.
t.Run("TheWordsTheRunnerStatesAreItsManifestsStates", func(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Build struct {
Artifacts []struct {
Name string `json:"name"`
States []string `json:"states"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
var declared []string
for _, a := range m.Build.Artifacts {
if a.Name == "runtime" {
declared = a.States
}
}
slices.Sort(declared)
if got := CallCapabilities(); !slices.Equal(got, declared) {
t.Errorf("the runner states %v, its manifest's states are %v", got, declared)
}
})
} }
+83 -10
View File
@@ -1,12 +1,16 @@
package runtime package runtime
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"sort" "sort"
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/buscalls"
"github.com/novox/mesh-tools/node-tools/internal/bus"
) )
// The capabilities the tool runner states of the calls it answers (novox/hq ADR 0595 §9, ADR 0597; design 25 § 12.4): each is // The capabilities the tool runner states of the calls it answers (novox/hq ADR 0595 §9, ADR 0597; design 25 § 12.4): each is
@@ -14,10 +18,20 @@ import (
// (announce.Capabilities) for its machine's node-engine to take into the node's record. A module whose manifest asks for one is // (announce.Capabilities) for its machine's node-engine to take into the node's record. A module whose manifest asks for one is
// held off a machine whose runner does not state it. // held off a machine whose runner does not state it.
const ( const (
CapBusStateCalls = "bus-state-calls" CapBusStateCalls = buscalls.BusStateCalls
CapLedgerCalls = "ledger-calls" CapLedgerCalls = buscalls.LedgerCalls
// CapStreamPublish is the call that says an event on the module's own bound stream (A-44). // CapLedgerUpkeepCalls is the ledger's fill and removal (§462, §488) and CapSentObjectsCalls the read of a sent object (§488):
CapStreamPublish = "stream-publish" // each is stated once the runner answers its family, the words mesh-sdk's buscalls names (§622).
CapLedgerUpkeepCalls = buscalls.LedgerUpkeepCalls
CapSentObjectsCalls = buscalls.SentObjectsCalls
// CapPutSessions is the put and get sessions' word (design 47 G §518, §622), mesh-sdk's.
CapPutSessions = stdio.CapPutSessions
// CapApprovalMark says the runner honours the approval mark: its gate sits on every handler it serves (design 46
// 7-approval-mark §6, §20), so the grants part composes no deny of its modules' marked subjects.
CapApprovalMark = "approval-mark/1"
// CapStreamPublish is the call that says an event on the module's own bound stream (design 42 zfs-owns-zfs, A-44), the
// word mesh-sdk's buscalls names (§622).
CapStreamPublish = buscalls.StreamPublishWord
) )
// callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer.
@@ -27,12 +41,16 @@ var callFamilies = map[string][]string{
CapStreamPublish: {buscalls.StreamPublish}, CapStreamPublish: {buscalls.StreamPublish},
CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery, CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery,
buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag}, buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag},
CapLedgerUpkeepCalls: {buscalls.LedgerFill, buscalls.LedgerRemove},
CapSentObjectsCalls: {buscalls.ObjectsSentGet},
CapPutSessions: {buscalls.ObjectsPutChunk, buscalls.ObjectsGetChunk},
} }
// CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat // CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat
// message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5). // message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5); so does `finding-calls`,
// the acknowledged `mesh/publish` and the `mesh/subscribe` for new messages the launch answers (design 47 G §559).
func CallCapabilities() []string { func CallCapabilities() []string {
out := []string{stdio.CapSeatSettleDelay} out := []string{stdio.CapSeatSettleDelay, stdio.CapFindingCalls, CapApprovalMark}
for c := range callFamilies { for c := range callFamilies {
out = append(out, c) out = append(out, c)
} }
@@ -56,11 +74,10 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes
case buscalls.ObjectsGet: case buscalls.ObjectsGet:
var r buscalls.ObjectsNameRequest var r buscalls.ObjectsNameRequest
if err = decode(method, params, &r); err == nil { if err = decode(method, params, &r); err == nil {
var got *buscalls.ObjectData answer, err = b.all.objectsGet(b.module, r)
if got, err = conn.ObjectsGet(b.module, r); err == nil && got == nil { if err == nil && answer == nil {
return json.RawMessage("null"), nil return json.RawMessage("null"), nil
} }
answer = got
} }
case buscalls.ObjectsDelete: case buscalls.ObjectsDelete:
var r buscalls.ObjectsNameRequest var r buscalls.ObjectsNameRequest
@@ -68,6 +85,15 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes
err = conn.ObjectsDelete(b.module, r) err = conn.ObjectsDelete(b.module, r)
answer = buscalls.Done{} answer = buscalls.Done{}
} }
case buscalls.ObjectsSentGet:
var r buscalls.ObjectsSentGetRequest
if err = decode(method, params, &r); err == nil {
answer, err = b.all.sentGet(b.module, r)
}
case buscalls.ObjectsPutChunk:
answer, err = b.all.putChunk(b.module, params)
case buscalls.ObjectsGetChunk:
answer, err = b.all.getChunk(b.module, params)
case buscalls.ObjectsList: case buscalls.ObjectsList:
var r buscalls.ObjectsRequest var r buscalls.ObjectsRequest
if err = decode(method, params, &r); err == nil { if err = decode(method, params, &r); err == nil {
@@ -104,7 +130,7 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes
answer, err = conn.WorkQueueDead(b.module, r) answer, err = conn.WorkQueueDead(b.module, r)
} }
case buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerQuery, buscalls.LedgerRebuild, case buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerQuery, buscalls.LedgerRebuild,
buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag: buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag, buscalls.LedgerFill, buscalls.LedgerRemove:
answer, err = b.ledgerCall(method, params) answer, err = b.ledgerCall(method, params)
default: default:
return nil, fmt.Errorf("the runtime answers no %s", method) return nil, fmt.Errorf("the runtime answers no %s", method)
@@ -143,3 +169,50 @@ func decode(method string, params json.RawMessage, into any) error {
} }
return nil return nil
} }
// objectsGet answers a get: the object whole when it is at most one chunk, else the session that reads it chunk by chunk
// (design 47 G §490, §518); nil when there is none.
func (c *consumers) objectsGet(module string, r buscalls.ObjectsNameRequest) (any, error) {
st := c.objects()
if st == nil {
return nil, fmt.Errorf("the runner has no bus to read %s of %s from", r.Name, module)
}
ctx, cancel := context.WithTimeout(context.Background(), bus.StateTimeout)
defer cancel()
info, rd, found, err := st.ObjectsOpen(ctx, module, r)
if err != nil || !found {
return nil, err
}
if info.Size > buscalls.ChunkBytes {
return c.openGet(module, info, rd, sessionDigest(info.Digest), false)
}
defer rd.Close()
data, err := io.ReadAll(rd)
if err != nil {
return nil, err
}
return &buscalls.ObjectData{Info: info, Data: data}, nil
}
// sentGet answers a get of a sent object by its reference: whole when the reference's size is at most one chunk, else a
// session (design 47 G §620).
func (c *consumers) sentGet(module string, r buscalls.ObjectsSentGetRequest) (any, error) {
if r.Size <= buscalls.ChunkBytes {
got, err := c.conn.SentGet(r)
if err != nil {
return nil, err
}
return got, nil
}
st := c.objects()
if st == nil {
return nil, fmt.Errorf("the runner has no bus to read %s from", r.Object)
}
ctx, cancel := context.WithTimeout(context.Background(), bus.StateTimeout)
defer cancel()
info, rd, err := st.SentOpen(ctx, r)
if err != nil {
return nil, err
}
return c.openGet(module, info, rd, sessionDigest(r.Digest), true)
}
+103 -4
View File
@@ -2,9 +2,13 @@ package runtime
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
@@ -24,7 +28,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
mesh := mt.New(t) mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}})) mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}}))
nodeTools := connect(t, "node-tools", "anchor") nodeTools := connect(t, "node-tools", "anchor")
stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -62,13 +66,20 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
} }
} }
// The subject that names no caller is still served, for one release (retired by hq issue 464), and says so: // deskDefined is the seat list's definition of the fixture seat node-desk, its verb who taking its class's default.
// the module is named no caller, whatever the body claims. var deskDefined = WithSeatDefinitions(func() map[string]seatdef.Definition {
return map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who"}}}}
})
// The subject that names no caller is still served for a module's tool, for one release (retired by hq issue 464), and says
// so: the module is named no caller, whatever the body claims. A seat's verb refuses it before the bundle runs (design 47 G
// §610; lead answers A-165, A-179), and refuses every caller while the seat's definition is unread, names no such verb or
// opens it to none.
func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) { func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
mesh := mt.New(t) mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}})) mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}}))
nodeTools := connect(t, "node-tools", "anchor") nodeTools := connect(t, "node-tools", "anchor")
stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -91,8 +102,96 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
} }
return err return err
}) })
if strings.HasPrefix(subject, "mesh.seat.") {
if !strings.Contains(reply.Error, "node-desk.who takes no unproven caller") || reply.Result.Call != "" {
t.Errorf("%s: an unproven caller of a seat's verb was answered %+v (error %q), want refused before the bundle", subject, reply.Result, reply.Error)
}
continue
}
if reply.Error != "" || reply.Result.Caller != "" { if reply.Error != "" || reply.Result.Caller != "" {
t.Errorf("%s: the module was named caller %q (error %q), want none", subject, reply.Result.Caller, reply.Error) t.Errorf("%s: the module was named caller %q (error %q), want none", subject, reply.Result.Caller, reply.Error)
} }
} }
for _, c := range []struct {
defs map[string]seatdef.Definition
says string
}{
{nil, "is not yet read"},
{map[string]seatdef.Definition{"node-desk": {Seat: "node-desk"}}, "names no verb who"},
{map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Callers: &seatdef.Callers{List: []string{}}}}}}, "open to no caller"},
{map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Class: seatdef.ClassAct}}}}, ""},
} {
defs := c.defs
err := (&consumers{definitions: func() map[string]seatdef.Definition { return defs }}).admitted("node-desk", "who", "workstation.node-tools")
if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) {
t.Errorf("a proven caller with the definitions %v: %v, want %q", c.defs, err, c.says)
}
}
// Design 47 G A-249: a bundle's seat call is received by the runner in chunks only for a verb whose definition says `chunks`,
// of any machine: a verb a node holds is asked of the node that ran the ask (A-254).
t.Run("OnlyAChunkedSeatVerbIsAskedInChunks", func(t *testing.T) {
c := &consumers{definitions: func() map[string]seatdef.Definition {
return map[string]seatdef.Definition{"mesh-catalog": {Verbs: []seatdef.Verb{{Name: "input", Chunks: true}, {Name: "entry"}}}}
}}
for key, want := range map[string]bool{
"seat:mesh-catalog.input": true,
"seat:mesh-catalog.entry": false,
"seat:mesh-catalog.input@home-server": true,
"seat:mesh-catalog.entry@home-server": false,
"seat:mesh-unread.input": false,
"mesh-delivery-indexer.indexer": false,
} {
if got := c.chunked(key); got != want {
t.Errorf("chunked(%s) = %v, want %v", key, got, want)
}
}
if (&consumers{}).chunked("seat:mesh-catalog.input") {
t.Error("a runner that holds no seat definitions asks no chunks")
}
})
// **The tool runner answers a held seat's marked verb by caller and serves the seat's `approvals`** (design 47 G §677; lead
// answers A-265, A-271), with zsh's node-login-shell `execute` as the case: the marked verb reaches the gate with the caller
// its subject names, and `approvals` is answered from the runner's own record of the calls its gate holds, with the
// arguments for the call's own caller only, though no bundle names the verb.
t.Run("TheRunnerServesApprovalsForTheSeatsItsBundlesHold", func(t *testing.T) {
mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("zsh", "anchor", true, map[string][]string{"node-login-shell": {"execute", seatdef.ApprovalsVerb}}))
nodeTools := connect(t, "node-tools", "anchor")
gate := &gateHolding{}
defined := WithSeatDefinitions(func() map[string]seatdef.Definition {
return map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{
{Name: "execute", NeedsApproval: true}, {Name: seatdef.ApprovalsVerb}}}}
})
stop, err := Run(nodeTools, []Served{{"zsh", []string{mt.Fixture("login-shell-execute.py")}}}, nil, func(string, ...any) {}, defined, WithGate(gate))
if err != nil {
t.Fatal(err)
}
defer stop()
asker, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "workstation", User: "workstation.node-tools", Password: "unused"})
if err != nil {
t.Fatal(err)
}
asker.Logf = func(string, ...any) {}
defer asker.Close()
mt.Until(t, func() error {
_, err := asker.Ask("seat:node-login-shell.execute@anchor", map[string]any{"command": "ls"}, "")
return err
})
if len(gate.calls) != 1 || gate.calls[0].Seat != "node-login-shell" || gate.calls[0].Name != "execute" || gate.calls[0].Caller != "workstation.node-tools" {
t.Fatalf("the marked verb reached the gate as %+v, want the seat's execute by workstation.node-tools", gate.calls)
}
var got approval.Approvals
mt.Until(t, func() error {
answered, err := asker.Ask("seat:node-login-shell.approvals@anchor", map[string]any{}, "")
if err == nil {
err = json.Unmarshal(answered.Result, &got)
}
return err
})
if len(got.Calls) != 1 || got.Calls[0].Caller != "workstation.node-tools" || got.Calls[0].Verb != "node-login-shell.execute" ||
string(got.Calls[0].Arguments) != `{"command":"ls"}` {
t.Fatalf("approvals answered %+v, want the one call the gate holds, with its caller's arguments", got)
}
})
} }
+118
View File
@@ -0,0 +1,118 @@
package runtime
import (
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"github.com/novox/mesh-tools/node-tools/internal/launch"
)
// FingerprintWord is the word of a module's composed environment that names the build its bundle serves (design 47 G §424).
const FingerprintWord = "MESH_FINGERPRINT"
// Gate is the approval gate the runner serves through (design 46 7-approval-mark §4, §20; node-tools/internal/approval).
type Gate interface {
// Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding.
Unserved(module, fingerprint string, registered []string) []string
// Run is a call through the gate: run is called only when the call may run now, once.
Run(call approval.Call, run func() (any, error)) (any, error)
// Approvals is the seat verb `approvals` for a caller: the calls the gate holds, its acts and claims (A-265, A-271).
Approvals(caller string) (approval.Approvals, error)
}
// gatedRun is how a handler calls a tool: through the gate where there is one.
type gatedRun func(call approval.Call, t launch.Tool, caller string) (any, error)
// gated calls the tool through the gate, or straight where the runner has none.
func (c *consumers) gated(call approval.Call, t launch.Tool, caller string) (any, error) {
run := func() (any, error) { return t.Run(caller, call.Args) }
if c.gate == nil {
return run()
}
return c.gate.Run(call, run)
}
// approvalsOf is how the runner answers a held seat's verb `approvals`: from its gate's record, nil without a gate.
func (r *runner) approvalsOf() func(caller string) (any, error) {
if r.events.gate == nil {
return nil
}
return func(caller string) (any, error) { return r.events.gate.Approvals(caller) }
}
// WithGate serves every call through the approval gate.
func WithGate(g Gate) Option { return func(c *consumers) { c.gate = g } }
var _ Gate = (*approval.Gate)(nil)
// WithSeatDefinitions refuses, where a seat's verb is answered, a caller outside the verb's effective callers, before the
// bundle runs (design 47 G §610; lead answers A-165, A-179): defs are the seat definitions the runner's seat list holds.
// Without it a verb is answered for every caller, as before.
func WithSeatDefinitions(defs func() map[string]seatdef.Definition) Option {
return func(c *consumers) { c.definitions = defs }
}
// admitted says why a seat's verb is refused to a caller, nil when it is answered: an unproven caller is refused, and so is
// every caller while the seat's definition is not read, names no such verb, or the verb's effective callers are empty; a
// proven caller is among them, since the grants part composes a verb's call subject only for its effective callers (A-179).
func (c *consumers) admitted(seat, verb, caller string) error {
if c.definitions == nil {
return nil
}
if caller == "" {
return fmt.Errorf("Not run: %s.%s takes no unproven caller: call it on its subject that names its caller, "+
"mesh.seat.%s.call.%s.<caller>", seat, verb, seat, verb)
}
def, ok := c.definitions()[seat]
if !ok {
return fmt.Errorf("Not run: the definition of the seat %s is not yet read by this tool runner's seat list, so no caller "+
"of %s.%s is admitted", seat, seat, verb)
}
for _, v := range def.Verbs {
if v.Name == verb {
if len(v.EffectiveCallers()) == 0 {
return fmt.Errorf("Not run: %s.%s is open to no caller", seat, verb)
}
return nil
}
}
return fmt.Errorf("Not run: the seat %s's definition names no verb %s, so no caller is admitted", seat, verb)
}
// unservedMarks is, of the module's marked tools, those its bundle's registrations under its own name do not register.
func (r *runner) unservedMarks(module string, regs []launch.Registration) []string {
if r.events.gate == nil {
return nil
}
var registered []string
for _, reg := range regs {
if reg.Module == module {
for _, t := range reg.Tools {
registered = append(registered, t.Name)
}
}
}
return r.events.gate.Unserved(module, r.fingerprintOf(module), registered)
}
// chunked says whether key names a seat's verb whose definition says `chunks` (design 47 G A-249): its answer is received in
// chunks by the runner, whose call hands the bundle the whole.
func (c *consumers) chunked(key string) bool {
if c.definitions == nil || !strings.HasPrefix(key, "seat:") {
return false
}
name, _, _ := strings.Cut(key, "@") // a verb asked of the machine that ran the ask is chunked as the verb is (A-254)
seat, verb, ok := strings.Cut(strings.TrimPrefix(name, "seat:"), ".")
if !ok {
return false
}
for _, v := range c.definitions()[seat].Verbs {
if v.Name == verb {
return v.Chunks
}
}
return false
}
@@ -0,0 +1,26 @@
package runtime
import (
"git.novox.be/novox/mesh-sdk/go/approval"
)
// gateHolding is the runner's gate as far as the runtime reaches it: it records the calls it is run for and answers
// `approvals` from them.
type gateHolding struct{ calls []approval.Call }
func (g *gateHolding) Unserved(string, string, []string) []string { return nil }
func (g *gateHolding) Run(c approval.Call, run func() (any, error)) (any, error) {
g.calls = append(g.calls, c)
return run()
}
func (g *gateHolding) Approvals(caller string) (approval.Approvals, error) {
out := approval.Approvals{Calls: []approval.ListedCall{}, Acts: []approval.ListedAct{}, Claims: []string{}}
for i, c := range g.calls {
l := approval.ListedCall{Call: string(rune('a' + i)), Caller: c.Caller, Verb: c.Seat + "." + c.Name, State: "waiting"}
if caller == c.Caller {
l.Arguments = c.Args
}
out.Calls = append(out.Calls, l)
}
return out, nil
}
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"testing" "testing"
"git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/buscalls"
@@ -240,6 +241,35 @@ func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) {
t.Fatalf("queried %+v, %v", q, err) t.Fatalf("queried %+v, %v", q, err)
} }
}}, }},
{"fill and removal are answered through the calls and remove an instance from the bus whole", "decision.gone", func(t *testing.T) {
must(t, open(t), "decision/gone", "title", `{"title":"Gone"}`)
must(t, open(t), "decision/gone", "note", `{"says":"two"}`)
must(t, open(t), "decision/stays", "note", `{"says":"kept"}`)
raw, err := m.BusState(buscalls.LedgerFill, json.RawMessage(`{"ledger":"records"}`))
var fill buscalls.Fill
if _ = json.Unmarshal(raw, &fill); err != nil || fill.Count < 3 || fill.Bytes == 0 {
t.Fatalf("the fill answered %s (%v)", raw, err)
}
raw, err = m.BusState(buscalls.LedgerRemove, json.RawMessage(`{"ledger":"records","instance":"decision/gone"}`))
var removed buscalls.LedgerRemoved
if _ = json.Unmarshal(raw, &removed); err != nil || removed.Operations != 2 || removed.Bytes == 0 {
t.Fatalf("the removal answered %s (%v)", raw, err)
}
l := open(t)
if last, _ := l.History.Last("decision.gone"); last != 0 {
t.Fatalf("operations are left, at %d", last)
}
if st, _, _ := l.State.Get("decision.gone"); st != nil {
t.Fatalf("the state is left: %s", st)
}
if last, _ := l.History.Last("decision.stays"); last == 0 {
t.Fatal("another instance lost its operations")
}
if raw, err = m.BusState(buscalls.LedgerRemove, json.RawMessage(`{"ledger":"records","instance":"decision/gone"}`)); err != nil ||
!strings.Contains(string(raw), `"operations":0`) {
t.Fatalf("a removal said twice answered %s (%v)", raw, err)
}
}},
{"a forged author is refused", "decision.forged", func(t *testing.T) { {"a forged author is refused", "decision.forged", func(t *testing.T) {
_, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"decision/forged","operation":"title","body":{"title":"T","author":"op@shanks"},"call":"forged"}`)) _, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"decision/forged","operation":"title","body":{"title":"T","author":"op@shanks"},"call":"forged"}`))
if err == nil || !strings.Contains(err.Error(), "the call forged, which is not a tool call in flight") { if err == nil || !strings.Contains(err.Error(), "the call forged, which is not a tool call in flight") {
@@ -258,4 +288,70 @@ func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) {
} }
}) })
} }
// Design 47 G §559, A-153: against the real bus a publish with ack answers the sequence the bus gave it, and a subscription
// for new messages hears only what is published after it was made, on the subject mesh-sdk's one rule names for the event;
// a wildcard is refused.
t.Run("AnAckedPublishAnswersItsSequenceAndANewSubscriptionHearsOnlyWhatComesAfter", func(t *testing.T) {
mt.New(t)
nc, err := nats.Connect(mt.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, _ := jetstream.New(nc)
ctx := context.Background()
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEATEVENTS", Subjects: []string{"mesh.seat.mesh-conditions.event.>"}}); err != nil {
t.Fatal(err)
}
conn := connect(t, "node-tools", "anchor")
publish := func(body string) {
t.Helper()
if _, err := js.Publish(ctx, "mesh.seat.mesh-conditions.event.findings-asked", []byte(body)); err != nil {
t.Fatal(err)
}
}
publish(`{"before":true}`)
var mu sync.Mutex
var heard []string
stop, err := conn.SubscribeNew("mesh-conditions.findings-asked", func(raw json.RawMessage) error {
var env bus.Envelope
_ = json.Unmarshal(raw, &env)
mu.Lock()
heard = append(heard, env.Key+" "+string(env.Body))
mu.Unlock()
return nil
})
if err != nil {
t.Fatal(err)
}
defer stop()
publish(`{"after":true}`)
publish(`{"after":2}`)
mt.Until(t, func() error {
mu.Lock()
defer mu.Unlock()
if len(heard) < 2 {
return context.DeadlineExceeded
}
return nil
})
mu.Lock()
if len(heard) != 2 || heard[0] != `mesh-conditions.findings-asked {"after":true}` {
t.Errorf("heard %v, want the messages after the subscription alone, in order", heard)
}
mu.Unlock()
for name, want := range map[string]string{"mesh-conditions.*": "[A-Za-z0-9_-]"} {
if _, err := conn.SubscribeNew(name, func(json.RawMessage) error { return nil }); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("a subscription for %s answered %v, want one naming %q", name, err, want)
}
}
first, err := conn.PublishAsAcked("node-tools", bus.Envelope{Key: "finding-written", Body: json.RawMessage(`{}`)})
second, err2 := conn.PublishAsAcked("node-tools", bus.Envelope{Key: "finding-written", Body: json.RawMessage(`{}`)})
if err != nil || err2 != nil || first == 0 || second != first+1 {
t.Errorf("acknowledged publishes answered %d (%v) and %d (%v), want consecutive sequences", first, err, second, err2)
}
})
} }
+11
View File
@@ -171,6 +171,17 @@ func (b *moduleBus) ledgerCall(method string, params json.RawMessage) (any, erro
var r buscalls.LedgerRequest var r buscalls.LedgerRequest
_ = json.Unmarshal(params, &r) _ = json.Unmarshal(params, &r)
return l.Lag(r) return l.Lag(r)
case buscalls.LedgerFill:
var r buscalls.LedgerRequest
_ = json.Unmarshal(params, &r)
return l.Fill(r)
case buscalls.LedgerRemove:
// Never on an apply: a removal is no operation of the history and has no author (design 47 G §587).
var r buscalls.LedgerRemoveRequest
if err := decode(method, params, &r); err != nil {
return nil, err
}
return l.Remove(r)
} }
return nil, fmt.Errorf("the runtime answers no %s", method) return nil, fmt.Errorf("the runtime answers no %s", method)
} }
+1 -1
View File
@@ -204,7 +204,7 @@ func TestABundleToolNamedAsALedgerRepairIsRefused(t *testing.T) {
// no other name; none answers "the runtime answers no ...". // no other name; none answers "the runtime answers no ...".
func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) { func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) {
m, _, _ := aLedger(t) m, _, _ := aLedger(t)
if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,ledger-calls,seat-settle-delay,stream-publish" { if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls,stream-publish" {
t.Fatalf("the runner states %s", got) t.Fatalf("the runner states %s", got)
} }
for capability, methods := range callFamilies { for capability, methods := range callFamilies {
@@ -36,6 +36,25 @@ func TestNoBusWordReachesABundle(t *testing.T) {
t.Errorf("%s did not reach the bundle", want) t.Errorf("%s did not reach the bundle", want)
} }
} }
// design 47 G §618, §519: the runtime's own fingerprint reaches no bundle; the fingerprint a module's composed words hold does.
t.Run("TheRuntimesOwnFingerprintReachesNoBundle", func(t *testing.T) {
base := []string{"MESH_FINGERPRINT=runner-build", "PATH=/usr/bin"}
for name, c := range map[string]struct {
given map[string]string
want string
}{
"composed with none sees none": {nil, ""},
"composed with one sees its own": {map[string]string{"MESH_FINGERPRINT": "module-build"}, "MESH_FINGERPRINT=module-build"},
} {
env := strings.Join(BundleEnv(base, c.given, "telegram", "anchor"), "\n")
if strings.Contains(env, "runner-build") {
t.Errorf("%s: the runtime's own fingerprint reached the bundle: %s", name, env)
}
if c.want == "" && strings.Contains(env, "MESH_FINGERPRINT") || !strings.Contains(env, c.want) {
t.Errorf("%s: the environment is %s", name, env)
}
}
})
} }
// A tool call reaches only a tool's subject: whatever key a caller names, it is never a seat's event, accept // A tool call reaches only a tool's subject: whatever key a caller names, it is never a seat's event, accept
+97 -8
View File
@@ -16,6 +16,9 @@ import (
"sync" "sync"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"github.com/novox/mesh-tools/node-tools/internal/announce" "github.com/novox/mesh-tools/node-tools/internal/announce"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/launch" "github.com/novox/mesh-tools/node-tools/internal/launch"
@@ -59,6 +62,10 @@ type ListedTool struct {
// NodeRuntimeModule is the module that is a machine's runtime, serving every module on it. // NodeRuntimeModule is the module that is a machine's runtime, serving every module on it.
const NodeRuntimeModule = "node-tools" const NodeRuntimeModule = "node-tools"
// fingerprintEnv is the word the node-engine starts each process with, the fingerprint of the want it applies (mesh-sdk's
// process.FingerprintEnv; this package imports no SDK): the runtime's own is no bundle's.
const fingerprintEnv = "MESH_FINGERPRINT"
// runtimeOnly are the words of the runtime's own environment no bundle is given. // runtimeOnly are the words of the runtime's own environment no bundle is given.
var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true, var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true,
"MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true, LedgersFile: true} "MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true, LedgersFile: true}
@@ -230,6 +237,10 @@ type runner struct {
of map[string]*loadedModule of map[string]*loadedModule
verbs map[string]func() // each module's `tools` verb, where it is answered verbs map[string]func() // each module's `tools` verb, where it is answered
// fingerprintOf is the build a module's bundle serves: the fingerprint its composed environment carries (design 47 G
// §424, §618), never the runner's own.
fingerprintOf func(module string) string
seatsAgain func() seatsAgain func()
stops []func() // the watch, the announcement and the seats, in the order they began stops []func() // the watch, the announcement and the seats, in the order they began
} }
@@ -263,6 +274,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
base := os.Environ() base := os.Environ()
r.envFor = func(module string) []string { return BundleEnv(base, envs[module], module, node) } r.envFor = func(module string) []string { return BundleEnv(base, envs[module], module, node) }
r.fingerprintOf = func(module string) string { return envs[module][FingerprintWord] }
// The module's events, for every child of it that subscribes (ADR 0198): one consumer per module, // The module's events, for every child of it that subscribes (ADR 0198): one consumer per module,
// bound the first time any of its children subscribes, each event handed to every child that did. // bound the first time any of its children subscribes, each event handed to every child that did.
@@ -298,7 +310,8 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
} }
logf("%s", line) logf("%s", line)
conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() })
stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf) stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.events.chunked, r.approvalsOf(),
r.fingerprintOf, logf)
r.seatsAgain = again r.seatsAgain = again
r.stops = append(r.stops, stopSeats) r.stops = append(r.stops, stopSeats)
@@ -308,7 +321,10 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
announced, err := announce.Serve(conn, announce.Service{ announced, err := announce.Serve(conn, announce.Service{
Name: conn.Module(), ID: instanceOf(conn), Name: conn.Module(), ID: instanceOf(conn),
Description: "the mesh's tool runtime on " + node + ": every assigned module's tools and the seats they hold", Description: "the mesh's tool runtime on " + node + ": every assigned module's tools and the seats they hold",
Metadata: map[string]string{"node": node, announce.Capabilities: strings.Join(CallCapabilities(), ",")}, // Its own fingerprint beside the capabilities, so a reading is tied to the runner that made it (design 46 approval
// mark §6, lead answer A-203).
Metadata: map[string]string{"node": node, announce.Capabilities: strings.Join(CallCapabilities(), ","),
announce.Fingerprint: os.Getenv(FingerprintWord)},
}, func() []announce.Endpoint { return endpointsOf(conn, r.own(), r.registrations(), r.modules) }) }, func() []announce.Endpoint { return endpointsOf(conn, r.own(), r.registrations(), r.modules) })
if err != nil { if err != nil {
logf("[mesh-tools] cannot announce what it serves: %v", err) logf("[mesh-tools] cannot announce what it serves: %v", err)
@@ -409,6 +425,13 @@ func (r *runner) load(s Served) *loadedModule {
fail(clashed) fail(clashed)
continue continue
} }
// A bundle whose marks name a tool it does not register is not loaded: no tool of its module is served here, rather
// than one served unmarked that the manifest meant to mark (design 46 7-approval-mark §4).
if unserved := r.unservedMarks(module, child.Registrations); len(unserved) > 0 {
child.Stop()
fail(fmt.Sprintf("its marks name %s, which it does not register (design 46 7-approval-mark §4)", strings.Join(unserved, ", ")))
continue
}
l.children = append(l.children, child.Stop) l.children = append(l.children, child.Stop)
for _, reg := range child.Registrations { for _, reg := range child.Registrations {
got := registration{module: reg.Module, owner: module, tools: reg.Tools} got := registration{module: reg.Module, owner: module, tools: reg.Tools}
@@ -452,8 +475,11 @@ func (r *runner) serve(l *loadedModule) {
for _, t := range reg.tools { for _, t := range reg.tools {
t := t t := t
// Told who called, from the subject the call arrived on, never from its body (novox/hq issue 365). // Told who called, from the subject the call arrived on, never from its body (novox/hq issue 365).
// Through the approval gate, before the bundle or the runner's own tool (design 46 7-approval-mark §20).
module, own := reg.module, reg.repairs
stop, err := r.conn.HandleCalled(reg.module+"."+t.Name, func(caller string, body json.RawMessage) (any, error) { stop, err := r.conn.HandleCalled(reg.module+"."+t.Name, func(caller string, body json.RawMessage) (any, error) {
return t.Run(caller, argsOf(body)) return r.events.gated(approval.Call{Address: module + "." + t.Name, Module: module, Name: t.Name, Own: own,
Fingerprint: r.fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller)
}) })
if err != nil { if err != nil {
r.logf("[mesh-tools] cannot serve %s.%s: %v", reg.module, t.Name, err) r.logf("[mesh-tools] cannot serve %s.%s: %v", reg.module, t.Name, err)
@@ -637,6 +663,9 @@ func endpointsOf(conn *bus.Conn, own []registration, registrations []registratio
} }
for _, v := range m.Seats { for _, v := range m.Seats {
t, ok := impl[v.Seat][v.Verb] t, ok := impl[v.Seat][v.Verb]
if v.Verb == seatdef.ApprovalsVerb && !ok {
t, ok = launch.Tool{Name: v.Verb, Description: "The calls this seat's holder holds for the operator's approval, its acts and its claims."}, true
}
if !ok || have[v.Subject] { if !ok || have[v.Subject] {
continue continue
} }
@@ -691,7 +720,9 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string {
// the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again // the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again
// whenever a membership changes, and when again is called because a bundle was loaded again. Whether // whenever a membership changes, and when again is called because a bundle was loaded again. Whether
// this machine holds the seat is the bus's to decide. It answers its stop and again. // this machine holds the seat is the bus's to decide. It answers its stop and again.
func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf func(string, ...any)) (func(), func()) { func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun,
admitted func(seat, verb, caller string) error, chunked func(key string) bool, approvals func(caller string) (any, error),
fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) {
var mu sync.Mutex var mu sync.Mutex
var stops []func() var stops []func()
stopped := false stopped := false
@@ -725,13 +756,43 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration,
continue continue
} }
have[v.Subject] = true have[v.Subject] = true
// The seat's verb `approvals` is the gate's own, served wherever a marked verb is: the runner answers it from its
// record of the calls its gate holds, by caller, and no bundle names it (lead answers A-265, A-266, A-271).
if v.Verb == seatdef.ApprovalsVerb && approvals != nil {
seat := v.Seat
stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, _ json.RawMessage) (any, error) {
if err := admitted(seat, seatdef.ApprovalsVerb, caller); err != nil {
return nil, err
}
return approvals(caller)
})
if err != nil {
logf("[mesh-tools] cannot serve %s's approvals on %s: %v", seat, v.Subject, err)
continue
}
stops = append(stops, stop)
logf("[mesh-tools] serving %s's approvals on %s, the gate's own verb", seat, v.Subject)
continue
}
t, ok := impl[v.Seat][v.Verb] t, ok := impl[v.Seat][v.Verb]
if !ok { if !ok {
logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb) logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb)
continue continue
} }
// Its callers checked, then through the approval gate, before the bundle (design 47 G §610, A-179; design 46
// 7-approval-mark §20).
module, v := module, v
stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, body json.RawMessage) (any, error) { stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, body json.RawMessage) (any, error) {
return t.Run(caller, argsOf(body)) if err := admitted(v.Seat, v.Verb, caller); err != nil {
return nil, err
}
answer, err := gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb,
Fingerprint: fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller)
if err != nil || !chunked("seat:"+v.Seat+"."+v.Verb) {
return answer, err
}
// A verb marked chunks: the bundle answers the whole file, the runner sends it in chunks (design 47 G A-257).
return conn.AnswerChunked(v.Seat, v.Verb, body, answer)
}) })
if err != nil { if err != nil {
logf("[mesh-tools] cannot serve %s's %s on %s: %v", v.Seat, v.Verb, v.Subject, err) logf("[mesh-tools] cannot serve %s's %s on %s: %v", v.Seat, v.Verb, v.Subject, err)
@@ -770,6 +831,12 @@ type consumers struct {
ledgers ledgerSource ledgers ledgerSource
// calls are the tool calls in flight, by the id each bundle was handed (ledgers.go, novox/hq ADR 0597 §7). // calls are the tool calls in flight, by the id each bundle was handed (ledgers.go, novox/hq ADR 0597 §7).
calls callsInFlight calls callsInFlight
// sessions are the bundles' put and get sessions (sessions.go).
sessions sessionTable
// gate is the approval gate every call is served through (gate.go); nil serves every call as before.
gate Gate
// definitions are the seat definitions a seat verb's callers are read from (gate.go); nil admits every caller.
definitions func() map[string]seatdef.Definition
} }
type moduleEvents struct { type moduleEvents struct {
@@ -845,6 +912,7 @@ func (c *consumers) dropModule(module string) {
} }
} }
c.provisioned.stopModule(module) c.provisioned.stopModule(module)
c.dropModuleSessions(module)
} }
// forModule is the bus one launched bundle of a module reaches the mesh through. // forModule is the bus one launched bundle of a module reaches the mesh through.
@@ -869,6 +937,21 @@ func (b *moduleBus) Publish(params json.RawMessage) error {
return b.all.conn.PublishAs(b.module, env) return b.all.conn.PublishAs(b.module, env)
} }
// PublishAcked is Publish that answers the sequence the bus acknowledged the event at (`mesh/publish` with `ack`, design 47 G §559).
func (b *moduleBus) PublishAcked(params json.RawMessage) (uint64, error) {
var env bus.Envelope
if err := json.Unmarshal(params, &env); err != nil {
return 0, fmt.Errorf("not an event envelope: %w", err)
}
return b.all.conn.PublishAsAcked(b.module, env)
}
// SubscribeNew hands the bundle one event's messages published from now on, on an ordered consumer of its own (`mesh/subscribe`
// with `deliver: new`, design 47 G §559); the consumer ends with the bundle's channel.
func (b *moduleBus) SubscribeNew(pattern string, deliver func(json.RawMessage) error) (func(), error) {
return b.all.conn.SubscribeNew(pattern, deliver)
}
// Ask calls a tool as the module: `{key, body}`, answered with the tool's result (ADR 0198). // Ask calls a tool as the module: `{key, body}`, answered with the tool's result (ADR 0198).
func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) { func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) {
var asked struct { var asked struct {
@@ -882,7 +965,11 @@ func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) {
if len(asked.Body) == 0 { if len(asked.Body) == 0 {
body = map[string]any{} body = map[string]any{}
} }
answered, err := b.all.conn.AskAs(b.module, asked.Key, body) ask := b.all.conn.AskAs
if b.all.chunked(asked.Key) {
ask = b.all.conn.AskAsChunked // the runner receives the chunks of the answer; the bundle receives none (design 47 G A-249)
}
answered, err := ask(b.module, asked.Key, body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1366,11 +1453,13 @@ func (b *moduleBus) bind(key string, stop func()) {
// BundleEnv is what one module's bundle is started with: the runtime's environment without its own words, // BundleEnv is what one module's bundle is started with: the runtime's environment without its own words,
// the module's composed words over it, and the module's and machine's names. **No bus word reaches a // the module's composed words over it, and the module's and machine's names. **No bus word reaches a
// bundle** (security review of 2026-10-08): the credential and the bus's address are the runtime's, and a // bundle** (security review of 2026-10-08): the credential and the bus's address are the runtime's, and a
// bundle reaches the bus only through the runtime. // bundle reaches the bus only through the runtime. **The runtime's own fingerprint reaches none either**: a bundle reads
// only the `MESH_FINGERPRINT` its module's composed words hold, the build the node-engine applied for that module, and with
// none it has none (design 47 G §519, §618).
func BundleEnv(base []string, given map[string]string, module, node string) []string { func BundleEnv(base []string, given map[string]string, module, node string) []string {
words := map[string]string{} words := map[string]string{}
for _, kv := range base { for _, kv := range base {
if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] { if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] && k != fingerprintEnv {
words[k] = v words[k] = v
} }
} }
+44 -2
View File
@@ -4,10 +4,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"os" "os"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/approval"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/launch" "github.com/novox/mesh-tools/node-tools/internal/launch"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
@@ -192,10 +194,50 @@ func TestEachBundleIsGivenItsOwnEnvironment(t *testing.T) {
} }
} }
// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). // unservingGate is an approval gate whose marks of each module name the tools given, so a bundle not registering them is
// refused at the load (design 46 7-approval-mark §4).
type unservingGate map[string][]string
func (g unservingGate) Unserved(module, _ string, registered []string) []string {
var missing []string
for _, tool := range g[module] {
if !slices.Contains(registered, tool) {
missing = append(missing, tool)
}
}
return missing
}
func (unservingGate) Run(_ approval.Call, run func() (any, error)) (any, error) { return run() }
func (unservingGate) Approvals(string) (approval.Approvals, error) { return approval.Approvals{}, nil }
// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). A bundle whose marks name a
// tool it does not register is refused at the load, serving no tool of its module, and one that registers it serves
// (design 46 7-approval-mark §4).
func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) { func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) {
mt.NeedBundles(t)
mesh := mt.New(t) mesh := mt.New(t)
{
mesh.Issue(t, mt.MembershipOf("marked", "anchor", true, nil))
mesh.Issue(t, mt.MembershipOf("served", "anchor", true, nil))
nodeTools := connect(t, "node-tools", "anchor")
asker := connect(t, "console", "workstation")
logs := &mt.Logs{}
stop, err := Run(nodeTools, []Served{{"marked", []string{mt.Fixture("caller-echo.py")}}, {"served", []string{mt.Fixture("caller-echo.py")}}},
nil, logs.Logf, WithGate(unservingGate{"marked": {"wipe"}, "served": {"who"}}))
if err != nil {
t.Fatal(err)
}
if !logs.Has("marked's bundle", "failed to load: its marks name wipe, which it does not register (design 46 7-approval-mark §4)") {
t.Errorf("a bundle not serving its marked tool was not refused at the load:\n%s", logs.All())
}
if _, err := call(t, asker, "served.who@anchor", map[string]any{}); err != nil {
t.Errorf("a bundle serving its marked tool: %v", err)
}
if _, err := asker.Ask("marked.who@anchor", map[string]any{}, ""); !errors.Is(err, bus.ErrNoResponders) {
t.Errorf("a refused bundle's tool answered: %v", err)
}
stop()
}
mt.NeedBundles(t)
mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil))
mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil)) mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil))
nodeTools := connect(t, "node-tools", "anchor") nodeTools := connect(t, "node-tools", "anchor")
+307
View File
@@ -0,0 +1,307 @@
package runtime
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"github.com/novox/mesh-tools/node-tools/internal/launch"
)
// A bundle's put and get sessions (design 47 G §518, A-21, A-25, §620): a value over buscalls.ChunkBytes crosses the
// bundle's channel as calls the runner holds the session of, nothing of it on the bus. A put writes each chunk into the
// module's object store as it arrives (through a pipe into the store's own chunking, so no more than one chunk is held) and
// seals the object on the last chunk; a get reads the object out as the bundle asks its chunks, in order.
// SessionBound is how long a session has, counted once for the whole session from its first chunk (§518 "The bound"): the
// call's bound, launch.CallTimeout.
var SessionBound = launch.CallTimeout
// objectSessions is what a session needs of the bus: *bus.Conn.
type objectSessions interface {
ObjectsPutStream(ctx context.Context, module string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error)
ObjectsOpen(ctx context.Context, module string, r buscalls.ObjectsNameRequest) (buscalls.ObjectInfo, io.ReadCloser, bool, error)
SentOpen(ctx context.Context, r buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error)
}
// sessionTable holds the sessions of every module, each under its module's name and the bundle's call id, so a bundle
// reaches its own sessions alone.
type sessionTable struct {
mu sync.Mutex
puts map[string]*putSession
gets map[string]*getSession
// store replaces the bus where a test has none.
store objectSessions
}
type putSession struct {
mu sync.Mutex
next uint32
pw *io.PipeWriter
done chan struct{} // closed once res is set
res putResult
cancel context.CancelFunc
timer *time.Timer
store string
}
type putResult struct {
info buscalls.ObjectInfo
err error
}
type getSession struct {
mu sync.Mutex
next uint32
rd *bufio.Reader
closer io.Closer
timer *time.Timer
}
var errSessionBound = errors.New("the session was not completed within the call's bound")
func sessionKey(module, call string) string { return module + " " + call }
// putChunk takes one chunk of a put session: it answers {seq}, or {error}, and the put's own answer for the last chunk.
func (c *consumers) putChunk(module string, params json.RawMessage) (any, error) {
var r buscalls.ObjectsPutChunkRequest
if err := decode(buscalls.ObjectsPutChunk, params, &r); err != nil {
return nil, err
}
if r.Call == "" {
return nil, errors.New("a chunk names the put's call")
}
t := &c.sessions
key := sessionKey(module, r.Call)
t.mu.Lock()
s := t.puts[key]
if r.Seq == 1 && s == nil {
var err error
if s, err = c.beginPut(module, key, r); err != nil {
t.mu.Unlock()
return buscalls.ObjectsChunkTaken{Error: err.Error()}, nil
}
}
t.mu.Unlock()
if s == nil {
return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("no put %s of %s is open at chunk %d: it was dropped at its bound, or never begun", r.Call, r.Name, r.Seq)}, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if r.Seq != s.next {
c.dropPut(key, s, errors.New("a chunk out of order"))
return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s: chunk %d came where chunk %d was next", r.Name, r.Seq, s.next)}, nil
}
if len(r.Data) > buscalls.ChunkBytes {
c.dropPut(key, s, errors.New("a chunk over the limit"))
return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s: chunk %d is %d bytes, over the %d a chunk carries", r.Name, r.Seq, len(r.Data), buscalls.ChunkBytes)}, nil
}
if _, err := s.pw.Write(r.Data); err != nil {
// The store refused the chunk (its cap) or the bound passed: its own reason is the answer.
<-s.done
res := s.res
c.forgetPut(key, s)
reason := res.err
if reason == nil || errors.Is(reason, io.ErrClosedPipe) {
reason = err
}
return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s into %s, at chunk %d: %v", r.Name, s.store, r.Seq, reason)}, nil
}
if !r.Last {
s.next++
return buscalls.ObjectsChunkTaken{Seq: r.Seq}, nil
}
_ = s.pw.Close()
<-s.done
res := s.res
c.forgetPut(key, s)
if res.err != nil {
return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s into %s: %v", r.Name, s.store, res.err)}, nil
}
return res.info, nil
}
// beginPut opens the put of the first chunk, with t.mu held.
func (c *consumers) beginPut(module, key string, r buscalls.ObjectsPutChunkRequest) (*putSession, error) {
if r.Name == "" {
return nil, errors.New("an object is put under a name")
}
st := c.objects()
if st == nil {
return nil, errors.New("the runner has no bus to put on")
}
pr, pw := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
s := &putSession{next: 1, pw: pw, done: make(chan struct{}), cancel: cancel, store: r.Objects}
s.timer = time.AfterFunc(SessionBound, func() {
c.sessions.mu.Lock()
current := c.sessions.puts[key] == s
c.sessions.mu.Unlock()
if current {
_ = pw.CloseWithError(errSessionBound)
cancel()
}
})
if c.sessions.puts == nil {
c.sessions.puts = map[string]*putSession{}
}
c.sessions.puts[key] = s
go func() {
info, err := st.ObjectsPutStream(ctx, module, buscalls.ObjectsNameRequest{Objects: r.Objects, Name: r.Name}, r.Description, pr)
_ = pr.CloseWithError(err)
s.res = putResult{info, err}
close(s.done)
}()
return s, nil
}
// dropPut ends a put that will not be sealed: the store purges what it wrote.
func (c *consumers) dropPut(key string, s *putSession, why error) {
_ = s.pw.CloseWithError(why)
<-s.done
c.forgetPut(key, s)
}
func (c *consumers) forgetPut(key string, s *putSession) {
s.timer.Stop()
s.cancel()
c.sessions.mu.Lock()
delete(c.sessions.puts, key)
c.sessions.mu.Unlock()
}
// objects is the bus a session reads and writes objects through.
func (c *consumers) objects() objectSessions {
if c.sessions.store != nil {
return c.sessions.store
}
if c.conn == nil {
return nil
}
return c.conn
}
// openGet registers the get session of an opened object and answers the chunked answer that begins it.
func (c *consumers) openGet(module string, info buscalls.ObjectInfo, rd io.ReadCloser, digest string, sent bool) (any, error) {
call := newCall()
g := &getSession{next: 1, rd: bufio.NewReaderSize(rd, buscalls.ChunkBytes), closer: rd}
key := sessionKey(module, call)
g.timer = time.AfterFunc(SessionBound, func() { c.dropGet(key, g) })
c.sessions.mu.Lock()
if c.sessions.gets == nil {
c.sessions.gets = map[string]*getSession{}
}
c.sessions.gets[key] = g
c.sessions.mu.Unlock()
var chunked json.RawMessage = json.RawMessage("true")
if !sent {
chunked = json.RawMessage(fmt.Sprint((info.Size + buscalls.ChunkBytes - 1) / buscalls.ChunkBytes))
}
return buscalls.ObjectsChunked{Chunked: chunked, Size: info.Size, Digest: digest, Call: call}, nil
}
func (c *consumers) dropGet(key string, g *getSession) {
g.timer.Stop()
c.sessions.mu.Lock()
delete(c.sessions.gets, key)
c.sessions.mu.Unlock()
_ = g.closer.Close()
}
// getChunk answers the next chunk of a get session: {seq, last, data}, or {error}.
func (c *consumers) getChunk(module string, params json.RawMessage) (any, error) {
var r buscalls.ObjectsGetChunkRequest
if err := decode(buscalls.ObjectsGetChunk, params, &r); err != nil {
return nil, err
}
key := sessionKey(module, r.Call)
c.sessions.mu.Lock()
g := c.sessions.gets[key]
c.sessions.mu.Unlock()
if g == nil {
return buscalls.ObjectsChunk{Error: fmt.Sprintf("no get %s is open at chunk %d: it was dropped at its bound, or never begun", r.Call, r.Seq)}, nil
}
g.mu.Lock()
defer g.mu.Unlock()
if r.Seq != g.next {
c.dropGet(key, g)
return buscalls.ObjectsChunk{Error: fmt.Sprintf("chunk %d was asked where chunk %d is next", r.Seq, g.next)}, nil
}
buf := make([]byte, buscalls.ChunkBytes)
n, err := io.ReadFull(g.rd, buf)
last := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
if err != nil && !last {
c.dropGet(key, g)
return buscalls.ObjectsChunk{Error: fmt.Sprintf("reading chunk %d: %v", r.Seq, err)}, nil
}
if !last {
if _, perr := g.rd.Peek(1); errors.Is(perr, io.EOF) {
last = true
}
}
g.next++
if last {
c.dropGet(key, g)
}
return buscalls.ObjectsChunk{Seq: r.Seq, Last: last, Data: buf[:n]}, nil
}
// dropModuleSessions ends every session of a module whose bundle is gone.
func (c *consumers) dropModuleSessions(module string) {
c.sessions.mu.Lock()
var puts []*putSession
var putKeys []string
var gets []*getSession
var getKeys []string
for k, s := range c.sessions.puts {
if strings.HasPrefix(k, module+" ") {
puts, putKeys = append(puts, s), append(putKeys, k)
}
}
for k, g := range c.sessions.gets {
if strings.HasPrefix(k, module+" ") {
gets, getKeys = append(gets, g), append(getKeys, k)
}
}
c.sessions.mu.Unlock()
for i, s := range puts {
c.dropPut(putKeys[i], s, errors.New("the bundle's channel closed"))
}
for i, g := range gets {
c.dropGet(getKeys[i], g)
}
}
// sessionDigest is the `sha256:` form of an object's digest as the bus store keeps it (`SHA-256=` and base64), or as a
// reference carries it (hex).
func sessionDigest(d string) string {
if raw, ok := strings.CutPrefix(d, "SHA-256="); ok {
if b, err := base64.URLEncoding.DecodeString(raw); err == nil {
return "sha256:" + hex.EncodeToString(b)
}
}
return "sha256:" + strings.TrimPrefix(d, "sha256:")
}
func newCall() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// CapabilityWords are the capability words this runner states, answered to a bundle's `mesh/capabilities` (§576).
func (b *moduleBus) CapabilityWords() []string { return CallCapabilities() }
// DropSessions ends the sessions of the module whose bundle's channel closed (§518).
func (b *moduleBus) DropSessions() { b.all.dropModuleSessions(b.module) }
@@ -0,0 +1,193 @@
package runtime
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// fakeObjects is the bus of a session: what was put whole, what a read gives, and what the store deleted.
type fakeObjects struct {
put map[string][]byte
description string
deleted []string
read []byte
ctxEnded chan struct{}
}
func (f *fakeObjects) ObjectsPutStream(ctx context.Context, _ string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error) {
data, err := io.ReadAll(in)
if err != nil {
f.deleted = append(f.deleted, r.Name)
if f.ctxEnded != nil {
<-ctx.Done()
close(f.ctxEnded)
}
return buscalls.ObjectInfo{}, err
}
f.put[r.Name], f.description = data, description
return buscalls.ObjectInfo{Name: r.Name, Size: uint64(len(data)), Description: description}, nil
}
func (f *fakeObjects) ObjectsOpen(context.Context, string, buscalls.ObjectsNameRequest) (buscalls.ObjectInfo, io.ReadCloser, bool, error) {
return buscalls.ObjectInfo{Name: "o", Size: uint64(len(f.read))}, io.NopCloser(bytes.NewReader(f.read)), true, nil
}
func (f *fakeObjects) SentOpen(context.Context, buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error) {
return buscalls.ObjectInfo{Size: uint64(len(f.read))}, io.NopCloser(bytes.NewReader(f.read)), nil
}
func aSession(t *testing.T, f *fakeObjects) *moduleBus {
t.Helper()
c := &consumers{}
c.sessions.store = f
return &moduleBus{all: c, module: "m"}
}
func asked(t *testing.T, b *moduleBus, method string, req any, into any) {
t.Helper()
params, _ := json.Marshal(req)
raw, err := b.BusState(method, params)
if err != nil {
t.Fatalf("%s: %v", method, err)
}
if err := json.Unmarshal(raw, into); err != nil {
t.Fatal(err)
}
}
// §490, §518, §620: a value over one chunk is got as a session (the chunked answer, then each chunk by its call and sequence,
// the whole checked against the digest); a sent object over one chunk the same, with `chunked` true.
func TestAGetOverOneChunkRunsAsASession(t *testing.T) {
whole := bytes.Repeat([]byte("y"), 2*buscalls.ChunkBytes)
f := &fakeObjects{read: whole}
b := aSession(t, f)
sum := sha256.Sum256(whole)
want := "sha256:" + hex.EncodeToString(sum[:])
for name, run := range map[string]func() buscalls.ObjectsChunked{
"state": func() buscalls.ObjectsChunked {
var c buscalls.ObjectsChunked
asked(t, b, buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: "files", Name: "o"}, &c)
return c
},
"sent": func() buscalls.ObjectsChunked {
var c buscalls.ObjectsChunked
asked(t, b, buscalls.ObjectsSentGet, buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: int64(len(whole)), Digest: hex.EncodeToString(sum[:])}, &c)
if string(c.Chunked) != "true" {
t.Errorf("a sent object's chunked is %s", c.Chunked)
}
return c
},
} {
t.Run(name, func(t *testing.T) {
c := run()
if c.Call == "" || c.Size != uint64(len(whole)) {
t.Fatalf("the get answered %+v", c)
}
if name == "state" {
if string(c.Chunked) != "2" {
t.Errorf("chunked is %s, want 2", c.Chunked)
}
}
var got []byte
for seq := uint32(1); ; seq++ {
var ch buscalls.ObjectsChunk
asked(t, b, buscalls.ObjectsGetChunk, buscalls.ObjectsGetChunkRequest{Call: c.Call, Seq: seq}, &ch)
if ch.Error != "" || ch.Seq != seq {
t.Fatalf("chunk %d: %+v", seq, ch)
}
got = append(got, ch.Data...)
if ch.Last {
if seq != 2 {
t.Fatalf("an object of two chunks ended at chunk %d", seq)
}
break
}
}
if !bytes.Equal(got, whole) || name == "state" && c.Digest == "" {
t.Fatalf("read %d bytes, digest %s", len(got), c.Digest)
}
if name == "sent" && c.Digest != want {
t.Errorf("digest %s, want %s", c.Digest, want)
}
var again buscalls.ObjectsChunk
asked(t, b, buscalls.ObjectsGetChunk, buscalls.ObjectsGetChunkRequest{Call: c.Call, Seq: 3}, &again)
if again.Error == "" {
t.Error("a finished session answered another chunk")
}
})
}
// A value of one chunk or less is the value itself, as before.
f.read = []byte("small")
var small buscalls.ObjectData
asked(t, b, buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: "files", Name: "o"}, &small)
if string(small.Data) != "small" {
t.Fatalf("a small value was answered %+v", small)
}
// Design 47 G §518, A-21, A-25: a put session writes each chunk as it is taken, carries the description of its first chunk,
// and answers the put's own info for the last; a chunk out of order fails the put naming both sequences and leaves none.
t.Run("APutSessionSealsOnTheLastChunkAndDropsOnADisorder", func(t *testing.T) {
f := &fakeObjects{put: map[string][]byte{}}
b := aSession(t, f)
whole := bytes.Repeat([]byte("x"), 2*buscalls.ChunkBytes+5)
var taken buscalls.ObjectsChunkTaken
for seq, at := uint32(1), 0; ; seq++ {
end := min(at+buscalls.ChunkBytes, len(whole))
req := buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "a", Call: "c1", Seq: seq, Last: end == len(whole), Data: whole[at:end]}
if seq == 1 {
req.Description = "what it is"
}
if req.Last {
var info buscalls.ObjectInfo
asked(t, b, buscalls.ObjectsPutChunk, req, &info)
if info.Size != uint64(len(whole)) || info.Description != "what it is" {
t.Fatalf("the put answered %+v", info)
}
break
}
asked(t, b, buscalls.ObjectsPutChunk, req, &taken)
if taken.Seq != seq || taken.Error != "" {
t.Fatalf("chunk %d was answered %+v", seq, taken)
}
at = end
}
if !bytes.Equal(f.put["a"], whole) {
t.Fatalf("the store holds %d bytes, want %d", len(f.put["a"]), len(whole))
}
asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 1, Data: []byte("one")}, &taken)
asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 3, Data: []byte("three")}, &taken)
if !strings.Contains(taken.Error, "chunk 3") || !strings.Contains(taken.Error, "chunk 2") {
t.Fatalf("a disorder was answered %+v", taken)
}
if _, ok := f.put["b"]; ok || len(f.deleted) != 1 || f.deleted[0] != "b" {
t.Fatalf("a dropped put left %v, deleted %v", f.put, f.deleted)
}
asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 2, Data: []byte("two")}, &taken)
if taken.Error == "" {
t.Fatal("a chunk of a dropped put was taken")
}
})
// §518 "The bound": a put not sealed within the bound is dropped, its object deleted.
t.Run("APutNotSealedWithinTheBoundIsDropped", func(t *testing.T) {
was := SessionBound
SessionBound = time.Millisecond
t.Cleanup(func() { SessionBound = was })
f := &fakeObjects{put: map[string][]byte{}, ctxEnded: make(chan struct{})}
b := aSession(t, f)
var taken buscalls.ObjectsChunkTaken
asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "a", Call: "c", Seq: 1, Data: []byte("x")}, &taken)
<-f.ctxEnded
if len(f.deleted) != 1 {
t.Fatalf("the unsealed object was left: %v", f.deleted)
}
})
}
+35 -13
View File
@@ -1,17 +1,13 @@
{ {
"module": "node-tools", "module": "node-tools",
"version": "2", "version": "0.1",
"slug": "node-tools", "slug": "node-tools",
"tool-runner": true, "tool-runner": true,
"invokes": [ "invokes": [
"*" "*"
], ],
"waits": [ "uses": [
"mesh-knowledge.reviewed", "operator-channel"
"mesh-delivery-gate.transition",
"gitea.forge.status",
"mesh-self-check.checked",
"operator-channel.decided.claude-code"
], ],
"provides": [ "provides": [
{ {
@@ -62,21 +58,47 @@
"language": "go", "language": "go",
"system": "arch", "system": "arch",
"from": "cmd/node-tools", "from": "cmd/node-tools",
"binary": "node-tools" "binary": "node-tools",
"states": [
"approval-mark/1",
"bus-state-calls",
"finding-calls",
"ledger-calls",
"ledger-upkeep-calls",
"put-sessions",
"seat-settle-delay",
"sent-objects-calls",
"stream-publish"
]
} }
] ]
}, },
"state": [ "state": [
"bound", "bound",
"findings" "findings",
{
"name": "approvals",
"per-machine": true,
"ttl-seconds": 172800
}
], ],
"consumes": [ "consumes": [
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed",
"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed", "mesh-catalog.seat-definition-removed",
"mesh-delivery-assigner.views.assignments", "mesh-conditions.findings-asked",
"mesh-delivery-assigner.views.seat-holders", "seat:mesh-module-assigner.views.assignments",
"mesh-delivery-indexer.views.manifests", "seat:mesh-module-assigner.views.seat-holders",
"mesh-delivery-indexer.views.modules" "mesh-delivery.source-moved"
],
"streams": [
{
"name": "node-tools-findings",
"events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"],
"max-age-days": 7,
"max-mib": 16
}
], ],
"reads": [ "reads": [
"mesh-provisioner.provisions", "mesh-provisioner.provisions",
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
# The login shell's bundle as the seat node-login-shell has it: MCP over stdio, no SDK, one seat verb, execute, which says
# the command it was given and the caller the call's `_meta` named.
import json, sys
def say(m):
m["jsonrpc"] = "2.0"; sys.stdout.write(json.dumps(m) + "\n"); sys.stdout.flush()
TOOLS = [
{"name": "node-login-shell.execute", "description": "run a command", "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}}}},
]
for line in sys.stdin:
req = json.loads(line); rid = req.get("id"); m = req.get("method"); p = req.get("params") or {}
if m == "initialize":
say({"id": rid, "result": {"protocolVersion": "2025-03-26", "capabilities": {"tools": {}}, "serverInfo": {"name": "zsh", "version": "1"}}})
elif m == "tools/list":
say({"id": rid, "result": {"tools": TOOLS}})
elif m == "tools/call":
meta = p.get("_meta") or {}
said = {"ran": (p.get("arguments") or {}).get("command", ""), "caller": meta.get("mesh/caller", "")}
say({"id": rid, "result": {"content": [{"type": "text", "text": json.dumps(said)}]}})
elif rid is not None:
say({"id": rid, "result": {}})