52 changed files with 894 additions and 1618 deletions
+3 -3
View File
@@ -45,7 +45,7 @@ MESH_OPERATOR_ACCOUNT whose machine this is, and MESH_OPERATOR_HOME where thei
MESH_BROKER_URL a plain URL instead of the credential, for the bootstrap case MESH_BROKER_URL a plain URL instead of the credential, for the bootstrap case
``` ```
`node dist/main.js`. On a node the mesh composes the variables and the node-engine supervises the `node dist/main.js`. On a node the controller composes the variables and the host supervises the
process like any other host-side workload (novox/hq to-be 38). As `node-tools` the same process is process like any other host-side workload (novox/hq to-be 38). As `node-tools` the same process is
the console: MCP on `127.0.0.1:4270` (or `MESH_CONSOLE_LISTEN`). the console: MCP on `127.0.0.1:4270` (or `MESH_CONSOLE_LISTEN`).
@@ -66,14 +66,14 @@ answer took, its size in bytes, how many modules and tools it announced, whether
fit the bus, when it was last heard — and who was expected and not heard). fit the bus, when it was last heard — and who was expected and not heard).
`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0245): each seat verb and `mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0245): each seat verb and
module tool says what it replaces in the indexer's records (`replaces`, read from its module tool says what it replaces in the controller's records (`replaces`, read from the controller's
`tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb `tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb
for it first, and says the command it matched as `instead_of`. A word also matches through a short table for it first, and says the command it matched as `instead_of`. A word also matches through a short table
of the words agents use for the mesh's (`logs` finds the journal). Best first: a replaced command, then of the words agents use for the mesh's (`logs` finds the journal). Best first: a replaced command, then
words in a name or a replaced command, then words in a description; seats before modules on a tie. words in a name or a replaced command, then words in a description; seats before modules on a tie.
Discovery asks the bus (ADR 0197): every runtime answers the NATS services protocol's `$SRV.PING` and Discovery asks the bus (ADR 0197): every runtime answers the NATS services protocol's `$SRV.PING` and
`$SRV.INFO` with what it serves at that moment, and the console reads where the placement seat's records `$SRV.INFO` with what it serves at that moment, and the console reads where the controller's records
place each module. The console waits at least 750 ms, and up to 5 s for every runtime that answered place each module. The console waits at least 750 ms, and up to 5 s for every runtime that answered
PING to send what it serves — a large runtime's answer crossing the bus to a broker on another machine PING to send what it serves — a large runtime's answer crossing the bus to a broker on another machine
can arrive well after the rest. A runtime that said it is there and did not say what it serves, or can arrive well after the rest. A runtime that said it is there and did not say what it serves, or
+1 -1
View File
@@ -3,7 +3,7 @@
The node's tool runtime as a module (novox/hq ADR 0175, to-be 38 WP3). Assigned to a machine, it is The node's tool runtime as a module (novox/hq ADR 0175, to-be 38 WP3). Assigned to a machine, it is
one process the host runs from this bundle, as the operator's account: it serves every assigned one process the host runs from this bundle, as the operator's account: it serves every assigned
module's tools and every held seat's verbs on the bus, and answers MCP on the machine's loopback — module's tools and every held seat's verbs on the bus, and answers MCP on the machine's loopback —
the console (design 34). The mesh composes the process (which bundles to load, where the the console (design 34). The controller composes the process (which bundles to load, where the
credential is, whose machine it is); this manifest says only what the machine must have for it: the credential is, whose machine it is); this manifest says only what the machine must have for it: the
interpreter, a place for the credential, the loopback port, and leave to call every tool. interpreter, a place for the credential, the loopback port, and leave to call every tool.
+1 -1
View File
@@ -8,7 +8,7 @@ require (
) )
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+8 -2
View File
@@ -1,5 +1,11 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d h1:jFOz6wUfZvIayd4DctwlaFKKZHQNME6NLs2OS1rNv30=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde h1:eKXSllHCS31GgMazfvZ7BeiLoXIUibnUmzxWVkj+Vcw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -3,7 +3,7 @@
// **A machine heard and its runtime gone is a machine nobody can ask anything.** The node-engine says // **A machine heard and its runtime gone is a machine nobody can ask anything.** The node-engine says
// every minute that the machine is there; nothing said whether the runtime serving every module's // every minute that the machine is there; nothing said whether the runtime serving every module's
// tools and every held seat's verbs was. Now it says so too, on its own subject, every interval, with // tools and every held seat's verbs was. Now it says so too, on its own subject, every interval, with
// the interval — the mesh's watchdog raises `tools-silent` after three missed — and its build. // the interval — the controller's watchdog raises `tools-silent` after three missed — and its build.
// Core NATS, kept by nobody, as the host's heartbeat: a lost one is the next one. // Core NATS, kept by nobody, as the host's heartbeat: a lost one is the next one.
package alive package alive
+86 -152
View File
@@ -10,7 +10,6 @@
package bus package bus
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
@@ -18,8 +17,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/membership"
"log" "log"
"regexp" "regexp"
"strings" "strings"
@@ -32,9 +29,13 @@ import (
"github.com/novox/mesh-tools/node-tools/internal/wire" "github.com/novox/mesh-tools/node-tools/internal/wire"
) )
// RequestTimeout is how long a call waits for its answer — what modules already expect. // RequestTimeout is how long a call waits for its answer — what modules already expect. The
// controller answers its verbs well inside it (its AnswerWithin, ten seconds), with a call id while a
// verb still runs, so a caller is never left guessing past this (novox/hq issue 265).
const RequestTimeout = 30 * time.Second const RequestTimeout = 30 * time.Second
const assignmentsStream = "ASSIGNMENTS"
// Credential is a broker credential as the mesh delivers it (novox/hq ADR 0120). // Credential is a broker credential as the mesh delivers it (novox/hq ADR 0120).
type Credential struct { type Credential struct {
URL string `json:"url"` URL string `json:"url"`
@@ -339,10 +340,13 @@ func (c *Conn) Node() string { return c.node }
// Credential is what this connection was opened with. // Credential is what this connection was opened with.
func (c *Conn) Credential() Credential { return c.cred } func (c *Conn) Credential() Credential { return c.cred }
// MembershipSubject is the one address a runtime derives for an assignment (ADR 0160).
func MembershipSubject(node, module string) string {
return "mesh.assignment." + node + "." + module
}
// Follow reads a module's membership on this machine once and follows it live, so its tools are // Follow reads a module's membership on this machine once and follows it live, so its tools are
// served where the mesh issued them (ADR 0175). It is read from the grants part's state memberships, its key // served where the mesh issued them (ADR 0175).
// `<node>.<module>` (novox/hq ADR 0571 clause 7 and 10, mesh-sdk buscompose.GrantsMembershipsBucket), and nowhere else:
// the old assignments stream is not read and its subject is not heard.
func (c *Conn) Follow(module string) { func (c *Conn) Follow(module string) {
c.mu.Lock() c.mu.Lock()
if c.node == "" { if c.node == "" {
@@ -355,24 +359,28 @@ func (c *Conn) Follow(module string) {
} }
c.issued[module] = nil c.issued[module] = nil
c.mu.Unlock() c.mu.Unlock()
c.followGrants(module) subject := MembershipSubject(c.node, module)
// The subject-addressed direct get: the one address the mesh grants this account on the
// stream's API.
if got, err := c.nc.Request("$JS.API.DIRECT.GET."+assignmentsStream+"."+subject, nil, 5*time.Second); err == nil {
if got.Header.Get("Status") == "" && len(got.Data) > 0 {
var m Membership
if json.Unmarshal(got.Data, &m) == nil {
c.mu.Lock()
c.issued[module] = &m
c.mu.Unlock()
}
}
}
if c.Membership(module) == nil { if c.Membership(module) == nil {
c.Logf("[mesh-tools] no membership issued for %s on %s yet; serving the derived shape until one arrives", module, c.node) c.Logf("[mesh-tools] no membership issued for %s on %s yet; serving the derived shape until one arrives", module, c.node)
} }
} sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) {
var m Membership
// GrantsPatience bounds the first read of a membership from the grants part's state. if err := json.Unmarshal(msg.Data, &m); err != nil {
var GrantsPatience = 2 * time.Second c.Logf("[mesh-tools] a membership arrived that is not one: %v", err)
return
// GrantsRetry is the first wait before the memberships state is read again after it could not be; it doubles up to }
// GrantsRetryMost.
var (
GrantsRetry = time.Second
GrantsRetryMost = 30 * time.Second
)
// issue applies a membership that arrived for module, live, and re-serves on it.
func (c *Conn) issue(module string, m Membership) {
c.mu.Lock() c.mu.Lock()
c.issued[module] = &m c.issued[module] = &m
handlers := make([]func(Membership), 0, len(c.onNew)) handlers := make([]func(Membership), 0, len(c.onNew))
@@ -385,120 +393,10 @@ func (c *Conn) issue(module string, m Membership) {
h(m) h(m)
} }
_ = c.nc.Flush() _ = c.nc.Flush()
} })
// followGrants reads module's key of the grants part's state memberships and watches it: the value there now is applied
// before Follow returns, each change after it live. A state that is not there yet, or not readable (a bus answering 503
// right after its boot, a timeout), is said once and read again, with a growing wait, until it can be or the
// connection closes: Follow runs once per module, so nothing else would ever read it again.
func (c *Conn) followGrants(module string) {
if c.watchGrants(module, true) {
return
}
go func() {
wait := GrantsRetry
for !c.nc.IsClosed() {
time.Sleep(wait)
if c.nc.IsClosed() {
return
}
if c.watchGrants(module, false) {
c.Logf("[mesh-tools] the grants part's memberships are read again; %s on %s follows its key", module, c.node)
return
}
if wait *= 2; wait > GrantsRetryMost {
wait = GrantsRetryMost
}
}
}()
}
// watchGrants is one attempt of followGrants: true once the key is watched. A failure is said when say is true.
func (c *Conn) watchGrants(module string, say bool) bool {
logf := func(format string, a ...any) {
if say {
c.Logf(format, a...)
}
}
key := buscompose.MembershipKey(c.node, module)
// First the key's direct get, which the account is granted alone: a bus that refuses it, or a state not there yet,
// answers at once, where binding the bucket would wait out its bound on a refusal.
bucket := buscompose.GrantsMembershipsBucket
probe, err := c.nc.Request("$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key, nil, GrantsPatience)
if err != nil || probe.Header.Get("Status") == "503" {
why := "it did not answer"
if err != nil {
why = err.Error()
}
logf("[mesh-tools] the grants part's memberships (%s) cannot be read (%s); %s has no membership "+
"until it can be", bucket, why, module)
return false
}
js, err := jetstream.New(c.nc)
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := js.KeyValue(ctx, bucket)
if err != nil {
logf("[mesh-tools] the grants part's memberships (%s) cannot be read (%v); %s has no membership "+
"until it can be", buscompose.GrantsMembershipsBucket, err, module)
return false
}
w, err := kv.Watch(context.Background(), key)
if err != nil {
logf("[mesh-tools] %s's key %s of the grants part's memberships cannot be watched (%v); %s has no membership "+
"until it can be", module, key, err, module)
return false
}
apply := func(e jetstream.KeyValueEntry) {
if e.Operation() != jetstream.KeyValuePut {
return // a membership withdrawn is the stream's, or the module's unassignment's, to say
}
// Read through mesh-sdk's one codec of this state, the grants part's writer's (issue 774): it takes the bare
// membership and a wrapper the part wrote before of one published or unchanged, and refuses a held-back one's
// wrapper or a value naming no node or module, which then leaves the module with no membership.
// A refused value arriving after a good one keeps the last good membership: nothing here clears it.
var m Membership
body, err := membership.BareGrantsMembership(e.Value())
if err == nil { if err == nil {
err = json.Unmarshal(body, &m) c.track(sub)
} }
if err != nil {
c.Logf("[mesh-tools] the membership at %s is not one: %v", key, err)
return
}
c.issue(module, m)
}
timer := time.NewTimer(StateTimeout)
defer timer.Stop()
for current := true; current; {
select {
case e, ok := <-w.Updates():
if !ok {
return false
}
if e == nil {
current = false
continue
}
apply(e)
case <-timer.C:
_ = w.Stop()
logf("[mesh-tools] %s's key %s of the grants part's memberships did not answer in %s; %s has no membership "+
"until it can be", module, key, StateTimeout, module)
return false
}
}
go func() {
for e := range w.Updates() {
if e != nil {
apply(e)
}
}
}()
return true
} }
// Membership is what the mesh issued a module here, or nil when nothing has been issued. // Membership is what the mesh issued a module here, or nil when nothing has been issued.
@@ -788,47 +686,83 @@ func (c *Conn) Ask(key string, body any, on string) (Answered, error) {
// account publish with its own name alone; asked again on the subject that names no caller only when the // account publish with its own name alone; asked again on the subject that names no caller only when the
// first reached nobody — nothing serves it yet, or the bus does not grant it yet — so no call is done twice. // first reached nobody — nothing serves it yet, or the bus does not grant it yet — so no call is done twice.
if called := CalledSubject(subject, c.cred.User); called != "" { if called := CalledSubject(subject, c.cred.User); called != "" {
answered, fallBack, err := c.askCalled(called, data) answered, handingOver, fallBack, err := c.askCalled(called, data)
if !fallBack { if !fallBack {
if !handingOver {
return answered, err
}
time.Sleep(HandoverPause)
answered, again, _, err := c.askCalled(called, data)
if err != nil && again {
return Answered{}, fmt.Errorf("asked twice, %s apart, and both times refused as a handover: %w", HandoverPause, err)
}
return answered, err return answered, err
} }
} }
return c.askOnce(subject, data) answered, handingOver, err := c.askOnce(subject, data)
if !handingOver {
return answered, err
}
// **Refused because the controller is handing over: asked once more** (novox/hq issue 289). The
// controller being replaced did nothing and said so; the one after it answers the same call. Once:
// a second refusal is a controller that is not coming back, and the caller is told.
time.Sleep(HandoverPause)
answered, again, err := c.askOnce(subject, data)
if err != nil && again {
return Answered{}, fmt.Errorf("asked twice, %s apart, and both times refused as a handover: %w", HandoverPause, err)
}
return answered, err
} }
// HandoverPause is how long a call refused as a handover waits before it is asked once more: the
// supervisor's restart of the controller, which stops the one being replaced and starts the next. A
// variable so a test need not wait.
var HandoverPause = 3 * time.Second
// RetryHandingOver is the mark the controller puts on a call it refused because it is being replaced
// (mesh-controller internal/link, `"retry": "handing-over"`).
const RetryHandingOver = "handing-over"
// ErrNoResponders is the bus's own refusal: nothing subscribes to the subject asked. It is told apart // ErrNoResponders is the bus's own refusal: nothing subscribes to the subject asked. It is told apart
// from a tool's own error by errors.Is, never by its text (a tool may say "503" itself, issue 1529). // from a tool's own error by errors.Is, never by its text (a tool may say "503" itself, issue 1529).
var ErrNoResponders = errors.New("503 no responders") var ErrNoResponders = errors.New("503 no responders")
// askOnce asks once. // askOnce asks once, and says whether the answer was a refusal marked to be asked again.
func (c *Conn) askOnce(subject string, data []byte) (Answered, error) { func (c *Conn) askOnce(subject string, data []byte) (Answered, bool, error) {
msg, err := c.nc.Request(subject, data, RequestTimeout) msg, err := c.nc.Request(subject, data, RequestTimeout)
if err != nil { if err != nil {
if errors.Is(err, nats.ErrNoResponders) { if errors.Is(err, nats.ErrNoResponders) {
return Answered{}, ErrNoResponders return Answered{}, false, ErrNoResponders
} }
if errors.Is(err, nats.ErrTimeout) { if errors.Is(err, nats.ErrTimeout) {
return Answered{}, errors.New("timeout") return Answered{}, false, errors.New("timeout")
} }
return Answered{}, err return Answered{}, false, err
} }
return answerOf(msg.Data) return c.answerOf(subject, msg.Data)
} }
// answerOf reads one answer. // answerOf reads one answer, asking the rest of a paged one on the subject it was asked on, and says whether it
func answerOf(data []byte) (Answered, error) { // was a refusal marked to be asked again.
func (c *Conn) answerOf(subject string, data []byte) (Answered, bool, error) {
// An answer larger than one message comes in parts (pages.go, novox/hq issue 314).
body, err := c.whole(subject, data)
if err != nil {
return Answered{}, false, err
}
var r struct { var r struct {
Result json.RawMessage `json:"result"` Result json.RawMessage `json:"result"`
Error string `json:"error"` Error string `json:"error"`
Node string `json:"node"` Node string `json:"node"`
Retry string `json:"retry"`
} }
if err := json.Unmarshal(data, &r); err != nil { if err := json.Unmarshal(body, &r); err != nil {
return Answered{}, err return Answered{}, false, err
} }
if r.Error != "" { if r.Error != "" {
return Answered{}, errors.New(r.Error) return Answered{}, r.Retry == RetryHandingOver, errors.New(r.Error)
} }
return Answered{Result: r.Result, Node: r.Node}, nil return Answered{Result: r.Result, Node: r.Node}, false, nil
} }
// PublishAs emits an event as a module: published into JetStream and awaited, de-duplicated by its // PublishAs emits an event as a module: published into JetStream and awaited, de-duplicated by its
@@ -1032,7 +966,7 @@ func (c *Conn) AskAs(module, key string, body any) (Answered, error) {
} }
// EventsStream is where every module's events land, and ConsumerOf the durable consumer the // EventsStream is where every module's events land, and ConsumerOf the durable consumer the
// mesh makes for a module on a machine: the same names the module's own runtime bound // controller makes for a module on a machine: the same names the module's own runtime bound
// (node-tools/src/broker-nats.ts), so moving the module into the node's runtime neither loses an // (node-tools/src/broker-nats.ts), so moving the module into the node's runtime neither loses an
// event nor sees one twice. // event nor sees one twice.
const EventsStream = "EVENTS" const EventsStream = "EVENTS"
@@ -1047,7 +981,7 @@ func ConsumerOf(node, module string) string {
// NakDelay is how long an event a handler failed waits before it is offered again: a transient cause // NakDelay is how long an event a handler failed waits before it is offered again: a transient cause
// gets another attempt, a permanent one exhausts the consumer's max-deliver rather than spinning. The // gets another attempt, a permanent one exhausts the consumer's max-deliver rather than spinning. The
// consumer then gives the event up, and the mesh keeps it in DEAD_LETTERS until a person delivers // consumer then gives the event up, and the controller keeps it in DEAD_LETTERS until a person delivers
// it again — under `mesh.again.<consumer>.…`, from which keyOf reads the same key — or drops it // it again — under `mesh.again.<consumer>.…`, from which keyOf reads the same key — or drops it
// (novox/hq issue 330). // (novox/hq issue 330).
var NakDelay = 5 * time.Second var NakDelay = 5 * time.Second
@@ -1061,7 +995,7 @@ var EventHeartbeat = 10 * time.Second
// ConsumeAs reads a module's durable consumer on EVENTS, and each one on a module's stream its // ConsumeAs reads a module's durable consumer on EVENTS, and each one on a module's stream its
// membership names (novox/hq ADR 0363 §8), and hands each event to deliver one at a time (novox/hq // membership names (novox/hq ADR 0363 §8), and hands each event to deliver one at a time (novox/hq
// ADR 0198): acknowledged when deliver returns nil, negatively acknowledged after NakDelay when it // ADR 0198): acknowledged when deliver returns nil, negatively acknowledged after NakDelay when it
// returns an error, terminated when it is not an event at all. The consumers are the mesh's to // returns an error, terminated when it is not an event at all. The consumers are the controller's to
// create; this binds to them and never makes one. Runs until stopped. // create; this binds to them and never makes one. Runs until stopped.
func (c *Conn) ConsumeAs(module string, deliver func(Envelope) error) (func(), error) { func (c *Conn) ConsumeAs(module string, deliver func(Envelope) error) (func(), error) {
durable := ConsumerOf(c.node, module) durable := ConsumerOf(c.node, module)
@@ -1071,7 +1005,7 @@ func (c *Conn) ConsumeAs(module string, deliver func(Envelope) error) (func(), e
} }
h := newHearing(c, js, module, deliver) h := newHearing(c, js, module, deliver)
// Bound by name, never created and never matched against a subject: the consumer and its // Bound by name, never created and never matched against a subject: the consumer and its
// filters are the mesh's (design 29 §3), exactly as the module's own runtime bound it. // filters are the controller's (design 29 §3), exactly as the module's own runtime bound it.
events, _, doing, err := h.bind(EventsStream, durable, nil) events, _, doing, err := h.bind(EventsStream, durable, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s %s's consumer %s: %w", doing, module, durable, err) return nil, fmt.Errorf("%s %s's consumer %s: %w", doing, module, durable, err)
+11 -11
View File
@@ -18,7 +18,7 @@ import (
// mesh.seat.<seat>.call.<verb>.<caller> a mesh seat's verb // mesh.seat.<seat>.call.<verb>.<caller> a mesh seat's verb
// mesh.seat.<seat>.call.<verb>.<node>.<caller> a node seat's verb on one machine // mesh.seat.<seat>.call.<verb>.<node>.<caller> a node seat's verb on one machine
// //
// The last token is the bus user that published the call, and the mesh grants each user the publish of // The last token is the bus user that published the call, and the controller grants each user the publish of
// these subjects with its own name there and no other — so the caller is a fact the bus server enforces, and // these subjects with its own name there and no other — so the caller is a fact the bus server enforces, and
// the runtime hands it to the module from the subject a call arrived on, never from the payload. // the runtime hands it to the module from the subject a call arrived on, never from the payload.
// //
@@ -32,7 +32,7 @@ import (
const CallKind = "call" const CallKind = "call"
// A bus user's name carries dots (`<node>.<module>`, `person.<name>`); a subject token cannot. In the token // A bus user's name carries dots (`<node>.<module>`, `person.<name>`); a subject token cannot. In the token
// each dot is written `~`, which no part of a bus user's name may hold (the mesh refuses any part that // each dot is written `~`, which no part of a bus user's name may hold (the controller refuses any part that
// is not letters, digits, `_` or `-`), so the token is the user's name and nothing else. // is not letters, digits, `_` or `-`), so the token is the user's name and nothing else.
const userDot = "~" const userDot = "~"
@@ -118,7 +118,7 @@ type callers struct {
pending map[string]map[uint64]context.CancelCauseFunc pending map[string]map[uint64]context.CancelCauseFunc
} }
// errCallRefused is the bus refusing a caller-named publish: this user is not granted it yet (the mesh's // errCallRefused is the bus refusing a caller-named publish: this user is not granted it yet (the controller's
// grant not delivered to the bus), so the call is asked again on the subject that names no caller. // grant not delivered to the bus), so the call is asked again on the subject that names no caller.
var errCallRefused = errors.New("the bus refused the caller-named subject") var errCallRefused = errors.New("the bus refused the caller-named subject")
@@ -150,7 +150,7 @@ func (c *Conn) publishRefused(err error) bool {
// askCalled asks once on a caller-named subject. fallBack says the call reached nobody — nothing serves that // askCalled asks once on a caller-named subject. fallBack says the call reached nobody — nothing serves that
// subject yet, or the bus refused this user its publish — so it may be asked on the subject that names no // subject yet, or the bus refused this user its publish — so it may be asked on the subject that names no
// caller without being done twice. // caller without being done twice.
func (c *Conn) askCalled(subject string, data []byte) (answered Answered, fallBack bool, err error) { func (c *Conn) askCalled(subject string, data []byte) (answered Answered, handingOver, fallBack bool, err error) {
ctx, cancel := context.WithCancelCause(context.Background()) ctx, cancel := context.WithCancelCause(context.Background())
ctx, stop := context.WithTimeout(ctx, RequestTimeout) ctx, stop := context.WithTimeout(ctx, RequestTimeout)
defer stop() defer stop()
@@ -180,22 +180,22 @@ func (c *Conn) askCalled(subject string, data []byte) (answered Answered, fallBa
if err != nil { if err != nil {
switch { switch {
case errors.Is(context.Cause(ctx), errCallRefused): case errors.Is(context.Cause(ctx), errCallRefused):
return Answered{}, true, nil return Answered{}, false, true, nil
case errors.Is(err, nats.ErrNoResponders): case errors.Is(err, nats.ErrNoResponders):
c.saidFallBack(subject) c.saidFallBack(subject)
return Answered{}, true, nil return Answered{}, false, true, nil
case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout): case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout):
return Answered{}, false, errors.New("timeout") return Answered{}, false, false, errors.New("timeout")
} }
return Answered{}, false, err return Answered{}, false, false, err
} }
answered, err = answerOf(msg.Data) answered, handingOver, err = c.answerOf(subject, msg.Data)
return answered, false, err return answered, handingOver, false, err
} }
// saidFallBack says, once per subject, that a caller-named call reached nobody and is asked on the subject that // saidFallBack says, once per subject, that a caller-named call reached nobody and is asked on the subject that
// names no caller: a runtime or holder not yet serving it, or a grant that drifted, is seen in the log rather // names no caller: a runtime or holder not yet serving it, or a grant that drifted, is seen in the log rather
// than read later as a quiet "unproven" (novox/hq issue 365; expected until hq issue // than read later as a quiet "unproven" (novox/hq issue 365; expected for the controller's seat until hq issue
// 464). // 464).
func (c *Conn) saidFallBack(subject string) { func (c *Conn) saidFallBack(subject string) {
c.calls.mu.Lock() c.calls.mu.Lock()
+1 -1
View File
@@ -16,7 +16,7 @@ import (
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
// A bus whose users are granted as the controller grants them (mesh-placement internal/broker): each named // A bus whose users are granted as the controller grants them (mesh-controller internal/broker): each named
// user publishes the subjects listed for it. // user publishes the subjects listed for it.
func grantedBus(t *testing.T, publish map[string][]string) string { func grantedBus(t *testing.T, publish map[string][]string) string {
t.Helper() t.Helper()
+73
View File
@@ -0,0 +1,73 @@
package bus_test
import (
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// **A call the controller refused because it is handing over is asked once more** (novox/hq issue 289):
// the controller being replaced did nothing and marked its refusal, and the one after it answers. A
// refusal not so marked is final, and a second handover refusal is said, not asked a third time.
func TestAHandoverRefusalIsAskedOnceMore(t *testing.T) {
was := bus.HandoverPause
bus.HandoverPause = 10 * time.Millisecond
t.Cleanup(func() { bus.HandoverPause = was })
url := meshtest.URL(t)
seat, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
defer seat.Close()
c, err := bus.Connect(bus.Credential{URL: url, Module: "console", Node: "anchor"})
if err != nil {
t.Fatal(err)
}
defer c.Close()
serve := func(t *testing.T, verb string, answers ...map[string]any) *atomic.Int32 {
t.Helper()
var asked atomic.Int32
sub, err := seat.Subscribe(bus.SeatToolSubject("mesh-controller", verb, "", ""), func(m *nats.Msg) {
n := int(asked.Add(1)) - 1
if n >= len(answers) {
n = len(answers) - 1
}
body, _ := json.Marshal(answers[n])
_ = m.Respond(body)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
_ = seat.Flush()
return &asked
}
handingOver := map[string]any{"error": "the controller is handing over to the next one; nothing was done, ask again",
"retry": bus.RetryHandingOver}
asked := serve(t, "rotate", handingOver, map[string]any{"result": "rotated"})
got, err := c.Ask("seat:mesh-controller.rotate", map[string]any{}, "")
if err != nil || string(got.Result) != `"rotated"` || asked.Load() != 2 {
t.Fatalf("after a handover refusal: %s %v, asked %d times", got.Result, err, asked.Load())
}
asked = serve(t, "push", map[string]any{"error": "refused for its own reason"})
if _, err := c.Ask("seat:mesh-controller.push", map[string]any{}, ""); err == nil || asked.Load() != 1 {
t.Fatalf("an unmarked refusal was asked again: %v, asked %d times", err, asked.Load())
}
asked = serve(t, "status", handingOver)
_, err = c.Ask("seat:mesh-controller.status", map[string]any{}, "")
if err == nil || asked.Load() != 2 || !strings.Contains(err.Error(), "both times refused as a handover") {
t.Fatalf("two handover refusals: %v, asked %d times", err, asked.Load())
}
}
+44 -148
View File
@@ -1,7 +1,6 @@
package bus package bus
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,7 +8,6 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
@@ -17,7 +15,7 @@ import (
) )
// A module's log on the bus (novox/hq ADR 0297): a stream of operations, one subject per key, that the // A module's log on the bus (novox/hq ADR 0297): a stream of operations, one subject per key, that the
// mesh creates from what the module declared under `logs` and issues to the module's own instances // controller creates from what the module declared under `logs` and issues to the module's own instances
// in its membership. It mirrors a bucket (ADR 0201) in how it is reached: what a module may reach is // in its membership. It mirrors a bucket (ADR 0201) in how it is reached: what a module may reach is
// decided here, from its membership, and refused with the reason before anything is sent. // decided here, from its membership, and refused with the reason before anything is sent.
// //
@@ -96,10 +94,6 @@ func logFilter(l LogIssued, pattern string) (string, error) {
} }
parts := strings.Split(pattern, ".") parts := strings.Split(pattern, ".")
for i, p := range parts { for i, p := range parts {
if p == "**" && i == len(parts)-1 {
// `**` for the rest, as a state's key pattern and mesh-sdk's log reader say it.
parts[i], p = ">", ">"
}
switch { switch {
case p == ">" && i == len(parts)-1, p == "*", logToken.MatchString(p): case p == ">" && i == len(parts)-1, p == "*", logToken.MatchString(p):
default: default:
@@ -110,7 +104,7 @@ func logFilter(l LogIssued, pattern string) (string, error) {
if len(pattern) > LogKeyMax { if len(pattern) > LogKeyMax {
return "", fmt.Errorf("%q is longer than a key can be (%d bytes)", pattern, LogKeyMax) return "", fmt.Errorf("%q is longer than a key can be (%d bytes)", pattern, LogKeyMax)
} }
return l.Subject + "." + strings.Join(parts, "."), nil return l.Subject + "." + pattern, nil
} }
// issuedLog is the log a module may reach by a name, or the reason it may not. // issuedLog is the log a module may reach by a name, or the reason it may not.
@@ -132,14 +126,13 @@ func (c *Conn) issuedLog(module, name string) (LogIssued, error) {
if len(names) > 0 { if len(names) > 0 {
issued = strings.Join(names, ", ") issued = strings.Join(names, ", ")
} }
return LogIssued{}, fmt.Errorf("%s keeps no log called %q: a module declares the logs it keeps under `logs`, and "+ return LogIssued{}, fmt.Errorf("%s keeps no log called %q: a module declares the logs it keeps under `logs`, "+
"another module's it reads under `reads` as <module>.<log>; it was issued: %s (novox/hq ADR 0297, ADR 0571)", "and a log is its owner's alone; it was issued: %s (novox/hq ADR 0297)", module, name, issued)
module, name, issued)
} }
// notOnTheBus says a log is issued and its stream is not there yet. // notOnTheBus says a log is issued and its stream is not there yet.
func notOnTheBus(l LogIssued) error { func notOnTheBus(l LogIssued) error {
return fmt.Errorf("the log %q is issued and its stream %s is not on the bus yet: the mesh creates "+ return fmt.Errorf("the log %q is issued and its stream %s is not on the bus yet: the controller creates "+
"it from the catalogue on its next raise (novox/hq ADR 0297)", l.Name, l.Stream) "it from the catalogue on its next raise (novox/hq ADR 0297)", l.Name, l.Stream)
} }
@@ -207,10 +200,9 @@ func (c *Conn) LogAppend(module, name, key string, value json.RawMessage, expect
return ack.Seq, nil return ack.Seq, nil
} }
// LogRead is the entries from a sequence on, or from a time on (since, when not zero), of the keys matching a pattern, at // LogRead is the entries from a sequence on, of the keys matching a pattern, at most limit of them and
// most limit of them and about 4 MiB, by one batch direct get. A log another module keeps is read as its own is, by the // about 4 MiB, by one batch direct get.
// name its reads declare, `<module>.<log>` (novox/hq ADR 0571). func (c *Conn) LogRead(module, name string, from uint64, keys string, limit int) (*LogPage, error) {
func (c *Conn) LogRead(module, name string, from uint64, since time.Time, keys string, limit int) (*LogPage, error) {
l, err := c.issuedLog(module, name) l, err := c.issuedLog(module, name)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -226,24 +218,20 @@ func (c *Conn) LogRead(module, name string, from uint64, since time.Time, keys s
if err != nil { if err != nil {
return nil, err return nil, err
} }
if from != 0 && !since.IsZero() {
return nil, fmt.Errorf("a read of %s starts from a sequence or from a time, not both", name)
}
if from == 0 { if from == 0 {
from = 1 from = 1
} }
req := map[string]any{"seq": from, "next_by_subj": filter, "batch": limit, "max_bytes": LogPageBytes} msgs, next, err := c.directBatch(l.Stream, filter, from, limit, name, func() error { return notOnTheBus(l) })
if !since.IsZero() {
delete(req, "seq")
req["start_time"] = since.UTC()
}
entries, more, err := c.logBatch(l, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
page := &LogPage{Entries: entries} page := &LogPage{Entries: []LogEntry{}, Next: next}
if more && len(entries) > 0 { for _, msg := range msgs {
page.Next = entries[len(entries)-1].Sequence + 1 e, err := logEntryOf(l, msg)
if err != nil {
return nil, err
}
page.Entries = append(page.Entries, *e)
} }
info, err := c.logInfo(l) info, err := c.logInfo(l)
if err != nil { if err != nil {
@@ -253,148 +241,56 @@ func (c *Conn) LogRead(module, name string, from uint64, since time.Time, keys s
return page, nil return page, nil
} }
// LogNewest is the newest entry of each key matching a pattern, in the log's order, by one batch direct get (mesh-sdk // directBatch is the messages of a stream from a sequence on, on the subjects matching filter, at most limit and about
// process.LogReader.Newest). // LogPageBytes, by one batch direct get (NATS 2.11), and the sequence to read on from, 0 when nothing follows. name is
func (c *Conn) LogNewest(module, name, keys string) ([]LogEntry, error) { // what is read, for its errors; missing is the error of a stream that is not on the bus.
l, err := c.issuedLog(module, name) func (c *Conn) directBatch(stream, filter string, from uint64, limit int, name string, missing func() error) ([]*nats.Msg, uint64, error) {
if err != nil { if from == 0 {
return nil, err from = 1
} }
filter, err := logFilter(l, keys) req, _ := json.Marshal(map[string]any{"seq": from, "next_by_subj": filter, "batch": limit, "max_bytes": LogPageBytes})
if err != nil {
return nil, err
}
entries, more, err := c.logBatch(l, map[string]any{"multi_last": []string{filter}, "batch": LogReadMax})
if err != nil {
return nil, err
}
if more {
return nil, fmt.Errorf("%s holds more than %d keys matching %q: name fewer", name, LogReadMax, keys)
}
return entries, nil
}
// logBatch is one batch direct get of a log: its entries, and whether more are pending after them.
func (c *Conn) logBatch(l LogIssued, request map[string]any) ([]LogEntry, bool, error) {
req, _ := json.Marshal(request)
inbox := c.nc.NewInbox() inbox := c.nc.NewInbox()
sub, err := c.nc.SubscribeSync(inbox) sub, err := c.nc.SubscribeSync(inbox)
if err != nil { if err != nil {
return nil, false, err return nil, 0, err
} }
defer func() { _ = sub.Unsubscribe() }() defer func() { _ = sub.Unsubscribe() }()
if err := c.nc.PublishRequest("$JS.API.DIRECT.GET."+l.Stream, inbox, req); err != nil { if err := c.nc.PublishRequest("$JS.API.DIRECT.GET."+stream, inbox, req); err != nil {
return nil, false, err return nil, 0, err
} }
entries := []LogEntry{} var msgs []*nats.Msg
var next uint64
deadline := time.Now().Add(StateTimeout) deadline := time.Now().Add(StateTimeout)
for { for done := false; !done; {
msg, err := sub.NextMsg(time.Until(deadline)) msg, err := sub.NextMsg(time.Until(deadline))
if err != nil { if err != nil {
if errors.Is(err, nats.ErrNoResponders) { if errors.Is(err, nats.ErrNoResponders) {
return nil, false, notOnTheBus(l) return nil, 0, missing()
} }
if errors.Is(err, nats.ErrTimeout) { if errors.Is(err, nats.ErrTimeout) {
return nil, false, fmt.Errorf("the read of %s did not end in %s", l.Name, StateTimeout) return nil, 0, fmt.Errorf("the read of %s did not end in %s", name, StateTimeout)
} }
return nil, false, err return nil, 0, err
} }
switch status := msg.Header.Get("Status"); status { switch status := msg.Header.Get("Status"); status {
case "": case "":
e, err := logEntryOf(l, msg) msgs = append(msgs, msg)
if err != nil { case "204": // the end of the batch: what is still pending after the last message sent
return nil, false, err
}
entries = append(entries, *e)
case "204": // the end of the batch: what is still pending after the last entry sent
pending, _ := strconv.ParseUint(msg.Header.Get("Nats-Num-Pending"), 10, 64) pending, _ := strconv.ParseUint(msg.Header.Get("Nats-Num-Pending"), 10, 64)
return entries, pending > 0, nil if pending > 0 && len(msgs) > 0 {
case "404": // nothing from there on last, _ := strconv.ParseUint(msgs[len(msgs)-1].Header.Get("Nats-Sequence"), 10, 64)
return entries, false, nil next = last + 1
}
done = true
case "404": // nothing from that sequence on
done = true
case "503": case "503":
return nil, false, notOnTheBus(l) return nil, 0, missing()
default: default:
return nil, false, fmt.Errorf("the read of %s was refused by the bus: %s %s", l.Name, status, msg.Header.Get("Description")) return nil, 0, fmt.Errorf("the read of %s was refused by the bus: %s %s", name, status, msg.Header.Get("Description"))
} }
} }
} return msgs, next, nil
// LogWatch hands deliver every entry of the keys matching a pattern from a sequence on (from), from a time on (since),
// or, with neither, each new one, in the log's order, one at a time, through an ordered consumer it lets go of when
// stopped (mesh-sdk process.LogReader.Watch). It returns once the entries there at its start are handed over; an error
// from deliver is said, and the watch goes on with the next entry.
func (c *Conn) LogWatch(module, name, keys string, from uint64, since time.Time, deliver func(LogEntry) error) (func(), error) {
l, err := c.issuedLog(module, name)
if err != nil {
return nil, err
}
filter, err := logFilter(l, keys)
if err != nil {
return nil, err
}
cfg := jetstream.OrderedConsumerConfig{FilterSubjects: []string{filter}, DeliverPolicy: jetstream.DeliverNewPolicy}
switch {
case from != 0 && !since.IsZero():
return nil, fmt.Errorf("a watch of %s starts from a sequence or from a time, not both", name)
case from != 0:
cfg.DeliverPolicy, cfg.OptStartSeq = jetstream.DeliverByStartSequencePolicy, from
case !since.IsZero():
at := since.UTC()
cfg.DeliverPolicy, cfg.OptStartTime = jetstream.DeliverByStartTimePolicy, &at
}
js, err := jetstream.New(c.nc)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
cons, err := js.OrderedConsumer(ctx, l.Stream, cfg)
if errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, notOnTheBus(l)
}
if err != nil {
return nil, fmt.Errorf("the watch of %s (stream %s): %w", name, l.Stream, err)
}
info, err := cons.Info(ctx)
if err != nil {
return nil, fmt.Errorf("the watch of %s (stream %s): %w", name, l.Stream, err)
}
it, err := cons.Messages()
if err != nil {
return nil, fmt.Errorf("the watch of %s (stream %s): %w", name, l.Stream, err)
}
hand := func(m jetstream.Msg) error {
meta, err := m.Metadata()
if err != nil {
return err
}
e := LogEntry{Sequence: meta.Sequence.Stream, Key: strings.TrimPrefix(m.Subject(), l.Subject+"."),
Value: valueOf(m.Data()), At: meta.Timestamp.UTC()}
return deliver(e)
}
for pending := info.NumPending; pending > 0; pending-- {
m, err := it.Next()
if err == nil {
err = hand(m)
}
if err != nil {
it.Stop()
return nil, fmt.Errorf("the watch of %s: %w", name, err)
}
}
var stopped atomic.Bool
go func() {
for {
m, err := it.Next()
if err != nil || stopped.Load() {
return // stopped, or the connection closed; a message fetched as it stopped is not handed over
}
if err := hand(m); err != nil {
c.Logf("[mesh-tools] %s did not take entry of %s: %v; it is in the log, a read finds it", module, name, err)
}
}
}()
return func() { stopped.Store(true); it.Stop() }, nil
} }
// LogLast is a key's last entry, or nil when it has none. // LogLast is a key's last entry, or nil when it has none.
+97 -2
View File
@@ -1,7 +1,13 @@
package bus package bus
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-tools/node-tools/internal/wire" "github.com/novox/mesh-tools/node-tools/internal/wire"
) )
@@ -9,8 +15,97 @@ import (
// An answer larger than the bus carries in one message (novox/hq issue 314). // An answer larger than the bus carries in one message (novox/hq issue 314).
// //
// The bus carries one message of at most its max_payload, and the client library refuses a larger one // The bus carries one message of at most its max_payload, and the client library refuses a larger one
// before it leaves the process that sent it. A module's answer too large is refused here, loudly, in a // before it leaves the process that sent it. Until this, a holder whose answer was too large wrote one
// message that fits, so its caller reads why instead of waiting out RequestTimeout. // line to its own log and its caller waited out RequestTimeout and read "did not answer" — the
// controller's `conditions` and `status` for hours on 2026-10-08, and the operator's channel with them.
//
// The controller pages such an answer (mesh-controller internal/link, pages.go): it answers an error
// carrying `paged` — the call it holds the answer under, its size and its parts — and a caller asks the
// same subject again once per part with PageHeader, each part answered raw with PartHeader. Asking the
// same subject means no grant beyond the one the caller already had. A module's answer too large is
// refused here, loudly, in a message that fits: its instances may be several, and a part asked again
// could reach another.
const (
// PageHeader asks for one part of a paged answer: `<call> <part>`, from zero.
PageHeader = "Mesh-Page"
// PartHeader is on a part: `<part>/<parts>`.
PartHeader = "Mesh-Part"
// partTries is how often one part is asked: during a handover a part can reach the controller that
// did not hold it.
partTries = 3
)
// PartTimeout bounds the asking of one part.
var PartTimeout = 10 * time.Second
// paged is what a too-large answer says instead of itself.
type paged struct {
Call string `json:"call"`
Bytes int `json:"bytes"`
Parts int `json:"parts"`
}
func pagedIn(data []byte) (paged, bool) {
if !bytes.Contains(data, []byte(`"paged"`)) {
return paged{}, false
}
var r struct {
Paged *paged `json:"paged"`
}
if json.Unmarshal(data, &r) != nil || r.Paged == nil || r.Paged.Call == "" || r.Paged.Parts < 1 {
return paged{}, false
}
return *r.Paged, true
}
// whole is a reply's whole answer: the reply itself, or every part of a paged one, joined. A part that
// cannot be had is an error naming it, never a shorter answer.
func (c *Conn) whole(subject string, data []byte) ([]byte, error) {
p, ok := pagedIn(data)
if !ok {
return data, nil
}
out := make([]byte, 0, p.Bytes)
for n := 0; n < p.Parts; n++ {
var part []byte
var err error
for try := 0; try < partTries; try++ {
if part, err = c.askPart(subject, p, n); err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("the answer was %d bytes, more than the bus carries in one message, paged as %s "+
"in %d parts, and part %d could not be had: %w", p.Bytes, p.Call, p.Parts, n, err)
}
out = append(out, part...)
}
if len(out) != p.Bytes {
return nil, fmt.Errorf("the answer paged as %s was %d bytes, and its parts joined are %d", p.Call, p.Bytes, len(out))
}
return out, nil
}
func (c *Conn) askPart(subject string, p paged, n int) ([]byte, error) {
ask := nats.NewMsg(subject)
ask.Header.Set(PageHeader, fmt.Sprintf("%s %d", p.Call, n))
ask.Data = []byte(`{}`)
reply, err := c.nc.RequestMsg(ask, PartTimeout)
if err != nil {
return nil, err
}
if got := reply.Header.Get(PartHeader); got != fmt.Sprintf("%d/%d", n, p.Parts) {
var r struct {
Error string `json:"error"`
}
if json.Unmarshal(reply.Data, &r) == nil && r.Error != "" {
return nil, errors.New(r.Error)
}
return nil, fmt.Errorf("asked part %d/%d, answered %q", n, p.Parts, got)
}
return reply.Data, nil
}
// fits is an answer the bus can carry: the answer itself, or — when it is larger than one message — // fits is an answer the bus can carry: the answer itself, or — when it is larger than one message —
// an error that says so, which is. // an error that says so, which is.
+39 -3
View File
@@ -2,6 +2,7 @@ package bus_test
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -12,8 +13,8 @@ import (
"github.com/novox/mesh-tools/node-tools/internal/meshtest" "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
// novox/hq issue 314: a module's answer larger than the bus carries in one message is said to be too large, // novox/hq issue 314: an answer larger than the bus carries in one message arrives whole when the
// at once, not a 30-second silence. (The controller's paging ended with the controller, issue 1126.) // controller pages it, and a module's is said to be too large — neither is a 30-second silence.
func TestAnAnswerLargerThanOneMessage(t *testing.T) { func TestAnAnswerLargerThanOneMessage(t *testing.T) {
url := meshtest.URL(t) url := meshtest.URL(t)
seat, err := nats.Connect(url) seat, err := nats.Connect(url)
@@ -26,8 +27,43 @@ func TestAnAnswerLargerThanOneMessage(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer c.Close() defer c.Close()
large := strings.Repeat("a condition held past its bound; ", 3*int(seat.MaxPayload())/32) limit := int(seat.MaxPayload())
// The controller as it pages (mesh-controller internal/link, pages.go): the first answer an error
// with `paged`, each part asked again on the same subject with the page header.
large := strings.Repeat("a condition held past its bound; ", 3*limit/32)
whole, _ := json.Marshal(map[string]any{"result": large})
partSize := limit - 1024
parts := (len(whole) + partSize - 1) / partSize
sub, err := seat.Subscribe(bus.SeatToolSubject("mesh-controller", "conditions", "", ""), func(m *nats.Msg) {
if page := m.Header.Get(bus.PageHeader); page != "" {
var id string
var n int
fmt.Sscanf(page, "%s %d", &id, &n)
end := min((n+1)*partSize, len(whole))
part := nats.NewMsg(m.Reply)
part.Header.Set(bus.PartHeader, fmt.Sprintf("%d/%d", n, parts))
part.Data = whole[n*partSize : end]
_ = m.RespondMsg(part)
return
}
body, _ := json.Marshal(map[string]any{"error": "too large for one message",
"paged": map[string]any{"call": "call-1-1", "bytes": len(whole), "parts": parts}})
_ = m.Respond(body)
})
if err != nil {
t.Fatal(err)
}
defer sub.Unsubscribe()
_ = seat.Flush()
got, err := c.Ask("seat:mesh-controller.conditions", map[string]any{}, "")
var said string
if err != nil || json.Unmarshal(got.Result, &said) != nil || said != large {
t.Fatalf("an answer of %d bytes in %d parts arrived as %d bytes: %v", len(whole), parts, len(got.Result), err)
}
// A module's answer too large is refused here in words, at once.
stop, err := c.HandleSubject("mesh.mod.journal.tool.lines", func(json.RawMessage) (any, error) { return large, nil }) stop, err := c.HandleSubject("mesh.mod.journal.tool.lines", func(json.RawMessage) (any, error) { return large, nil })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+3 -3
View File
@@ -14,16 +14,16 @@ import (
// ADR 0370 changes it, issue 685). // ADR 0370 changes it, issue 685).
// //
// While a module's code runs here, its asks reach the bus under this runtime's account, and the bus proves only // While a module's code runs here, its asks reach the bus under this runtime's account, and the bus proves only
// that account. So the runtime asks its own machine's client on ProvisionAskSubject — which the mesh grants // that account. So the runtime asks its own machine's client on ProvisionAskSubject — which the controller grants
// this machine's tool runner alone to publish (ADR 0370 §3) — naming the module itself, from the bundle that asks // this machine's tool runner alone to publish (ADR 0370 §3) — naming the module itself, from the bundle that asks
// and never from the module's code or arguments (§4). The client answers that module's config values, for what its // and never from the module's code or arguments (§4). The client answers that module's config values, for what its
// manifest declares, and no secret: the vault hands a secret only to a caller the bus proves (§2). // manifest declares, and no secret: the vault hands a secret only to a caller the bus proves (§2).
// ProvisionAskSubject is where this machine's tool runner asks its provisioner client (the node-engine // ProvisionAskSubject is where this machine's tool runner asks its provisioner client (mesh-controller
// internal/broker ProvisionerAskSubject): `prov.ask.<node>`. // internal/broker ProvisionerAskSubject): `prov.ask.<node>`.
func ProvisionAskSubject(node string) string { return "prov.ask." + node } func ProvisionAskSubject(node string) string { return "prov.ask." + node }
// ProvisionChangedSubjects is every module's word of what moved on a machine (the node-engine // ProvisionChangedSubjects is every module's word of what moved on a machine (mesh-controller internal/broker
// ProvisionerChangedSubject): `prov.changed.<node>.<module>`, a module's name dots and all. // ProvisionerChangedSubject): `prov.changed.<node>.<module>`, a module's name dots and all.
func ProvisionChangedSubjects(node string) string { return "prov.changed." + node + ".>" } func ProvisionChangedSubjects(node string) string { return "prov.changed." + node + ".>" }
+4 -4
View File
@@ -24,7 +24,7 @@ import (
// and anything else is refused here, with the reason, before it is sent. A bundle reaches this only through // and anything else is refused here, with the reason, before it is sent. A bundle reaches this only through
// its own standard input and output: no tool call does. // its own standard input and output: no tool call does.
// SeatTraffic is what a membership lists of a module's seat traffic (the grants part's record). // SeatTraffic is what a membership lists of a module's seat traffic (the controller's broker.SeatTraffic).
type SeatTraffic struct { type SeatTraffic struct {
Publish []string `json:"publish,omitempty"` Publish []string `json:"publish,omitempty"`
Subscribe []string `json:"subscribe,omitempty"` Subscribe []string `json:"subscribe,omitempty"`
@@ -32,7 +32,7 @@ type SeatTraffic struct {
Workers []SeatWorker `json:"workers,omitempty"` Workers []SeatWorker `json:"workers,omitempty"`
Records []string `json:"records,omitempty"` Records []string `json:"records,omitempty"`
// Kinds are the holders of every kinded bench the module uses or watches, with what each promises: the // Kinds are the holders of every kinded bench the module uses or watches, with what each promises: the
// record of their claims (novox/hq ADR 0259 §5). // controller's record of their claims (novox/hq ADR 0259 §5).
Kinds []KindHeld `json:"kinds,omitempty"` Kinds []KindHeld `json:"kinds,omitempty"`
} }
@@ -45,7 +45,7 @@ type KindHeld struct {
Capabilities []string `json:"capabilities,omitempty"` Capabilities []string `json:"capabilities,omitempty"`
} }
// SeatKinds is the kinds the module's membership names, as the grants part issued them. // SeatKinds is the kinds the module's membership names, as the controller issued them.
func (c *Conn) SeatKinds(module string) ([]KindHeld, error) { func (c *Conn) SeatKinds(module string) ([]KindHeld, error) {
t, err := c.trafficOf(module) t, err := c.trafficOf(module)
if err != nil { if err != nil {
@@ -249,7 +249,7 @@ type Work struct {
// SeatTake reads a worker the module's seat traffic lists and hands each message to deliver: acknowledged // SeatTake reads a worker the module's seat traffic lists and hands each message to deliver: acknowledged
// when deliver returns nil, offered again after NakDelay when it returns an error, ended when it is not // when deliver returns nil, offered again after NakDelay when it returns an error, ended when it is not
// JSON. The worker is the mesh's to create; this binds to it by name and never makes one. // JSON. The worker is the controller's to create; this binds to it by name and never makes one.
func (c *Conn) SeatTake(module, consumer string, deliver func(Work) error) (func(), error) { func (c *Conn) SeatTake(module, consumer string, deliver func(Work) error) (func(), error) {
t, err := c.trafficOf(module) t, err := c.trafficOf(module)
if err != nil { if err != nil {
+2 -2
View File
@@ -98,7 +98,7 @@ func TestAModuleSubmitsAndSaysOnlyUnderItsOwnNameAndKind(t *testing.T) {
t.Fatalf("an asker could not ask under its own name: %v", err) t.Fatalf("an asker could not ask under its own name: %v", err)
} }
for module, subject := range map[string]string{ for module, subject := range map[string]string{
"mesh-delivery": "mesh.seat.operator-channel.accept.ask.mesh-placement", "mesh-delivery": "mesh.seat.operator-channel.accept.ask.mesh-controller",
"telegram": "mesh.seat.intake.event.choice.desktop", "telegram": "mesh.seat.intake.event.choice.desktop",
"messenger": "mesh.seat.intake.event.choice.telegram", "messenger": "mesh.seat.intake.event.choice.telegram",
} { } {
@@ -206,7 +206,7 @@ func TestAnAskerReadsItsOwnRecordAndNoOther(t *testing.T) {
if _, err := kv.Put("mesh-delivery.a1", []byte(`{"state":"open"}`)); err != nil { if _, err := kv.Put("mesh-delivery.a1", []byte(`{"state":"open"}`)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := kv.Put("mesh-placement.a1", []byte(`{"state":"open"}`)); err != nil { if _, err := kv.Put("mesh-controller.a1", []byte(`{"state":"open"}`)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, err := conn.SeatRecord("mesh-delivery", "messenger_asks", "a1") got, err := conn.SeatRecord("mesh-delivery", "messenger_asks", "a1")
+3 -124
View File
@@ -14,7 +14,7 @@ import (
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
) )
// A module's state on the bus (novox/hq ADR 0201): key-value buckets the mesh creates from what // A module's state on the bus (novox/hq ADR 0201): key-value buckets the controller creates from what
// the module declared, and issues to each assignment in its membership by the name the module uses — // the module declared, and issues to each assignment in its membership by the name the module uses —
// its own state by the local name, another module's as `<module>.<name>`. // its own state by the local name, another module's as `<module>.<name>`.
// //
@@ -37,8 +37,7 @@ type StateEntry struct {
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
} }
// StateChange is one change a watch delivers: a key put, deleted, or expired (its own time to live, or the // StateChange is one change a watch delivers: a key put or deleted.
// bucket's age, ended; issue 1952).
type StateChange struct { type StateChange struct {
State string `json:"state"` State string `json:"state"`
Key string `json:"key"` Key string `json:"key"`
@@ -89,7 +88,7 @@ func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, e
kv, err := js.KeyValue(ctx, s.Bucket) kv, err := js.KeyValue(ctx, s.Bucket)
if err != nil { if err != nil {
if errors.Is(err, jetstream.ErrBucketNotFound) { if errors.Is(err, jetstream.ErrBucketNotFound) {
return nil, fmt.Errorf("the state %q is issued and its bucket is not on the bus yet: the mesh "+ return nil, fmt.Errorf("the state %q is issued and its bucket is not on the bus yet: the controller "+
"creates it from the catalogue on its next raise", s.Name) "creates it from the catalogue on its next raise", s.Name)
} }
return nil, err return nil, err
@@ -174,51 +173,6 @@ func (c *Conn) StateHistory(module, name, key string) ([]StateKept, error) {
return kept, nil return kept, nil
} }
// StateInfo is how full a state's bucket is against its caps (tools-are-how-a-module-is-spoken-to): a bundle's tools
// say fill against cap from it.
type StateInfo struct {
State string `json:"state"`
Bucket string `json:"bucket"`
// Bytes is what the bucket holds now, every kept value and marker counted; Keys how many keys it holds, a key
// whose last value is a delete or expiry marker counted until the marker goes.
Bytes uint64 `json:"bytes"`
Keys uint64 `json:"keys"`
// MaxBytes and MaxValueBytes are the bucket's caps; -1 is none.
MaxBytes int64 `json:"max_bytes"`
MaxValueBytes int32 `json:"max_value_bytes"`
// History is how many values a key keeps; TTLSeconds the bucket's age for a value (0 none); KeyTTL whether a key may
// carry its own time to live (issue 1952).
History int64 `json:"history"`
TTLSeconds int64 `json:"ttl_seconds"`
KeyTTL bool `json:"key_ttl"`
}
// StateInfo reads a state's bucket's fill and caps. It is a read like StateGet: served to the owner and every reader.
func (c *Conn) StateInfo(module, name string) (*StateInfo, error) {
s, err := c.issuedState(module, name)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := c.bucket(ctx, s)
if err != nil {
return nil, err
}
status, err := kv.Status(ctx)
if err != nil {
return nil, err
}
b, ok := status.(*jetstream.KeyValueBucketStatus)
if !ok || b.StreamInfo() == nil {
return nil, fmt.Errorf("the bus answered no stream behind %s's state %s", module, name)
}
info := b.StreamInfo()
return &StateInfo{State: name, Bucket: status.Bucket(), Bytes: info.State.Bytes, Keys: uint64(info.State.NumSubjects),
MaxBytes: info.Config.MaxBytes, MaxValueBytes: info.Config.MaxMsgSize, History: status.History(),
TTLSeconds: int64(status.TTL() / time.Second), KeyTTL: info.Config.AllowMsgTTL && status.LimitMarkerTTL() > 0}, nil
}
// StatePut writes one key, as the module, where the module keeps the state. It answers the revision. // StatePut writes one key, as the module, where the module keeps the state. It answers the revision.
func (c *Conn) StatePut(module, name, key string, value json.RawMessage) (uint64, error) { func (c *Conn) StatePut(module, name, key string, value json.RawMessage) (uint64, error) {
s, err := c.writable(module, name) s, err := c.writable(module, name)
@@ -245,61 +199,6 @@ func (c *Conn) StatePut(module, name, key string, value json.RawMessage) (uint64
return kv.Put(ctx, key, value) return kv.Put(ctx, key, value)
} }
// MinKeyTTL is the shortest time to live a key may be given: the bus counts a key's age in whole seconds (mesh-sdk
// process.MinTTL).
const MinKeyTTL = time.Second
// StateCreateTTL writes one key only when it has no value, with its own time to live (novox/hq ADR 0564 clauses 4 and
// 7, issue 1952; mesh-sdk process.State.CreateTTL): the bus removes it when ttl passes and every watch hears the
// removal as an expiry, so a bundle's wake needs no timer. A key that has a value is refused as ErrStateChanged. The
// bucket must take keys with their own time to live (a state declared `key-ttl`); one that does not is refused, naming
// it, before anything is written.
func (c *Conn) StateCreateTTL(module, name, key string, value json.RawMessage, ttl time.Duration) (uint64, error) {
if ttl < MinKeyTTL {
return 0, fmt.Errorf("%s's state %s, key %s: a time to live of %s is shorter than the bus keeps (%s)",
module, name, key, ttl, MinKeyTTL)
}
return c.stateWrite(module, name, key, value, func(ctx context.Context, kv jetstream.KeyValue) (uint64, error) {
if err := takesKeyTTL(ctx, kv, module, name); err != nil {
return 0, err
}
return kv.Create(ctx, key, value, jetstream.KeyTTL(ttl))
})
}
// StateRenewTTL writes one key whole with a fresh time to live, whatever it held (mesh-sdk process.State.RenewTTL): a
// time to live is set only when a key is created, so a live key is deleted first, and its watches hear the delete and
// then the new value.
func (c *Conn) StateRenewTTL(module, name, key string, value json.RawMessage, ttl time.Duration) (uint64, error) {
rev, err := c.StateCreateTTL(module, name, key, value, ttl)
if !errors.Is(err, ErrStateChanged) {
return rev, err
}
if err := c.StateDelete(module, name, key); err != nil {
return 0, err
}
return c.StateCreateTTL(module, name, key, value, ttl)
}
// takesKeyTTL refuses a bucket that does not allow a time to live per key, or leaves no marker when one ends: there the
// server would refuse the write, or the key would vanish and no watch would hear it.
func takesKeyTTL(ctx context.Context, kv jetstream.KeyValue, module, name string) error {
status, err := kv.Status(ctx)
if err != nil {
return err
}
allows := status.LimitMarkerTTL() > 0
if b, ok := status.(*jetstream.KeyValueBucketStatus); ok && b.StreamInfo() != nil {
allows = allows && b.StreamInfo().Config.AllowMsgTTL
}
if !allows {
return fmt.Errorf("%s's state %s does not take keys with their own time to live: its bucket %s allows none, or "+
"leaves no marker a watch hears when one ends; %s declares the state `key-ttl` for it (novox/hq ADR 0564, "+
"issue 1952)", module, name, status.Bucket(), module)
}
return nil
}
// StateDelete removes one key, as the module, where the module keeps the state. A key that was not // StateDelete removes one key, as the module, where the module keeps the state. A key that was not
// there is not an error: what is asked for is that it is gone. // there is not an error: what is asked for is that it is gone.
func (c *Conn) StateDelete(module, name, key string) error { func (c *Conn) StateDelete(module, name, key string) error {
@@ -423,12 +322,6 @@ func (c *Conn) StateWatch(module, name, pattern string, deliver func(StateChange
go func() { go func() {
initial := true initial := true
for e := range w.Updates() { for e := range w.Updates() {
// A watch given up, or stopped, hands over nothing more, though values stay buffered in the
// watcher: they would reach a watch id the bundle no longer holds (issue 774: 1257 orphan
// changes after 51 failed watches).
if watching.Err() != nil {
break
}
if e == nil { if e == nil {
// The end of what was there when the watch began. // The end of what was there when the watch began.
if initial { if initial {
@@ -437,11 +330,6 @@ func (c *Conn) StateWatch(module, name, pattern string, deliver func(StateChange
} }
continue continue
} }
if initial {
// Arrival from the bus is progress before the bundle is asked: the bound measures the current
// values coming in, and restarts when the bundle has taken one.
progress()
}
change := StateChange{State: name, Key: e.Key(), Revision: e.Revision(), Current: initial} change := StateChange{State: name, Key: e.Key(), Revision: e.Revision(), Current: initial}
switch e.Operation() { switch e.Operation() {
case jetstream.KeyValuePut: case jetstream.KeyValuePut:
@@ -455,15 +343,6 @@ func (c *Conn) StateWatch(module, name, pattern string, deliver func(StateChange
continue continue
} }
change.Op = "delete" change.Op = "delete"
if e.Operation() == jetstream.KeyValuePurge {
// The marker the server leaves when a key's time to live ends (Nats-Marker-Reason MaxAge,
// read by the client as a purge): nothing else purges a module's state, since neither the
// runtime nor mesh-sdk's State offers a purge (issue 1952).
change.Op = "expire"
}
}
if watching.Err() != nil {
break
} }
if err := deliver(change); err != nil { if err := deliver(change); err != nil {
c.Logf("[mesh-tools] %s did not take %s %s of %s: %v; its next change, or its next start, reads it again", c.Logf("[mesh-tools] %s did not take %s %s of %s: %v; its next change, or its next start, reads it again",
+1 -115
View File
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -33,7 +32,7 @@ func TestAWatchHoldsWhileItsCurrentValuesKeepArriving(t *testing.T) {
for i := 0; i < older; i++ { for i := 0; i < older; i++ {
keys = append(keys, fmt.Sprintf("novox/mesh-catalog.%d.%040x.status=mesh/check", 400+i, i)) keys = append(keys, fmt.Sprintf("novox/mesh-catalog.%d.%040x.status=mesh/check", 400+i, i))
} }
for _, member := range []string{"novox/mesh-sdk.76.34f210e5", "novox/mesh-placement.453.c06f75d0", "novox/mesh-delivery.33.28ee22ef"} { for _, member := range []string{"novox/mesh-sdk.76.34f210e5", "novox/mesh-controller.453.c06f75d0", "novox/mesh-delivery.33.28ee22ef"} {
keys = append(keys, member+strings.Repeat("0", 32)+".status=mesh/check") keys = append(keys, member+strings.Repeat("0", 32)+".status=mesh/check")
} }
for _, k := range keys { for _, k := range keys {
@@ -117,50 +116,6 @@ func TestAWatchIsRefusedWhenNoCurrentValueIsTakenWithinTheBound(t *testing.T) {
} }
} }
// **A membership the grants part wrote in its old wrapper is served whole** (issue 774). Until 2026-10-30 the part wrote
// its pass record, the membership inside, into its state memberships; the tool runner read the value as a bare
// membership, and served no tool and no state. This is what the live mesh held: read through mesh-sdk's codec, its tools
// are answered where the membership says and its state is reached. A held-back one's wrapper is refused, and the module has
// no membership then (774/S32/AC2: no stream is read in its place).
func TestAMembershipTheGrantsPartWroteWrappedIsServed(t *testing.T) {
mesh := mt.New(t)
mesh.Bucket(t, "gitea_repos")
gitea := mt.MembershipOf("gitea", "anchor", false, nil)
gitea.State = []bus.StateIssued{{Name: "repos", Bucket: "gitea_repos", Writes: true}}
mesh.IssueInGrantsWrapped(t, gitea, "published")
// The old wrapper of a membership the part held back carries its unpublished composition: refused, never served.
heldBack := mt.MembershipOf("pages", "anchor", true, nil)
mesh.IssueInGrantsWrapped(t, heldBack, "not published, a shadow difference: publishing would lose the last holder of x")
conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"})
if err != nil {
t.Fatal(err)
}
var logged []string
conn.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
t.Cleanup(conn.Close)
conn.Follow("gitea")
mt.Until(t, func() error {
if m := conn.Membership("gitea"); m == nil || len(m.Serves) == 0 {
return fmt.Errorf("gitea's membership is %+v, want the one inside the wrapper", m)
}
return nil
})
if got, want := fmt.Sprint(conn.ServedOn("gitea", "gitea_list")), "[{mesh.mod.gitea.tool.gitea_list.anchor }]"; got != want {
t.Errorf("gitea_list is served on %s, want %s", got, want)
}
if _, err := conn.StatePut("gitea", "repos", "novox.hq", []byte(`{"v":1}`)); err != nil {
t.Fatalf("the state the membership issues is refused: %v", err)
}
if e, err := conn.StateGet("gitea", "repos", "novox.hq"); err != nil || e == nil || string(e.Value) != `{"v":1}` {
t.Errorf("the state reads back %+v, %v", e, err)
}
conn.Follow("pages")
time.Sleep(300 * time.Millisecond)
if m := conn.Membership("pages"); m != nil || !strings.Contains(strings.Join(logged, "\n"), "no membership issued for pages") {
t.Errorf("a held-back membership's wrapper was served or not said: %+v, %q", m, logged)
}
}
// owedWatcher is the gitea module's view of the checker's owed: the bucket, and a runtime connection // owedWatcher is the gitea module's view of the checker's owed: the bucket, and a runtime connection
// that serves gitea with the owed issued for reading. // that serves gitea with the owed issued for reading.
func owedWatcher(t *testing.T) (*bus.Conn, interface { func owedWatcher(t *testing.T) (*bus.Conn, interface {
@@ -187,72 +142,3 @@ func owedWatcher(t *testing.T) (*bus.Conn, interface {
}) })
return conn, kv return conn, kv
} }
// **A watch given up, or stopped, hands over nothing more** (issue 774). The runtime's watch stopped its watcher
// when the current values did not arrive within the bound, but the watcher still held values, and its goroutine
// went on handing them over: to a watch id the bundle no longer held (on the provisioner, 51 failed watches were
// followed by 1257 orphan changes). Each row blocks the bundle on its first value, gives the watch up, lets the
// bundle go and counts what it is still handed.
func TestAWatchGivenUpHandsOverNothingMore(t *testing.T) {
for _, tc := range []struct {
name string
refused bool // the bound gives it up, or the caller stops it
}{
{"refused at the bound", true},
{"stopped by its caller", false},
} {
t.Run(tc.name, func(t *testing.T) {
was := bus.StateTimeout
bus.StateTimeout = 300 * time.Millisecond
t.Cleanup(func() { bus.StateTimeout = was })
conn, kv := owedWatcher(t)
put := func(i int) {
if _, err := kv.Put(fmt.Sprintf("novox/hq.%d.%040x.status=mesh/check", i+1, i), []byte(`{"v":1}`)); err != nil {
t.Fatal(err)
}
}
if tc.refused {
for i := 0; i < 4; i++ {
put(i)
}
}
var calls atomic.Int32
first := make(chan struct{}, 1)
release := make(chan struct{})
deliver := func(ch bus.StateChange) error {
if calls.Add(1) == 1 {
first <- struct{}{}
<-release
}
return nil
}
var stop func()
if tc.refused {
if _, err := conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err == nil {
t.Fatal("the watch whose bundle took nothing was not refused")
}
} else {
var err error
if stop, err = conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
put(i)
}
select {
case <-first:
case <-time.After(5 * time.Second):
t.Fatal("the bundle was handed no change")
}
stop()
}
// Let the watcher's server side deliver what it held, then free the bundle.
time.Sleep(300 * time.Millisecond)
close(release)
time.Sleep(500 * time.Millisecond)
if n := calls.Load(); n != 1 {
t.Fatalf("the bundle was handed %d changes of a watch given up, want 1 (the one in hand)", n)
}
})
}
}
+126
View File
@@ -0,0 +1,126 @@
package bus
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// A module reading its own stream back (novox/hq ADR 0363; the operator, 2026-10-30: a build step's whole failure output
// is said on its build module's stream, and the module's tools read it there). As a log's read: one batch direct get,
// no consumer, and only a stream the module's own membership names (`streams`); the bus grants its owner that stream's
// info and direct get and no other's (mesh-sdk buscompose ownStreamGrants).
// StreamEntry is one event of a module's own stream: its sequence, its event (the subject after the stream's
// `…event.`), its body and when the bus took it.
type StreamEntry struct {
Sequence uint64 `json:"sequence"`
Event string `json:"event"`
Body json.RawMessage `json:"body"`
At time.Time `json:"at"`
}
// StreamPage is one page of a stream's read: Next is the sequence to continue from, 0 when nothing follows; Last the
// stream's last sequence.
type StreamPage struct {
Entries []StreamEntry `json:"entries"`
Next uint64 `json:"next"`
Last uint64 `json:"last"`
}
// streamBusName is a stream's local name as the bus holds it (mesh-sdk catalogue.StreamBusName): capitals, a hyphen an
// underscore; a bus name is itself.
func streamBusName(name string) string { return strings.ToUpper(strings.ReplaceAll(name, "-", "_")) }
// issuedStream is the stream of its own a module may read by a name, local or the bus's, or the reason it may not.
func (c *Conn) issuedStream(module, name string) (StreamIssued, error) {
m := c.Membership(module)
if m == nil {
return StreamIssued{}, fmt.Errorf("%s has no membership issued on %s yet, so no stream of it is readable "+
"until the mesh issues one (novox/hq ADR 0363)", module, c.node)
}
var names []string
for _, s := range m.Streams {
if s.Stream == streamBusName(name) && strings.Contains(s.Subject, "{event}") {
return s, nil
}
names = append(names, s.Stream)
}
sort.Strings(names)
issued := "none"
if len(names) > 0 {
issued = strings.Join(names, ", ")
}
return StreamIssued{}, fmt.Errorf("%s keeps no stream called %q: a module reads the streams it declares under `streams`, "+
"and no other's; it was issued: %s (novox/hq ADR 0363)", module, name, issued)
}
// StreamRead is a module's own stream's events from a sequence on, whose event names match a pattern (`*` one name, `>`
// the rest, last; empty is every event), at most limit (LogReadDefault when 0, at most LogReadMax) and about LogPageBytes.
func (c *Conn) StreamRead(module, name, events string, from uint64, limit int) (*StreamPage, error) {
s, err := c.issuedStream(module, name)
if err != nil {
return nil, err
}
switch {
case limit == 0:
limit = LogReadDefault
case limit < 0 || limit > LogReadMax:
return nil, fmt.Errorf("a read's limit is 1 to %d events, %d when none is given, not %d", LogReadMax, LogReadDefault, limit)
}
if events == "" {
events = ">"
}
parts := strings.Split(events, ".")
for i, p := range parts {
if !(p == ">" && i == len(parts)-1) && p != "*" && !logToken.MatchString(p) {
return nil, fmt.Errorf("%q is not an event pattern: names, `*` for one and `>` for the rest, last", events)
}
}
prefix, _, _ := strings.Cut(s.Subject, "{event}")
missing := func() error {
return fmt.Errorf("the stream %s is issued and not on the bus yet: the controller creates it from the catalogue "+
"on its next raise, and reads it directly once it allows that (novox/hq ADR 0363)", s.Stream)
}
msgs, next, err := c.directBatch(s.Stream, prefix+events, from, limit, s.Stream, missing)
if err != nil {
return nil, err
}
page := &StreamPage{Entries: []StreamEntry{}, Next: next}
for _, msg := range msgs {
e, err := streamEntryOf(s.Stream, prefix, msg)
if err != nil {
return nil, err
}
page.Entries = append(page.Entries, *e)
}
info, err := c.logInfo(LogIssued{Name: s.Stream, Stream: s.Stream})
if err != nil {
return nil, err
}
page.Last = info.Last
return page, nil
}
// streamEntryOf is an event a direct get answered: its event is its subject after prefix.
func streamEntryOf(stream, prefix string, msg *nats.Msg) (*StreamEntry, error) {
subject := msg.Header.Get("Nats-Subject")
event, ok := strings.CutPrefix(subject, prefix)
if !ok {
return nil, fmt.Errorf("the bus answered an event of %s on %q, which is not under %s", stream, subject, prefix)
}
seq, err := strconv.ParseUint(msg.Header.Get("Nats-Sequence"), 10, 64)
if err != nil {
return nil, fmt.Errorf("the bus answered an event of %s with no sequence", stream)
}
at, err := time.Parse(time.RFC3339Nano, msg.Header.Get("Nats-Time-Stamp"))
if err != nil {
return nil, fmt.Errorf("the bus answered event %d of %s with no time: %v", seq, stream, err)
}
return &StreamEntry{Sequence: seq, Event: event, Body: valueOf(msg.Data), At: at.UTC()}, nil
}
+4 -4
View File
@@ -14,7 +14,7 @@ import (
// A module's own stream, as the runtime publishes to it and hears it (novox/hq ADR 0363 §8, issue 725). // A module's own stream, as the runtime publishes to it and hears it (novox/hq ADR 0363 §8, issue 725).
// //
// A module may declare a stream of its own; the mesh creates it and says, in the module's // A module may declare a stream of its own; the controller creates it and says, in the module's
// membership, which of its events go there and on which subject (`streams`), and, in the membership of // membership, which of its events go there and on which subject (`streams`), and, in the membership of
// each module granted to hear it, which durable consumer on it to bind (`hears`). The runtime publishes and // each module granted to hear it, which durable consumer on it to bind (`hears`). The runtime publishes and
// binds exactly as the membership says. A membership naming neither is what every membership was before: // binds exactly as the membership says. A membership naming neither is what every membership was before:
@@ -30,7 +30,7 @@ type StreamIssued struct {
} }
// HeardIssued is one module stream an assignment hears: the stream and the durable consumer on it the // HeardIssued is one module stream an assignment hears: the stream and the durable consumer on it the
// runtime binds. The consumer is the mesh's to make, to filter and to delete. // runtime binds. The consumer is the controller's to make, to filter and to delete.
type HeardIssued struct { type HeardIssued struct {
Stream string `json:"stream"` Stream string `json:"stream"`
Consumer string `json:"consumer"` Consumer string `json:"consumer"`
@@ -73,7 +73,7 @@ func eventMatches(pattern, event string) bool {
} }
// RebindEvery is how often a consumer the membership names and the bus did not let the runtime bind is // RebindEvery is how often a consumer the membership names and the bus did not let the runtime bind is
// tried again: the mesh makes the consumer and sends the user list carrying its grant in the // tried again: the controller makes the consumer and sends the user list carrying its grant in the
// composition that issues the membership, so one may arrive before the other (ADR 0363 §6). // composition that issues the membership, so one may arrive before the other (ADR 0363 §6).
var RebindEvery = 30 * time.Second var RebindEvery = 30 * time.Second
@@ -294,7 +294,7 @@ func (h *hearing) reconcile() {
} }
} }
// **A loop the bus ended is no longer reading** (nats.go ends a pull loop on its own when its consumer is // **A loop the bus ended is no longer reading** (nats.go ends a pull loop on its own when its consumer is
// deleted): the mesh deletes and makes again under the same name a restricted consumer it cannot // deleted): the controller deletes and makes again under the same name a restricted consumer it cannot
// narrow (ADR 0363 §6), and the membership, unchanged, says nothing — so it is bound again here. // narrow (ADR 0363 §6), and the membership, unchanged, says nothing — so it is bound again here.
// **A loop the bus did not end is asked about**: nats.go learns of a deleted consumer only from the answer // **A loop the bus did not end is asked about**: nats.go learns of a deleted consumer only from the answer
// to a pull it has pending, and a consumer deleted and made again between two pulls, or while none is // to a pull it has pending, and a consumer deleted and made again between two pulls, or while none is
-62
View File
@@ -160,68 +160,6 @@ func TestAModuleHearsEveryStreamItsMembershipNames(t *testing.T) {
} }
return nil return nil
}) })
// The membership as the grants part keeps it (ADR 0571 clause 7, 10): read first and applied live, and nothing
// published on the controller's subject is heard.
granted := reader
granted.Hears = []bus.HeardIssued{{Stream: "MAIL", Consumer: "from-grants"}}
mesh.Issue(t, granted)
again, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"})
if err != nil {
t.Fatal(err)
}
again.Logf = func(string, ...any) {}
t.Cleanup(again.Close)
again.Follow("reader")
consumerOf := func() string {
if m := again.Membership("reader"); m != nil && len(m.Hears) == 1 {
return m.Hears[0].Consumer
}
return ""
}
if got := consumerOf(); got != "from-grants" {
t.Fatalf("the membership read at the follow names %q, want the grants part's", got)
}
granted.Hears[0].Consumer = "from-grants-2"
mesh.Issue(t, granted)
mt.Until(t, func() error {
if got := consumerOf(); got != "from-grants-2" {
return fmt.Errorf("the membership names %q, want the grants part's change", got)
}
return nil
})
// 774/S32/AC1: a copy said on the controller's subject (mesh.assignment) changes nothing; there is no stream to read it from.
streamed := reader
streamed.Hears = []bus.HeardIssued{{Stream: "MAIL", Consumer: "from-subject"}}
mesh.Publish(t, "mesh.assignment.anchor.reader", streamed)
again.Flush()
time.Sleep(200 * time.Millisecond)
if got := consumerOf(); got != "from-grants-2" {
t.Fatalf("a membership said on the controller's subject was applied: the membership names %q", got)
}
// Review g1 round 1: memberships that cannot be read at the follow (a bus not ready after its boot) are read again until
// they can be; the follow, which runs once per module, does not give the module up for good.
was := bus.GrantsRetry
bus.GrantsRetry = 50 * time.Millisecond
t.Cleanup(func() { bus.GrantsRetry = was })
mesh.DropGrants(t)
late, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"})
if err != nil {
t.Fatal(err)
}
late.Logf = func(string, ...any) {}
t.Cleanup(late.Close)
late.Follow("reader")
if m := late.Membership("reader"); m != nil {
t.Fatalf("a membership was served from a state not there: %+v", m)
}
mesh.Issue(t, granted)
mt.Until(t, func() error {
if m := late.Membership("reader"); m == nil || len(m.Hears) != 1 || m.Hears[0].Consumer != "from-grants-2" {
return fmt.Errorf("the membership is %+v, want the one written once the state is there", m)
}
return nil
})
} }
// novox/hq ADR 0363 §6 (issue 725): a consumer the membership names before the controller made it is bound once // novox/hq ADR 0363 §6 (issue 725): a consumer the membership names before the controller made it is bound once
+71 -175
View File
@@ -17,9 +17,6 @@ package console
// session — so a tool that arrived a minute ago is found without the client reconnecting. // session — so a tool that arrived a minute ago is found without the client reconnecting.
import ( import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -50,7 +47,7 @@ const (
// searchCap is how many matches a search answers before it says how many more there were. // searchCap is how many matches a search answers before it says how many more there were.
const searchCap = 25 const searchCap = 25
const grammar = "Addresses: `<seat>.<verb>` for a seat held once for the mesh (e.g. `mesh-placement.placement`); " + const grammar = "Addresses: `<seat>.<verb>` for a seat held once for the mesh (e.g. `mesh-controller.nodes`); " +
"`<node>/<seat>.<verb>` for a seat every machine holds (e.g. `ace/node-packet-filter.rules`); " + "`<node>/<seat>.<verb>` for a seat every machine holds (e.g. `ace/node-packet-filter.rules`); " +
"`<node>/<module>.<tool>` for a module on one machine (e.g. `novox/postgres.postgres_list_databases`); " + "`<node>/<module>.<tool>` for a module on one machine (e.g. `novox/postgres.postgres_list_databases`); " +
"and `<module>.<tool>` also for a module whose instances are interchangeable." "and `<module>.<tool>` also for a module whose instances are interchangeable."
@@ -131,17 +128,13 @@ type index struct {
NotAnswering []string NotAnswering []string
Listing *Listing // the flat catalogue the call path resolves subjects with Listing *Listing // the flat catalogue the call path resolves subjects with
Discovery announce.Discovery Discovery announce.Discovery
// Recorded is where placement places each module that declares tools. // Recorded is where the controller's records place each module that declares tools.
Recorded map[string][]string Recorded map[string][]string
// Unheard is every runtime whose answer this index lacks: it said it was there and did not say // Unheard is every runtime whose answer this index lacks: it said it was there and did not say
// what it serves in time, or it answered before and not now. An address it might answer is never // what it serves in time, or it answered before and not now. An address it might answer is never
// called missing while it is here (2026-10-05). // called missing while it is here (2026-10-05).
Unheard []unheard Unheard []unheard
// Placed is the state seat-holders as the seat mesh-placement's holder wrote it (novox/hq ADR 0571 clause 4), by its // Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0245):
// keys `<seat>` and `<seat>.<node>`, read with delivery.HoldersOf; PlacedWhy says why it could not be read.
Placed map[string][]byte
PlacedWhy string
// Replaces is what each verb and tool replaces (novox/hq ADR 0245):
// `seat:<seat>.<verb>` and `<module>.<tool>`. // `seat:<seat>.<verb>` and `<module>.<tool>`.
Replaces map[string][]string Replaces map[string][]string
// Earlier is every runtime not heard this round whose last answer, within RuntimeForgotten, this // Earlier is every runtime not heard this round whose last answer, within RuntimeForgotten, this
@@ -231,8 +224,42 @@ func findTool(tools []Tool, name string) *Tool {
return nil return nil
} }
// recordedModule is a module as the bus holds it: where the placement seat places it, and whether its manifest declares // controllerOutput is a controller seat verb's answer: the command's printed output.
// tools — what should announce itself, and where (novox/hq ADR 0571). func controllerOutput(conn *bus.Conn, verb string) (string, error) {
got, err := conn.Ask("seat:mesh-controller."+verb, map[string]any{}, "")
if err != nil {
return "", err
}
var r struct {
Output string `json:"output"`
OK *bool `json:"ok"`
}
if json.Unmarshal(got.Result, &r) != nil {
return "", fmt.Errorf("mesh-controller.%s answered something that is not its output", verb)
}
if r.OK != nil && !*r.OK {
return "", fmt.Errorf("mesh-controller.%s: %s", verb, strings.TrimSpace(r.Output))
}
return r.Output, nil
}
// jsonIn is the JSON document a command printed, after any lines it said first: a seat verb runs
// the controller's command, and a command may warn before it answers.
func jsonIn(output string) string {
t := strings.TrimSpace(output)
if strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") {
return t
}
for _, open := range []string{"\n{", "\n["} {
if i := strings.Index(output, open); i >= 0 {
return strings.TrimSpace(output[i+1:])
}
}
return ""
}
// recordedModule is a module as the controller's records hold it (`module list --json`): where the
// mesh assigned it, and whether it declares tools — what should announce itself, and where.
type recordedModule struct { type recordedModule struct {
Module string `json:"module"` Module string `json:"module"`
On []string `json:"on"` On []string `json:"on"`
@@ -241,8 +268,13 @@ type recordedModule struct {
Replaces map[string][]string `json:"replaces,omitempty"` Replaces map[string][]string `json:"replaces,omitempty"`
} }
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
type recordedMachine struct {
Name string `json:"name"`
}
// indexOn asks the mesh what it holds (novox/hq ADR 0197): what answers, from every runtime's own // indexOn asks the mesh what it holds (novox/hq ADR 0197): what answers, from every runtime's own
// announcement on the bus — one `$SRV.INFO` request — and what should, from the indexer's records // announcement on the bus — one `$SRV.INFO` request — and what should, from the controller's records
// read as JSON. Nothing is inferred from a roster and nothing is parsed from print. // read as JSON. Nothing is inferred from a roster and nothing is parsed from print.
// //
// `held` is what the console holds already, so an instance whose announcement is unchanged is not // `held` is what the console holds already, so an instance whose announcement is unchanged is not
@@ -250,17 +282,19 @@ type recordedModule struct {
// index holds instead, marked with its age (novox/hq issue 554). // index holds instead, marked with its age (novox/hq issue 554).
func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery) []earlier) (*index, error) { func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery) []earlier) (*index, error) {
var wg sync.WaitGroup var wg sync.WaitGroup
var placed map[string][]byte var nodesOut, modulesOut string
var placedErr error var toolsAnswer json.RawMessage
var recorded []recordedModule wg.Add(3)
var known []string go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
var recordsWhy []string go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
wg.Add(2) // The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0245): answered
// Which modules there are and where they are placed, and which machines the mesh has, from the bus (ADR 0571 // in-process from the records, never a command.
// clauses 4 and 5): the indexer's modules and manifests and the placement seat's placements, never any other record. go func() {
go func() { defer wg.Done(); recorded, known, recordsWhy = recordsOn(conn) }() defer wg.Done()
// Who holds which seat on which node, from placement (ADR 0571 clause 4): no other call. if got, err := conn.Ask("seat:mesh-controller.tools", map[string]any{}, ""); err == nil {
go func() { defer wg.Done(); placed, placedErr = seatHoldersOn(conn) }() toolsAnswer = got.Result
}
}()
d, err := announce.Gather(conn, held) d, err := announce.Gather(conn, held)
wg.Wait() wg.Wait()
if err != nil { if err != nil {
@@ -299,10 +333,6 @@ func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery
l := x.Listing l := x.Listing
x.Discovery = d x.Discovery = d
x.Stale, x.Earlier = stale, earlierAt x.Stale, x.Earlier = stale, earlierAt
x.Placed = placed
if placedErr != nil {
x.PlacedWhy = placedErr.Error()
}
for _, s := range d.Silent { for _, s := range d.Silent {
x.Unheard = append(x.Unheard, unheard{Runtime: s.Name, Machine: s.Machine, x.Unheard = append(x.Unheard, unheard{Runtime: s.Name, Machine: s.Machine,
Why: fmt.Sprintf("it answered PING; what it serves did not arrive within %s", announce.Patience)}) Why: fmt.Sprintf("it answered PING; what it serves did not arrive within %s", announce.Patience)})
@@ -310,7 +340,10 @@ func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery
// What should have answered: every assignment of a module that declares tools. Silence is named; // What should have answered: every assignment of a module that declares tools. Silence is named;
// a module with no tools is never a name here. // a module with no tools is never a name here.
x.Replaces = replacesOf(catalogue.DefaultSeats(), recorded) var recorded []recordedModule
recordedOK := json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil
x.Replaces = replacesOf(toolsAnswer, recorded)
if recordedOK {
for _, m := range recorded { for _, m := range recorded {
if !m.Tools { if !m.Tools {
continue continue
@@ -324,10 +357,8 @@ func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery
} }
} }
} }
l.NotAnswering = append(l.NotAnswering, recordsWhy...) } else {
if x.PlacedWhy != "" { l.NotAnswering = append(l.NotAnswering, "mesh-controller (its records of the modules did not answer, so what is missing cannot be said)")
l.NotAnswering = append(l.NotAnswering, delivery.SeatHoldersRead+" (who holds which seat by placement could not be read: "+
x.PlacedWhy+")")
} }
for _, u := range x.Unheard { for _, u := range x.Unheard {
l.NotAnswering = append(l.NotAnswering, "the runtime "+sayUnheard([]unheard{u})) l.NotAnswering = append(l.NotAnswering, "the runtime "+sayUnheard([]unheard{u}))
@@ -335,8 +366,13 @@ func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery
sort.Strings(l.NotAnswering) sort.Strings(l.NotAnswering)
x.NotAnswering = l.NotAnswering x.NotAnswering = l.NotAnswering
var known []recordedMachine
if json.Unmarshal([]byte(jsonIn(nodesOut)), &known) == nil {
for _, n := range known { for _, n := range known {
machines[n] = true if n.Name != "" {
machines[n.Name] = true
}
}
} }
for n := range machines { for n := range machines {
x.Machines = append(x.Machines, n) x.Machines = append(x.Machines, n)
@@ -345,138 +381,6 @@ func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery
return x, nil return x, nil
} }
// stateOn reads a state the runtime's membership reads, whole: its current values by key, a deleted key absent.
func stateOn(conn *bus.Conn, name string) (map[string][]byte, error) {
var mu sync.Mutex
keys := map[string][]byte{}
stop, err := conn.StateWatch(conn.Credential().Module, name, "", func(c bus.StateChange) error {
if c.Current && c.Op == "put" {
mu.Lock()
keys[c.Key] = c.Value
mu.Unlock()
}
return nil
})
if err != nil {
return nil, err
}
stop()
mu.Lock()
defer mu.Unlock()
return keys, nil
}
// seatHoldersOn reads the state seat-holders whole (novox/hq ADR 0571 clause 4), as delivery.SeatHoldersRead.
func seatHoldersOn(conn *bus.Conn) (map[string][]byte, error) {
return stateOn(conn, delivery.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 = delivery.IndexerModule + ".modules"
manifestsRead = delivery.IndexerModule + ".manifests"
)
// manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces. A manifest's
// digest never names another, so what is read once is kept.
type manifestFacts struct {
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 placement seat places it and whether it declares tools,
// and every machine placement names (delivery.ModuleNames, delivery.MachineNames). What cannot be read is said, one line
// per state, for the listing's not answering.
func recordsOn(conn *bus.Conn) (recorded []recordedModule, machines []string, why []string) {
placements, err := stateOn(conn, delivery.PlacementRead)
if err != nil {
why = append(why, delivery.PlacementRead+" (where modules are placed could not be read, so what is missing cannot "+
"be said: "+err.Error()+")")
}
machines = delivery.MachineNames(placements)
on := map[string][]string{}
// The placement's keys in either form, a node key and its module keys or the form before (research 072 row 37).
assembled, err := delivery.PlacementsOf(placements)
if err != nil {
why = append(why, delivery.PlacementRead+" (not every key is a placement: "+err.Error()+")")
}
for _, node := range machines {
for _, m := range assembled[node].Modules {
on[m.Module] = append(on[m.Module], node)
}
}
modules, err := stateOn(conn, modulesRead)
if err != nil {
return nil, machines, append(why, modulesRead+" (which modules there are could not be read: "+err.Error()+")")
}
names, err := delivery.ModuleNames(modules)
if err != nil {
return nil, machines, append(why, modulesRead+" ("+err.Error()+")")
}
for _, name := range names {
r := recordedModule{Module: name, On: on[name]}
if m, err := delivery.DecodeModule(modules[name]); err == nil && m.Manifest != "" {
facts, err := manifestOf(conn, m.Manifest)
if err != nil {
why = append(why, name+" (its manifest could not be read, so whether it answers tools is not known: "+err.Error()+")")
} else {
r.Tools, r.Replaces = len(facts.Tools) > 0, facts.Replaces
}
}
recorded = append(recorded, r)
}
return recorded, machines, why
}
// manifestOf is what the console takes of the manifest of a digest, read from the indexer's manifests once.
func manifestOf(conn *bus.Conn, digest string) (manifestFacts, error) {
if kept, ok := manifestsKept.Load(digest); ok {
return kept.(manifestFacts), nil
}
entry, err := conn.StateGet(conn.Credential().Module, manifestsRead, digest)
if err != nil {
return manifestFacts{}, err
}
if entry == nil {
return manifestFacts{}, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := delivery.DecodeManifest(entry.Value)
if err != nil {
return manifestFacts{}, err
}
var facts manifestFacts
if err := json.Unmarshal([]byte(m.JSON), &facts); err != nil {
return manifestFacts{}, err
}
manifestsKept.Store(digest, facts)
return facts, nil
}
// placedHolders is who holds seat (on node, for a seat held per machine) by placement, or none.
func (x *index) placedHolders(seat, node string) []delivery.SeatHolder {
if len(x.Placed) == 0 {
return nil
}
hs, err := delivery.HoldersOf(x.Placed, seat, node)
if err != nil || len(hs) > 0 || node != "" {
return hs
}
// A seat held per machine, asked without one: every node's key of it.
var all []delivery.SeatHolder
for key := range x.Placed {
if s, n, ok := strings.Cut(key, "."); ok && s == seat {
if more, err := delivery.HoldersOf(x.Placed, seat, n); err == nil {
all = append(all, more...)
}
}
}
sort.Slice(all, func(i, j int) bool { return all[i].Node+"/"+all[i].Module < all[j].Node+"/"+all[j].Module })
return all
}
func containsHolder(hs []holder, h holder) bool { func containsHolder(hs []holder, h holder) bool {
for _, x := range hs { for _, x := range hs {
if x == h { if x == h {
@@ -702,14 +606,6 @@ func resolve(x *index, address string) (target, error) {
return target{}, fmt.Errorf("%s is assigned to %s in the mesh's records, but nothing that answered discovery serves it: %s. "+ return target{}, fmt.Errorf("%s is assigned to %s in the mesh's records, but nothing that answered discovery serves it: %s. "+
"Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, strings.Join(on, ", "), why) "Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, strings.Join(on, ", "), why)
} }
if hs := x.placedHolders(prefix, node); len(hs) > 0 {
var held []string
for _, h := range hs {
held = append(held, h.Module+" on "+h.Node)
}
return target{}, fmt.Errorf("the seat %s is held by %s by placement, but no holder of it announced itself on the bus. "+
"Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, strings.Join(held, ", "))
}
if len(x.Unheard) > 0 { if len(x.Unheard) > 0 {
return target{}, fmt.Errorf("nothing that answered discovery is called %s, but not every runtime answered: %s — "+ return target{}, fmt.Errorf("nothing that answered discovery is called %s, but not every runtime answered: %s — "+
"%s may be theirs. Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, sayUnheard(x.Unheard), prefix) "%s may be theirs. Ask again in a moment; mesh_runtimes shows which runtimes answered", prefix, sayUnheard(x.Unheard), prefix)
@@ -815,7 +711,7 @@ func firstLine(s string) string {
// the machine, `node` is taken out: the machine is in the address. Where it does not — a seat held // the machine, `node` is taken out: the machine is in the address. Where it does not — a seat held
// once for the mesh, or an interchangeable module — `node` is the tool's own argument, and is kept. // once for the mesh, or an interchangeable module — `node` is the tool's own argument, and is kept.
// //
// **Taking it out everywhere made the mesh's own verbs lie** (novox/hq issue 244): the old controller verbs // **Taking it out everywhere made the mesh's own verbs lie** (novox/hq issue 244): the controller's
// `push`, `plan`, `assign`, `pin` and `settings` take the machine they act on as `node`, and were // `push`, `plan`, `assign`, `pin` and `settings` take the machine they act on as `node`, and were
// described without it — `push` as taking nothing at all, so a push naming one machine arrived as a // described without it — `push` as taking nothing at all, so a push naming one machine arrived as a
// push of every machine behind. // push of every machine behind.
+35 -25
View File
@@ -70,34 +70,35 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
t.Cleanup(stop) t.Cleanup(stop)
} }
// The controller: its own seat's verbs announced on the bus like every runtime's; its records are asked by no one. // The controller: its records as JSON, as its seat verbs answer them, and its own seat's verbs
controller := connect(t, "mesh-placement", "bench") // announced on the bus like every runtime's.
// Its verb nodes, called through the console as any seat's verb is. controller := connect(t, "mesh-controller", "bench")
stopNodes, err := controller.HandleSubject("mesh.seat.mesh-placement.tool.nodes", func(json.RawMessage) (any, error) { out := func(s string) map[string]any { return map[string]any{"output": s, "ok": true} }
return map[string]any{"ok": true, "output": `[{"name":"bench","mode":"converged"},{"name":"desk","mode":"converged"}]`}, nil serve := func(verb string, answer func() any) {
stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) {
return answer(), nil
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(stopNodes) t.Cleanup(stop)
// What the mesh holds, as the indexer and placement write it (ADR 0571 clauses 4, 5): never the controller's records.
tools := map[string][]string{"alpha": {"one", "two"}, "beta": {"three"}, "gamma": {"given"}, "delta": nil,
"epsilon": {"seven"}, "mesh-placement": {"nodes"}}
mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta"}, "bench": {"epsilon", "mesh-placement"}})
console := mt.MembershipOf("node-tools", "desk", false, nil)
console.State = mt.ConsoleReads()
mesh.Issue(t, console)
mt.Until(t, func() error {
if m := nodeTools.Membership("node-tools"); m == nil || len(m.State) == 0 {
return fmt.Errorf("the console's reads are not issued yet")
} }
return nil serve("nodes", func() any {
return out("the bus's user list leaves out 2 user(s)\n" +
`[{"name":"bench","heard":"3m ago","mode":"converged","id":"1f2e"},{"name":"desk","heard":"here","mode":"converged","id":"9a8b"}]`)
}) })
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-placement", ID: "bench"}, func() []announce.Endpoint { var gammaOn atomic.Value
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-placement", Seat: "mesh-placement", Scope: "mesh", gammaOn.Store("[]")
serve("modules", func() any {
return out(fmt.Sprintf(`[{"module":"alpha","on":["desk"],"tools":true},{"module":"beta","on":["desk"],"tools":true},`+
`{"module":"gamma","on":%s,"tools":true},{"module":"delta","on":["desk"],"tools":false},`+
`{"module":"epsilon","on":["bench"],"tools":true},{"module":"mesh-controller","on":["bench"],"tools":true}]`, gammaOn.Load().(string)))
})
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-controller", ID: "bench"}, func() []announce.Endpoint {
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", Scope: "mesh",
// As the live controller announces: a mesh seat's holder names no machine. // As the live controller announces: a mesh seat's holder names no machine.
Tool: "nodes", Node: "", Description: "Every machine the mesh knows.", Schema: json.RawMessage(`{}`), Tool: "nodes", Node: "", Description: "Every machine the mesh knows.", Schema: json.RawMessage(`{}`),
Subject: "mesh.seat.mesh-placement.tool.nodes"}} Subject: "mesh.seat.mesh-controller.tool.nodes"}}
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -131,13 +132,13 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
// The overview names the mesh's seats, the machines' seats and the machines. // The overview names the mesh's seats, the machines' seats and the machines.
overview, isErr := call(t, endpoint, "mesh_overview", nil) overview, isErr := call(t, endpoint, "mesh_overview", nil)
if isErr || !strings.Contains(overview, "mesh-placement.nodes") || !strings.Contains(overview, "<node>/node-shelf.list") || if isErr || !strings.Contains(overview, "mesh-controller.nodes") || !strings.Contains(overview, "<node>/node-shelf.list") ||
!strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) { !strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) {
t.Errorf("overview: %s", overview) t.Errorf("overview: %s", overview)
} }
// Silence is named only where tools should have answered: epsilon declares tools on bench and // Silence is named only where tools should have answered: epsilon declares tools on bench and
// nothing there announced it; delta declares none and is never a name. // nothing there announced it; delta declares none and is never a name.
if strings.Contains(overview, "mesh-placement on bench") { if strings.Contains(overview, "mesh-controller on bench") {
t.Errorf("a mesh seat's holder that announced no machine is called silent on its machine:\n%s", overview) t.Errorf("a mesh seat's holder that announced no machine is called silent on its machine:\n%s", overview)
} }
if !strings.Contains(overview, "epsilon on bench") || strings.Contains(overview, "delta") { if !strings.Contains(overview, "epsilon on bench") || strings.Contains(overview, "delta") {
@@ -150,7 +151,7 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
} }
// A mesh seat, a node seat, an assignment and an interchangeable module, each by address. // A mesh seat, a node seat, an assignment and an interchangeable module, each by address.
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-placement.nodes"}); isErr || !strings.Contains(got, "converged") { if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-controller.nodes"}); isErr || !strings.Contains(got, "converged") {
t.Errorf("mesh seat: %s", got) t.Errorf("mesh seat: %s", got)
} }
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/node-shelf.list"}); isErr || !strings.Contains(got, `"a"`) { if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/node-shelf.list"}); isErr || !strings.Contains(got, `"a"`) {
@@ -168,7 +169,7 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
for address, want := range map[string]string{ for address, want := range map[string]string{
"beta.three": "keeps state on each machine it runs on, so a call names the machine: write <node>/beta.three — it runs on desk", "beta.three": "keeps state on each machine it runs on, so a call names the machine: write <node>/beta.three — it runs on desk",
"node-shelf.list": "held once per machine: write <node>/node-shelf.list — it is held on desk", "node-shelf.list": "held once per machine: write <node>/node-shelf.list — it is held on desk",
"desk/mesh-placement.nodes": "held once for the whole mesh", "desk/mesh-controller.nodes": "held once for the whole mesh",
"bench/beta.three": "beta does not run on bench; it runs on desk", "bench/beta.three": "beta does not run on bench; it runs on desk",
"nonsense": "is not an address", "nonsense": "is not an address",
} { } {
@@ -201,7 +202,7 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
t.Fatalf("gamma was found before it served: %s", got) t.Fatalf("gamma was found before it served: %s", got)
} }
mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil)) mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil))
mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta", "gamma"}, "bench": {"epsilon", "mesh-placement"}}) gammaOn.Store(`["desk"]`)
late := connect(t, "node-tools", "desk") late := connect(t, "node-tools", "desk")
stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}}, stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}},
nil, (&mt.Logs{}).Logf) nil, (&mt.Logs{}).Logf)
@@ -232,3 +233,12 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
t.Errorf("an old name: %s", got) t.Errorf("an old name: %s", got)
} }
} }
func TestTheControllersRecordsAreReadAsJSON(t *testing.T) {
if got := jsonIn("a warning printed first\n[{\"name\":\"ace\"}]"); got != `[{"name":"ace"}]` {
t.Errorf("an array after a warning: %q", got)
}
if got := jsonIn(`{"seats":[]}`); got != `{"seats":[]}` {
t.Errorf("an object: %q", got)
}
}
+9
View File
@@ -118,6 +118,9 @@ var (
timedOut = regexp.MustCompile(`(?i)timeout`) timedOut = regexp.MustCompile(`(?i)timeout`)
) )
// controllerSeat is the seat whose holder keeps what came of every call (novox/hq issue 265).
const controllerSeat = "mesh-controller"
// whyItFailed says why a call failed, so the remedy is in the words. // whyItFailed says why a call failed, so the remedy is in the words.
func whyItFailed(key string, err error) string { func whyItFailed(key string, err error) string {
if err == nil { if err == nil {
@@ -142,6 +145,12 @@ func whyItFailed(key string, err error) string {
said := key + " did not answer within " + bus.RequestTimeout.String() + ". That is not a failure: " + said := key + " did not answer within " + bus.RequestTimeout.String() + ". That is not a failure: " +
"something is serving it, so it may still be running and may already have done what was " + "something is serving it, so it may still be running and may already have done what was " +
"asked — check before repeating a call that changes the mesh." "asked — check before repeating a call that changes the mesh."
if strings.HasPrefix(strings.TrimPrefix(key, "seat:"), controllerSeat+".") {
// The controller answers every call within seconds, with an id while it runs, so silence
// from it is an answer lost on the way — which it keeps.
said += " The controller answers every call within seconds, so its answer was lost on the " +
"way; " + controllerSeat + ".calls lists its recent calls and what came of each."
}
return said return said
} }
return key + " failed: " + msg return key + " failed: " + msg
+7 -13
View File
@@ -3,7 +3,6 @@ package console
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@@ -57,19 +56,14 @@ func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) {
} }
defer stop() defer stop()
// What the mesh holds, as the indexer and placement write it (ADR 0571): ghost declares tools on desk and nothing // The controller's records (ADR 0197): ghost declares tools on desk and nothing announces it.
// announces it. controller := connect(t, "mesh-controller", "")
mesh.Placed(t, map[string][]string{"alpha": {"one", "two"}, "beta": {"three"}, "ghost": {"boo"}}, stopSeat, _ := controller.HandleSubject("mesh.seat.mesh-controller.tool.modules", func(json.RawMessage) (any, error) {
map[string][]string{"desk": {"alpha", "beta", "ghost"}}) return map[string]any{"ok": true, "output": `[{"module":"alpha","on":["desk"],"tools":true},` +
console := mt.MembershipOf("node-tools", "desk", false, nil) `{"module":"beta","on":["desk"],"tools":true},{"module":"ghost","on":["desk"],"tools":true}]`}, nil
console.State = mt.ConsoleReads()
mesh.Issue(t, console)
mt.Until(t, func() error {
if m := nodeTools.Membership("node-tools"); m == nil || len(m.State) == 0 {
return fmt.Errorf("the console's reads are not issued yet")
}
return nil
}) })
defer stopSeat()
controller.Flush()
flat := NewSurface(nodeTools, "desk.node-tools") flat := NewSurface(nodeTools, "desk.node-tools")
flat.Flat = true // the whole catalogue, as before ADR 0195: still reachable, no longer announced flat.Flat = true // the whole catalogue, as before ADR 0195: still reachable, no longer announced
@@ -26,7 +26,7 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status", want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status",
"mesh-self-check.checked", "operator-channel.decided.claude-code"} "mesh-controller.checked", "operator-channel.decided.claude-code"}
if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) { if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) {
t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want) t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want)
} }
+1 -36
View File
@@ -6,8 +6,6 @@ package console
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -62,34 +60,6 @@ type Surface struct {
Flat bool Flat bool
} }
// MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape
// the old `tools` answered: `{seats: [{seat, scope, tools: [{name, description, input, replaces}], holders:
// [{module, node}]}], holders_why}`.
const MethodSeats = "mesh/seats"
// seatsAnswer is MethodSeats' answer: mesh-sdk's seat definitions, with each seat's holders by placement.
func (s *Surface) seatsAnswer() map[string]any {
placed, err := seatHoldersOn(s.conn)
x := &index{Placed: placed}
seats := []map[string]any{}
for _, st := range catalogue.DefaultSeats() {
holders := x.placedHolders(st.Name, "")
if holders == nil {
holders = []delivery.SeatHolder{}
}
tools := st.Serves
if tools == nil {
tools = []catalogue.Verb{}
}
seats = append(seats, map[string]any{"seat": st.Name, "scope": st.Scope, "tools": tools, "holders": holders})
}
out := map[string]any{"seats": seats}
if err != nil {
out["holders_why"] = "who holds which seat by placement could not be read: " + err.Error()
}
return out
}
// NewSurface is the surface over a connection, as `who`. // NewSurface is the surface over a connection, as `who`.
func NewSurface(conn *bus.Conn, who string) *Surface { func NewSurface(conn *bus.Conn, who string) *Surface {
return &Surface{conn: conn, who: who, Flat: os.Getenv("MESH_CONSOLE_FLAT") == "1"} return &Surface{conn: conn, who: who, Flat: os.Getenv("MESH_CONSOLE_FLAT") == "1"}
@@ -155,11 +125,6 @@ func (s *Surface) HandleContext(ctx context.Context, r Request) *Reply {
return nil return nil
} }
return answer(r.ID, map[string]any{}) return answer(r.ID, map[string]any{})
case MethodSeats:
// The seats' contract and who holds each, for a client on this machine that has no seat definitions of its own
// (the TypeScript `mesh`): read in-process from mesh-sdk and from placement, never asked of anything else
// (novox/hq ADR 0571 clauses 4 and 5).
return answer(r.ID, s.seatsAnswer())
case "tools/list": case "tools/list":
if !s.Flat { if !s.Flat {
return answer(r.ID, map[string]any{"tools": discovery()}) return answer(r.ID, map[string]any{"tools": discovery()})
@@ -338,7 +303,7 @@ func (s *Surface) instructions() string {
"that serves it; what may be called was fixed when this account was issued, so a " + "that serves it; what may be called was fixed when this account was issued, so a " +
"refusal means the account, not the tool. The list is what the running modules " + "refusal means the account, not the tool. The list is what the running modules " +
"answered, plus every role's tools from the mesh's records — the mesh's own verbs " + "answered, plus every role's tools from the mesh's records — the mesh's own verbs " +
"(mesh-delivery.deliveries, mesh-placement.propose …) among them; a module that did not answer " + "(mesh-controller.status, .push, .assign …) among them; a module that did not answer " +
"is named in the list's _meta and can still be called by <module>.<tool>." "is named in the list's _meta and can still be called by <module>.<tool>."
} }
return "The tools of a Novox mesh, reached as " + s.who + ", found by address rather than listed " + return "The tools of a Novox mesh, reached as " + s.who + ", found by address rather than listed " +
@@ -11,9 +11,9 @@ import (
// 2026-10-05 the controller's push was described as taking nothing, and a push naming one machine // 2026-10-05 the controller's push was described as taking nothing, and a push naming one machine
// ran as a push of every machine behind. // ran as a push of every machine behind.
func TestAMeshSeatsNodeIsItsOwnArgument(t *testing.T) { func TestAMeshSeatsNodeIsItsOwnArgument(t *testing.T) {
push := Tool{Module: "mesh-placement", Name: "push", Seat: true, Scope: "mesh", push := Tool{Module: "mesh-controller", Name: "push", Seat: true, Scope: "mesh",
Input: json.RawMessage(`{"type":"object","properties":{"node":{"type":"string"},"behind":{"type":"string"}}}`)} Input: json.RawMessage(`{"type":"object","properties":{"node":{"type":"string"},"behind":{"type":"string"}}}`)}
mesh := target{Address: "mesh-placement.push", Tool: push, Seat: true} mesh := target{Address: "mesh-controller.push", Tool: push, Seat: true}
props := schemaAsPassed(mesh)["properties"].(map[string]any) props := schemaAsPassed(mesh)["properties"].(map[string]any)
if _, has := props["node"]; !has { if _, has := props["node"]; !has {
+7 -14
View File
@@ -22,23 +22,16 @@ func TestARefusalIsSaidOnlyForTheBussOwn(t *testing.T) {
} }
// **A timeout says it is not a failure** (novox/hq issue 265): a push that outlasted the wait had // **A timeout says it is not a failure** (novox/hq issue 265): a push that outlasted the wait had
// pushed, and the caller read "did not answer in time". No hint names a seat that is gone (novox/hq issue 1126): // pushed, and the caller read "did not answer in time". And from the controller, where its answer is.
// the grammar's example, the handshake's instructions and a timeout's remedy named the controller's verbs.
func TestATimeoutIsNotAFailure(t *testing.T) { func TestATimeoutIsNotAFailure(t *testing.T) {
got := whyItFailed("seat:mesh-delivery.deliveries", errors.New("timeout")) got := whyItFailed("seat:mesh-controller.push", errors.New("timeout"))
for _, want := range []string{"not a failure", "may already have done"} { for _, want := range []string{"not a failure", "may already have done", "mesh-controller.calls"} {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("a seat's timeout does not say %q: %s", want, got) t.Errorf("a controller timeout does not say %q: %s", want, got)
} }
} }
for name, text := range map[string]string{ if other := whyItFailed("novox/postgres.postgres_list_databases", errors.New("timeout")); !strings.Contains(other, "not a failure") ||
"grammar": grammar, strings.Contains(other, "calls") {
"flat instructions": (&Surface{Flat: true, who: "x"}).instructions(), t.Errorf("a module's timeout: %s", other)
"deep instructions": (&Surface{who: "x"}).instructions(),
"timeout": got,
} {
if strings.Contains(text, "controller") {
t.Errorf("%s names the controller: %s", name, text)
}
} }
} }
+7 -48
View File
@@ -2,13 +2,10 @@ package console
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/micro" "github.com/nats-io/nats.go/micro"
"github.com/novox/mesh-tools/node-tools/internal/announce" "github.com/novox/mesh-tools/node-tools/internal/announce"
@@ -55,23 +52,7 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T)
// Kept for the test: one round of discovery answers every question but mesh_runtimes, which asks anew. // Kept for the test: one round of discovery answers every question but mesh_runtimes, which asks anew.
IndexKept, announce.Patience = time.Minute, 2500*time.Millisecond IndexKept, announce.Patience = time.Minute, 2500*time.Millisecond
t.Cleanup(func() { IndexKept, announce.Patience = keptWas, patienceWas }) t.Cleanup(func() { IndexKept, announce.Patience = keptWas, patienceWas })
mesh := mt.New(t) mt.New(t)
// What the mesh holds, as the indexer and placement write it (ADR 0571): never the controller's records.
mesh.Placed(t, map[string][]string{"slack": {"slack_check"}, "nap": {"doze"}, "nextcloud-client": {"nextcloud_check"}},
map[string][]string{"laptop": {"slack", "nextcloud-client"}, "sleeper": {"nap", "nextcloud-client"},
"desktop": {"nextcloud-client"}, "bench": nil})
// Who holds which seat, by placement (ADR 0571 clause 4), as the seat mesh-placement's holder composes it.
placedKV := mesh.Bucket(t, delivery.SeatHoldersBucket)
for key, sh := range delivery.SeatHoldersOf(map[string]delivery.Placement{"bench": {Node: "bench",
Holds: []delivery.Holding{{Claim: "mesh-wants", Scope: "mesh", Module: "mesh-delivery-assigner"}}}}) {
raw, _ := json.Marshal(sh)
if _, err := placedKV.Put(key, raw); err != nil {
t.Fatal(err)
}
}
consoleMembership := mt.MembershipOf("node-tools", "laptop-console", false, nil)
consoleMembership.State = mt.ConsoleReads()
mesh.Issue(t, consoleMembership)
laptop := connect(t, "node-tools", "laptop") laptop := connect(t, "node-tools", "laptop")
stopTool, err := laptop.HandleSubject("mesh.mod.slack.tool.slack_check.laptop", func(json.RawMessage) (any, error) { stopTool, err := laptop.HandleSubject("mesh.mod.slack.tool.slack_check.laptop", func(json.RawMessage) (any, error) {
@@ -95,17 +76,14 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T)
Subject: "mesh.mod.nextcloud-client.tool.nextcloud_check.desktop"}, Subject: "mesh.mod.nextcloud-client.tool.nextcloud_check.desktop"},
}) })
controller := connect(t, "mesh-placement", "bench") controller := connect(t, "mesh-controller", "bench")
var toolsAsked atomic.Int32
for verb, output := range map[string]string{ for verb, output := range map[string]string{
// The controller's records are served here only to see that none is asked (ADR 0571 clauses 4 and 5). "nodes": `[{"name":"laptop"},{"name":"sleeper"},{"name":"desktop"},{"name":"bench"}]`,
"nodes": `[{"name":"laptop"}]`, "modules": `[{"module":"slack","on":["laptop"],"tools":true},{"module":"nap","on":["sleeper"],"tools":true},` +
"modules": `[]`, `{"module":"nextcloud-client","on":["laptop","desktop","sleeper"],"tools":true}]`,
"tools": `{"seats":[]}`,
} { } {
output, verb := output, verb output := output
stop, err := controller.HandleSubject("mesh.seat.mesh-placement.tool."+verb, func(json.RawMessage) (any, error) { stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) {
toolsAsked.Add(1)
return map[string]any{"output": output, "ok": true}, nil return map[string]any{"output": output, "ok": true}, nil
}) })
if err != nil { if err != nil {
@@ -116,13 +94,6 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T)
controller.Flush() controller.Flush()
console := connect(t, "node-tools", "laptop-console") console := connect(t, "node-tools", "laptop-console")
console.Follow("node-tools")
mt.Until(t, func() error {
if console.Membership("node-tools") == nil {
return fmt.Errorf("the console's membership is not issued yet")
}
return nil
})
up, err := Serve(NewSurface(console, "laptop.node-tools"), "127.0.0.1:0") up, err := Serve(NewSurface(console, "laptop.node-tools"), "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -142,20 +113,8 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T)
t.Errorf("the late runtime's machine lists nothing: %s", machine) t.Errorf("the late runtime's machine lists nothing: %s", machine)
} }
// The seats' contract and holders, for a client on this machine with no seat definitions (the TypeScript `mesh`).
seats := post(t, endpoint, `{"jsonrpc":"2.0","id":9,"method":"mesh/seats"}`)
if raw, _ := json.Marshal(seats["result"]); !strings.Contains(string(raw), `"holders":[{"module":"mesh-delivery-assigner","node":"bench"}],"scope":"mesh","seat":"mesh-wants"`) ||
!strings.Contains(string(raw), `"replaces":["journalctl"]`) {
t.Errorf("mesh/seats answered %s", raw)
}
if n := toolsAsked.Load(); n != 0 {
t.Errorf("the console asked the controller's records %d times", n)
}
// The silent one is named, never called missing. // The silent one is named, never called missing.
for address, want := range map[string]string{ for address, want := range map[string]string{
// A seat whose holder announced nothing is named with its holder by placement, never called missing.
"mesh-wants.read": "the seat mesh-wants is held by mesh-delivery-assigner on bench by placement",
"sleeper/nap.doze": "nap is assigned to sleeper in the mesh's records, but nothing that answered discovery serves it: discovery did not hear in full from node-tools on sleeper (it answered PING", "sleeper/nap.doze": "nap is assigned to sleeper in the mesh's records, but nothing that answered discovery serves it: discovery did not hear in full from node-tools on sleeper (it answered PING",
"sleeper/nextcloud-client.nextcloud_check": "nextcloud-client on sleeper did not answer discovery: discovery did not hear in full from node-tools on sleeper", "sleeper/nextcloud-client.nextcloud_check": "nextcloud-client on sleeper did not answer discovery: discovery did not hear in full from node-tools on sleeper",
} { } {
+21 -11
View File
@@ -19,8 +19,7 @@ package console
// Ties go to a seat — the place tools belong — then to the shorter name, then by address. // Ties go to a seat — the place tools belong — then to the shorter name, then by address.
import ( import (
"git.novox.be/novox/mesh-sdk/go/catalogue" "encoding/json"
"path" "path"
"sort" "sort"
"strings" "strings"
@@ -266,16 +265,27 @@ func (x *index) search(query string) []hit {
return hits return hits
} }
// replacesOf is what every verb and tool replaces (novox/hq ADR 0245): a seat's verbs from the seat's contract in // replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0245):
// mesh-sdk, keyed `seat:<seat>.<verb>`, read in-process and asked of no part (ADR 0571 clause 5: no part keeps a // a seat's verbs from its `tools` answer, keyed `seat:<seat>.<verb>`, and a module's own tools from
// central record of tools); and a module's own tools from its records, keyed `<module>.<tool>`. A verb or a tool with // its `modules` answer, keyed `<module>.<tool>`. A controller older than the field answers neither,
// no `replaces` is matched by its name and description as before. // and search matches names and descriptions as before.
func replacesOf(seats []catalogue.Seat, modules []recordedModule) map[string][]string { func replacesOf(toolsAnswer json.RawMessage, modules []recordedModule) map[string][]string {
out := map[string][]string{} out := map[string][]string{}
for _, s := range seats { var seats struct {
for _, v := range s.Serves { Seats []struct {
if len(v.Replaces) > 0 { Seat string `json:"seat"`
out["seat:"+s.Name+"."+v.Name] = append([]string(nil), v.Replaces...) Tools []struct {
Name string `json:"name"`
Replaces []string `json:"replaces"`
} `json:"tools"`
} `json:"seats"`
}
if json.Unmarshal(toolsAnswer, &seats) == nil {
for _, s := range seats.Seats {
for _, t := range s.Tools {
if len(t.Replaces) > 0 {
out["seat:"+s.Seat+"."+t.Name] = t.Replaces
}
} }
} }
} }
+17 -12
View File
@@ -1,8 +1,7 @@
package console package console
import ( import (
"git.novox.be/novox/mesh-sdk/go/catalogue" "encoding/json"
"strings" "strings"
"testing" "testing"
@@ -20,7 +19,6 @@ func meshToSearch(t *testing.T) *index {
"status": "One unit as the service manager sees it now: its states, whether it starts at boot.", "status": "One unit as the service manager sees it now: its states, whether it starts at boot.",
"restart": "Restart one unit.", "restart": "Restart one unit.",
"journal": "The last lines of one unit's journal (at most 2000).", "journal": "The last lines of one unit's journal (at most 2000).",
"failed": "The units that failed.",
} }
for _, node := range []string{"anchor", "laptop"} { for _, node := range []string{"anchor", "laptop"} {
for verb, does := range verbs { for verb, does := range verbs {
@@ -33,8 +31,8 @@ func meshToSearch(t *testing.T) *index {
"node": "What one machine reported it can do, what it is assigned, and why.", "node": "What one machine reported it can do, what it is assigned, and why.",
"nodes": "Every machine the mesh knows, with whether it is converged or adopted.", "nodes": "Every machine the mesh knows, with whether it is converged or adopted.",
} { } {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-placement", Seat: "mesh-placement", endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller",
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-placement.tool." + verb}) Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb})
} }
for tool, does := range map[string]string{ for tool, does := range map[string]string{
"docker_list": "Every container on this machine: name, image, state, ports.", "docker_list": "Every container on this machine: name, image, state, ports.",
@@ -46,7 +44,13 @@ func meshToSearch(t *testing.T) *index {
Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"}) Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"})
} }
x, _, _ := indexOfEndpoints(endpoints) x, _, _ := indexOfEndpoints(endpoints)
x.Replaces = replacesOf(catalogue.DefaultSeats(), []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true, tools := json.RawMessage(`{"seats":[{"seat":"node-service-manager","scope":"node","tools":[
{"name":"units","replaces":["systemctl list-units","systemctl --failed"]},
{"name":"status","replaces":["systemctl status","systemctl is-active"]},
{"name":"restart","replaces":["systemctl restart"]},
{"name":"journal","replaces":["journalctl"]}]},
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","replaces":["hostnamectl","uptime"]},{"name":"nodes"}]}]}`)
x.Replaces = replacesOf(tools, []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true,
Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}}) Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}})
return x return x
} }
@@ -57,20 +61,21 @@ func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) {
x := meshToSearch(t) x := meshToSearch(t)
for query, want := range map[string]string{ for query, want := range map[string]string{
"journalctl": "anchor/node-service-manager.journal", "journalctl": "anchor/node-service-manager.journal",
"journalctl -u mesh-placement -n 50": "anchor/node-service-manager.journal", "journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
"sudo journalctl -fu sshd": "anchor/node-service-manager.journal", "sudo journalctl -fu sshd": "anchor/node-service-manager.journal",
"/usr/bin/journalctl": "anchor/node-service-manager.journal", "/usr/bin/journalctl": "anchor/node-service-manager.journal",
"logs": "anchor/node-service-manager.journal", "logs": "anchor/node-service-manager.journal",
"systemctl status": "anchor/node-service-manager.status", "systemctl status": "anchor/node-service-manager.status",
"systemctl status nats.service": "anchor/node-service-manager.status", "systemctl status nats.service": "anchor/node-service-manager.status",
"systemctl restart mesh-placement": "anchor/node-service-manager.restart", "systemctl restart mesh-controller": "anchor/node-service-manager.restart",
"systemctl --failed": "anchor/node-service-manager.failed", "systemctl --failed": "anchor/node-service-manager.units",
"docker ps": "anchor/docker.docker_list", "docker ps": "anchor/docker.docker_list",
"docker ps -a": "anchor/docker.docker_list", "docker ps -a": "anchor/docker.docker_list",
"docker logs": "anchor/docker.docker_logs", "docker logs": "anchor/docker.docker_logs",
"docker logs --tail 100 nats": "anchor/docker.docker_logs", "docker logs --tail 100 nats": "anchor/docker.docker_logs",
"docker restart nats": "anchor/docker.docker_restart", "docker restart nats": "anchor/docker.docker_restart",
"container logs": "anchor/docker.docker_logs", "container logs": "anchor/docker.docker_logs",
"uptime": "mesh-controller.node",
} { } {
hits := x.search(query) hits := x.search(query)
if len(hits) == 0 { if len(hits) == 0 {
@@ -111,7 +116,7 @@ func TestASearchByCommandSaysWhatItReplaces(t *testing.T) {
// A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`. // A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`.
func TestSearchStillMatchesEveryWord(t *testing.T) { func TestSearchStillMatchesEveryWord(t *testing.T) {
x := meshToSearch(t) x := meshToSearch(t)
if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-placement.nodes" { if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-controller.nodes" {
t.Errorf("words in a description: %+v", hits) t.Errorf("words in a description: %+v", hits)
} }
if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" { if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" {
@@ -129,10 +134,10 @@ func TestSearchStillMatchesEveryWord(t *testing.T) {
} }
} }
// A seat's contract and a module's records without `replaces`: search matches names and descriptions as before. // A controller older than `replaces` answers without it: search matches names and descriptions as before.
func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) { func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) {
x := meshToSearch(t) x := meshToSearch(t)
x.Replaces = replacesOf([]catalogue.Seat{{Name: "node-service-manager", Serves: []catalogue.Verb{{Name: "journal"}}}}, x.Replaces = replacesOf(json.RawMessage(`{"seats":[{"seat":"node-service-manager","tools":[{"name":"journal"}]}]}`),
[]recordedModule{{Module: "docker"}}) []recordedModule{{Module: "docker"}})
if len(x.Replaces) != 0 { if len(x.Replaces) != 0 {
t.Fatalf("replaces from records that have none: %v", x.Replaces) t.Fatalf("replaces from records that have none: %v", x.Replaces)
+1 -1
View File
@@ -3,7 +3,7 @@ package console
// mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367). // mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367).
// //
// **Only the families the tool runner was issued** (§2, §6): its membership names them, composed by the // **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. // controller 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. // A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one.
// **An event only wakes the session** (§4): what it says is read again from the verb that owns it. // **An event only wakes the session** (§4): what it says is read again from the verb that owns it.
@@ -38,6 +38,7 @@ func (b *subscribing) Publish(json.RawMessage) error {
func (b *subscribing) Ask(json.RawMessage) (json.RawMessage, error) { return nil, nil } func (b *subscribing) Ask(json.RawMessage) (json.RawMessage, error) { return nil, nil }
func (b *subscribing) State(string, json.RawMessage) (json.RawMessage, error) { return nil, nil } func (b *subscribing) State(string, json.RawMessage) (json.RawMessage, error) { return nil, nil }
func (b *subscribing) Log(string, json.RawMessage) (json.RawMessage, error) { return nil, nil } func (b *subscribing) Log(string, json.RawMessage) (json.RawMessage, error) { return nil, nil }
func (b *subscribing) StreamRead(json.RawMessage) (json.RawMessage, error) { return nil, nil }
func (b *subscribing) Watch(json.RawMessage, func(json.RawMessage) error) (func(), error) { func (b *subscribing) Watch(json.RawMessage, func(json.RawMessage) error) (func(), error) {
return func() {}, nil return func() {}, nil
} }
@@ -95,7 +96,3 @@ func TestAnEventIsTakenByAnyAnswerThatIsNotAnError(t *testing.T) {
t.Fatalf("an error answer was not the bundle's refusal of mesh/event: %#v", err) t.Fatalf("an error answer was not the bundle's refusal of mesh/event: %#v", err)
} }
} }
func (b *subscribing) LogWatch(json.RawMessage, func(json.RawMessage) error) (func(), error) {
return func() {}, nil
}
+10 -23
View File
@@ -80,8 +80,9 @@ type Bus interface {
Watch(params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error) Watch(params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error)
// Log answers a bundle's `append`, `read`, `last` and `info` on its module's log (novox/hq ADR 0297). // Log answers a bundle's `append`, `read`, `last` and `info` on its module's log (novox/hq ADR 0297).
Log(verb string, params json.RawMessage) (json.RawMessage, error) Log(verb string, params json.RawMessage) (json.RawMessage, error)
// LogWatch hands deliver each entry of a log from where the bundle asked, one at a time (novox/hq ADR 0571). // StreamRead answers a bundle's `read` of one of its module's own streams (novox/hq ADR 0363): {stream, subject,
LogWatch(params json.RawMessage, deliver func(entry json.RawMessage) error) (stop func(), err error) // from, limit}, as a log's read.
StreamRead(params json.RawMessage) (json.RawMessage, error)
// Seat answers a bundle's seat traffic (novox/hq ADR 0259 §3): `publish`, `prove` and `record` at once; // Seat answers a bundle's seat traffic (novox/hq ADR 0259 §3): `publish`, `prove` and `record` at once;
// `take` and `answer` bind once and hand each message or proof on through handOn, to whichever child // `take` and `answer` bind once and hand each message or proof on through handOn, to whichever child
// of the module asked last — a restarted child asks again as its code runs again. // of the module asked last — a restarted child asks again as its code runs again.
@@ -325,16 +326,21 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
result = answered result = answered
} }
case "mesh/state.get", "mesh/state.put", "mesh/state.delete", "mesh/state.keys", case "mesh/state.get", "mesh/state.put", "mesh/state.delete", "mesh/state.keys",
"mesh/state.create", "mesh/state.update", "mesh/state.history", "mesh/state.info": "mesh/state.create", "mesh/state.update", "mesh/state.history":
var answered json.RawMessage var answered json.RawMessage
if answered, err = mesh.State(strings.TrimPrefix(m.Method, "mesh/state."), m.Params); err == nil { if answered, err = mesh.State(strings.TrimPrefix(m.Method, "mesh/state."), m.Params); err == nil {
result = answered result = answered
} }
case "mesh/log.append", "mesh/log.read", "mesh/log.newest", "mesh/log.last", "mesh/log.info": case "mesh/log.append", "mesh/log.read", "mesh/log.last", "mesh/log.info":
var answered json.RawMessage var answered json.RawMessage
if answered, err = mesh.Log(strings.TrimPrefix(m.Method, "mesh/log."), m.Params); err == nil { if answered, err = mesh.Log(strings.TrimPrefix(m.Method, "mesh/log."), m.Params); err == nil {
result = answered result = answered
} }
case "mesh/stream.read":
var answered json.RawMessage
if answered, err = mesh.StreamRead(m.Params); err == nil {
result = answered
}
case "mesh/provision.get": case "mesh/provision.get":
var answered json.RawMessage var answered json.RawMessage
if answered, err = mesh.Provision("get", m.Params); err == nil { if answered, err = mesh.Provision("get", m.Params); err == nil {
@@ -359,25 +365,6 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
c.mu.Unlock() c.mu.Unlock()
} }
} }
case "mesh/log.watch":
// As a state's watch: each entry asked of this child in order, `mesh/log {watch, entry}`,
// naming the watch by the id the child asked it with (novox/hq ADR 0571).
var stop func()
watch := m.ID
stop, err = mesh.LogWatch(m.Params, func(entry json.RawMessage) error {
_, err := c.ask(module, "mesh/log", map[string]any{"watch": watch, "entry": entry}, 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()
}
}
case "mesh/state.watch": case "mesh/state.watch":
// Each change is asked of this child, in order, naming the watch it is for by // Each change is asked of this child, in order, naming the watch it is for by
// the id the child asked it with — two watches of one state are two handlers; // the id the child asked it with — two watches of one state are two handlers;
@@ -105,7 +105,3 @@ func TestABundleAsksForItsValuesAndIsHandedItsWatchsChanges(t *testing.T) {
t.Errorf("the runtime's bus was asked %q", b.asked) t.Errorf("the runtime's bus was asked %q", b.asked)
} }
} }
func (b *provisioning) LogWatch(json.RawMessage, func(json.RawMessage) error) (func(), error) {
return func() {}, nil
}
+19 -156
View File
@@ -1,7 +1,7 @@
// Package meshtest raises what the mesh would, for tests against a real bus: the ASSIGNMENTS // Package meshtest raises what the controller would, for tests against a real bus: the ASSIGNMENTS
// and EVENTS streams, memberships issued by hand, and a fixture's path. // and EVENTS streams, memberships issued by hand, and a fixture's path.
// //
// **Every test has a bus of its own** (novox/hq ADR 0237, the old testbus): a server // **Every test has a bus of its own** (novox/hq ADR 0237, the controller's internal/testbus): a server
// linked in at the release go.mod pins — the release the mesh runs, held so by a test beside this one — // linked in at the release go.mod pins — the release the mesh runs, held so by a test beside this one —
// started for the one test and gone after it. The packages shared one bus and had to run one at a time: // started for the one test and gone after it. The packages shared one bus and had to run one at a time:
// each test raised the streams afresh, and the console discovers every runtime that announces itself on // each test raised the streams afresh, and the console discovers every runtime that announces itself on
@@ -11,11 +11,7 @@
package meshtest package meshtest
import ( import (
"context"
"encoding/json" "encoding/json"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -26,7 +22,6 @@ import (
"github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go" "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/bus"
) )
@@ -72,7 +67,7 @@ func URL(t *testing.T) string {
// buses are the running tests' buses, by test. // buses are the running tests' buses, by test.
var buses sync.Map var buses sync.Map
// Mesh is the mesh's job, done by hand. // Mesh is the controller's job, done by hand.
type Mesh struct { type Mesh struct {
nc *nats.Conn nc *nats.Conn
js nats.JetStreamContext js nats.JetStreamContext
@@ -86,10 +81,11 @@ func New(t *testing.T) *Mesh {
t.Fatal(err) t.Fatal(err)
} }
js, _ := nc.JetStream() js, _ := nc.JetStream()
_ = js.DeleteStream("EVENTS") for _, s := range []string{"ASSIGNMENTS", "EVENTS"} {
// The grants part's state memberships, where a runtime reads what is issued it (ADR 0571 clause 10). _ = js.DeleteStream(s)
_ = js.DeleteKeyValue(buscompose.GrantsMembershipsBucket) }
if _, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: buscompose.GrantsMembershipsBucket, History: 1}); err != nil { if _, err := js.AddStream(&nats.StreamConfig{Name: "ASSIGNMENTS", Subjects: []string{"mesh.assignment.>"},
MaxMsgsPerSubject: 1, AllowDirect: true}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil { if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
@@ -99,117 +95,13 @@ func New(t *testing.T) *Mesh {
return &Mesh{nc: nc, js: js} return &Mesh{nc: nc, js: js}
} }
// Issue writes a membership where the grants part keeps it, its state memberships, key `<node>.<module>` (novox/hq ADR // Issue publishes a membership.
// 0571 clause 7 and 10; mesh-sdk buscompose.GrantsMembershipsBucket), the one place a runtime reads it. It writes with
// mesh-sdk's encoder, the grants part's own, never json of this package's type: a value built here by hand agreed with
// the reader's mistake and hid that the part wrote another shape (issue 774).
func (m *Mesh) Issue(t *testing.T, mem bus.Membership) { func (m *Mesh) Issue(t *testing.T, mem bus.Membership) {
t.Helper() t.Helper()
m.putGrants(t, mem, membership.EncodeGrantsMembership(sdkMembership(t, mem))) body, _ := json.Marshal(mem)
} if _, err := m.js.Publish(bus.MembershipSubject(mem.Node, mem.Module), body); err != nil {
// IssueInGrantsWrapped writes a membership as the grants part wrote it before mesh-sdk's codec (until 2026-10-30, what
// the live mesh holds until the part writes again): its pass record, the membership inside, published saying what
// became of it ("published", "unchanged", or why it was held back).
func (m *Mesh) IssueInGrantsWrapped(t *testing.T, mem bus.Membership, published string) {
t.Helper()
body, _ := json.Marshal(map[string]any{"node": mem.Node, "module": mem.Module, "digest": "sha256:0",
"membership": sdkMembership(t, mem), "published": published})
m.putGrants(t, mem, body)
}
func (m *Mesh) putGrants(t *testing.T, mem bus.Membership, body []byte) {
t.Helper()
kv, err := m.js.KeyValue(buscompose.GrantsMembershipsBucket)
if err != nil {
if kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: buscompose.GrantsMembershipsBucket, History: 1}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}
if _, err := kv.Put(buscompose.MembershipKey(mem.Node, mem.Module), body); err != nil {
t.Fatal(err)
}
}
// DropGrants deletes the grants part's state memberships, as a bus that has not made it yet holds none; Issue makes it again.
func (m *Mesh) DropGrants(t *testing.T) {
t.Helper()
if err := m.js.DeleteKeyValue(buscompose.GrantsMembershipsBucket); err != nil {
t.Fatal(err)
}
}
// sdkMembership is a membership of this package's type as mesh-sdk's, which the grants part composes and writes.
func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership {
t.Helper()
raw, _ := json.Marshal(mem)
var out membership.Membership
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
// ConsoleReads are the states a console's runtime reads to say what the mesh holds (novox/hq ADR 0571 clauses 4, 5):
// placement, the indexer's modules and manifests, and the seat holders.
func ConsoleReads() []bus.StateIssued {
return []bus.StateIssued{
{Name: delivery.PlacementRead, Bucket: delivery.PlacementBucket},
{Name: delivery.IndexerModule + ".modules", Bucket: delivery.ModulesBucket},
{Name: delivery.IndexerModule + ".manifests", Bucket: delivery.ManifestsBucket},
{Name: delivery.SeatHoldersRead, Bucket: delivery.SeatHoldersBucket},
}
}
// Placed writes what the mesh holds as its parts write it, with mesh-sdk's encoders: each module the indexer holds with
// its manifest (tools, the tools it declares; none for a module without), and each node's placement of modules.
func (m *Mesh) Placed(t *testing.T, tools map[string][]string, on map[string][]string) {
t.Helper()
kv := func(bucket string) nats.KeyValue {
_ = m.js.DeleteKeyValue(bucket)
b, err := m.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 1, MaxValueSize: 256 * 1024})
if err != nil {
t.Fatal(err)
}
return b
}
modules, manifests, placement := kv(delivery.ModulesBucket), kv(delivery.ManifestsBucket), kv(delivery.PlacementBucket)
kv(delivery.SeatHoldersBucket)
for module, declared := range tools {
manifest, _ := json.Marshal(map[string]any{"module": module, "tools": declared})
digest := delivery.ManifestDigest(manifest)
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: string(manifest)})
if err != nil {
t.Fatal(err)
}
if _, err := manifests.Put(digest, raw); err != nil {
t.Fatal(err)
}
raw, err = delivery.EncodeModule(delivery.Module{None: "not built here", Manifest: digest, Repository: "novox/" + module,
Commit: strings.Repeat("a", 40)})
if err != nil {
t.Fatal(err)
}
if _, err := modules.Put(module, raw); err != nil {
t.Fatal(err)
}
}
for node, placed := range on {
p := delivery.Placement{V: delivery.Version, Node: node}
for _, module := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
}
// As the seat writes it: its node key and a key per module (research 072 row 37).
values, err := delivery.PlacementValues(p)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
if _, err := placement.Put(key, raw); err != nil {
t.Fatal(err)
}
}
}
} }
// NextEvent is the subject the next event under a pattern lands on. // NextEvent is the subject the next event under a pattern lands on.
@@ -230,7 +122,7 @@ func (m *Mesh) NextEvent(t *testing.T, pattern string) <-chan string {
return ch return ch
} }
// MembershipOf is a membership as the mesh issues one on a machine. // MembershipOf is a membership as the controller issues one on a machine.
func MembershipOf(module, node string, plain bool, seats map[string][]string) bus.Membership { func MembershipOf(module, node string, plain bool, seats map[string][]string) bus.Membership {
own := "mesh.mod." + module own := "mesh.mod." + module
serves := []bus.Served{{Subject: own + ".tool.{tool}." + node}} serves := []bus.Served{{Subject: own + ".tool.{tool}." + node}}
@@ -291,7 +183,7 @@ func (l *Logs) Has(fragments ...string) bool {
// All is every line. // All is every line.
func (l *Logs) All() string { return strings.Join(l.lines, "\n") } func (l *Logs) All() string { return strings.Join(l.lines, "\n") }
// Consumer makes a module's durable consumer on a machine as the mesh does: pull, on the // Consumer makes a module's durable consumer on a machine as the controller does: pull, on the
// EVENTS stream, filtered to what the module consumes, with a short ack wait so a test sees a // EVENTS stream, filtered to what the module consumes, with a short ack wait so a test sees a
// redelivery in seconds rather than the mesh's minutes. // redelivery in seconds rather than the mesh's minutes.
func (m *Mesh) Consumer(t *testing.T, node, module string, filters []string, ackWait time.Duration) { func (m *Mesh) Consumer(t *testing.T, node, module string, filters []string, ackWait time.Duration) {
@@ -317,18 +209,6 @@ func (m *Mesh) Emit(t *testing.T, subject string, body any) {
} }
} }
// Publish says body on a plain subject, as a core publish with no stream behind it.
func (m *Mesh) Publish(t *testing.T, subject string, body any) {
t.Helper()
data, _ := json.Marshal(body)
if err := m.nc.Publish(subject, data); err != nil {
t.Fatal(err)
}
if err := m.nc.Flush(); err != nil {
t.Fatal(err)
}
}
// Pending is what a module's consumer still holds: delivered and not acknowledged, and not yet // Pending is what a module's consumer still holds: delivered and not acknowledged, and not yet
// delivered. // delivered.
func (m *Mesh) Pending(t *testing.T, node, module string) (ackPending, notDelivered uint64) { func (m *Mesh) Pending(t *testing.T, node, module string) (ackPending, notDelivered uint64) {
@@ -351,7 +231,7 @@ func (m *Mesh) BucketKeeping(t *testing.T, name string, history uint8) nats.KeyV
return kv return kv
} }
// Bucket makes a module's state afresh as the mesh does from the catalogue (novox/hq ADR 0201), // Bucket makes a module's state afresh as the controller does from the catalogue (novox/hq ADR 0201),
// and answers it for writing what is there before a bundle starts. // and answers it for writing what is there before a bundle starts.
func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue { func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue {
t.Helper() t.Helper()
@@ -363,27 +243,10 @@ func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue {
return kv return kv
} }
// BucketKeyTTL makes a module's state afresh as the mesh does for one declared `key-ttl` (mesh-sdk buscompose: // Log makes a module's log afresh as the controller does from the catalogue (novox/hq ADR 0297): a file
// a time to live per key allowed, its end leaving a marker kept a minute; issue 1952).
func (m *Mesh) BucketKeyTTL(t *testing.T, name string) {
t.Helper()
_ = m.js.DeleteKeyValue(name)
js, err := jetstream.New(m.nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 1, MaxValueSize: 256 * 1024,
LimitMarkerTTL: time.Minute}); err != nil {
t.Fatal(err)
}
}
// Log makes a module's log afresh as the mesh does from the catalogue (novox/hq ADR 0297): a file
// stream named LOG_<module>_<name> on mesh.log.<module>.<name>.>, limits retention, no age, no cap per // stream named LOG_<module>_<name> on mesh.log.<module>.<name>.>, limits retention, no age, no cap per
// key, the declared cap, 260 KiB a message (a 256 KiB value and its headers), refusing new entries when full, direct get, no delete or // key, the declared cap, 260 KiB a message (a 256 KiB value and its headers), refusing new entries when full, direct get, no delete or
// purge. It answers the membership entry the mesh issues for it. // purge. It answers the membership entry the controller issues for it.
func (m *Mesh) Log(t *testing.T, module, name string, maxBytes int64) bus.LogIssued { func (m *Mesh) Log(t *testing.T, module, name string, maxBytes int64) bus.LogIssued {
t.Helper() t.Helper()
stream := "LOG_" + module + "_" + name stream := "LOG_" + module + "_" + name
@@ -409,13 +272,13 @@ func (m *Mesh) Consumers(t *testing.T, stream string) int {
return info.State.Consumers return info.State.Consumers
} }
// ModuleStream makes a module's own stream afresh as the mesh does from the catalogue (novox/hq ADR // ModuleStream makes a module's own stream afresh as the controller does from the catalogue (novox/hq ADR
// 0363 §1): named in capitals, taking `mesh.stream.<module>.event.>`, a subject space EVENTS does not take. // 0363 §1): named in capitals, taking `mesh.stream.<module>.event.>`, a subject space EVENTS does not take.
func (m *Mesh) ModuleStream(t *testing.T, module, name string) { func (m *Mesh) ModuleStream(t *testing.T, module, name string) {
t.Helper() t.Helper()
_ = m.js.DeleteStream(name) _ = m.js.DeleteStream(name)
if _, err := m.js.AddStream(&nats.StreamConfig{Name: name, Subjects: []string{"mesh.stream." + module + ".event.>"}, if _, err := m.js.AddStream(&nats.StreamConfig{Name: name, Subjects: []string{"mesh.stream." + module + ".event.>"},
Retention: nats.LimitsPolicy, Discard: nats.DiscardOld}); err != nil { Retention: nats.LimitsPolicy, Discard: nats.DiscardOld, AllowDirect: true}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
@@ -455,7 +318,7 @@ func (m *Mesh) Messages(t *testing.T, stream string) uint64 {
return info.State.Msgs return info.State.Msgs
} }
// RemoveConsumer deletes a module's consumer on a machine on a stream, as the mesh does when a grant // RemoveConsumer deletes a module's consumer on a machine on a stream, as the controller does when a grant
// it cannot narrow is withdrawn (novox/hq ADR 0363 §6). // it cannot narrow is withdrawn (novox/hq ADR 0363 §6).
func (m *Mesh) RemoveConsumer(t *testing.T, stream, node, module string) { func (m *Mesh) RemoveConsumer(t *testing.T, stream, node, module string) {
t.Helper() t.Helper()
+5 -10
View File
@@ -1,6 +1,6 @@
// Package provide is the runtime's half of node-tools being a provider of `mcp-endpoint`: it runs the mesh SDK's // Package provide is the runtime's half of node-tools being a provider of `mcp-endpoint`: it runs the mesh SDK's
// provider helper, so a consumer handed to the provisioner (claude-code's `${bound:mcp-endpoint:port}`) is answered // provider helper, so a consumer handed to the provisioner (claude-code's `${bound:mcp-endpoint:port}`) is answered
// from an entry node-tools writes in its own bucket, not from the old binding file (novox/hq ADR 0349 // from an entry node-tools writes in its own bucket, not from the controller's binding file (novox/hq ADR 0349
// clause 12, issue 791). // clause 12, issue 791).
// //
// The offer carries no credential: the console's authority is the machine's login (ADR 0152, 0175), so the helper // The offer carries no credential: the console's authority is the machine's login (ADR 0152, 0175), so the helper
@@ -16,7 +16,6 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/provider" "git.novox.be/novox/mesh-sdk/go/provider"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
) )
@@ -39,18 +38,15 @@ func Offers() map[string]provider.Offer {
} }
// busOf is the helper's bus over the runtime's own state: the provider's bucket is its state `bound`, and the // busOf is the helper's bus over the runtime's own state: the provider's bucket is its state `bound`, and the
// provisions and plans of the seat mesh-provisioner are the states it reads, by mesh-sdk's names (novox/hq ADR 0570, // controller's assignments are the state it reads, `mesh-controller.assignments` (both declared in the manifest).
// issue 1954 L6-T1). The old assignments are read no more (mesh-sdk 692dbf9, L6-P3).
type busOf struct{ conn *bus.Conn } type busOf struct{ conn *bus.Conn }
func (b busOf) state(bucket string) (string, error) { func (b busOf) state(bucket string) (string, error) {
switch bucket { switch bucket {
case provider.BucketOf(Module): case provider.BucketOf(Module):
return "bound", nil return "bound", nil
case provisions.ProvisionsBucket: case provider.AssignmentsBucket:
return provisions.ProvisionsRead, nil return "mesh-controller.assignments", nil
case provisions.PlansBucket:
return provisions.PlansRead, nil
} }
return "", fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket) return "", fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket)
} }
@@ -61,8 +57,7 @@ func (b busOf) Watch(ctx context.Context, bucket string, handle func(key string,
return err return err
} }
stop, err := b.conn.StateWatch(Module, name, "", func(ch bus.StateChange) error { stop, err := b.conn.StateWatch(Module, name, "", func(ch bus.StateChange) error {
if ch.Op != "put" { if ch.Op == "delete" {
// A delete, or an expiry (issue 1952): the key is gone.
handle(ch.Key, nil) handle(ch.Key, nil)
} else { } else {
handle(ch.Key, ch.Value) handle(ch.Key, ch.Value)
+6 -69
View File
@@ -7,10 +7,6 @@ import (
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/provider"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
@@ -45,39 +41,19 @@ func fill(t *testing.T, template string, entry map[string]any) string {
func TestTheRuntimeAnswersAHandedConsumersMcpEndpointThroughTheHelper(t *testing.T) { func TestTheRuntimeAnswersAHandedConsumersMcpEndpointThroughTheHelper(t *testing.T) {
mesh := mt.New(t) mesh := mt.New(t)
bound := mesh.Bucket(t, "node-tools_bound") bound := mesh.Bucket(t, "node-tools_bound")
plans := mesh.Bucket(t, provisions.PlansBucket) assignments := mesh.Bucket(t, "mesh-controller_assignments")
provs := mesh.Bucket(t, provisions.ProvisionsBucket)
mem := mt.MembershipOf("node-tools", "anchor", false, nil) mem := mt.MembershipOf("node-tools", "anchor", false, nil)
mem.State = []bus.StateIssued{ mem.State = []bus.StateIssued{
{Name: "bound", Bucket: "node-tools_bound", Writes: true}, {Name: "bound", Bucket: "node-tools_bound", Writes: true},
{Name: provisions.PlansRead, Bucket: provisions.PlansBucket}, {Name: "mesh-controller.assignments", Bucket: "mesh-controller_assignments"},
{Name: provisions.ProvisionsRead, Bucket: provisions.ProvisionsBucket},
} }
mesh.Issue(t, mem) mesh.Issue(t, mem)
// claude-code, handed on this machine, is bound to node-tools here for mcp-endpoint: its plan and its provision // claude-code, handed on this machine, is bound to node-tools here for mcp-endpoint (issue 788's plan entry).
// key, written as mesh-provisioner writes them, with mesh-sdk's encoders (ADR 0570 clauses 1-3, issue 1954). plan := `{"node":"anchor","module":"claude-code","provisioning":{"provisions":[{"provision":"mcp-endpoint",` +
plan, err := provisions.EncodePlan(provisions.Plan{Node: "anchor", Module: "claude-code", Plan: catalogue.Plan{ `"provider":"anchor","provider-module":"node-tools","at":"127.0.0.1","from":"anchor","as":"mesh_anchor_claude_code"}]}}`
Provisions: []catalogue.PlanProvision{{Provision: "mcp-endpoint", Provider: "anchor", ProviderModule: "node-tools", if _, err := assignments.Put("anchor.claude-code", []byte(plan)); err != nil {
At: "127.0.0.1", From: "anchor", As: "mesh_anchor_claude_code"}}}})
if err != nil {
t.Fatal(err)
}
if _, err := plans.Put("anchor.claude-code", plan); err != nil {
t.Fatal(err)
}
key, err := provisions.Key("anchor", "claude-code", "mcp-endpoint", "")
if err != nil {
t.Fatal(err)
}
asked, err := provisions.Encode(provisions.Provision{Provision: "mcp-endpoint", State: provisions.Asked,
Consumer: provisions.End{Node: "anchor", Module: "claude-code"}, Provider: provisions.End{Node: "anchor", Module: "node-tools"},
At: time.Now()})
if err != nil {
t.Fatal(err)
}
if _, err := provs.Put(key, asked); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -126,8 +102,6 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
var m struct { var m struct {
Serves map[string]map[string]any `json:"serves"` Serves map[string]map[string]any `json:"serves"`
Moved bool `json:"moved"` Moved bool `json:"moved"`
State []string `json:"state"`
Reads []string `json:"reads"`
Provides []struct { Provides []struct {
Name string `json:"name"` Name string `json:"name"`
AnsweredBy string `json:"answered-by"` AnsweredBy string `json:"answered-by"`
@@ -159,32 +133,11 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
t.Error("the manifest still carries a consumers file the helper does not need") t.Error("the manifest still carries a consumers file the helper does not need")
} }
} }
for _, r := range m.Reads {
if r == "mesh-placement.assignments" {
t.Error("the manifest still reads mesh-placement.assignments, which the sdk's helper no longer reads (issue 1954)")
}
}
for _, a := range m.Build.Artifacts { for _, a := range m.Build.Artifacts {
if a.Name == "files" || len(a.Env) > 0 { if a.Name == "files" || len(a.Env) > 0 {
t.Errorf("the build still carries %+v, which only a moved module needs", a) t.Errorf("the build still carries %+v, which only a moved module needs", a)
} }
} }
// 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...) {
declared[n] = true
}
for bucket, want := range map[string]string{
provider.BucketOf(Module): "bound",
provisions.ProvisionsBucket: "mesh-provisioner.provisions",
provisions.PlansBucket: "mesh-provisioner.plans",
} {
got, err := (busOf{}).state(bucket)
if err != nil || got != want || !declared[got] {
t.Errorf("the helper's bucket %s reaches state %q (%v), want %q declared in the manifest", bucket, got, err, want)
}
}
} }
// Only the machine's runtime is the provider: a process on another credential, or with no machine, answers nobody. // Only the machine's runtime is the provider: a process on another credential, or with no machine, answers nobody.
@@ -194,19 +147,3 @@ func TestOnlyTheMachinesRuntimeStartsTheHelper(t *testing.T) {
Start(nil, c.module, c.node, time.Second, said.Logf)() Start(nil, c.module, c.node, time.Second, said.Logf)()
} }
} }
// 1126/B19 (ADR 0590): this manifest is not refused by the check that refuses any manifest naming the controller:
// it reads, invokes, claims and requires nothing of it.
func TestTheManifestNamesNothingOfTheController(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
m, err := catalogue.Decode(raw)
if err != nil {
t.Fatal(err)
}
if refused := catalogue.ManifestRefusals(map[string]catalogue.Manifest{Module: m}); len(refused) != 0 {
t.Errorf("the controller-dependence check refuses node-tools' manifest: %v", refused)
}
}
+38 -59
View File
@@ -20,13 +20,11 @@ func logBuses(t *testing.T, maxBytes int64) (issues, other launch.Bus, mesh *mt.
t.Helper() t.Helper()
mesh = mt.New(t) mesh = mt.New(t)
m := mt.MembershipOf("issues", "anchor", false, nil) m := mt.MembershipOf("issues", "anchor", false, nil)
changes := mesh.Log(t, "issues", "changes", maxBytes) m.Logs = []bus.LogIssued{mesh.Log(t, "issues", "changes", maxBytes)}
m.Logs = []bus.LogIssued{changes} mesh.ModuleStream(t, "issues", "ISSUE_MOVES")
m.Streams = []bus.StreamIssued{{Stream: "ISSUE_MOVES", Events: []string{"moved.>"}, Subject: "mesh.stream.issues.event.{event}"}}
mesh.Issue(t, m) mesh.Issue(t, m)
// other reads issues' log, as its manifest's reads declare it (ADR 0571): `<module>.<log>`, without writing it. mesh.Issue(t, mt.MembershipOf("other", "anchor", false, nil))
o := mt.MembershipOf("other", "anchor", false, nil)
o.Logs = []bus.LogIssued{{Name: "issues.changes", Stream: changes.Stream, Subject: changes.Subject}}
mesh.Issue(t, o)
conn := connect(t, "node-tools", "anchor") conn := connect(t, "node-tools", "anchor")
conn.Follow("issues") conn.Follow("issues")
conn.Follow("other") conn.Follow("other")
@@ -194,63 +192,44 @@ func TestALogAnswersEachVerbOnTheBus(t *testing.T) {
t.Fatalf("the runtime made %d consumers of the log; it uses none", n) t.Fatalf("the runtime made %d consumers of the log; it uses none", n)
} }
// From a time (ADR 0571): `since` in place of `from`, RFC 3339; both at once are refused. // A module reads its own stream back as it reads its log (mesh/stream.read): by its local name, an event pattern,
cut := all.Entries[2].At // in pages; another module's stream is refused with the reason.
if p := readOf(t, b, fmt.Sprintf(`{"log":"changes","since":%q}`, cut)); len(p.Entries) == 0 || p.Entries[0].Sequence > seqs[2] || for i, ev := range []string{"moved.a.1", "moved.b.1", "moved.a.1"} {
p.Entries[len(p.Entries)-1].Sequence != seqs[4] { if err := b.Publish(json.RawMessage(fmt.Sprintf(`{"key":%q,"body":{"n":%d}}`, ev, i))); err != nil {
t.Fatalf("a read since %s: %+v", cut, p)
}
if p := readOf(t, b, fmt.Sprintf(`{"log":"changes","since":%q}`, time.Now().Add(time.Hour).Format(time.RFC3339))); len(p.Entries) != 0 {
t.Fatalf("a read since an hour ahead: %+v", p)
}
if _, err := logAsk(b, "read", `{"log":"changes","from":2,"since":"2026-10-29T00:00:00Z"}`); err == nil {
t.Fatal("a read from a sequence and a time was not refused")
}
// The newest of each key, in the log's order; `**` for the rest, as mesh-sdk says it.
raw, err = logAsk(b, "newest", `{"log":"changes"}`)
var newest []struct {
Sequence uint64 `json:"sequence"`
Key string `json:"key"`
}
if err != nil || json.Unmarshal(raw, &newest) != nil || len(newest) != 3 || newest[0].Key != "13" || newest[1].Key != "14.notes" ||
newest[2].Sequence != seqs[4] {
t.Fatalf("newest: %s %v", raw, err)
}
if raw, err := logAsk(b, "newest", `{"log":"changes","keys":"14.**"}`); err != nil || !strings.Contains(string(raw), `"14.notes"`) ||
strings.Contains(string(raw), `"12"`) {
t.Fatalf("newest of 14.**: %s %v", raw, err)
}
// Another module's log, read by the name its reads declare; never written.
if p := readOf(t, other, `{"log":"issues.changes","from":1}`); len(p.Entries) != 5 {
t.Fatalf("other's read of issues.changes: %+v", p)
}
if _, err := logAsk(other, "append", `{"log":"issues.changes","key":"1","value":{}}`); err == nil ||
!strings.Contains(err.Error(), "without writing it") {
t.Fatalf("other's append to the log it reads: %v", err)
}
// A watch hands each entry from where it is asked, then each new one, one at a time.
heard := make(chan bus.LogEntry, 16)
stop, err := b.LogWatch(json.RawMessage(fmt.Sprintf(`{"log":"changes","keys":"12","from":%d}`, seqs[2])), func(raw json.RawMessage) error {
var e bus.LogEntry
_ = json.Unmarshal(raw, &e)
heard <- e
return nil
})
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer stop()
next := appendOf(t, b, `{"log":"changes","key":"12","value":{"op":"late"}}`)
for _, want := range []uint64{seqs[2], seqs[4], next} {
select {
case e := <-heard:
if e.Sequence != want || e.Key != "12" {
t.Fatalf("the watch handed %d %s, want %d", e.Sequence, e.Key, want)
} }
case <-time.After(5 * time.Second): streamOf := func(b launch.Bus, params string) (wireStream, error) {
t.Fatalf("the watch did not hand entry %d", want) var p wireStream
raw, err := b.StreamRead(json.RawMessage(params))
if err == nil {
err = json.Unmarshal(raw, &p)
} }
return p, err
} }
page, err := streamOf(b, `{"stream":"issue-moves","subject":"moved.a.>","limit":1}`)
if err != nil || len(page.Entries) != 1 || page.Entries[0].Event != "moved.a.1" || string(page.Entries[0].Body) != `{"n":0}` || page.Next == 0 || page.Last != 3 {
t.Fatalf("the first page of moved.a: %+v, %v", page, err)
}
page, err = streamOf(b, fmt.Sprintf(`{"stream":"ISSUE_MOVES","subject":"moved.a.>","from":%d}`, page.Next))
if err != nil || len(page.Entries) != 1 || string(page.Entries[0].Body) != `{"n":2}` || page.Next != 0 {
t.Fatalf("the second page of moved.a: %+v, %v", page, err)
}
if _, err := streamOf(other, `{"stream":"issue-moves"}`); err == nil || !strings.Contains(err.Error(), "keeps no stream") {
t.Fatalf("another module's stream: %v", err)
}
}
// wireStream is a stream read's answer with exactly the contract's field names (mesh-sdk stdio.StreamPage).
type wireStream struct {
Entries []struct {
Sequence uint64 `json:"sequence"`
Event string `json:"event"`
Body json.RawMessage `json:"body"`
At string `json:"at"`
} `json:"entries"`
Next uint64 `json:"next"`
Last uint64 `json:"last"`
} }
// A page holds about 4 MiB at most, whatever its limit, and says where to continue. // A page holds about 4 MiB at most, whatever its limit, and says where to continue.
@@ -329,7 +308,7 @@ func TestALogNotIssuedIsRefusedWithTheReason(t *testing.T) {
} { } {
_, err := logAsk(other, verb, params) _, err := logAsk(other, verb, params)
if err == nil || !strings.Contains(err.Error(), `other keeps no log called "changes"`) || if err == nil || !strings.Contains(err.Error(), `other keeps no log called "changes"`) ||
!strings.Contains(err.Error(), "issued: issues.changes") { !strings.Contains(err.Error(), "issued: none") {
t.Errorf("%s of a log not issued: %v", verb, err) t.Errorf("%s of a log not issued: %v", verb, err)
} }
} }
+20 -73
View File
@@ -868,14 +868,10 @@ type stateAsked struct {
Key string `json:"key"` Key string `json:"key"`
Value json.RawMessage `json:"value"` Value json.RawMessage `json:"value"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
// TTLSeconds gives a created or put key its own time to live (issue 1952): create makes it only when it has no
// value, put renews it whole. Zero is none.
TTLSeconds int `json:"ttl_seconds"`
} }
// State answers a bundle's `get`, `put`, `delete`, `keys`, `create`, `update`, `history` and `info` on its // State answers a bundle's `get`, `put`, `delete`, `keys`, `create`, `update` and `history` on its
// module's state (ADR 0201, ADR 0259, ADR 0307); `create` and `put` take `ttl_seconds`, a key's own time to live, // module's state (ADR 0201, ADR 0259, ADR 0307). The runtime refuses, with the reason, a state the module was not issued and a write to one it only reads.
// whose end every watch hears as an `expire` (issue 1952). The runtime refuses, with the reason, a state the module was not issued and a write to one it only reads.
func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage, error) { func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage, error) {
var asked stateAsked var asked stateAsked
if err := json.Unmarshal(params, &asked); err != nil || asked.State == "" { if err := json.Unmarshal(params, &asked); err != nil || asked.State == "" {
@@ -901,13 +897,7 @@ func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage,
} }
answer = kept answer = kept
case "put": case "put":
put := conn.StatePut revision, err := conn.StatePut(b.module, asked.State, asked.Key, asked.Value)
if asked.TTLSeconds != 0 {
put = func(module, name, key string, value json.RawMessage) (uint64, error) {
return conn.StateRenewTTL(module, name, key, value, time.Duration(asked.TTLSeconds)*time.Second)
}
}
revision, err := put(b.module, asked.State, asked.Key, asked.Value)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -917,13 +907,6 @@ func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage,
return nil, err return nil, err
} }
answer = map[string]any{} answer = map[string]any{}
case "info":
// The bucket's fill against its caps, so a bundle's tools can say how full it is.
info, err := conn.StateInfo(b.module, asked.State)
if err != nil {
return nil, err
}
answer = info
case "keys": case "keys":
keys, err := conn.StateKeys(b.module, asked.State) keys, err := conn.StateKeys(b.module, asked.State)
if err != nil { if err != nil {
@@ -932,23 +915,13 @@ func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage,
answer = keys answer = keys
case "create": case "create":
// Only when the key has no value: of two writers making it, one wins (novox/hq ADR 0259). // Only when the key has no value: of two writers making it, one wins (novox/hq ADR 0259).
create := conn.StateCreate revision, err := conn.StateCreate(b.module, asked.State, asked.Key, asked.Value)
if asked.TTLSeconds != 0 {
create = func(module, name, key string, value json.RawMessage) (uint64, error) {
return conn.StateCreateTTL(module, name, key, value, time.Duration(asked.TTLSeconds)*time.Second)
}
}
revision, err := create(b.module, asked.State, asked.Key, asked.Value)
if err != nil { if err != nil {
return nil, err return nil, err
} }
answer = map[string]any{"revision": revision} answer = map[string]any{"revision": revision}
case "update": case "update":
// Only when the key is still at the revision read: a second answer to one ask loses (ADR 0259). // Only when the key is still at the revision read: a second answer to one ask loses (ADR 0259).
if asked.TTLSeconds != 0 {
return nil, fmt.Errorf("mesh/state.update gives no time to live: the bus sets one only when a key is " +
"created; put the key with ttl_seconds to renew it whole (issue 1952)")
}
revision, err := conn.StateUpdate(b.module, asked.State, asked.Key, asked.Value, asked.Revision) revision, err := conn.StateUpdate(b.module, asked.State, asked.Key, asked.Value, asked.Revision)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -969,24 +942,9 @@ type logAsked struct {
From uint64 `json:"from"` From uint64 `json:"from"`
Keys string `json:"keys"` Keys string `json:"keys"`
Limit int `json:"limit"` Limit int `json:"limit"`
// Since is a read's or a watch's start as an RFC 3339 time, in place of From (novox/hq ADR 0571).
Since string `json:"since"`
} }
// since is the asked start time, zero when none. // Log answers a bundle's `append`, `read`, `last` and `info` on its module's log (ADR 0297). The runtime
func (a logAsked) since() (time.Time, error) {
if a.Since == "" {
return time.Time{}, nil
}
at, err := time.Parse(time.RFC3339Nano, a.Since)
if err != nil {
return time.Time{}, fmt.Errorf("since is an RFC 3339 time (2026-10-29T09:30:00Z), not %q", a.Since)
}
return at, nil
}
// Log answers a bundle's `append`, `read`, `newest`, `last` and `info` on its module's log and on a log its module reads
// as `<module>.<log>` (ADR 0297, ADR 0571). The runtime
// refuses, with the reason, a log the module was not issued; an append whose `expect` is stale is // refuses, with the reason, a log the module was not issued; an append whose `expect` is stale is
// refused with the code of a lost state update. // refused with the code of a lost state update.
func (b *moduleBus) Log(verb string, params json.RawMessage) (json.RawMessage, error) { func (b *moduleBus) Log(verb string, params json.RawMessage) (json.RawMessage, error) {
@@ -1004,22 +962,11 @@ func (b *moduleBus) Log(verb string, params json.RawMessage) (json.RawMessage, e
} }
answer = map[string]any{"sequence": seq} answer = map[string]any{"sequence": seq}
case "read": case "read":
since, err := asked.since() page, err := conn.LogRead(b.module, asked.Log, asked.From, asked.Keys, asked.Limit)
if err != nil {
return nil, err
}
page, err := conn.LogRead(b.module, asked.Log, asked.From, since, asked.Keys, asked.Limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
answer = page answer = page
case "newest":
// The newest entry of each key the pattern names (mesh-sdk process.LogReader.Newest).
entries, err := conn.LogNewest(b.module, asked.Log, asked.Keys)
if err != nil {
return nil, err
}
answer = entries
case "last": case "last":
entry, err := conn.LogLast(b.module, asked.Log, asked.Key) entry, err := conn.LogLast(b.module, asked.Log, asked.Key)
if err != nil { if err != nil {
@@ -1041,24 +988,24 @@ func (b *moduleBus) Log(verb string, params json.RawMessage) (json.RawMessage, e
return json.Marshal(answer) return json.Marshal(answer)
} }
// LogWatch hands a bundle each entry of a log, `{log, keys, from, since}`: from a sequence, from a time, or with neither // StreamRead answers a bundle's read of one of its module's own streams (ADR 0363): {stream, subject, from, limit}, the
// each new one, one at a time (novox/hq ADR 0571; mesh-sdk process.LogReader.Watch). // stream by its local name, the subject an event pattern. The runtime refuses, with the reason, a stream the module's
func (b *moduleBus) LogWatch(params json.RawMessage, deliver func(json.RawMessage) error) (func(), error) { // membership does not name.
var asked logAsked func (b *moduleBus) StreamRead(params json.RawMessage) (json.RawMessage, error) {
if err := json.Unmarshal(params, &asked); err != nil || asked.Log == "" { var asked struct {
return nil, fmt.Errorf("mesh/log.watch names no log: {log, keys, from, since}") Stream string `json:"stream"`
Subject string `json:"subject"`
From uint64 `json:"from"`
Limit int `json:"limit"`
} }
since, err := asked.since() if err := json.Unmarshal(params, &asked); err != nil || asked.Stream == "" {
return nil, fmt.Errorf("mesh/stream.read names no stream: {stream, subject, from, limit}")
}
page, err := b.all.conn.StreamRead(b.module, asked.Stream, asked.Subject, asked.From, asked.Limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return b.all.conn.LogWatch(b.module, asked.Log, asked.Keys, asked.From, since, func(e bus.LogEntry) error { return json.Marshal(page)
raw, err := json.Marshal(e)
if err != nil {
return err
}
return deliver(raw)
})
} }
// Watch hands a bundle its module's state as it is and as it changes (ADR 0201): `{state, key}`, the // Watch hands a bundle its module's state as it is and as it changes (ADR 0201): `{state, key}`, the
@@ -70,7 +70,7 @@ func TestABundlesSeatTrafficGoesThroughItsModulesBus(t *testing.T) {
if !strings.Contains(string(first), `"duplicate":false`) || !strings.Contains(string(again), `"duplicate":true`) { if !strings.Contains(string(first), `"duplicate":false`) || !strings.Contains(string(again), `"duplicate":true`) {
t.Errorf("the same id twice: %s, then %s", first, again) t.Errorf("the same id twice: %s, then %s", first, again)
} }
if _, err := asking.Seat("publish", json.RawMessage(`{"subject":"mesh.seat.operator-channel.accept.ask.mesh-placement","body":{}}`), nil); err == nil { if _, err := asking.Seat("publish", json.RawMessage(`{"subject":"mesh.seat.operator-channel.accept.ask.mesh-controller","body":{}}`), nil); err == nil {
t.Error("a module submitted under another's name") t.Error("a module submitted under another's name")
} }
got, err := asking.Seat("record", json.RawMessage(`{"key":"d-1"}`), nil) got, err := asking.Seat("record", json.RawMessage(`{"key":"d-1"}`), nil)
@@ -4,10 +4,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/launch" "github.com/novox/mesh-tools/node-tools/internal/launch"
@@ -20,10 +18,8 @@ func counterBus(t *testing.T) launch.Bus {
t.Helper() t.Helper()
mesh := mt.New(t) mesh := mt.New(t)
mesh.Bucket(t, "issues_numbers") mesh.Bucket(t, "issues_numbers")
mesh.BucketKeyTTL(t, "issues_wakes")
m := mt.MembershipOf("issues", "anchor", false, nil) m := mt.MembershipOf("issues", "anchor", false, nil)
m.State = []bus.StateIssued{{Name: "numbers", Bucket: "issues_numbers", Writes: true}, m.State = []bus.StateIssued{{Name: "numbers", Bucket: "issues_numbers", Writes: true}}
{Name: "wakes", Bucket: "issues_wakes", Writes: true}}
mesh.Issue(t, m) mesh.Issue(t, m)
conn := connect(t, "node-tools", "anchor") conn := connect(t, "node-tools", "anchor")
conn.Follow("issues") conn.Follow("issues")
@@ -135,9 +131,6 @@ func TestTwoWritersIncrementingOneCounterOnTheBusLoseNoUpdate(t *testing.T) {
// A write at a revision that is no longer the key's is refused with the lost-write code and writes // A write at a revision that is no longer the key's is refused with the lost-write code and writes
// nothing; create is refused for a key with a value and taken for one deleted; an update names the // nothing; create is refused for a key with a value and taken for one deleted; an update names the
// revision it read, never zero. // revision it read, never zero.
// A key's own time to live (issue 1952, mesh-sdk process.State.CreateTTL and RenewTTL): created once, renewed whole by a
// put, its end heard by a watch as an expiry; refused, writing nothing, on a bucket that takes none, under a second, and
// on an update.
func TestAStaleWriteIsRefusedAndWritesNothing(t *testing.T) { func TestAStaleWriteIsRefusedAndWritesNothing(t *testing.T) {
b := counterBus(t) b := counterBus(t)
state := func(verb string, extra map[string]any) (uint64, error) { state := func(verb string, extra map[string]any) (uint64, error) {
@@ -207,63 +200,4 @@ func TestAStaleWriteIsRefusedAndWritesNothing(t *testing.T) {
if v := value(); v != "1" { if v := value(); v != "1" {
t.Fatalf("after delete and create: %s", v) t.Fatalf("after delete and create: %s", v)
} }
// A time to live per key: refused where it cannot be kept, writing nothing.
for _, c := range []struct {
verb, state, why string
ttl int
}{
{"create", "numbers", "does not take keys with their own time to live", 60},
{"put", "numbers", "does not take keys with their own time to live", 60},
{"create", "wakes", "shorter than the bus keeps", -1},
{"update", "wakes", "gives no time to live", 60},
} {
_, err := ask(b, c.verb, map[string]any{"state": c.state, "key": "late", "value": 1, "revision": 1, "ttl_seconds": c.ttl})
if err == nil || !strings.Contains(err.Error(), c.why) {
t.Errorf("%s with ttl %d on %s: want refused for %q, got %v", c.verb, c.ttl, c.state, c.why, err)
}
if raw, _ := ask(b, "get", map[string]any{"state": c.state, "key": "late"}); string(raw) != "null" {
t.Errorf("%s with ttl %d on %s wrote %s", c.verb, c.ttl, c.state, raw)
}
}
// The bucket's fill against its caps, for a bundle's tools.
if raw, err := ask(b, "info", map[string]any{"state": "wakes"}); err != nil || !strings.Contains(string(raw),
`"bucket":"issues_wakes","bytes":0,"keys":0,"max_bytes":-1,"max_value_bytes":262144,"history":1,"ttl_seconds":0,"key_ttl":true`) {
t.Errorf("info of a key-ttl bucket answered %s, %v", raw, err)
}
// Created once, renewed whole, and its end heard: a wake with no timer.
heard := make(chan bus.StateChange, 8)
stop, err := b.Watch(json.RawMessage(`{"state":"wakes"}`), func(raw json.RawMessage) error {
var c bus.StateChange
_ = json.Unmarshal(raw, &c)
heard <- c
return nil
})
if err != nil {
t.Fatal(err)
}
defer stop()
if _, err := ask(b, "create", map[string]any{"state": "wakes", "key": "settle", "value": 1, "ttl_seconds": 60}); err != nil {
t.Fatalf("create with a time to live: %v", err)
}
if _, err := ask(b, "create", map[string]any{"state": "wakes", "key": "settle", "value": 2, "ttl_seconds": 60}); !isLost(err) {
t.Fatalf("a second create with a time to live: %v", err)
}
if _, err := ask(b, "put", map[string]any{"state": "wakes", "key": "settle", "value": 3, "ttl_seconds": 1}); err != nil {
t.Fatalf("a put renewing the time to live: %v", err)
}
var ops []string
for want := "put delete put expire"; strings.Join(ops, " ") != want; {
select {
case c := <-heard:
ops = append(ops, c.Op)
if c.Op == "put" && len(ops) == 3 && string(c.Value) != "3" {
t.Fatalf("the renewed key holds %s", c.Value)
}
case <-time.After(5 * time.Second):
t.Fatalf("the watch heard %q, want %q", strings.Join(ops, " "), want)
}
}
} }
+2 -10
View File
@@ -9,16 +9,13 @@
"mesh-knowledge.reviewed", "mesh-knowledge.reviewed",
"mesh-delivery-gate.transition", "mesh-delivery-gate.transition",
"gitea.forge.status", "gitea.forge.status",
"mesh-self-check.checked", "mesh-controller.checked",
"operator-channel.decided.claude-code" "operator-channel.decided.claude-code"
], ],
"provides": [ "provides": [
{ {
"name": "mcp-endpoint", "name": "mcp-endpoint",
"scope": "node", "scope": "node",
"credential": {
"form": "none"
},
"answered-by": "helper" "answered-by": "helper"
} }
], ],
@@ -68,11 +65,6 @@
"bound" "bound"
], ],
"reads": [ "reads": [
"mesh-provisioner.provisions", "mesh-controller.assignments"
"mesh-provisioner.plans",
"mesh-delivery-assigner.seat-holders",
"mesh-delivery-assigner.placement",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests"
] ]
} }
+6 -6
View File
@@ -433,7 +433,7 @@ export async function connectNats(
/** /**
* React to events. * React to events.
* *
* The durable consumer is the **mesh's** to create, from what this module declared it * The durable consumer is the **controller's** to create, from what this module declared it
* consumes (design 29 §3) — this binds to it and never creates one. A runtime that created * consumes (design 29 §3) — this binds to it and never creates one. A runtime that created
* its own would be a module deciding its own delivery semantics, and its account cannot * its own would be a module deciding its own delivery semantics, and its account cannot
* reach the JetStream API to do it anyway. * reach the JetStream API to do it anyway.
@@ -499,14 +499,14 @@ async function deliver(
env = toEnvelope<unknown>(msg); env = toEnvelope<unknown>(msg);
} catch { } catch {
// Unparseable: terminate it. Redelivering a message no version of this code can read is // Unparseable: terminate it. Redelivering a message no version of this code can read is
// an infinite loop, and what the mesh keeps of a message given up on (DEAD_LETTERS, // an infinite loop, and what the controller keeps of a message given up on (DEAD_LETTERS,
// novox/hq issue 330) is for handlers that fail, not for bytes that were never an envelope. // novox/hq issue 330) is for handlers that fail, not for bytes that were never an envelope.
msg.term(); msg.term();
return; return;
} }
const forThis = listeners.filter((l) => topicMatches(l.pattern, env.key)); const forThis = listeners.filter((l) => topicMatches(l.pattern, env.key));
if (forThis.length === 0) { if (forThis.length === 0) {
// The consumer's filters are the mesh's, derived from what the module declared it // The consumer's filters are the controller's, derived from what the module declared it
// consumes, and may be wider than anything it registered a handler for. Acknowledge it, or it // consumes, and may be wider than anything it registered a handler for. Acknowledge it, or it
// would be redelivered until it expired. // would be redelivered until it expired.
msg.ack(); msg.ack();
@@ -518,10 +518,10 @@ async function deliver(
} catch { } catch {
// Negative-acknowledge with a delay, so a handler failing on a transient cause gets another // Negative-acknowledge with a delay, so a handler failing on a transient cause gets another
// attempt, and one failing permanently exhausts max-deliver rather than spinning: the consumer // attempt, and one failing permanently exhausts max-deliver rather than spinning: the consumer
// gives the event up, and the mesh keeps it in DEAD_LETTERS until a person delivers it // gives the event up, and the controller keeps it in DEAD_LETTERS until a person delivers it
// again or drops it (novox/hq issue 330). Delivered again, it arrives under // again or drops it (novox/hq issue 330). Delivered again, it arrives under
// `mesh.again.<consumer>.…`, and keyFromSubject reads the same key from it. The consumer's // `mesh.again.<consumer>.…`, and keyFromSubject reads the same key from it. The consumer's
// limits are the mesh's; this only says "not done". // limits are the controller's; this only says "not done".
msg.nak(5_000); msg.nak(5_000);
} }
} }
@@ -592,7 +592,7 @@ function toolSubject(key: string, self: string): string {
} }
/** A seat's verb, as its holder serves it: flat for a mesh seat, carrying the machine for a /** A seat's verb, as its holder serves it: flat for a mesh seat, carrying the machine for a
* node-scoped one (design 33 §4) — the same shape the mesh grants. */ * node-scoped one (design 33 §4) — the same shape the controller grants. */
export function seatToolSubject(seat: string, verb: string, scope: string | undefined, node: string | undefined): string { export function seatToolSubject(seat: string, verb: string, scope: string | undefined, node: string | undefined): string {
const base = `mesh.seat.${seat}.tool.${verb}`; const base = `mesh.seat.${seat}.tool.${verb}`;
return scope === "node" && node ? `${base}.${node}` : base; return scope === "node" && node ? `${base}.${node}` : base;
+15 -33
View File
@@ -26,28 +26,9 @@ import { TOOLS_VERB, type ToolsAnswer } from "./runtime.js";
/** Where the catalogue answers which modules the mesh holds. */ /** Where the catalogue answers which modules the mesh holds. */
const CATALOGUE_MODULES = "mesh-catalog.catalog_modules"; const CATALOGUE_MODULES = "mesh-catalog.catalog_modules";
/** Where every role's tools are answered: the console on this machine, the Go runtime, which holds mesh-sdk's seat /** Where the mesh answers every role's tools, from its records: the mesh-controller seat's own
* definitions and reads who holds each seat from placement (novox/hq ADR 0571 clauses 4 and 5). Asked over the loopback * `tools` verb (novox/hq ADR 0154, design 33 §5). */
* channel `mesh --console` already uses, by the method `mesh/seats` beside MCP's; never another record. */ const SEAT_TOOLS = "seat:mesh-controller.tools";
export const SEATS_METHOD = "mesh/seats";
const DEFAULT_CONSOLE = "http://127.0.0.1:4270";
type SeatsAnswer = { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[]; holders_why?: string };
/** Every role's tools from the console on this machine. */
async function seatsFromConsole(consoleUrl: string): Promise<SeatsAnswer> {
const endpoint = consoleUrl.endsWith("/mcp") ? consoleUrl : `${consoleUrl.replace(/\/$/, "")}/mcp`;
const res = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: SEATS_METHOD }),
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) throw new Error(`the console at ${endpoint} answered ${res.status}`);
const reply = (await res.json()) as { result?: SeatsAnswer; error?: { message: string } };
if (reply.error) throw new Error(reply.error.message);
return reply.result ?? {};
}
/** A tool as its module describes it. */ /** A tool as its module describes it. */
export interface Tool { export interface Tool {
@@ -77,8 +58,8 @@ export interface Tool {
export interface Listing { export interface Listing {
tools: Tool[]; tools: Tool[];
/** Modules the catalogue holds whose runtime did not answer `tools`: not assigned, not up, or built /** Modules the catalogue holds whose runtime did not answer `tools`: not assigned, not up, or built
* before the runtime answered it. Each may still be called by name. The roles' tools are listed here, with the * before the runtime answered it. Each may still be called by name. The mesh's own records are
* reason, when the console on this machine did not answer them. */ * listed here as `mesh-controller (seat)` when the control plane did not answer. */
notAnswering: string[]; notAnswering: string[];
} }
@@ -178,16 +159,17 @@ export async function connectAsTheConsole(path: string): Promise<{ bus: Broker;
* list that goes stale the first time a module is assigned. Every module is asked at once, and the bus * list that goes stale the first time a module is assigned. Every module is asked at once, and the bus
* refuses at once a request nothing serves, so the cost is bounded by the modules that are up. * refuses at once a request nothing serves, so the cost is bounded by the modules that are up.
*/ */
export async function toolsOn(bus: Broker, consoleUrl: string = process.env.MESH_CONSOLE || DEFAULT_CONSOLE): Promise<Listing> { export async function toolsOn(bus: Broker): Promise<Listing> {
let rolesWhy = "";
const [answered, roles] = await Promise.all([ const [answered, roles] = await Promise.all([
bus.request<Record<string, never>, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}), bus.request<Record<string, never>, { modules?: { module: string }[] }>(CATALOGUE_MODULES, {}),
// The roles' tools, from the console on this machine (ADR 0571). Asked beside the modules rather than first: a // The roles' tools, from the mesh's records (design 33 §5). Asked beside the modules rather
// console that is restarting must not hide every module's tools with it. // than first: a control plane that is restarting must not hide every module's tools with it.
seatsFromConsole(consoleUrl).catch((e: unknown) => { bus
rolesWhy = e instanceof Error ? e.message : String(e); .request<Record<string, never>, { seats?: { seat: string; scope?: string; tools?: ToolsAnswer["tools"] }[] }>(
return undefined; SEAT_TOOLS,
}), {},
)
.catch(() => undefined),
]); ]);
const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string"); const names = (answered.modules ?? []).map((m) => m.module).filter((m) => typeof m === "string");
@@ -206,7 +188,7 @@ export async function toolsOn(bus: Broker, consoleUrl: string = process.env.MESH
} }
} }
} else { } else {
notAnswering.push(`the roles' tools (the console at ${consoleUrl} did not answer ${SEATS_METHOD}: ${rolesWhy})`); notAnswering.push("mesh-controller (seat)");
} }
asked.forEach((outcome, i) => { asked.forEach((outcome, i) => {
const module = names[i]!; const module = names[i]!;
+1 -1
View File
@@ -97,7 +97,7 @@ export function mcpSurface(bus: Broker, who: string): Surface {
`that serves it; what may be called was fixed when this account was issued, so a ` + `that serves it; what may be called was fixed when this account was issued, so a ` +
`refusal means the account, not the tool. The list is what the running modules ` + `refusal means the account, not the tool. The list is what the running modules ` +
`answered, plus every role's tools from the mesh's records — the mesh's own verbs ` + `answered, plus every role's tools from the mesh's records — the mesh's own verbs ` +
`(mesh-delivery.deliveries, mesh-placement.propose …) among them; a module that did not answer ` + `(mesh-controller.status, .push, .assign …) among them; a module that did not answer ` +
`is named in the list's _meta and can still be called by <module>.<tool>.`, `is named in the list's _meta and can still be called by <module>.<tool>.`,
}); });
+15 -22
View File
@@ -16,8 +16,6 @@ import { test } from "node:test";
import { connectNats } from "../dist/broker-nats.js"; import { connectNats } from "../dist/broker-nats.js";
import { callTool, seatsIn, toolKey, toolsOn, whyItFailed } from "../dist/client.js"; import { callTool, seatsIn, toolKey, toolsOn, whyItFailed } from "../dist/client.js";
import { aConsoleWithSeats } from "./console-seats.ts";
const url = process.env.MESH_TEST_NATS; const url = process.env.MESH_TEST_NATS;
/** A mesh as discovery sees it (design 34 §3): the catalogue holding three modules, two of them up /** A mesh as discovery sees it (design 34 §3): the catalogue holding three modules, two of them up
@@ -38,26 +36,21 @@ async function aMeshWithTools() {
tools: [{ name: "price", description: "what something costs", input: { type: "object" } }], tools: [{ name: "price", description: "what something costs", input: { type: "object" } }],
})); }));
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
// Every role's tools, from the console on this machine (ADR 0571), the method beside MCP's; the controller's // And the mesh's own records, served by the holder of the mesh-controller seat (ADR 0154): one
// verb answers a call of its seat and is never asked for the roles. // role's tool, and the verb that lists every role's.
const controller = await connectNats({ url: url!, module: "mesh-placement" }); const controller = await connectNats({ url: url!, module: "mesh-controller" });
let controllerToolsAsked = 0; await controller.handle("seat:mesh-controller.tools", async () => ({
await controller.handle("seat:mesh-placement.tools", async () => { seats: [
controllerToolsAsked++; { seat: "mesh-controller", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] },
return { seats: [] };
});
await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true }));
const local = await aConsoleWithSeats([
{ seat: "mesh-placement", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] },
{ seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] },
]); ],
}));
await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true }));
return { return {
async close() { async close() {
await catalogue.close(); await catalogue.close();
await shop.close(); await shop.close();
await controller.close(); await controller.close();
await local.close();
assert.equal(controllerToolsAsked, 0, "the controller was asked for the roles' tools");
}, },
}; };
} }
@@ -71,12 +64,12 @@ test("a person sees what the running modules answer, sorted, and who did not ans
const have = await toolsOn(person); const have = await toolsOn(person);
assert.deepEqual( assert.deepEqual(
have.tools.map((x) => `${x.module}.${x.name}`), have.tools.map((x) => `${x.module}.${x.name}`),
["mesh-catalog.catalog_modules", "mesh-placement.status", "node-dns-resolver.lookup", "shop.price"], ["mesh-catalog.catalog_modules", "mesh-controller.status", "node-dns-resolver.lookup", "shop.price"],
"the list is what the modules answered plus every role's tools, in a stable order", "the list is what the modules answered plus every role's tools, in a stable order",
); );
// A role's tool is marked as one; a node-scoped seat's carries its scope, so a caller names // A role's tool is marked as one; a node-scoped seat's carries its scope, so a caller names
// the machine and the verb resolves as the seat's (design 33 §4, ADR 0170). // the machine and the verb resolves as the seat's (design 33 §4, ADR 0170).
assert.ok(have.tools.find((x) => x.module === "mesh-placement")!.seat); assert.ok(have.tools.find((x) => x.module === "mesh-controller")!.seat);
const lookup = have.tools.find((x) => x.module === "node-dns-resolver")!; const lookup = have.tools.find((x) => x.module === "node-dns-resolver")!;
assert.ok(lookup.seat && lookup.scope === "node"); assert.ok(lookup.seat && lookup.scope === "node");
assert.equal(toolKey("node-dns-resolver.lookup", seatsIn(have)), "seat:node-dns-resolver.lookup"); assert.equal(toolKey("node-dns-resolver.lookup", seatsIn(have)), "seat:node-dns-resolver.lookup");
@@ -146,12 +139,12 @@ test("a role's tool is reached through the seat, and a module's own name is neve
const person = await connectNats({ url, module: "person.ada" }); const person = await connectNats({ url, module: "person.ada" });
try { try {
const roles = seatsIn(await toolsOn(person)); const roles = seatsIn(await toolsOn(person));
assert.equal(toolKey("mesh-placement.status", roles), "seat:mesh-placement.status"); assert.equal(toolKey("mesh-controller.status", roles), "seat:mesh-controller.status");
assert.equal(toolKey("mesh-placement.other", roles), "mesh-placement.other", "a verb the seat does not declare is a module's"); assert.equal(toolKey("mesh-controller.other", roles), "mesh-controller.other", "a verb the seat does not declare is a module's");
assert.equal(toolKey("shop.price", roles), "shop.price"); assert.equal(toolKey("shop.price", roles), "shop.price");
const answer = await callTool(person, "mesh-placement.status", {}, roles); const answer = await callTool(person, "mesh-controller.status", {}, roles);
assert.deepEqual(answer.result, { output: "all quiet", ok: true }); assert.deepEqual(answer.result, { output: "all quiet", ok: true });
const direct = await callTool(person, "seat:mesh-placement.status", {}); const direct = await callTool(person, "seat:mesh-controller.status", {});
assert.deepEqual(direct.result, { output: "all quiet", ok: true }); assert.deepEqual(direct.result, { output: "all quiet", ok: true });
} finally { } finally {
await person.close(); await person.close();
-31
View File
@@ -1,31 +0,0 @@
/**
* The console on this machine as a client asks it for every role's tools (novox/hq ADR 0571): the method `mesh/seats`
* beside MCP's, answered in the shape the Go console's `seatsAnswer` gives (internal/console/mcp.go). Sets
* MESH_CONSOLE to it, so a client, and a `mesh` it spawns, asks it.
*/
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { SEATS_METHOD } from "../dist/client.js";
export type SeatOfConsole = { seat: string; scope: string; tools: { name: string; description?: string; input?: unknown }[] };
export async function aConsoleWithSeats(seats: SeatOfConsole[]): Promise<{ url: string; close: () => Promise<void> }> {
const local = createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
const asked = JSON.parse(body) as { id: number; method: string };
res.setHeader("content-type", "application/json");
if (req.url !== "/mcp" || asked.method !== SEATS_METHOD) {
res.end(JSON.stringify({ jsonrpc: "2.0", id: asked.id, error: { code: -32601, message: "no such method" } }));
return;
}
res.end(JSON.stringify({ jsonrpc: "2.0", id: asked.id, result: { seats: seats.map((s) => ({ ...s, holders: [] })) } }));
});
});
await new Promise<void>((done) => local.listen(0, "127.0.0.1", done));
const url = `http://127.0.0.1:${(local.address() as AddressInfo).port}`;
process.env.MESH_CONSOLE = url;
return { url, close: () => new Promise((done) => local.close(() => done())) };
}
+11 -13
View File
@@ -12,8 +12,6 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { aConsoleWithSeats } from "./console-seats.ts";
import { connectNats } from "../dist/broker-nats.js"; import { connectNats } from "../dist/broker-nats.js";
const url = process.env.MESH_TEST_NATS; const url = process.env.MESH_TEST_NATS;
@@ -30,24 +28,24 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise<void> | void)
tools: [{ name: "price", description: "what something costs", input: { of: { type: "string" } } }], tools: [{ name: "price", description: "what something costs", input: { of: { type: "string" } } }],
})); }));
await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 })); await shop.handle("price", async (body: { of?: string }) => ({ of: body.of ?? "nothing", cost: 12 }));
const controller = await connectNats({ url: url!, module: "mesh-placement" }); const controller = await connectNats({ url: url!, module: "mesh-controller" });
// Every role's tools, from the console on this machine (ADR 0571), which the spawned `mesh` asks through MESH_CONSOLE. await controller.handle("seat:mesh-controller.tools", async () => ({
const local = await aConsoleWithSeats([ seats: [
{ seat: "mesh-placement", scope: "mesh", tools: [ { seat: "mesh-controller", scope: "mesh", tools: [
{ name: "status", description: "what is wrong", input: {} }, { name: "status", description: "what is wrong", input: {} },
{ name: "push", description: "tell a machine", input: { node: { type: "string" } } }, { name: "push", description: "tell a machine", input: { node: { type: "string" } } },
] }, ] },
// A seat held once per machine (design 33 §4, ADR 0170): its verb is asked of one. // A seat held once per machine (design 33 §4, ADR 0170): its verb is asked of one.
{ seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] }, { seat: "node-dns-resolver", scope: "node", tools: [{ name: "lookup", description: "one machine's", input: {} }] },
]); ],
}));
await controller.handle("seat:node-dns-resolver.lookup@anchor", async () => ({ machine: "anchor", answered: true })); await controller.handle("seat:node-dns-resolver.lookup@anchor", async () => ({ machine: "anchor", answered: true }));
await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true })); await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true }));
await controller.handle("seat:mesh-placement.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" })); await controller.handle("seat:mesh-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" }));
t.after(async () => { t.after(async () => {
await catalogue.close(); await catalogue.close();
await shop.close(); await shop.close();
await controller.close(); await controller.close();
await local.close();
}); });
const { mkdtemp, writeFile } = await import("node:fs/promises"); const { mkdtemp, writeFile } = await import("node:fs/promises");
@@ -107,7 +105,7 @@ test("a host initialises, lists the mesh's tools and calls one", async (t) => {
const listed = byId.get(2)!.result.tools; const listed = byId.get(2)!.result.tools;
assert.deepEqual(listed.map((x: { name: string }) => x.name), assert.deepEqual(listed.map((x: { name: string }) => x.name),
["mesh-placement.push", "mesh-placement.status", "node-dns-resolver.lookup", "shop.price"], ["mesh-controller.push", "mesh-controller.status", "node-dns-resolver.lookup", "shop.price"],
"the modules' tools and the roles', named the way a person names them"); "the modules' tools and the roles', named the way a person names them");
// A node-scoped seat's verb takes the machine, and requires it (ADR 0170). // A node-scoped seat's verb takes the machine, and requires it (ADR 0170).
const lookup = listed[2]; const lookup = listed[2];
@@ -164,7 +162,7 @@ test("a seat's verb keeps a node of its own; only a module's tool gives it to th
if (!url) return t.skip("MESH_TEST_NATS unset"); if (!url) return t.skip("MESH_TEST_NATS unset");
const credential = await aMeshAndACredential(t); const credential = await aMeshAndACredential(t);
const replies = await driving(credential, [ const replies = await driving(credential, [
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-placement.push", arguments: { node: "anchor" } } }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-controller.push", arguments: { node: "anchor" } } },
]); ]);
const result = replies[0].result; const result = replies[0].result;
assert.ok(!result.isError, JSON.stringify(replies[0])); assert.ok(!result.isError, JSON.stringify(replies[0]));
@@ -175,7 +173,7 @@ test("a host calls the mesh's own verb through the seat", async (t) => {
if (!url) return t.skip("MESH_TEST_NATS unset"); if (!url) return t.skip("MESH_TEST_NATS unset");
const credential = await aMeshAndACredential(t); const credential = await aMeshAndACredential(t);
const replies = await driving(credential, [ const replies = await driving(credential, [
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-placement.status", arguments: {} } }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-controller.status", arguments: {} } },
]); ]);
const result = replies[0].result; const result = replies[0].result;
assert.ok(!result.isError, JSON.stringify(replies[0])); assert.ok(!result.isError, JSON.stringify(replies[0]));
+2 -5
View File
@@ -172,12 +172,9 @@ test("the node's runtime serves five modules' bundles on one credential — two
const catalogue = await connectNats({ url, module: "mesh-catalog" }); const catalogue = await connectNats({ url, module: "mesh-catalog" });
await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "alpha" }, { module: "beta" }, { module: "gamma" }, { module: "delta" }, { module: "epsilon" }] })); await catalogue.handle("catalog_modules", async () => ({ modules: [{ module: "alpha" }, { module: "beta" }, { module: "gamma" }, { module: "delta" }, { module: "epsilon" }] }));
try { try {
// No console on this port: the roles' tools are said missing with the reason, never asked of the controller. const have = await toolsOn(asker);
const have = await toolsOn(asker, "http://127.0.0.1:9");
assert.deepEqual(have.tools.map((x) => `${x.module}.${x.name}`), ["alpha.one", "alpha.two", "beta.five", "beta.four", "beta.three", "delta.die", "delta.greet", "epsilon.seven"]); assert.deepEqual(have.tools.map((x) => `${x.module}.${x.name}`), ["alpha.one", "alpha.two", "beta.five", "beta.four", "beta.three", "delta.die", "delta.greet", "epsilon.seven"]);
assert.equal(have.notAnswering[0], "gamma (its tools bundle failed to load: gamma's bundle exited (1): Error: gamma's bundle cannot find its client)"); assert.deepEqual(have.notAnswering, ["gamma (its tools bundle failed to load: gamma's bundle exited (1): Error: gamma's bundle cannot find its client)", "mesh-controller (seat)"]);
assert.match(have.notAnswering[1] ?? "", /^the roles' tools \(the console at http:\/\/127\.0\.0\.1:9 did not answer mesh\/seats: /);
assert.equal(have.notAnswering.length, 2);
} finally { } finally {
await catalogue.close(); await catalogue.close();
} }
+3 -3
View File
@@ -69,12 +69,12 @@ test("preparing with nothing named fails rather than passing quietly", async ()
// therefore pure and tested as such: what a message is for is decided by the patterns registered, not // therefore pure and tested as such: what a message is for is decided by the patterns registered, not
// by which reader happened to fetch it. // by which reader happened to fetch it.
test("a message is for every pattern that matches it, and nothing else", () => { test("a message is for every pattern that matches it, and nothing else", () => {
const registered = ["mesh-build-machine.built", "mesh-placement.built-before"]; const registered = ["mesh-build-machine.built", "mesh-controller.built-before"];
const matched = (key: string) => registered.filter((p) => topicMatches(p, key)); const matched = (key: string) => registered.filter((p) => topicMatches(p, key));
assert.deepEqual(matched("mesh-build-machine.built"), ["mesh-build-machine.built"]); assert.deepEqual(matched("mesh-build-machine.built"), ["mesh-build-machine.built"]);
assert.deepEqual(matched("mesh-placement.built-before"), ["mesh-placement.built-before"]); assert.deepEqual(matched("mesh-controller.built-before"), ["mesh-controller.built-before"]);
// Nothing registered for it: the consumer's filter is the controller's and may be wider. // Nothing registered for it: the consumer's filter is the controller's and may be wider.
assert.deepEqual(matched("mesh-catalog.upgraded"), []); assert.deepEqual(matched("mesh-catalog.upgraded"), []);
// And a handler that asked for everything gets both, which is what the audit logger does. // And a handler that asked for everything gets both, which is what the audit logger does.
assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-placement.built-before")), ["#"]); assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-controller.built-before")), ["#"]);
}); });
+1 -1
View File
@@ -5,7 +5,7 @@ import { connectNats } from "../dist/broker-nats.js";
const URL = "nats://127.0.0.1:14222"; const URL = "nats://127.0.0.1:14222";
// The mesh's job, done by hand here: the stream and the module's durable consumer. // The controller's job, done by hand here: the stream and the module's durable consumer.
const admin = await connect({ servers: URL }); const admin = await connect({ servers: URL });
const jsm = await admin.jetstreamManager(); const jsm = await admin.jetstreamManager();
await jsm.streams.add({ name: "EVENTS", subjects: ["mesh.mod.*.event.>", "mesh.seat.*.event.>"] }); await jsm.streams.add({ name: "EVENTS", subjects: ["mesh.mod.*.event.>", "mesh.seat.*.event.>"] });