Name no controller verb in hints and drop its handover and paging reads, since the controller ended (issue 1126 B19)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 11:23:14 +01:00
parent 49f9269376
commit 4f636b7080
36 changed files with 171 additions and 416 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
```
`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
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).
`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
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
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
`$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
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
+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
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 —
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
interpreter, a place for the credential, the loopback port, and leave to call every tool.
+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
// 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
// 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.
package alive
+20 -58
View File
@@ -32,9 +32,7 @@ import (
"github.com/novox/mesh-tools/node-tools/internal/wire"
)
// RequestTimeout is how long a call waits for its answer — what modules already expect. The
// controller answers its verbs well inside it (its AnswerWithin, ten seconds), with a call id while a
// verb still runs, so a caller is never left guessing past this (novox/hq issue 265).
// RequestTimeout is how long a call waits for its answer — what modules already expect.
const RequestTimeout = 30 * time.Second
// 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
// 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:
// 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) {
c.mu.Lock()
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
// 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 != "" {
answered, handingOver, fallBack, err := c.askCalled(called, data)
answered, fallBack, err := c.askCalled(called, data)
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
}
}
answered, handingOver, err := c.askOnce(subject, data)
if !handingOver {
return answered, err
}
// **Refused because the controller is handing over: asked once more** (novox/hq issue 289). The
// controller being replaced did nothing and said so; the one after it answers the same call. Once:
// a second refusal is a controller that is not coming back, and the caller is told.
time.Sleep(HandoverPause)
answered, again, err := c.askOnce(subject, data)
if err != nil && again {
return Answered{}, fmt.Errorf("asked twice, %s apart, and both times refused as a handover: %w", HandoverPause, err)
}
return answered, err
return c.askOnce(subject, data)
}
// 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
// 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")
// askOnce asks once, and says whether the answer was a refusal marked to be asked again.
func (c *Conn) askOnce(subject string, data []byte) (Answered, bool, error) {
// askOnce asks once.
func (c *Conn) askOnce(subject string, data []byte) (Answered, error) {
msg, err := c.nc.Request(subject, data, RequestTimeout)
if err != nil {
if errors.Is(err, nats.ErrNoResponders) {
return Answered{}, false, ErrNoResponders
return Answered{}, ErrNoResponders
}
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
// was a refusal marked to be asked again.
func (c *Conn) answerOf(subject string, data []byte) (Answered, bool, error) {
// An answer larger than one message comes in parts (pages.go, novox/hq issue 314).
body, err := c.whole(subject, data)
if err != nil {
return Answered{}, false, err
}
// answerOf reads one answer.
func answerOf(data []byte) (Answered, error) {
var r struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
Node string `json:"node"`
Retry string `json:"retry"`
}
if err := json.Unmarshal(body, &r); err != nil {
return Answered{}, false, err
if err := json.Unmarshal(data, &r); err != nil {
return Answered{}, err
}
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
@@ -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
// 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
// event nor sees one twice.
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
// 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
// (novox/hq issue 330).
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
// 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
// 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.
func (c *Conn) ConsumeAs(module string, deliver func(Envelope) error) (func(), error) {
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)
// 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)
if err != nil {
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>.<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
// 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"
// 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.
const userDot = "~"
@@ -118,7 +118,7 @@ type callers struct {
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.
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
// 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.
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, stop := context.WithTimeout(ctx, RequestTimeout)
defer stop()
@@ -180,22 +180,22 @@ func (c *Conn) askCalled(subject string, data []byte) (answered Answered, handin
if err != nil {
switch {
case errors.Is(context.Cause(ctx), errCallRefused):
return Answered{}, false, true, nil
return Answered{}, true, nil
case errors.Is(err, nats.ErrNoResponders):
c.saidFallBack(subject)
return Answered{}, false, true, nil
return Answered{}, true, nil
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)
return answered, handingOver, false, err
answered, err = answerOf(msg.Data)
return answered, false, err
}
// 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
// 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).
func (c *Conn) saidFallBack(subject string) {
c.calls.mu.Lock()
+1 -1
View File
@@ -16,7 +16,7 @@ import (
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.
func grantedBus(t *testing.T, publish map[string][]string) string {
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
// 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
// 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.
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)
}
+2 -97
View File
@@ -1,13 +1,7 @@
package bus
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
"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).
//
// 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
// line to its own log and its caller waited out RequestTimeout and read "did not answer" — the
// controller's `conditions` and `status` for hours on 2026-10-08, and the operator's channel with them.
//
// The controller pages such an answer (mesh-controller internal/link, pages.go): it answers an error
// carrying `paged` — the call it holds the answer under, its size and its parts — and a caller asks the
// same subject again once per part with PageHeader, each part answered raw with PartHeader. Asking the
// same subject means no grant beyond the one the caller already had. A module's answer too large is
// refused here, loudly, in a message that fits: its instances may be several, and a part asked again
// could reach another.
const (
// PageHeader asks for one part of a paged answer: `<call> <part>`, from zero.
PageHeader = "Mesh-Page"
// PartHeader is on a part: `<part>/<parts>`.
PartHeader = "Mesh-Part"
// partTries is how often one part is asked: during a handover a part can reach the controller that
// did not hold it.
partTries = 3
)
// PartTimeout bounds the asking of one part.
var PartTimeout = 10 * time.Second
// paged is what a too-large answer says instead of itself.
type paged struct {
Call string `json:"call"`
Bytes int `json:"bytes"`
Parts int `json:"parts"`
}
func pagedIn(data []byte) (paged, bool) {
if !bytes.Contains(data, []byte(`"paged"`)) {
return paged{}, false
}
var r struct {
Paged *paged `json:"paged"`
}
if json.Unmarshal(data, &r) != nil || r.Paged == nil || r.Paged.Call == "" || r.Paged.Parts < 1 {
return paged{}, false
}
return *r.Paged, true
}
// whole is a reply's whole answer: the reply itself, or every part of a paged one, joined. A part that
// cannot be had is an error naming it, never a shorter answer.
func (c *Conn) whole(subject string, data []byte) ([]byte, error) {
p, ok := pagedIn(data)
if !ok {
return data, nil
}
out := make([]byte, 0, p.Bytes)
for n := 0; n < p.Parts; n++ {
var part []byte
var err error
for try := 0; try < partTries; try++ {
if part, err = c.askPart(subject, p, n); err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("the answer was %d bytes, more than the bus carries in one message, paged as %s "+
"in %d parts, and part %d could not be had: %w", p.Bytes, p.Call, p.Parts, n, err)
}
out = append(out, part...)
}
if len(out) != p.Bytes {
return nil, fmt.Errorf("the answer paged as %s was %d bytes, and its parts joined are %d", p.Call, p.Bytes, len(out))
}
return out, nil
}
func (c *Conn) askPart(subject string, p paged, n int) ([]byte, error) {
ask := nats.NewMsg(subject)
ask.Header.Set(PageHeader, fmt.Sprintf("%s %d", p.Call, n))
ask.Data = []byte(`{}`)
reply, err := c.nc.RequestMsg(ask, PartTimeout)
if err != nil {
return nil, err
}
if got := reply.Header.Get(PartHeader); got != fmt.Sprintf("%d/%d", n, p.Parts) {
var r struct {
Error string `json:"error"`
}
if json.Unmarshal(reply.Data, &r) == nil && r.Error != "" {
return nil, errors.New(r.Error)
}
return nil, fmt.Errorf("asked part %d/%d, answered %q", n, p.Parts, got)
}
return reply.Data, nil
}
// before it leaves the process that sent it. A module's answer too large is refused here, loudly, in a
// message that fits, so its caller reads why instead of waiting out RequestTimeout.
// 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.
+3 -39
View File
@@ -2,7 +2,6 @@ package bus_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@@ -13,8 +12,8 @@ import (
"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
// controller pages it, and a module's is said to be too large — neither is a 30-second silence.
// novox/hq issue 314: a module's answer larger than the bus carries in one message is said to be too large,
// at once, not a 30-second silence. (The controller's paging ended with the controller, issue 1126.)
func TestAnAnswerLargerThanOneMessage(t *testing.T) {
url := meshtest.URL(t)
seat, err := nats.Connect(url)
@@ -27,43 +26,8 @@ func TestAnAnswerLargerThanOneMessage(t *testing.T) {
t.Fatal(err)
}
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 })
if err != nil {
t.Fatal(err)
+3 -3
View File
@@ -14,16 +14,16 @@ import (
// 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
// 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
// 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).
// 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>`.
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.
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
// 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 {
Publish []string `json:"publish,omitempty"`
Subscribe []string `json:"subscribe,omitempty"`
@@ -32,7 +32,7 @@ type SeatTraffic struct {
Workers []SeatWorker `json:"workers,omitempty"`
Records []string `json:"records,omitempty"`
// 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"`
}
@@ -45,7 +45,7 @@ type KindHeld struct {
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) {
t, err := c.trafficOf(module)
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
// 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) {
t, err := c.trafficOf(module)
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)
}
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",
"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 {
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)
}
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"
)
// 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 —
// 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)
if err != nil {
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)
}
return nil, err
+1 -1
View File
@@ -33,7 +33,7 @@ func TestAWatchHoldsWhileItsCurrentValuesKeepArriving(t *testing.T) {
for i := 0; i < older; 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")
}
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 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
// 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:
@@ -30,7 +30,7 @@ type StreamIssued struct {
}
// 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 {
Stream string `json:"stream"`
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
// 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).
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
// 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.
// **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
+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.
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>/<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."
@@ -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
// 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.
//
// `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
wg.Add(2)
// 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) }()
// 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) }()
d, err := announce.Gather(conn, held)
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
// 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
// described without it — `push` as taking nothing at all, so a push naming one machine arrived as a
// 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.
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.
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
})
if err != nil {
@@ -82,8 +82,8 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
t.Cleanup(stopNodes)
// What the mesh holds, as the indexer and placement write it (ADR 0571 clauses 4, 5): never the controller's records.
tools := map[string][]string{"alpha": {"one", "two"}, "beta": {"three"}, "gamma": {"given"}, "delta": nil,
"epsilon": {"seven"}, "mesh-controller": {"nodes"}}
mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta"}, "bench": {"epsilon", "mesh-controller"}})
"epsilon": {"seven"}, "mesh-placement": {"nodes"}}
mesh.Placed(t, tools, map[string][]string{"desk": {"alpha", "beta", "delta"}, "bench": {"epsilon", "mesh-placement"}})
console := mt.MembershipOf("node-tools", "desk", false, nil)
console.State = mt.ConsoleReads()
mesh.Issue(t, console)
@@ -93,11 +93,11 @@ func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
}
return nil
})
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-controller", ID: "bench"}, func() []announce.Endpoint {
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", Scope: "mesh",
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-placement", ID: "bench"}, func() []announce.Endpoint {
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.
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 {
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.
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"`) {
t.Errorf("overview: %s", overview)
}
// 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.
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)
}
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.
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)
}
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.
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",
"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",
"bench/beta.three": "beta does not run on bench; it runs on desk",
"nonsense": "is not an address",
"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",
"desk/mesh-placement.nodes": "held once for the whole mesh",
"bench/beta.three": "beta does not run on bench; it runs on desk",
"nonsense": "is not an address",
} {
got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address})
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)
}
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")
stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}},
nil, (&mt.Logs{}).Logf)
-9
View File
@@ -118,9 +118,6 @@ var (
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.
func whyItFailed(key string, err error) string {
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: " +
"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."
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 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
// 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}`.
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{})
case MethodSeats:
// The seats' contract and who holds each, for a client on this machine that has no seat definitions of its own
// (the TypeScript `mesh`): read in-process from mesh-sdk and from placement, never asked of 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).
return answer(r.ID, s.seatsAnswer())
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 " +
"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 " +
"(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>."
}
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
// ran as a push of every machine behind.
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"}}}`)}
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)
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
// 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) {
got := whyItFailed("seat:mesh-controller.push", errors.New("timeout"))
for _, want := range []string{"not a failure", "may already have done", "mesh-controller.calls"} {
got := whyItFailed("seat:mesh-delivery.deliveries", errors.New("timeout"))
for _, want := range []string{"not a failure", "may already have done"} {
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") ||
strings.Contains(other, "calls") {
t.Errorf("a module's timeout: %s", other)
for name, text := range map[string]string{
"grammar": grammar,
"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"},
})
controller := connect(t, "mesh-controller", "bench")
controller := connect(t, "mesh-placement", "bench")
var toolsAsked atomic.Int32
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).
@@ -104,7 +104,7 @@ func TestARuntimeThatAnswersLateIsFoundAndOneThatNeverDoesIsNamed(t *testing.T)
"tools": `{"seats":[]}`,
} {
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)
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.",
"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",
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb})
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-placement", Seat: "mesh-placement",
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-placement.tool." + verb})
}
for tool, does := range map[string]string{
"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) {
x := meshToSearch(t)
for query, want := range map[string]string{
"journalctl": "anchor/node-service-manager.journal",
"journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
"sudo journalctl -fu sshd": "anchor/node-service-manager.journal",
"/usr/bin/journalctl": "anchor/node-service-manager.journal",
"logs": "anchor/node-service-manager.journal",
"systemctl status": "anchor/node-service-manager.status",
"systemctl status nats.service": "anchor/node-service-manager.status",
"systemctl restart mesh-controller": "anchor/node-service-manager.restart",
"systemctl --failed": "anchor/node-service-manager.failed",
"docker ps": "anchor/docker.docker_list",
"docker ps -a": "anchor/docker.docker_list",
"docker logs": "anchor/docker.docker_logs",
"docker logs --tail 100 nats": "anchor/docker.docker_logs",
"docker restart nats": "anchor/docker.docker_restart",
"container logs": "anchor/docker.docker_logs",
"uptime": "mesh-controller.node",
"journalctl": "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",
"/usr/bin/journalctl": "anchor/node-service-manager.journal",
"logs": "anchor/node-service-manager.journal",
"systemctl status": "anchor/node-service-manager.status",
"systemctl status nats.service": "anchor/node-service-manager.status",
"systemctl restart mesh-placement": "anchor/node-service-manager.restart",
"systemctl --failed": "anchor/node-service-manager.failed",
"docker ps": "anchor/docker.docker_list",
"docker ps -a": "anchor/docker.docker_list",
"docker logs": "anchor/docker.docker_logs",
"docker logs --tail 100 nats": "anchor/docker.docker_logs",
"docker restart nats": "anchor/docker.docker_restart",
"container logs": "anchor/docker.docker_logs",
} {
hits := x.search(query)
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`.
func TestSearchStillMatchesEveryWord(t *testing.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)
}
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).
//
// **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.
// **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.
//
// **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 —
// 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
@@ -72,7 +72,7 @@ func URL(t *testing.T) string {
// buses are the running tests' buses, by test.
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 {
nc *nats.Conn
js nats.JetStreamContext
@@ -230,7 +230,7 @@ func (m *Mesh) NextEvent(t *testing.T, pattern string) <-chan string {
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 {
own := "mesh.mod." + module
serves := []bus.Served{{Subject: own + ".tool.{tool}." + node}}
@@ -291,7 +291,7 @@ func (l *Logs) Has(fragments ...string) bool {
// All is every line.
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
// redelivery in seconds rather than the mesh's minutes.
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
}
// 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.
func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue {
t.Helper()
@@ -363,7 +363,7 @@ func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue {
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).
func (m *Mesh) BucketKeyTTL(t *testing.T, name string) {
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
// 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 {
t.Helper()
stream := "LOG_" + module + "_" + name
@@ -409,7 +409,7 @@ func (m *Mesh) Consumers(t *testing.T, stream string) int {
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.
func (m *Mesh) ModuleStream(t *testing.T, module, name string) {
t.Helper()
@@ -455,7 +455,7 @@ func (m *Mesh) Messages(t *testing.T, stream string) uint64 {
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).
func (m *Mesh) RemoveConsumer(t *testing.T, stream, node, module string) {
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
// 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).
//
// 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
// 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 }
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 {
if r == "mesh-controller.assignments" {
t.Error("the manifest still reads mesh-controller.assignments, which the sdk's helper no longer reads (issue 1954)")
if r == "mesh-placement.assignments" {
t.Error("the manifest still reads mesh-placement.assignments, which the sdk's helper no longer reads (issue 1954)")
}
}
for _, a := range m.Build.Artifacts {
@@ -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
// reads and invokes nothing of the controller and consumes nothing of it.
func TestTheManifestIsNotRefusedAtTheLeaseWrite(t *testing.T) {
// 1126/B19 (ADR 0590): this manifest is not refused by the check that refuses any manifest naming the controller:
// it reads, invokes, claims and requires nothing of it.
func TestTheManifestNamesNothingOfTheController(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
@@ -206,7 +206,7 @@ func TestTheManifestIsNotRefusedAtTheLeaseWrite(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if refused := catalogue.ManifestRefusals(map[string]catalogue.Manifest{Module: m}, catalogue.PhaseWrite); len(refused) != 0 {
t.Errorf("the lease write's check refuses node-tools' manifest: %v", refused)
if refused := catalogue.ManifestRefusals(map[string]catalogue.Manifest{Module: m}); len(refused) != 0 {
t.Errorf("the controller-dependence check refuses node-tools' manifest: %v", refused)
}
}
@@ -70,7 +70,7 @@ func TestABundlesSeatTrafficGoesThroughItsModulesBus(t *testing.T) {
if !strings.Contains(string(first), `"duplicate":false`) || !strings.Contains(string(again), `"duplicate":true`) {
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")
}
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.
*
* 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
* its own would be a module deciding its own delivery semantics, and its account cannot
* reach the JetStream API to do it anyway.
@@ -499,14 +499,14 @@ async function deliver(
env = toEnvelope<unknown>(msg);
} catch {
// 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.
msg.term();
return;
}
const forThis = listeners.filter((l) => topicMatches(l.pattern, env.key));
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
// would be redelivered until it expired.
msg.ack();
@@ -518,10 +518,10 @@ async function deliver(
} catch {
// 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
// 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
// `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);
}
}
@@ -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
* 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 {
const base = `mesh.seat.${seat}.tool.${verb}`;
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
* 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";
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 ` +
`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 ` +
`(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>.`,
});
+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 }));
// 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.
const controller = await connectNats({ url: url!, module: "mesh-controller" });
const controller = await connectNats({ url: url!, module: "mesh-placement" });
let controllerToolsAsked = 0;
await controller.handle("seat:mesh-controller.tools", async () => {
await controller.handle("seat:mesh-placement.tools", async () => {
controllerToolsAsked++;
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([
{ 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: {} }] },
]);
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);
assert.deepEqual(
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",
);
// 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).
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")!;
assert.ok(lookup.seat && lookup.scope === "node");
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" });
try {
const roles = seatsIn(await toolsOn(person));
assert.equal(toolKey("mesh-controller.status", roles), "seat:mesh-controller.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.status", roles), "seat:mesh-placement.status");
assert.equal(toolKey("mesh-placement.other", roles), "mesh-placement.other", "a verb the seat does not declare is a module's");
assert.equal(toolKey("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 });
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 });
} finally {
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" } } }],
}));
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.
const local = await aConsoleWithSeats([
{ seat: "mesh-controller", scope: "mesh", tools: [
{ seat: "mesh-placement", scope: "mesh", tools: [
{ name: "status", description: "what is wrong", input: {} },
{ 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: {} }] },
]);
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-controller.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" }));
await controller.handle("seat:mesh-placement.status", async () => ({ output: "all quiet", ok: true }));
await controller.handle("seat:mesh-placement.push", async (body: { node?: string }) => ({ told: body.node ?? "nobody" }));
t.after(async () => {
await catalogue.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;
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");
// A node-scoped seat's verb takes the machine, and requires it (ADR 0170).
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");
const credential = await aMeshAndACredential(t);
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;
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");
const credential = await aMeshAndACredential(t);
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;
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
// by which reader happened to fetch it.
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));
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.
assert.deepEqual(matched("mesh-catalog.upgraded"), []);
// 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";
// 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 jsm = await admin.jetstreamManager();
await jsm.streams.add({ name: "EVENTS", subjects: ["mesh.mod.*.event.>", "mesh.seat.*.event.>"] });