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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,7 +25,12 @@ import (
|
||||
"syscall"
|
||||
"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"
|
||||
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/console"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/firstview"
|
||||
@@ -51,7 +56,7 @@ func main() {
|
||||
cred := credential(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).
|
||||
_ = startSeatList(conn)
|
||||
seatList := startSeatList(conn)
|
||||
|
||||
served, err := runtime.ServedModulesFrom(os.Getenv(runtime.ToolModules), cred.Module)
|
||||
if err != nil {
|
||||
@@ -69,6 +74,30 @@ func main() {
|
||||
defer ledgers.Stop()
|
||||
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...)
|
||||
if err != nil {
|
||||
log.Fatalf("node-tools: %v", err)
|
||||
@@ -90,16 +119,14 @@ func main() {
|
||||
}
|
||||
var up *console.Listening
|
||||
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
|
||||
if node == "" {
|
||||
node = "?"
|
||||
}
|
||||
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 {
|
||||
log.Fatalf("node-tools: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@ import (
|
||||
"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
|
||||
// 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").
|
||||
func TestAwaitCredential(t *testing.T) {
|
||||
func awaitsCredential(t *testing.T) {
|
||||
const good = `{"url":"tls://bus.example:4222","module":"m"}`
|
||||
rereadEvery = time.Hour // only the watch can wake the read in time
|
||||
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
|
||||
// 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")
|
||||
unreachable := errors.New("nats: no servers available for connection")
|
||||
for _, c := range []struct {
|
||||
|
||||
@@ -71,3 +71,14 @@ func startSeatListOn(b seatlist.Bus, bound <-chan time.Time, logf func(string, .
|
||||
}
|
||||
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
@@ -5,11 +5,13 @@ go 1.26.0
|
||||
require (
|
||||
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/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/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-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/clients/meshcatalogclient 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
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
@@ -27,3 +29,7 @@ require (
|
||||
golang.org/x/crypto v0.57.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
|
||||
|
||||
@@ -48,6 +48,11 @@ const Digest = "digest"
|
||||
// module needing one is placed only where the runtime states it.
|
||||
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.
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -58,15 +58,16 @@ func (c *Conn) Bindings(module string) []membership.BusBinding {
|
||||
return nil
|
||||
}
|
||||
|
||||
// jetStream is a JetStream handle on this connection.
|
||||
func (c *Conn) jetStream() (jetstream.JetStream, error) { return jetstream.New(c.nc) }
|
||||
// JetStream is a JetStream handle on this connection: its one accessor, for a part that hands it to a finding writer (design 47 G
|
||||
// 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) {
|
||||
b, err := c.Binding(module, local, catalogue.StorageObjectStore)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
js, err := c.jetStream()
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
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) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
js, err := c.jetStream()
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
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)
|
||||
defer cancel()
|
||||
js, err := c.jetStream()
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
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)
|
||||
defer cancel()
|
||||
js, err := c.jetStream()
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
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)
|
||||
defer cancel()
|
||||
js, err := c.jetStream()
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
return buscalls.WorkQueueDeadLetters{}, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"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" {
|
||||
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 {
|
||||
t.Fatalf("listed %+v, %v", listed, err)
|
||||
}
|
||||
|
||||
@@ -75,9 +75,6 @@ type Membership struct {
|
||||
// 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).
|
||||
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
|
||||
// membership.BusBinding): its object stores, streams, work queues and ledgers, its own alone (bindings.go).
|
||||
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
|
||||
// own id (ADR 0042). The body is the payload; the metadata rides as headers.
|
||||
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
|
||||
// otherwise — and only there, as before, for a membership that names no stream.
|
||||
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 != "" {
|
||||
opts = append(opts, nats.MsgId(id))
|
||||
}
|
||||
_, err := c.js.PublishMsg(msg, opts...)
|
||||
return err
|
||||
ack, err := c.js.PublishMsg(msg, opts...)
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
"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) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
js, err := c.jetStream()
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
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 {
|
||||
c *Conn
|
||||
bucket string
|
||||
}
|
||||
|
||||
func (s *busState) kv(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
js, err := s.c.jetStream()
|
||||
js, err := s.c.JetStream()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -231,6 +302,11 @@ func (s *busSnapshots) Get(key string) ([]byte, uint64, error) {
|
||||
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 {
|
||||
if s.write == nil {
|
||||
return errors.New("a ledger's snapshot is published only on its module's ledger connection, and none is held")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -87,6 +87,74 @@ func TestAWatchHoldsWhileItsCurrentValuesKeepArriving(t *testing.T) {
|
||||
}
|
||||
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
|
||||
@@ -187,72 +255,3 @@ func owedWatcher(t *testing.T) (*bus.Conn, interface {
|
||||
})
|
||||
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
|
||||
|
||||
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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,38 +17,42 @@ import (
|
||||
// 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).
|
||||
|
||||
// WaitIssued is one event family the mesh issued this account to wait on (ADR 0367 §2): the name a session
|
||||
// asks for, and the subject it lands on. Only the tool runner's membership carries any.
|
||||
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.
|
||||
// Heard is one event a wait heard: the subject it arrived on, its body, when it arrived, and when it was published, the
|
||||
// event's own header `x-time` as its emitter wrote it, empty for an event that carries none (design 34 every-event §12).
|
||||
type Heard struct {
|
||||
Subject string
|
||||
Body json.RawMessage
|
||||
At time.Time
|
||||
Subject string
|
||||
Body json.RawMessage
|
||||
At time.Time
|
||||
Published string
|
||||
}
|
||||
|
||||
// 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
|
||||
// closed before it returns. A subscription the bus refuses is said as an error naming the subject, at once;
|
||||
// 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, err error) {
|
||||
// closed before it returns. A subscription the bus refuses is noted and the others go on waiting; refused lists
|
||||
// those noted by the time it answers (design 34 every-event §16). When the bus refused every subscription of the
|
||||
// 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 {
|
||||
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)
|
||||
refused := make(chan string, len(subjects))
|
||||
refusals := make(chan string, len(unique))
|
||||
var subs []*nats.Subscription
|
||||
defer func() {
|
||||
for _, s := range subs {
|
||||
_ = s.Unsubscribe()
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, subject := range subjects {
|
||||
delete(c.waits[subject], refused)
|
||||
for _, subject := range unique {
|
||||
delete(c.waits[subject], refusals)
|
||||
if len(c.waits[subject]) == 0 {
|
||||
delete(c.waits, subject)
|
||||
}
|
||||
@@ -58,14 +63,14 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur
|
||||
if c.waits == nil {
|
||||
c.waits = map[string]map[chan string]bool{}
|
||||
}
|
||||
for _, subject := range subjects {
|
||||
for _, subject := range unique {
|
||||
if c.waits[subject] == nil {
|
||||
c.waits[subject] = map[chan string]bool{}
|
||||
}
|
||||
c.waits[subject][refused] = true
|
||||
c.waits[subject][refusals] = true
|
||||
}
|
||||
c.mu.Unlock()
|
||||
for _, subject := range subjects {
|
||||
for _, subject := range unique {
|
||||
sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
body := json.RawMessage(msg.Data)
|
||||
if !json.Valid(body) {
|
||||
@@ -76,31 +81,39 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur
|
||||
return
|
||||
}
|
||||
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:
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
// The bus has every subscription — or has said which it refused — once this returns.
|
||||
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)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case h := <-got:
|
||||
return h, true, nil
|
||||
case subject := <-refused:
|
||||
return Heard{}, false, fmt.Errorf("the bus refused this account a subscription on %s: it is not in the "+
|
||||
"account's wait grant (novox/hq ADR 0367 §2)", subject)
|
||||
case <-timer.C:
|
||||
return Heard{}, false, nil
|
||||
case <-ctx.Done():
|
||||
return Heard{}, false, ctx.Err()
|
||||
for {
|
||||
select {
|
||||
case h := <-got:
|
||||
return h, true, refused, nil
|
||||
case subject := <-refusals:
|
||||
if !slices.Contains(refused, subject) {
|
||||
refused = append(refused, subject)
|
||||
}
|
||||
case <-timer.C:
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,24 @@ package bus_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
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
|
||||
// name — another asker's `decided` among them — and a wait on one says the bus refused it, at once.
|
||||
// novox/hq ADR 0670 §1 (design 34 every-event §12, §16): a subscription the bus refuses is noted and the others go on
|
||||
// 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) {
|
||||
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,
|
||||
Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{
|
||||
Publish: &server.SubjectPermission{Allow: []string{"$JS.API.DIRECT.GET.>", "mesh.>"}},
|
||||
@@ -33,33 +35,65 @@ func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) {
|
||||
t.Cleanup(s.Shutdown)
|
||||
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()
|
||||
_, ok, err := c.HearFirst(context.Background(), []string{"mesh.seat.operator-channel.event.decided.shop"}, 5*time.Second, nil)
|
||||
if ok || err == nil || !strings.Contains(err.Error(), "refused") {
|
||||
t.Errorf("a wait on another asker's decided: heard %v, %v", ok, err)
|
||||
_, ok, refused, err := c.HearFirst(context.Background(), []string{other}, 300*time.Millisecond, nil)
|
||||
if took := time.Since(start); took < 300*time.Millisecond || took > 3*time.Second {
|
||||
t.Errorf("a wait refused whole answered after %s, want its timeout of 300ms", took)
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Errorf("the refusal took %s to be said", time.Since(start))
|
||||
if ok || err == nil || !strings.Contains(err.Error(), "the bus refused this account a subscription on "+other) ||
|
||||
!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)
|
||||
go func() {
|
||||
h, ok, err := c.HearFirst(context.Background(), []string{granted}, 5*time.Second, func(body json.RawMessage) bool { return true })
|
||||
if ok && err == nil {
|
||||
done <- h
|
||||
}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if err := c.Say(granted, []byte(`{"ask":"a1"}`)); err != nil {
|
||||
// One refused beside one granted: the granted one hears, with the event's own time, and the refused one is listed.
|
||||
pub, err := nats.Connect(s.ClientURL(), nats.UserInfo("desk.node-tools", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case h := <-done:
|
||||
if h.Subject != granted || string(h.Body) != `{"ask":"a1"}` {
|
||||
t.Errorf("heard %+v", h)
|
||||
t.Cleanup(pub.Close)
|
||||
for _, published := range []string{"2026-11-12T03:00:00.5Z", ""} {
|
||||
type result struct {
|
||||
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())
|
||||
time.AfterFunc(200*time.Millisecond, cancel)
|
||||
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) {
|
||||
t.Errorf("a cancelled wait answered %v, %v", ok, err)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,12 @@ package console
|
||||
import (
|
||||
assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"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"
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"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; " +
|
||||
"for when an address is said to be missing or on another machine."},
|
||||
{"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 " +
|
||||
"assigner's assignments and seat-holders, the indexer's manifests and modules): for each, the owner, the tool " +
|
||||
"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 " +
|
||||
"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."},
|
||||
{"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 " +
|
||||
"the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."},
|
||||
waitTool(),
|
||||
approvalsTool(),
|
||||
}
|
||||
}
|
||||
|
||||
func isDiscovery(name string) bool {
|
||||
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 false
|
||||
@@ -359,14 +360,14 @@ func indexOn(conn *bus.Conn, src Source, held announce.Known, before func(announ
|
||||
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
|
||||
// assignments and seat-holders, the indexer's modules and manifests, by `<owner>.<state>` (firstview.AssignmentsRead and the
|
||||
// rest). The tool runner's is its first views (firstview.Views), never the owners' state (novox/hq ADR 0615 §1).
|
||||
// Source is where the console reads what it indexes the mesh by: the assigner's assignments and seat-holders, by
|
||||
// `<owner>.<state>` (firstview.AssignmentsRead and SeatHoldersRead), and the modules of the catalog half with their entries.
|
||||
// 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 {
|
||||
// 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)
|
||||
// Entry is one value of a state, nil when it holds none.
|
||||
Entry(name, key string) ([]byte, error)
|
||||
// Modules is every module the catalog half holds, by name, with its entry; an error says why it cannot be told yet.
|
||||
Modules() (map[string]meshcatalog.CatalogEntry, error)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// The indexer's states the console reads (novox/hq ADR 0571 clauses 4 and 5): every module, and each manifest by its
|
||||
// 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.
|
||||
// manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces.
|
||||
type manifestFacts struct {
|
||||
Tools []string `json:"tools"`
|
||||
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,
|
||||
// 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.
|
||||
@@ -411,18 +402,19 @@ func recordsOn(src Source) (recorded []recordedModule, machines []string, why []
|
||||
on[m.Module] = append(on[m.Module], node)
|
||||
}
|
||||
}
|
||||
modules, err := src.State(modulesRead)
|
||||
entries, err := src.Modules()
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, machines, append(why, modulesRead+" ("+err.Error()+")")
|
||||
names := make([]string, 0, len(entries))
|
||||
for name := range entries {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
r := recordedModule{Module: name, On: on[name]}
|
||||
if m, err := meshcatalogclient.DecodeModule(modules[name]); err == nil && m.Manifest != "" {
|
||||
facts, err := manifestOf(src, m.Manifest)
|
||||
if m := entries[name].Manifest; len(m) > 0 {
|
||||
facts, err := manifestOf(m)
|
||||
if err != nil {
|
||||
why = append(why, name+" (its manifest could not be read, so whether it answers tools is not known: "+err.Error()+")")
|
||||
} else {
|
||||
@@ -434,42 +426,16 @@ func recordsOn(src Source) (recorded []recordedModule, machines []string, 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
|
||||
// value; a deleted key absent). A key that is not a module's is refused, naming it.
|
||||
func moduleNames(keys map[string][]byte) ([]string, error) {
|
||||
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)
|
||||
// manifestOf is what the console takes of the manifest an entry of the catalog half carries whole (design 47 G §443).
|
||||
func manifestOf(manifest map[string]any) (manifestFacts, error) {
|
||||
raw, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return manifestFacts{}, err
|
||||
}
|
||||
var facts manifestFacts
|
||||
if err := json.Unmarshal([]byte(m.Manifest), &facts); err != nil {
|
||||
if err := json.Unmarshal(raw, &facts); err != nil {
|
||||
return manifestFacts{}, err
|
||||
}
|
||||
manifestsKept.Store(digest, facts)
|
||||
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.
|
||||
// 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 {
|
||||
var b strings.Builder
|
||||
enc := json.NewEncoder(&b)
|
||||
@@ -985,6 +966,9 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
|
||||
if name == verbWait {
|
||||
return s.waitAnswer(ctx, args)
|
||||
}
|
||||
if name == verbApprovals {
|
||||
return s.approvalsAnswer(args)
|
||||
}
|
||||
x, err := s.index()
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
if err != nil {
|
||||
why := whyItFailed(t.Address, err)
|
||||
if t.Stale != nil {
|
||||
why += " (" + t.Stale.say() + ")"
|
||||
}
|
||||
return failure(why)
|
||||
return failure(s.callFailed(t, err))
|
||||
}
|
||||
result, unread := resolveSent(s.conn, got.Result)
|
||||
content := []map[string]any{{"type": "text", "text": pretty(result)}}
|
||||
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 != "" {
|
||||
content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -16,6 +17,14 @@ type FirstViewer interface {
|
||||
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.
|
||||
func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[string]any) map[string]any {
|
||||
fv, ok := s.src.(FirstViewer)
|
||||
@@ -24,7 +33,14 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st
|
||||
"another source")
|
||||
}
|
||||
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)
|
||||
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
|
||||
// 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)
|
||||
if err := s.recordAct(map[string]any{"what": "asked the tool runner's four first views again", "why": 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 first views again", "why": why,
|
||||
"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())
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -14,12 +15,19 @@ import (
|
||||
type viewer struct {
|
||||
busSource
|
||||
asked []string
|
||||
build process.Build
|
||||
}
|
||||
|
||||
func (v *viewer) Status() []views.Status {
|
||||
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 {
|
||||
v.asked = append(v.asked, why)
|
||||
return nil
|
||||
@@ -35,9 +43,16 @@ func TestTheConsoleShowsAndAsksAgainTheRunnersFirstViews(t *testing.T) {
|
||||
v := &viewer{}
|
||||
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 }}
|
||||
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)
|
||||
}
|
||||
// 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"})
|
||||
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))
|
||||
|
||||
@@ -8,37 +8,70 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0367 §5: the tool runner's manifest names the families a session waits on today, one by one,
|
||||
// and the asker's `decided` for the claude-code module alone. Adding one is a reviewed change to this file
|
||||
// (§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
|
||||
// and 1406): today's module is forgotten.
|
||||
func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) {
|
||||
// The tool runner's manifest names no events to wait on (novox/hq ADR 0670 §2): a session waits on any event by its
|
||||
// name. It holds each change of design 34 every-event §11 (E-1): no `waits`, `operator-channel` in `uses` for the asks of
|
||||
// marked waits, the per-machine state `approvals` in the old `state` section with its 48 hours and none in `requires`
|
||||
// (design 46 7-approval-mark §13), and the finding stream (design 47 G §387).
|
||||
func TestTheManifestNamesNoWaitsAndKeepsItsApprovals(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
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 {
|
||||
Module string `json:"module"`
|
||||
Waits []string `json:"waits"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Module string `json:"module"`
|
||||
Uses []string `json:"uses"`
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status",
|
||||
"mesh-self-check.checked", "operator-channel.decided.claude-code"}
|
||||
if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) {
|
||||
t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want)
|
||||
if m.Module != "node-tools" || !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
|
||||
t.Errorf("%s uses %v, want operator-channel (design 46 7-approval-mark §7)", m.Module, m.Uses)
|
||||
}
|
||||
for _, w := range m.Waits {
|
||||
if strings.ContainsAny(w, "*>") {
|
||||
t.Errorf("a family named by a wildcard: %s", w)
|
||||
type state struct {
|
||||
Name string `json:"name"`
|
||||
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
|
||||
// 1966 TLS-5) and the two events of the seat that serves the seat list (ADR 0636 §4), and nothing else.
|
||||
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"}
|
||||
if want := []state{{Name: "approvals", PerMachine: true, TTLSeconds: 172800}}; !reflect.DeepEqual(approvals, want) {
|
||||
t.Errorf("the old state section's approvals is %+v, want %+v (design 46 7-approval-mark §13)", approvals, want)
|
||||
}
|
||||
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) {
|
||||
t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ type Surface struct {
|
||||
Flat bool
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -11,18 +11,33 @@ import (
|
||||
"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).
|
||||
func TestAToolsOwn503IsNotNothingServes(t *testing.T) {
|
||||
// A marked tool or verb that reaches no responder names withheld and the grants part's read (design 46 7-approval-mark §6);
|
||||
// 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"))
|
||||
if strings.Contains(got, "nothing serves") || !strings.HasPrefix(got, "scanner.scan failed: ") || !strings.Contains(got, "503") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARealNoRespondersStillSaysNothingServes(t *testing.T) {
|
||||
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") {
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"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
|
||||
@@ -19,6 +28,45 @@ func TestARefusalIsSaidOnlyForTheBussOwn(t *testing.T) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
|
||||
"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
|
||||
@@ -37,3 +41,20 @@ func (b busSource) Entry(name, key string) ([]byte, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package console
|
||||
|
||||
// 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
|
||||
// mesh from the tool runner's manifest together with the grant that lets this account subscribe them.
|
||||
// A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one.
|
||||
// **Any event, by its name** (novox/hq ADR 0670 §1, ADR 0671 §1; design 34 every-event §13, §26): the grants part
|
||||
// grants this machine's tool runner every event subject, a seat's, a module's and a module stream's, and a name
|
||||
// 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.
|
||||
|
||||
import (
|
||||
@@ -13,9 +13,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
"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
|
||||
@@ -25,20 +31,29 @@ var (
|
||||
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 {
|
||||
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"},
|
||||
"description": "one or more event families to wait on, by name, e.g. `mesh-knowledge.reviewed`; only " +
|
||||
"those the mesh granted this machine's tool runner — a refusal names them"},
|
||||
"description": fmt.Sprintf("one or more event names, `<seat or module>.<event>`, such as "+
|
||||
"`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",
|
||||
"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",
|
||||
"description": fmt.Sprintf("seconds to wait: %d when absent, %d at most", int(WaitDefault.Seconds()), int(WaitLongest.Seconds()))},
|
||||
}, "required": []string{"subjects"}},
|
||||
"description": "Wait for the first event of the named families 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 " +
|
||||
"answer read the state from the verb that owns it: an event only wakes you, and one published between two " +
|
||||
"waits is not heard (novox/hq ADR 0367)."}
|
||||
"description": "Wait for the first event of the named events that matches the filter, and answer it with its " +
|
||||
"name, where it came from, body and times — or say it timed out. Call it in a loop from a background command, " +
|
||||
"and after every answer read the state from the verb that owns it: an event only wakes you, and one " +
|
||||
"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.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
issued := map[string]string{} // name → subject
|
||||
if m := s.conn.Membership(s.conn.Module()); m != nil {
|
||||
for _, w := range m.Waits {
|
||||
issued[w.Event] = w.Subject
|
||||
}
|
||||
from, _ := args["from"].(string)
|
||||
if args["from"] != nil && from != "seat" && from != "module" && from != "stream" {
|
||||
return failure(fmt.Sprintf("mesh_wait's from is seat, module or stream, not %v", args["from"]))
|
||||
}
|
||||
if len(issued) == 0 {
|
||||
return failure("this machine's tool runner was issued no event families to wait on: the mesh grants them from " +
|
||||
"the tool runner's manifest (novox/hq ADR 0367 §2), and its membership names none yet")
|
||||
if len(names) > waitMostNames {
|
||||
return failure(fmt.Sprintf("mesh_wait names at most %d events in one call, not %d: each name is six "+
|
||||
"subscriptions for the call (design 34 every-event §13)", waitMostNames, len(names)))
|
||||
}
|
||||
byName := make([]string, 0, len(issued))
|
||||
for name := range issued {
|
||||
byName = append(byName, name)
|
||||
}
|
||||
sort.Strings(byName)
|
||||
family := map[string]string{} // subject → name
|
||||
var subjects []string
|
||||
known, unchecked := s.emitters()
|
||||
var subjects, undeclared []string
|
||||
for _, name := range names {
|
||||
subject, granted := issued[name]
|
||||
if !granted {
|
||||
return failure(fmt.Sprintf("mesh_wait may not wait on %q: this machine's tool runner is granted %s alone. "+
|
||||
"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, ", ")))
|
||||
subs, err := buscompose.EventSubjects(name, from)
|
||||
if err != nil {
|
||||
return failure("mesh_wait may not wait on it: " + err.Error())
|
||||
}
|
||||
if _, twice := family[subject]; !twice {
|
||||
subjects = append(subjects, subject)
|
||||
// A misspelt emitter is refused, never waited on for ever; a known emitter's undeclared event is waited on and
|
||||
// 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 {
|
||||
return failure("mesh_wait could not wait: " + err.Error())
|
||||
return failure(err.Error())
|
||||
}
|
||||
if !ok {
|
||||
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)",
|
||||
})
|
||||
return answerText(answer)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var names []string
|
||||
switch v := given.(type) {
|
||||
@@ -110,7 +269,7 @@ func waitNames(given any) ([]string, error) {
|
||||
for _, x := range v {
|
||||
name, ok := x.(string)
|
||||
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)
|
||||
}
|
||||
@@ -119,7 +278,7 @@ func waitNames(given any) ([]string, error) {
|
||||
names[i] = strings.TrimSpace(names[i])
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,32 +3,41 @@ package console
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
opseat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-delivery/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"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// waitingRuntime is the tool runner on a machine, issued the families ADR 0367 §5 names, as the controller
|
||||
// composes them from its manifest: the surface over its connection, and the mesh to publish on.
|
||||
// waitingRuntime is the tool runner on a machine, issued no waits (novox/hq ADR 0670 §2): the surface over its
|
||||
// connection, and the mesh to publish on.
|
||||
func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
|
||||
t.Helper()
|
||||
mesh := mt.New(t)
|
||||
m := mt.MembershipOf("node-tools", "desk", false, nil)
|
||||
m.Waits = []bus.WaitIssued{
|
||||
{Event: "mesh-knowledge.reviewed", Subject: "mesh.mod.mesh-knowledge.event.reviewed"},
|
||||
{Event: "operator-channel.decided.claude-code", Subject: "mesh.seat.operator-channel.event.decided.claude-code"},
|
||||
}
|
||||
mesh.Issue(t, m)
|
||||
mesh.Issue(t, mt.MembershipOf("node-tools", "desk", false, nil))
|
||||
conn := connect(t, "node-tools", "desk")
|
||||
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 nil
|
||||
@@ -36,6 +45,25 @@ func waitingRuntime(t *testing.T) (*Surface, *mt.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.
|
||||
func callWait(s *Surface, args map[string]any) (text string, isError bool) {
|
||||
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
|
||||
}
|
||||
|
||||
// novox/hq ADR 0367 §1: mesh_wait answers the first event that matches — by its family and its filter — with
|
||||
// its subject, body and time, within a second of its publication, and ignores the others.
|
||||
// novox/hq ADR 0367 §1, ADR 0670 §1 (design 34 every-event §13, §15, §19): mesh_wait answers the first event that
|
||||
// matches — by its name and its filter — with no membership naming it: its whole name, the namespace it arrived on, its
|
||||
// body, when it arrived and when it was published, within a second of its publication, and ignores the others.
|
||||
func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
|
||||
s, mesh := waitingRuntime(t)
|
||||
type answer struct {
|
||||
@@ -59,18 +88,39 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
|
||||
isErr bool
|
||||
at time.Time
|
||||
}
|
||||
type heardAnswer struct {
|
||||
Heard struct {
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
Body map[string]any `json:"body"`
|
||||
At time.Time `json:"at"`
|
||||
Published *string `json:"published"`
|
||||
} `json:"heard"`
|
||||
Read string `json:"read"`
|
||||
}
|
||||
nc, err := nats.Connect(mt.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
done := make(chan answer, 1)
|
||||
before := s.conn.Subscriptions()
|
||||
go func() {
|
||||
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"},
|
||||
"filter": map[string]any{"kind": "decision", "number": 367}, "timeout": 20})
|
||||
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-delivery-view.delivered"},
|
||||
"filter": map[string]any{"module": "netcheck"}, "timeout": 20})
|
||||
done <- answer{text, isErr, time.Now()}
|
||||
}()
|
||||
// Once it listens: another record's verdict, another family's event, then the one it waits for.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 366, "what": "approved"})
|
||||
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.proposed", map[string]any{"kind": "decision", "number": 367})
|
||||
// Once it listens: another module's delivery, another event, then the one it waits for, with its own time.
|
||||
listening(t, s, before, 6)
|
||||
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.delivered", map[string]any{"module": "grafana"})
|
||||
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.planned", map[string]any{"module": "netcheck"})
|
||||
msg := nats.NewMsg("mesh.mod.mesh-delivery-view.event.delivered")
|
||||
msg.Header.Set("x-time", "2026-11-12T03:00:00.5Z")
|
||||
msg.Data = []byte(`{"module":"netcheck"}`)
|
||||
published := time.Now()
|
||||
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
|
||||
select {
|
||||
case got = <-done:
|
||||
@@ -83,32 +133,32 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
|
||||
if got.at.Sub(published) > time.Second {
|
||||
t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published))
|
||||
}
|
||||
var heard struct {
|
||||
Heard struct {
|
||||
Subject string `json:"subject"`
|
||||
Body map[string]any `json:"body"`
|
||||
At time.Time `json:"at"`
|
||||
} `json:"heard"`
|
||||
}
|
||||
var heard heardAnswer
|
||||
if err := json.Unmarshal([]byte(got.text), &heard); err != nil {
|
||||
t.Fatalf("not an answer: %s", got.text)
|
||||
}
|
||||
if heard.Heard.Subject != "mesh-knowledge.reviewed" || heard.Heard.Body["number"] != float64(367) || heard.Heard.At.IsZero() {
|
||||
t.Errorf("answered %+v, want record 367's verdict with its time", heard.Heard)
|
||||
if h := heard.Heard; h.Subject != "mesh-delivery-view.delivered" || h.From != "module" || h.Body["module"] != "netcheck" ||
|
||||
h.At.IsZero() || h.Published == nil || *h.Published != "2026-11-12T03:00:00.5Z" || !strings.Contains(heard.Read, "only wakes you") {
|
||||
t.Errorf("answered %s, want netcheck's delivery from a module with its times", got.text)
|
||||
}
|
||||
// And 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() {
|
||||
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()}
|
||||
}()
|
||||
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.
|
||||
if err := connect(t, "messenger", "").Say("mesh.seat.operator-channel.event.decided.claude-code", []byte(`{"ask":"a1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case got = <-done:
|
||||
if got.isErr || !strings.Contains(got.text, `"subject": "operator-channel.decided.claude-code"`) {
|
||||
heard = heardAnswer{}
|
||||
_ = json.Unmarshal([]byte(got.text), &heard)
|
||||
if h := heard.Heard; got.isErr || h.Subject != "operator-channel.decided.claude-code" || h.From != "seat" ||
|
||||
h.Published != nil || !strings.Contains(heard.Read, "claude_code_answer") {
|
||||
t.Errorf("the asker's decided: %s", got.text)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
@@ -125,7 +175,8 @@ func TestMeshWaitSaysItTimedOutAndKeepsNothing(t *testing.T) {
|
||||
if took := time.Since(start); took < time.Second || took > 3*time.Second {
|
||||
t.Errorf("a one-second wait took %s", took)
|
||||
}
|
||||
if isErr || !strings.Contains(text, `"timed-out": true`) {
|
||||
// Before the catalog half is read, names are not checked, and the answer says so (design 34 every-event §14).
|
||||
if isErr || !strings.Contains(text, `"timed-out": true`) || !strings.Contains(text, `"unchecked"`) {
|
||||
t.Errorf("a wait that heard nothing: %s", text)
|
||||
}
|
||||
if after := s.conn.Subscriptions(); after != before {
|
||||
@@ -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
|
||||
// one is added; no argument — a wildcard, another asker's `decided`, a subject spelled out — widens it.
|
||||
func TestMeshWaitRefusesAnEventOutsideTheGrantedFamilies(t *testing.T) {
|
||||
// novox/hq ADR 0670 §1, §2 (design 34 every-event §13, §16, §19): mesh_wait refuses loudly and never loops: a name with
|
||||
// a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a
|
||||
// wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant.
|
||||
func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) {
|
||||
s, _ := waitingRuntime(t)
|
||||
for _, subjects := range []any{
|
||||
[]any{"mesh-knowledge.proposed"},
|
||||
[]any{"mesh-knowledge.reviewed", "gitea.forge.push"},
|
||||
[]any{"mesh-knowledge.*"},
|
||||
[]any{"operator-channel.decided.shop"},
|
||||
[]any{"operator-channel.decided.*"},
|
||||
[]any{"mesh.mod.mesh-knowledge.event.reviewed"},
|
||||
[]any{">"},
|
||||
[]any{},
|
||||
var seventeen []any
|
||||
for i := 0; i < 17; i++ {
|
||||
seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i))
|
||||
}
|
||||
for _, c := range []struct {
|
||||
args map[string]any
|
||||
says string
|
||||
}{
|
||||
{map[string]any{"subjects": []any{"mesh-knowledge.*"}}, "[A-Za-z0-9_-]+"},
|
||||
{map[string]any{"subjects": []any{"operator-channel.decided.>"}}, "[A-Za-z0-9_-]+"},
|
||||
{map[string]any{"subjects": []any{"mesh-knowledge"}}, "fewer than two tokens"},
|
||||
{map[string]any{"subjects": seventeen}, "at most 16"},
|
||||
{map[string]any{"subjects": []any{}}, "at least one event"},
|
||||
{map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "from": "bench"}, "seat, module or stream"},
|
||||
} {
|
||||
text, isErr := callWait(s, map[string]any{"subjects": subjects, "timeout": 1})
|
||||
if !isErr {
|
||||
t.Errorf("%v was waited on: %s", subjects, text)
|
||||
continue
|
||||
c.args["timeout"] = 1
|
||||
start := time.Now()
|
||||
text, isErr := callWait(s, c.args)
|
||||
if !isErr || !strings.Contains(text, c.says) || strings.Contains(text, "manifest") {
|
||||
t.Errorf("%v: %s, want refused saying %q", c.args, text, c.says)
|
||||
}
|
||||
if len(subjects.([]any)) > 0 && (!strings.Contains(text, "mesh-knowledge.reviewed") || !strings.Contains(text, "manifest")) {
|
||||
t.Errorf("%v refused without naming the families and how one is added: %s", subjects, text)
|
||||
if took := time.Since(start); took > 500*time.Millisecond {
|
||||
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.
|
||||
func TestMeshWaitWithNoFamiliesIssuedWaitsOnNothing(t *testing.T) {
|
||||
mt.New(t)
|
||||
s := NewSurface(connect(t, "node-tools", "desk"), nil, "desk.node-tools")
|
||||
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1})
|
||||
if !isErr || !strings.Contains(text, "no event families") {
|
||||
t.Errorf("a runtime issued no families: %s", text)
|
||||
// markedWaitsBeforeTheHalfIsRead are §37's rows of a wait before the catalog half is read, and of one whose runner build is unknown.
|
||||
func markedWaitsBeforeTheHalfIsRead(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)
|
||||
}
|
||||
// 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.
|
||||
@@ -194,7 +563,7 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// 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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// assignments and seat-holders, the indexer's manifests and modules (novox/hq ADR 0615 §1, §3; ADR 0621 §2; issue 1966 TLS-5).
|
||||
// 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 (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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
"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"
|
||||
|
||||
"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 (
|
||||
Indexer = "mesh-delivery-indexer"
|
||||
Assigner = "mesh-delivery-assigner"
|
||||
AssignmentsRead = AssignerSeat + ".assignments"
|
||||
SeatHoldersRead = AssignerSeat + ".seat-holders"
|
||||
)
|
||||
|
||||
// The names the console reads the four states by (`<owner>.<state>`), as the manifest's `consumes` names their views
|
||||
// families (no seat's client names a holder, novox/hq ADR 0625 §4; TestTheFirstViewsAreTheManifestsViews).
|
||||
const (
|
||||
AssignmentsRead = Assigner + ".assignments"
|
||||
SeatHoldersRead = Assigner + ".seat-holders"
|
||||
ModulesRead = Indexer + ".modules"
|
||||
ManifestsRead = Indexer + ".manifests"
|
||||
)
|
||||
// Spec is one first view the runner holds of the seat's holder: its first-view tool and the state.
|
||||
type Spec struct{ Tool, State string }
|
||||
|
||||
// Spec is one first view the runner holds.
|
||||
type Spec struct{ Owner, Tool, State string }
|
||||
|
||||
// Specs are the four.
|
||||
// Specs are the two states asked of the holder's tool; the catalog half's views are adopted by the branch follower.
|
||||
var Specs = []Spec{
|
||||
{Assigner, "assignments", "assignments"},
|
||||
{Assigner, "seat-holders", "seat-holders"},
|
||||
{Indexer, "manifests", "manifests"},
|
||||
{Indexer, "modules", "modules"},
|
||||
{"assignments", "assignments"},
|
||||
{"seat-holders", "seat-holders"},
|
||||
}
|
||||
|
||||
// Name is the name a state is read by, `<owner>.<state>`: AssignmentsRead, SeatHoldersRead and the indexer's.
|
||||
func (s Spec) Name() string { return s.Owner + "." + s.State }
|
||||
// Name is the name a state is read by, `<seat>.<state>`: AssignmentsRead and SeatHoldersRead.
|
||||
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 {
|
||||
logf func(string, ...any)
|
||||
followers map[string]*views.Follower // by Spec.Name()
|
||||
byState map[string]*views.Follower // by the state an event names
|
||||
mu sync.Mutex
|
||||
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
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
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.
|
||||
@@ -74,70 +87,147 @@ type Deps struct {
|
||||
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 func(deliver func(bus.Envelope) error) (stop func(), err error)
|
||||
// PutFinding and DeleteFinding keep and remove a finding in the runner's own state.
|
||||
PutFinding func(key string, value json.RawMessage) error
|
||||
DeleteFinding func(key string) error
|
||||
// Findings is the runner's held finding writer (views.HeldWriter): a follower's unanswered first view is written and cleared
|
||||
// through it with the contract's Finding (design 47 G A-28). Nil says it in the log only.
|
||||
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, Wait time.Duration
|
||||
}
|
||||
|
||||
// Start asks the four first views, each until answered, and follows the owners' views families on the runner's own
|
||||
// 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)
|
||||
// FindingWriter is the writer the runner's findings go through: views.HeldWriter over the contract's Finding.
|
||||
type FindingWriter interface {
|
||||
Write(ctx context.Context, finding condseat.Finding) error
|
||||
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.
|
||||
func DepsOf(conn *bus.Conn) Deps {
|
||||
// Start asks the first views, each until answered, and follows the owners' views families on the runner's own
|
||||
// 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()
|
||||
return Deps{Module: me,
|
||||
d := Deps{Module: me, Node: conn.Node(), Build: process.OwnBuild(),
|
||||
Ask: func(address string, body any) (json.RawMessage, error) {
|
||||
got, err := conn.Ask(address, body, "")
|
||||
return got.Result, err
|
||||
},
|
||||
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.
|
||||
func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*Views, func()) {
|
||||
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}
|
||||
for _, s := range Specs {
|
||||
s := s
|
||||
call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(s.Owner+"."+tool, body) }
|
||||
f := &views.Follower{Owner: s.Owner, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: logf,
|
||||
Bound: d.Bound, Wait: d.Wait,
|
||||
Raise: func(st views.Status) { v.finding(st, true) },
|
||||
Clear: func(st views.Status) { v.finding(st, false) }}
|
||||
v.followers[s.Name()] = f
|
||||
v.byState[s.State] = f
|
||||
v.wg.Add(1)
|
||||
go func() { defer v.wg.Done(); _ = f.Start(ctx) }()
|
||||
v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{},
|
||||
adopted: map[string]*adoption{}, hearers: map[string][]*adoption{}, deps: d}
|
||||
stopFindings := func() {}
|
||||
if d.StartFindings != nil {
|
||||
if stop, err := d.StartFindings(ctx); err != nil {
|
||||
logf("[mesh-tools] the runner's finding writer does not start (%v): the first views' findings are in the log only", err)
|
||||
} else {
|
||||
stopFindings = stop
|
||||
}
|
||||
}
|
||||
// The catalog half is followed by the shared branch follower on this process's own wiring (design 47 G §614, A-103): the
|
||||
// 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)
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil // not a views event: nothing to hold, nothing to retry
|
||||
}
|
||||
v.mu.Lock()
|
||||
f := v.byState[ev.State]
|
||||
v.mu.Unlock()
|
||||
if f == nil || f.Owner != ev.Owner {
|
||||
return nil
|
||||
}
|
||||
defer v.heardKey(ev.State, ev.Key)
|
||||
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)
|
||||
go func() { _ = f.Again(v.ctx, "a gap: "+why) }()
|
||||
@@ -145,27 +235,36 @@ func (v *Views) hear(env bus.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// finding writes or removes the runner's finding of a first view unanswered past its bound, in its own state.
|
||||
func (v *Views) finding(st views.Status, raised bool) {
|
||||
key, value, err := conditionOf(views.UnansweredFinding(v.deps.Module, st, time.Now()))
|
||||
if err != nil {
|
||||
v.logf("[mesh-tools] the finding of the first view %s is not made: %v", st.Tool, err)
|
||||
// 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(f condseat.Finding, tool string, raised bool) {
|
||||
if v.deps.Findings == nil {
|
||||
v.logf("[mesh-tools] %s", f.Summary)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if raised {
|
||||
err = v.deps.PutFinding(key, value)
|
||||
err = v.deps.Findings.Write(context.Background(), f)
|
||||
} 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 {
|
||||
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
|
||||
// not answered yet, so nothing is claimed of it.
|
||||
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]
|
||||
v.mu.Unlock()
|
||||
if f == nil {
|
||||
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.
|
||||
func (v *Views) Status() []views.Status {
|
||||
var out []views.Status
|
||||
for _, f := range v.followers {
|
||||
for _, f := range v.all() {
|
||||
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 })
|
||||
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.
|
||||
func (v *Views) Again(ctx context.Context, why string) []error {
|
||||
var errs []error
|
||||
for _, f := range v.followers {
|
||||
for _, f := range v.all() {
|
||||
if err := f.Again(ctx, why); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
@@ -211,20 +333,240 @@ func (v *Views) Again(ctx context.Context, why string) []error {
|
||||
return errs
|
||||
}
|
||||
|
||||
// conditionOf is a finding in the layout of the seat mesh-conditions' state findings (its contract, ADR 0634 §1), about a
|
||||
// module, a warning.
|
||||
func conditionOf(f views.Finding) (string, []byte, error) {
|
||||
key := conditions.Key(scopeModule, f.ID, f.Kind)
|
||||
v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning,
|
||||
Subject: condseat.Subject{Scope: scopeModule, ID: f.ID}, Summary: f.Summary, Headline: f.Headline,
|
||||
Explanation: f.Explanation, Source: f.Observer + "." + f.Kind, At: f.At.UTC(),
|
||||
Evidence: []condseat.Evidence{{At: f.At.UTC(), Said: f.Summary}}}
|
||||
if err := conditions.CheckFinding(v); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
return key, raw, err
|
||||
// unansweredFinding is the finding of a first view unanswered past its bound, in the contract's form (design 47 G A-28): about the
|
||||
// runner's module, a warning, its id the view's name escaped as a key's name is.
|
||||
func unansweredFinding(module, node string, st views.Status, at time.Time) condseat.Finding {
|
||||
return condseat.Finding{Kind: views.UnansweredKind, 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 has no first view of %s's %s: %s has not answered since %s (%s)", module, st.Owner, st.State, st.Tool,
|
||||
st.Asked.Format(time.RFC3339), strings.TrimSpace(st.Error)),
|
||||
Headline: fmt.Sprintf("%s waits for %s", module, st.Owner), At: at.UTC()}
|
||||
}
|
||||
|
||||
// scopeModule is the scope of a finding about a 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"
|
||||
"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/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
"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"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
@@ -40,28 +45,30 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
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{}
|
||||
families := 0
|
||||
for _, c := range manifest.Consumes {
|
||||
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
|
||||
}
|
||||
stream := streamOf[emitter]
|
||||
if stream == "" {
|
||||
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++
|
||||
}
|
||||
if 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)
|
||||
for _, owner := range []string{Assigner, Indexer} {
|
||||
stream := streamOf[owner]
|
||||
mesh.ModuleStream(t, owner, stream)
|
||||
for _, seat := range []string{"seat:" + AssignerSeat} {
|
||||
stream := streamOf[seat]
|
||||
mesh.ModuleStream(t, holder, stream)
|
||||
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"})
|
||||
}
|
||||
@@ -84,21 +91,21 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
|
||||
var mu sync.Mutex
|
||||
findings := map[string]bool{}
|
||||
deps := DepsOf(conn)
|
||||
deps := DepsOf(conn, t.Logf)
|
||||
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.DeleteFinding = func(key string) error { mu.Lock(); delete(findings, key); mu.Unlock(); return nil }
|
||||
deps.Findings = writes{&mu, findings}
|
||||
deps.StartFindings = nil
|
||||
v, stop := StartWith(context.Background(), deps, func(string, ...any) {})
|
||||
t.Cleanup(stop)
|
||||
|
||||
// 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")
|
||||
}
|
||||
mt.Until(t, func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(findings) != len(Specs) {
|
||||
if len(findings) != catalogViews {
|
||||
return errFindings(len(findings))
|
||||
}
|
||||
return nil
|
||||
@@ -110,10 +117,25 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
s := s
|
||||
if _, err := nc.Subscribe("mesh.mod."+s.Owner+".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(),
|
||||
if _, err := nc.Subscribe("mesh.mod."+holder+".tool."+s.Tool, func(m *nats.Msg) {
|
||||
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}`)}})
|
||||
body, _ := json.Marshal(map[string]json.RawMessage{"result": answer})
|
||||
_ = m.Respond(body)
|
||||
@@ -129,7 +151,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
mt.Until(t, func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(findings) != 0 {
|
||||
if len(findings) != 0 { // every view answered clears its finding
|
||||
return errFindings(len(findings))
|
||||
}
|
||||
return nil
|
||||
@@ -137,9 +159,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
|
||||
// Each owner's next event arrives on the runner's consumer: all four families are bound.
|
||||
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}`)})
|
||||
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 {
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
"testing"
|
||||
"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"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
@@ -25,12 +29,32 @@ type owners struct {
|
||||
asked map[string]int
|
||||
deliver func(bus.Envelope) error
|
||||
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 {
|
||||
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, "manifests": 7, "modules": 3}, asked: map[string]int{}, finds: map[string]bool{}}
|
||||
return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}},
|
||||
rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, 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) {
|
||||
@@ -41,6 +65,22 @@ func (o *owners) ask(address string, _ any) (json.RawMessage, error) {
|
||||
if o.down {
|
||||
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{}
|
||||
for k, v := range o.state[tool] {
|
||||
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 {
|
||||
for _, s := range Specs {
|
||||
if s.State == state {
|
||||
return s.Owner
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// fixtureHolder is the module the fixture catalog half says claims the assigner seat: no name is in the code.
|
||||
const fixtureHolder = "assigner-fixture"
|
||||
|
||||
func owner(state string) string { return fixtureHolder }
|
||||
|
||||
func eventually(t *testing.T, what string, ok func() bool) {
|
||||
t.Helper()
|
||||
@@ -87,41 +123,48 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
var logs []string
|
||||
var lmu sync.Mutex
|
||||
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 },
|
||||
PutFinding: func(key string, _ json.RawMessage) error {
|
||||
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
|
||||
}}
|
||||
Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil },
|
||||
Findings: o}
|
||||
v, stop := StartWith(context.Background(), deps, func(f string, a ...any) { lmu.Lock(); logs = append(logs, f); lmu.Unlock() })
|
||||
defer stop()
|
||||
|
||||
// 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") {
|
||||
t.Fatalf("a state its owner has not answered is claimed: %v", err)
|
||||
if _, err := v.State(AssignmentsRead); err == nil || !strings.Contains(err.Error(), "names no holder") {
|
||||
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.
|
||||
o.mu.Lock()
|
||||
o.down = false
|
||||
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 })
|
||||
if got, _ := v.Entry("mesh-delivery-indexer.modules", "web"); string(got) != `{"w":1}` {
|
||||
t.Errorf("the modules picture holds %q", got)
|
||||
}
|
||||
if got, _ := v.Entry("mesh-delivery-indexer.modules", "none"); got != nil {
|
||||
if got, _ := v.Entry(AssignerSeat+".seat-holders", "none"); got != nil {
|
||||
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.
|
||||
asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] }
|
||||
before := asked("assignments")
|
||||
@@ -129,7 +172,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
o.say(t, "assignments", "laptop", `{"p":0}`, 5, false)
|
||||
o.say(t, "assignments", "anchor", "", 6, false)
|
||||
// (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)
|
||||
}
|
||||
if n := asked("assignments"); n != before {
|
||||
@@ -147,18 +190,18 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
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)
|
||||
}
|
||||
manifests := asked("manifests")
|
||||
manifests := asked("seat-holders")
|
||||
if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 {
|
||||
t.Fatalf("the hand ask failed: %v", errs)
|
||||
}
|
||||
if asked("manifests") != manifests+1 {
|
||||
t.Errorf("the hand ask did not ask the manifests again")
|
||||
if asked("seat-holders") != manifests+1 {
|
||||
t.Errorf("the hand ask did not ask the seat-holders again")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -181,17 +224,72 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
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 {
|
||||
if !want[s.Name()] {
|
||||
t.Errorf("the first view %s is read by no name here", 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)
|
||||
}
|
||||
}
|
||||
if len(want) > 0 {
|
||||
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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"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.WorkQueueDead: true, buscalls.LedgerApply: true, buscalls.LedgerGet: true, buscalls.LedgerHistory: 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.
|
||||
@@ -386,13 +387,77 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
asked := time.Now()
|
||||
switch m.Method {
|
||||
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":
|
||||
var asked json.RawMessage
|
||||
if asked, err = mesh.Ask(m.Params); err == nil {
|
||||
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":
|
||||
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()
|
||||
subscriber = c
|
||||
mu.Unlock()
|
||||
@@ -577,6 +642,10 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
for _, stop := range watches {
|
||||
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)
|
||||
mu.Lock()
|
||||
// Only a child that was serving is brought back; one that died in its own handshake was
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -68,6 +69,65 @@ func TestABundleTakesASeatsWorkThroughItsOwnChannel(t *testing.T) {
|
||||
if err != nil || string(got) != `{"taken":true}` {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// sequence on, oldest first, at most limit of them (every one when limit is 0).
|
||||
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.
|
||||
@@ -69,6 +73,8 @@ type State interface {
|
||||
Write(key string, value []byte, revision uint64) (uint64, error)
|
||||
// Keys is every key the bucket holds.
|
||||
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.
|
||||
@@ -77,6 +83,8 @@ type Snapshots interface {
|
||||
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(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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -99,4 +99,50 @@ func TestApplyAppendsDerivesAndSnapshots(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"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
|
||||
@@ -12,9 +15,13 @@ import (
|
||||
|
||||
// MemoryHistory is a history in memory.
|
||||
type MemoryHistory struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
kept []struct {
|
||||
// MaxBytes is the cap its fill is said against.
|
||||
MaxBytes uint64
|
||||
// FillFails makes Fill an error, as a store that cannot say its fill.
|
||||
FillFails bool
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
kept []struct {
|
||||
key string
|
||||
Kept
|
||||
}
|
||||
@@ -73,6 +80,32 @@ func (h *MemoryHistory) Read(keys string, from uint64, limit int) ([]Kept, error
|
||||
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.
|
||||
func (h *MemoryHistory) Expire() {
|
||||
h.mu.Lock()
|
||||
@@ -129,6 +162,13 @@ func (s *MemoryState) Keys() ([]string, error) {
|
||||
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.
|
||||
type MemorySnapshots struct {
|
||||
mu sync.Mutex
|
||||
@@ -157,6 +197,13 @@ func (s *MemorySnapshots) Put(key string, value []byte, expect uint64) error {
|
||||
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.
|
||||
type MemoryIndex struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -23,8 +23,7 @@ import (
|
||||
|
||||
assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
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/contracts/go/seats/meshcatalog"
|
||||
meshcatalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
@@ -153,25 +152,27 @@ func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership {
|
||||
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).
|
||||
const (
|
||||
indexer = "mesh-delivery-indexer"
|
||||
assignerHolder = "mesh-delivery-assigner"
|
||||
catalogHolder = "mesh-catalog"
|
||||
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 = assignerHolder + "_assignments"
|
||||
SeatHoldersBucket = assignerHolder + "_seat-holders"
|
||||
ModulesBucket = indexer + "_modules"
|
||||
ManifestsBucket = indexer + "_manifests"
|
||||
// ModulesBucket holds the catalog half's entries by module, the test bed's own stand-in for the runner's first views of it.
|
||||
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):
|
||||
// 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 {
|
||||
return []bus.StateIssued{
|
||||
{Name: assignerHolder + ".assignments", Bucket: AssignmentsBucket},
|
||||
{Name: indexer + ".modules", Bucket: ModulesBucket},
|
||||
{Name: indexer + ".manifests", Bucket: ManifestsBucket},
|
||||
{Name: ModulesRead, Bucket: ModulesBucket},
|
||||
{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
|
||||
}
|
||||
modules, manifests, assigned := kv(ModulesBucket), kv(ManifestsBucket), kv(AssignmentsBucket)
|
||||
modules, assigned := kv(ModulesBucket), kv(AssignmentsBucket)
|
||||
kv(SeatHoldersBucket)
|
||||
for module, declared := range tools {
|
||||
manifest, _ := json.Marshal(map[string]any{"module": module, "tools": declared})
|
||||
digest := meshcatalogclient.ManifestDigest(manifest)
|
||||
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})
|
||||
manifest := map[string]any{"module": module, "tools": declared}
|
||||
raw, err := json.Marshal(meshcatalog.CatalogEntry{Module: module, Manifest: manifest, Commit: strings.Repeat("a", 40)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
|
||||
var m struct {
|
||||
Serves map[string]map[string]any `json:"serves"`
|
||||
Moved bool `json:"moved"`
|
||||
State []string `json:"state"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
Reads []string `json:"reads"`
|
||||
Provides []struct {
|
||||
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
|
||||
// own and the seat mesh-provisioner's provisions and plans (issue 1954 L6-T1); the controller's assignments no more.
|
||||
declared := map[string]bool{}
|
||||
for _, n := range append(m.State, m.Reads...) {
|
||||
for _, n := range m.Reads {
|
||||
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{
|
||||
provider.BucketOf(Module): "bound",
|
||||
provisions.ProvisionsBucketOf(Provisioner): "mesh-provisioner.provisions",
|
||||
|
||||
@@ -2,11 +2,15 @@ package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"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
|
||||
// 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) {
|
||||
t.Setenv(FingerprintWord, "sha256:runner")
|
||||
mesh := mt.New(t)
|
||||
mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil))
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
@@ -139,7 +144,63 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) {
|
||||
t.Fatalf("ping of the instance: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"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
|
||||
@@ -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
|
||||
// held off a machine whose runner does not state it.
|
||||
const (
|
||||
CapBusStateCalls = "bus-state-calls"
|
||||
CapLedgerCalls = "ledger-calls"
|
||||
// CapStreamPublish is the call that says an event on the module's own bound stream (A-44).
|
||||
CapStreamPublish = "stream-publish"
|
||||
CapBusStateCalls = buscalls.BusStateCalls
|
||||
CapLedgerCalls = buscalls.LedgerCalls
|
||||
// CapLedgerUpkeepCalls is the ledger's fill and removal (§462, §488) and CapSentObjectsCalls the read of a sent object (§488):
|
||||
// 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.
|
||||
@@ -27,12 +41,16 @@ var callFamilies = map[string][]string{
|
||||
CapStreamPublish: {buscalls.StreamPublish},
|
||||
CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery,
|
||||
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
|
||||
// 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 {
|
||||
out := []string{stdio.CapSeatSettleDelay}
|
||||
out := []string{stdio.CapSeatSettleDelay, stdio.CapFindingCalls, CapApprovalMark}
|
||||
for c := range callFamilies {
|
||||
out = append(out, c)
|
||||
}
|
||||
@@ -56,11 +74,10 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes
|
||||
case buscalls.ObjectsGet:
|
||||
var r buscalls.ObjectsNameRequest
|
||||
if err = decode(method, params, &r); err == nil {
|
||||
var got *buscalls.ObjectData
|
||||
if got, err = conn.ObjectsGet(b.module, r); err == nil && got == nil {
|
||||
answer, err = b.all.objectsGet(b.module, r)
|
||||
if err == nil && answer == nil {
|
||||
return json.RawMessage("null"), nil
|
||||
}
|
||||
answer = got
|
||||
}
|
||||
case buscalls.ObjectsDelete:
|
||||
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)
|
||||
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:
|
||||
var r buscalls.ObjectsRequest
|
||||
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)
|
||||
}
|
||||
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)
|
||||
default:
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"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/novox/mesh-tools/node-tools/internal/bus"
|
||||
@@ -24,7 +28,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
|
||||
mesh := mt.New(t)
|
||||
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}}))
|
||||
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 {
|
||||
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:
|
||||
// the module is named no caller, whatever the body claims.
|
||||
// deskDefined is the seat list's definition of the fixture seat node-desk, its verb who taking its class's default.
|
||||
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) {
|
||||
mesh := mt.New(t)
|
||||
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}}))
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -91,8 +102,96 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
|
||||
}
|
||||
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 != "" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
@@ -240,6 +241,35 @@ func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) {
|
||||
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) {
|
||||
_, 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") {
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,6 +171,17 @@ func (b *moduleBus) ledgerCall(method string, params json.RawMessage) (any, erro
|
||||
var r buscalls.LedgerRequest
|
||||
_ = json.Unmarshal(params, &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)
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ func TestABundleToolNamedAsALedgerRepairIsRefused(t *testing.T) {
|
||||
// no other name; none answers "the runtime answers no ...".
|
||||
func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.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)
|
||||
}
|
||||
for capability, methods := range callFamilies {
|
||||
|
||||
@@ -36,6 +36,25 @@ func TestNoBusWordReachesABundle(t *testing.T) {
|
||||
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
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
"sync"
|
||||
"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/bus"
|
||||
"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.
|
||||
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.
|
||||
var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true,
|
||||
"MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true, LedgersFile: true}
|
||||
@@ -230,6 +237,10 @@ type runner struct {
|
||||
of map[string]*loadedModule
|
||||
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()
|
||||
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()
|
||||
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,
|
||||
// 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)
|
||||
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.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{
|
||||
Name: conn.Module(), ID: instanceOf(conn),
|
||||
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) })
|
||||
if err != nil {
|
||||
logf("[mesh-tools] cannot announce what it serves: %v", err)
|
||||
@@ -409,6 +425,13 @@ func (r *runner) load(s Served) *loadedModule {
|
||||
fail(clashed)
|
||||
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)
|
||||
for _, reg := range child.Registrations {
|
||||
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 {
|
||||
t := t
|
||||
// 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) {
|
||||
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 {
|
||||
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 {
|
||||
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] {
|
||||
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
|
||||
// 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.
|
||||
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 stops []func()
|
||||
stopped := false
|
||||
@@ -725,13 +756,43 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration,
|
||||
continue
|
||||
}
|
||||
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]
|
||||
if !ok {
|
||||
logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb)
|
||||
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) {
|
||||
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 {
|
||||
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
|
||||
// calls are the tool calls in flight, by the id each bundle was handed (ledgers.go, novox/hq ADR 0597 §7).
|
||||
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 {
|
||||
@@ -845,6 +912,7 @@ func (c *consumers) dropModule(module string) {
|
||||
}
|
||||
}
|
||||
c.provisioned.stopModule(module)
|
||||
c.dropModuleSessions(module)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) {
|
||||
var asked struct {
|
||||
@@ -882,7 +965,11 @@ func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) {
|
||||
if len(asked.Body) == 0 {
|
||||
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 {
|
||||
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,
|
||||
// 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 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 {
|
||||
words := map[string]string{}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"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/launch"
|
||||
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) {
|
||||
mt.NeedBundles(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("plain", "anchor", false, nil))
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
|
||||
@@ -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
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"module": "node-tools",
|
||||
"version": "2",
|
||||
"version": "0.1",
|
||||
"slug": "node-tools",
|
||||
"tool-runner": true,
|
||||
"invokes": [
|
||||
"*"
|
||||
],
|
||||
"waits": [
|
||||
"mesh-knowledge.reviewed",
|
||||
"mesh-delivery-gate.transition",
|
||||
"gitea.forge.status",
|
||||
"mesh-self-check.checked",
|
||||
"operator-channel.decided.claude-code"
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -62,21 +58,47 @@
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"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": [
|
||||
"bound",
|
||||
"findings"
|
||||
"findings",
|
||||
{
|
||||
"name": "approvals",
|
||||
"per-machine": true,
|
||||
"ttl-seconds": 172800
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-catalog.registration-changed",
|
||||
"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"
|
||||
"mesh-conditions.findings-asked",
|
||||
"seat:mesh-module-assigner.views.assignments",
|
||||
"seat:mesh-module-assigner.views.seat-holders",
|
||||
"mesh-delivery.source-moved"
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "node-tools-findings",
|
||||
"events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
"mesh-provisioner.provisions",
|
||||
|
||||
+23
@@ -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": {}})
|
||||
Reference in New Issue
Block a user