Merge pull request 'node-tools without the controller's paging and handover reads (issue 1126 group B)' (#55) from feat/1126-removal into main

This commit was merged in pull request #55.
This commit is contained in:
2026-11-02 13:41:43 +00:00
38 changed files with 174 additions and 421 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 controller composes the variables and the host supervises the `node dist/main.js`. On a node the mesh composes the variables and the node-engine 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 controller's records (`replaces`, read from the controller's module tool says what it replaces in the indexer's records (`replaces`, read from its
`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 controller's records `$SRV.INFO` with what it serves at that moment, and the console reads where the placement seat'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 controller composes the process (which bundles to load, where the the console (design 34). The mesh 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.20261101235037-c21d8fd9692c git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
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
+2 -4
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= 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.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/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 controller's watchdog raises `tools-silent` after three missed — and its build. // the interval — the mesh'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
+20 -58
View File
@@ -32,9 +32,7 @@ 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. The // RequestTimeout is how long a call waits for its answer — what modules already expect.
// 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
// 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).
@@ -344,7 +342,7 @@ func (c *Conn) Credential() Credential { return c.cred }
// 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). It is read from the grants part's state memberships, its key
// `<node>.<module>` (novox/hq ADR 0571 clause 7 and 10, mesh-sdk buscompose.GrantsMembershipsBucket), and nowhere else: // `<node>.<module>` (novox/hq ADR 0571 clause 7 and 10, mesh-sdk buscompose.GrantsMembershipsBucket), and nowhere else:
// the controller's assignments stream is not read and its subject is not heard. // 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 == "" {
@@ -790,83 +788,47 @@ 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, handingOver, fallBack, err := c.askCalled(called, data) answered, 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
} }
} }
answered, handingOver, err := c.askOnce(subject, data) return 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, and says whether the answer was a refusal marked to be asked again. // askOnce asks once.
func (c *Conn) askOnce(subject string, data []byte) (Answered, bool, error) { func (c *Conn) askOnce(subject string, data []byte) (Answered, 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{}, false, ErrNoResponders return Answered{}, ErrNoResponders
} }
if errors.Is(err, nats.ErrTimeout) { if errors.Is(err, nats.ErrTimeout) {
return Answered{}, false, errors.New("timeout") return Answered{}, errors.New("timeout")
} }
return Answered{}, false, err return Answered{}, err
} }
return c.answerOf(subject, msg.Data) return answerOf(msg.Data)
} }
// answerOf reads one answer, asking the rest of a paged one on the subject it was asked on, and says whether it // answerOf reads one answer.
// was a refusal marked to be asked again. func answerOf(data []byte) (Answered, error) {
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(body, &r); err != nil { if err := json.Unmarshal(data, &r); err != nil {
return Answered{}, false, err return Answered{}, err
} }
if r.Error != "" { if r.Error != "" {
return Answered{}, r.Retry == RetryHandingOver, errors.New(r.Error) return Answered{}, errors.New(r.Error)
} }
return Answered{Result: r.Result, Node: r.Node}, false, nil return Answered{Result: r.Result, Node: r.Node}, 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
@@ -1070,7 +1032,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
// controller makes for a module on a machine: the same names the module's own runtime bound // mesh 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"
@@ -1085,7 +1047,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 controller keeps it in DEAD_LETTERS until a person delivers // consumer then gives the event up, and the mesh 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
@@ -1099,7 +1061,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 controller's to // returns an error, terminated when it is not an event at all. The consumers are the mesh'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)
@@ -1109,7 +1071,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 controller's (design 29 §3), exactly as the module's own runtime bound it. // filters are the mesh'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 controller grants each user the publish of // The last token is the bus user that published the call, and the mesh 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 controller refuses any part that // each dot is written `~`, which no part of a bus user's name may hold (the mesh 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 controller's // errCallRefused is the bus refusing a caller-named publish: this user is not granted it yet (the mesh'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, handingOver, fallBack bool, err error) { func (c *Conn) askCalled(subject string, data []byte) (answered Answered, 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, handin
if err != nil { if err != nil {
switch { switch {
case errors.Is(context.Cause(ctx), errCallRefused): case errors.Is(context.Cause(ctx), errCallRefused):
return Answered{}, false, true, nil return Answered{}, true, nil
case errors.Is(err, nats.ErrNoResponders): case errors.Is(err, nats.ErrNoResponders):
c.saidFallBack(subject) c.saidFallBack(subject)
return Answered{}, false, true, nil return Answered{}, 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, false, errors.New("timeout") return Answered{}, false, errors.New("timeout")
} }
return Answered{}, false, false, err return Answered{}, false, err
} }
answered, handingOver, err = c.answerOf(subject, msg.Data) answered, err = answerOf(msg.Data)
return answered, handingOver, false, err return answered, 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 for the controller's seat until hq issue // than read later as a quiet "unproven" (novox/hq issue 365; expected 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-controller internal/broker): each named // A bus whose users are granted as the controller grants them (mesh-placement 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
@@ -1,73 +0,0 @@
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())
}
}
+2 -2
View File
@@ -17,7 +17,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
// controller creates from what the module declared under `logs` and issues to the module's own instances // mesh 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.
// //
@@ -139,7 +139,7 @@ func (c *Conn) issuedLog(module, name string) (LogIssued, error) {
// 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 controller creates "+ return fmt.Errorf("the log %q is issued and its stream %s is not on the bus yet: the mesh 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)
} }
+2 -97
View File
@@ -1,13 +1,7 @@
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"
) )
@@ -15,97 +9,8 @@ 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. Until this, a holder whose answer was too large wrote one // before it leaves the process that sent it. A module's answer too large is refused here, loudly, in a
// line to its own log and its caller waited out RequestTimeout and read "did not answer" — the // message that fits, so its caller reads why instead of waiting out RequestTimeout.
// 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.
+3 -39
View File
@@ -2,7 +2,6 @@ package bus_test
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -13,8 +12,8 @@ import (
"github.com/novox/mesh-tools/node-tools/internal/meshtest" "github.com/novox/mesh-tools/node-tools/internal/meshtest"
) )
// novox/hq issue 314: an answer larger than the bus carries in one message arrives whole when the // novox/hq issue 314: a module's answer larger than the bus carries in one message is said to be too large,
// controller pages it, and a module's is said to be too large — neither is a 30-second silence. // at once, not a 30-second silence. (The controller's paging ended with the controller, issue 1126.)
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)
@@ -27,43 +26,8 @@ func TestAnAnswerLargerThanOneMessage(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer c.Close() defer c.Close()
limit := int(seat.MaxPayload()) large := strings.Repeat("a condition held past its bound; ", 3*int(seat.MaxPayload())/32)
// 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 controller grants // that account. So the runtime asks its own machine's client on ProvisionAskSubject — which the mesh 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 (mesh-controller // ProvisionAskSubject is where this machine's tool runner asks its provisioner client (the node-engine
// 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 (mesh-controller internal/broker // ProvisionChangedSubjects is every module's word of what moved on a machine (the node-engine
// 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 controller's broker.SeatTraffic). // SeatTraffic is what a membership lists of a module's seat traffic (the grants part's record).
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
// controller's record of their claims (novox/hq ADR 0259 §5). // 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 controller issued them. // SeatKinds is the kinds the module's membership names, as the grants part 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 controller's to create; this binds to it by name and never makes one. // JSON. The worker is the mesh'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-controller", "mesh-delivery": "mesh.seat.operator-channel.accept.ask.mesh-placement",
"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-controller.a1", []byte(`{"state":"open"}`)); err != nil { if _, err := kv.Put("mesh-placement.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")
+2 -2
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 controller creates from what // A module's state on the bus (novox/hq ADR 0201): key-value buckets the mesh 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>`.
// //
@@ -89,7 +89,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 controller "+ return nil, fmt.Errorf("the state %q is issued and its bucket is not on the bus yet: the mesh "+
"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
+1 -1
View File
@@ -33,7 +33,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-controller.453.c06f75d0", "novox/mesh-delivery.33.28ee22ef"} { for _, member := range []string{"novox/mesh-sdk.76.34f210e5", "novox/mesh-placement.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 {
+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 controller creates it and says, in the module's // A module may declare a stream of its own; the mesh 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 controller's to make, to filter and to delete. // runtime binds. The consumer is the mesh'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 controller makes the consumer and sends the user list carrying its grant in the // tried again: the mesh 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 controller deletes and makes again under the same name a restricted consumer it cannot // deleted): the mesh 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
+5 -5
View File
@@ -50,7 +50,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-controller.nodes`); " + const grammar = "Addresses: `<seat>.<verb>` for a seat held once for the mesh (e.g. `mesh-placement.placement`); " +
"`<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."
@@ -242,7 +242,7 @@ type recordedModule struct {
} }
// 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 controller's records // announcement on the bus — one `$SRV.INFO` request — and what should, from the indexer'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
@@ -257,9 +257,9 @@ func indexOn(conn *bus.Conn, held announce.Known, before func(announce.Discovery
var recordsWhy []string var recordsWhy []string
wg.Add(2) wg.Add(2)
// Which modules there are and where they are placed, and which machines the mesh has, from the bus (ADR 0571 // Which modules there are and where they are placed, and which machines the mesh has, from the bus (ADR 0571
// clauses 4 and 5): the indexer's modules and manifests and the placement seat's placements, never the controller. // clauses 4 and 5): the indexer's modules and manifests and the placement seat's placements, never any other record.
go func() { defer wg.Done(); recorded, known, recordsWhy = recordsOn(conn) }() go func() { defer wg.Done(); recorded, known, recordsWhy = recordsOn(conn) }()
// Who holds which seat on which node, from placement (ADR 0571 clause 4): no call of the controller. // Who holds which seat on which node, from placement (ADR 0571 clause 4): no other call.
go func() { defer wg.Done(); placed, placedErr = seatHoldersOn(conn) }() go func() { defer wg.Done(); placed, placedErr = seatHoldersOn(conn) }()
d, err := announce.Gather(conn, held) d, err := announce.Gather(conn, held)
wg.Wait() wg.Wait()
@@ -815,7 +815,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 controller's // **Taking it out everywhere made the mesh's own verbs lie** (novox/hq issue 244): the old controller verbs
// `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.
+16 -16
View File
@@ -71,9 +71,9 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
} }
// 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 own seat's verbs announced on the bus like every runtime's; its records are asked by no one.
controller := connect(t, "mesh-controller", "bench") controller := connect(t, "mesh-placement", "bench")
// Its verb nodes, called through the console as any seat's verb is. // Its verb nodes, called through the console as any seat's verb is.
stopNodes, err := controller.HandleSubject("mesh.seat.mesh-controller.tool.nodes", func(json.RawMessage) (any, error) { stopNodes, err := controller.HandleSubject("mesh.seat.mesh-placement.tool.nodes", func(json.RawMessage) (any, error) {
return map[string]any{"ok": true, "output": `[{"name":"bench","mode":"converged"},{"name":"desk","mode":"converged"}]`}, nil return map[string]any{"ok": true, "output": `[{"name":"bench","mode":"converged"},{"name":"desk","mode":"converged"}]`}, nil
}) })
if err != nil { if err != nil {
@@ -82,8 +82,8 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
t.Cleanup(stopNodes) t.Cleanup(stopNodes)
// What the mesh holds, as the indexer and placement write it (ADR 0571 clauses 4, 5): never the controller's records. // 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, tools := map[string][]string{"alpha": {"one", "two"}, "beta": {"three"}, "gamma": {"given"}, "delta": nil,
"epsilon": {"seven"}, "mesh-controller": {"nodes"}} "epsilon": {"seven"}, "mesh-placement": {"nodes"}}
mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta"}, "bench": {"epsilon", "mesh-controller"}}) mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta"}, "bench": {"epsilon", "mesh-placement"}})
console := mt.MembershipOf("node-tools", "desk", false, nil) console := mt.MembershipOf("node-tools", "desk", false, nil)
console.State = mt.ConsoleReads() console.State = mt.ConsoleReads()
mesh.Issue(t, console) mesh.Issue(t, console)
@@ -93,11 +93,11 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
} }
return nil return nil
}) })
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-controller", ID: "bench"}, func() []announce.Endpoint { stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-placement", ID: "bench"}, func() []announce.Endpoint {
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", Scope: "mesh", return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-placement", Seat: "mesh-placement", 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-controller.tool.nodes"}} Subject: "mesh.seat.mesh-placement.tool.nodes"}}
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -131,13 +131,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-controller.nodes") || !strings.Contains(overview, "<node>/node-shelf.list") || if isErr || !strings.Contains(overview, "mesh-placement.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-controller on bench") { if strings.Contains(overview, "mesh-placement 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 +150,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-controller.nodes"}); isErr || !strings.Contains(got, "converged") { if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-placement.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"`) {
@@ -166,11 +166,11 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
// Refused, by name, where the address does not say enough or says the wrong thing. // Refused, by name, where the address does not say enough or says the wrong thing.
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-controller.nodes": "held once for the whole mesh", "desk/mesh-placement.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",
} { } {
got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address}) got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address})
if !isErr || !strings.Contains(got, want) { if !isErr || !strings.Contains(got, want) {
@@ -201,7 +201,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-controller"}}) mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta", "gamma"}, "bench": {"epsilon", "mesh-placement"}})
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)
-9
View File
@@ -118,9 +118,6 @@ 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 {
@@ -145,12 +142,6 @@ 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
+3 -3
View File
@@ -63,7 +63,7 @@ type Surface struct {
} }
// MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape // MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape
// the controller's `tools` answered: `{seats: [{seat, scope, tools: [{name, description, input, replaces}], holders: // the old `tools` answered: `{seats: [{seat, scope, tools: [{name, description, input, replaces}], holders:
// [{module, node}]}], holders_why}`. // [{module, node}]}], holders_why}`.
const MethodSeats = "mesh/seats" const MethodSeats = "mesh/seats"
@@ -157,7 +157,7 @@ func (s *Surface) HandleContext(ctx context.Context, r Request) *Reply {
return answer(r.ID, map[string]any{}) return answer(r.ID, map[string]any{})
case MethodSeats: case MethodSeats:
// The seats' contract and who holds each, for a client on this machine that has no seat definitions of its own // 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 the controller // (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). // (novox/hq ADR 0571 clauses 4 and 5).
return answer(r.ID, s.seatsAnswer()) return answer(r.ID, s.seatsAnswer())
case "tools/list": case "tools/list":
@@ -338,7 +338,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-controller.status, .push, .assign …) among them; a module that did not answer " + "(mesh-delivery.deliveries, mesh-placement.propose …) 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-controller", Name: "push", Seat: true, Scope: "mesh", push := Tool{Module: "mesh-placement", 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-controller.push", Tool: push, Seat: true} mesh := target{Address: "mesh-placement.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 {
+14 -7
View File
@@ -22,16 +22,23 @@ 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". And from the controller, where its answer is. // pushed, and the caller read "did not answer in time". No hint names a seat that is gone (novox/hq issue 1126):
// 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-controller.push", errors.New("timeout")) got := whyItFailed("seat:mesh-delivery.deliveries", errors.New("timeout"))
for _, want := range []string{"not a failure", "may already have done", "mesh-controller.calls"} { for _, want := range []string{"not a failure", "may already have done"} {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("a controller timeout does not say %q: %s", want, got) t.Errorf("a seat's timeout does not say %q: %s", want, got)
} }
} }
if other := whyItFailed("novox/postgres.postgres_list_databases", errors.New("timeout")); !strings.Contains(other, "not a failure") || for name, text := range map[string]string{
strings.Contains(other, "calls") { "grammar": grammar,
t.Errorf("a module's timeout: %s", other) "flat instructions": (&Surface{Flat: true, who: "x"}).instructions(),
"deep instructions": (&Surface{who: "x"}).instructions(),
"timeout": got,
} {
if strings.Contains(text, "controller") {
t.Errorf("%s names the controller: %s", name, text)
}
} }
} }
+2 -2
View File
@@ -95,7 +95,7 @@ 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-controller", "bench") controller := connect(t, "mesh-placement", "bench")
var toolsAsked atomic.Int32 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). // The controller's records are served here only to see that none is asked (ADR 0571 clauses 4 and 5).
@@ -104,7 +104,7 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T)
"tools": `{"seats":[]}`, "tools": `{"seats":[]}`,
} { } {
output, verb := output, verb output, verb := output, verb
stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) { stop, err := controller.HandleSubject("mesh.seat.mesh-placement.tool."+verb, func(json.RawMessage) (any, error) {
toolsAsked.Add(1) toolsAsked.Add(1)
return map[string]any{"output": output, "ok": true}, nil return map[string]any{"output": output, "ok": true}, nil
}) })
+18 -19
View File
@@ -33,8 +33,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-controller", Seat: "mesh-controller", endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-placement", Seat: "mesh-placement",
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb}) Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-placement.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.",
@@ -56,22 +56,21 @@ func meshToSearch(t *testing.T) *index {
func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) { 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-controller -n 50": "anchor/node-service-manager.journal", "journalctl -u mesh-placement -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-controller": "anchor/node-service-manager.restart", "systemctl restart mesh-placement": "anchor/node-service-manager.restart",
"systemctl --failed": "anchor/node-service-manager.failed", "systemctl --failed": "anchor/node-service-manager.failed",
"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 {
@@ -112,7 +111,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-controller.nodes" { if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-placement.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" {
+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
// controller from the tool runner's manifest together with the grant that lets this account subscribe them. // mesh from the tool runner's manifest together with the grant that lets this account subscribe them.
// A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one. // 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.
+11 -11
View File
@@ -1,7 +1,7 @@
// Package meshtest raises what the controller would, for tests against a real bus: the ASSIGNMENTS // Package meshtest raises what the mesh 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 controller's internal/testbus): a server // **Every test has a bus of its own** (novox/hq ADR 0237, the old 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
@@ -72,7 +72,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 controller's job, done by hand. // Mesh is the mesh's job, done by hand.
type Mesh struct { type Mesh struct {
nc *nats.Conn nc *nats.Conn
js nats.JetStreamContext js nats.JetStreamContext
@@ -230,7 +230,7 @@ func (m *Mesh) NextEvent(t *testing.T, pattern string) <-chan string {
return ch return ch
} }
// MembershipOf is a membership as the controller issues one on a machine. // MembershipOf is a membership as the mesh 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 +291,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 controller does: pull, on the // Consumer makes a module's durable consumer on a machine as the mesh 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) {
@@ -351,7 +351,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 controller does from the catalogue (novox/hq ADR 0201), // Bucket makes a module's state afresh as the mesh 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,7 +363,7 @@ func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue {
return kv return kv
} }
// BucketKeyTTL makes a module's state afresh as the controller does for one declared `key-ttl` (mesh-sdk buscompose: // BucketKeyTTL makes a module's state afresh as the mesh does for one declared `key-ttl` (mesh-sdk buscompose:
// a time to live per key allowed, its end leaving a marker kept a minute; issue 1952). // 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) { func (m *Mesh) BucketKeyTTL(t *testing.T, name string) {
t.Helper() t.Helper()
@@ -380,10 +380,10 @@ func (m *Mesh) BucketKeyTTL(t *testing.T, name string) {
} }
} }
// Log makes a module's log afresh as the controller does from the catalogue (novox/hq ADR 0297): a file // 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 controller issues for it. // purge. It answers the membership entry the mesh 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,7 +409,7 @@ 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 controller does from the catalogue (novox/hq ADR // ModuleStream makes a module's own stream afresh as the mesh 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()
@@ -455,7 +455,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 controller does when a grant // RemoveConsumer deletes a module's consumer on a machine on a stream, as the mesh 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()
+2 -2
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 controller's binding file (novox/hq ADR 0349 // from an entry node-tools writes in its own bucket, not from the old 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
@@ -40,7 +40,7 @@ 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, // provisions and plans of the seat mesh-provisioner are the states it reads, by mesh-sdk's names (novox/hq ADR 0570,
// issue 1954 L6-T1). The controller's assignments are read no more (mesh-sdk 692dbf9, L6-P3). // 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) {
+7 -7
View File
@@ -160,8 +160,8 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
} }
} }
for _, r := range m.Reads { for _, r := range m.Reads {
if r == "mesh-controller.assignments" { if r == "mesh-placement.assignments" {
t.Error("the manifest still reads mesh-controller.assignments, which the sdk's helper no longer reads (issue 1954)") 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 {
@@ -195,9 +195,9 @@ func TestOnlyTheMachinesRuntimeStartsTheHelper(t *testing.T) {
} }
} }
// 774/S33 (ADR 0571 clause 13): this manifest, read as the lease write's check reads it, is not refused at the write: it // 1126/B19 (ADR 0590): this manifest is not refused by the check that refuses any manifest naming the controller:
// reads and invokes nothing of the controller and consumes nothing of it. // it reads, invokes, claims and requires nothing of it.
func TestTheManifestIsNotRefusedAtTheLeaseWrite(t *testing.T) { func TestTheManifestNamesNothingOfTheController(t *testing.T) {
raw, err := os.ReadFile("../../module.json") raw, err := os.ReadFile("../../module.json")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -206,7 +206,7 @@ func TestTheManifestIsNotRefusedAtTheLeaseWrite(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if refused := catalogue.ManifestRefusals(map[string]catalogue.Manifest{Module: m}, catalogue.PhaseWrite); len(refused) != 0 { if refused := catalogue.ManifestRefusals(map[string]catalogue.Manifest{Module: m}); len(refused) != 0 {
t.Errorf("the lease write's check refuses node-tools' manifest: %v", refused) t.Errorf("the controller-dependence check refuses node-tools' manifest: %v", refused)
} }
} }
@@ -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-controller","body":{}}`), nil); err == nil { if _, err := asking.Seat("publish", json.RawMessage(`{"subject":"mesh.seat.operator-channel.accept.ask.mesh-placement","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)
+6 -6
View File
@@ -433,7 +433,7 @@ export async function connectNats(
/** /**
* React to events. * React to events.
* *
* The durable consumer is the **controller's** to create, from what this module declared it * The durable consumer is the **mesh'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 controller keeps of a message given up on (DEAD_LETTERS, // an infinite loop, and what the mesh 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 controller's, derived from what the module declared it // The consumer's filters are the mesh'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 controller keeps it in DEAD_LETTERS until a person delivers it // gives the event up, and the mesh 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 controller's; this only says "not done". // limits are the mesh'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 controller grants. */ * node-scoped one (design 33 §4) — the same shape the mesh 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;
+1 -1
View File
@@ -28,7 +28,7 @@ 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 every role's tools are answered: the console on this machine, the Go runtime, which holds mesh-sdk's seat
* definitions and reads who holds each seat from placement (novox/hq ADR 0571 clauses 4 and 5). Asked over the loopback * definitions and reads who holds each seat from placement (novox/hq ADR 0571 clauses 4 and 5). Asked over the loopback
* channel `mesh --console` already uses, by the method `mesh/seats` beside MCP's; never the controller. */ * channel `mesh --console` already uses, by the method `mesh/seats` beside MCP's; never another record. */
export const SEATS_METHOD = "mesh/seats"; export const SEATS_METHOD = "mesh/seats";
const DEFAULT_CONSOLE = "http://127.0.0.1:4270"; const DEFAULT_CONSOLE = "http://127.0.0.1:4270";
+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-controller.status, .push, .assign …) among them; a module that did not answer ` + `(mesh-delivery.deliveries, mesh-placement.propose …) 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>.`,
}); });
+10 -10
View File
@@ -40,15 +40,15 @@ async function aMeshWithTools() {
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 // Every role's tools, from the console on this machine (ADR 0571), the method beside MCP's; the controller's
// verb answers a call of its seat and is never asked for the roles. // verb answers a call of its seat and is never asked for the roles.
const controller = await connectNats({ url: url!, module: "mesh-controller" }); const controller = await connectNats({ url: url!, module: "mesh-placement" });
let controllerToolsAsked = 0; let controllerToolsAsked = 0;
await controller.handle("seat:mesh-controller.tools", async () => { await controller.handle("seat:mesh-placement.tools", async () => {
controllerToolsAsked++; controllerToolsAsked++;
return { seats: [] }; return { seats: [] };
}); });
await controller.handle("seat:mesh-controller.status", async () => ({ output: "all quiet", ok: true })); await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true }));
const local = await aConsoleWithSeats([ const local = await aConsoleWithSeats([
{ seat: "mesh-controller", scope: "mesh", tools: [{ name: "status", description: "what is wrong", input: {} }] }, { 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: {} }] },
]); ]);
return { return {
@@ -71,12 +71,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-controller.status", "node-dns-resolver.lookup", "shop.price"], ["mesh-catalog.catalog_modules", "mesh-placement.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-controller")!.seat); assert.ok(have.tools.find((x) => x.module === "mesh-placement")!.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 +146,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-controller.status", roles), "seat:mesh-controller.status"); assert.equal(toolKey("mesh-placement.status", roles), "seat:mesh-placement.status");
assert.equal(toolKey("mesh-controller.other", roles), "mesh-controller.other", "a verb the seat does not declare is a module's"); assert.equal(toolKey("mesh-placement.other", roles), "mesh-placement.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-controller.status", {}, roles); const answer = await callTool(person, "mesh-placement.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-controller.status", {}); const direct = await callTool(person, "seat:mesh-placement.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();
+7 -7
View File
@@ -30,10 +30,10 @@ 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-controller" }); const controller = await connectNats({ url: url!, module: "mesh-placement" });
// Every role's tools, from the console on this machine (ADR 0571), which the spawned `mesh` asks through MESH_CONSOLE. // Every role's tools, from the console on this machine (ADR 0571), which the spawned `mesh` asks through MESH_CONSOLE.
const local = await aConsoleWithSeats([ const local = await aConsoleWithSeats([
{ seat: "mesh-controller", scope: "mesh", tools: [ { seat: "mesh-placement", 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" } } },
] }, ] },
@@ -41,8 +41,8 @@ async function aMeshAndACredential(t: { after: (fn: () => Promise<void> | void)
{ 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-controller.status", async () => ({ output: "all quiet", ok: true })); await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true }));
await controller.handle("seat:mesh-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" })); await controller.handle("seat:mesh-placement.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();
@@ -107,7 +107,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-controller.push", "mesh-controller.status", "node-dns-resolver.lookup", "shop.price"], ["mesh-placement.push", "mesh-placement.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 +164,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-controller.push", arguments: { node: "anchor" } } }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-placement.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 +175,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-controller.status", arguments: {} } }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "mesh-placement.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]));
+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-controller.built-before"]; const registered = ["mesh-build-machine.built", "mesh-placement.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-controller.built-before"), ["mesh-controller.built-before"]); assert.deepEqual(matched("mesh-placement.built-before"), ["mesh-placement.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-controller.built-before")), ["#"]); assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-placement.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 controller's job, done by hand here: the stream and the module's durable consumer. // The mesh'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.>"] });