mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/asks-answered-on-any-channel checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
495 lines
17 KiB
Go
495 lines
17 KiB
Go
// Package stdio is the Novox Mesh SDK for Go (novox/hq ADR 0188, ADR 0193).
|
|
//
|
|
// A tools bundle is a process the node's runtime launches and speaks MCP over stdio to:
|
|
// `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and
|
|
// stdout. This package is the loop, so a bundle writes its tools and nothing else. A tool named
|
|
// `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
|
|
// module's own.
|
|
//
|
|
// No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175).
|
|
// What a bundle asks of the bus — emitting an event — it asks of the runtime over the same channel,
|
|
// and the runtime does it as the module (ADR 0193). stdout is the protocol; log to stderr.
|
|
package stdio
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Protocol is the MCP version this speaks; the runtime says the same.
|
|
const Protocol = "2025-03-26"
|
|
|
|
// ServedAs is the word the runtime sets for a bundle it launches: the module it serves it as
|
|
// (ADR 0193). A bundle served under that name lists its own tools by their names.
|
|
const ServedAs = "MESH_SERVED_MODULE"
|
|
|
|
// Tool is one tool a bundle serves.
|
|
type Tool struct {
|
|
Name string
|
|
Description string
|
|
// Input is the JSON schema of the arguments; nil is a tool that takes nothing.
|
|
Input map[string]any
|
|
Run func(args map[string]any) (any, error)
|
|
}
|
|
|
|
type message struct {
|
|
ID *json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
// channel is the one conversation with the runtime: what this bundle writes, serialised, and what
|
|
// it asked and waits for.
|
|
type channel struct {
|
|
mu sync.Mutex
|
|
enc *json.Encoder
|
|
next int
|
|
waiting map[string]chan message
|
|
listeners []listener
|
|
// subscribed numbers the listeners, so one whose subscribe failed is the one removed.
|
|
subscribed int
|
|
// watches are this bundle's state watches by the id each was asked with (ADR 0201).
|
|
watches map[string]func(StateChange) error
|
|
// takers and provers are this bundle's seat work and proof handlers, by worker and subject (ADR 0259).
|
|
takers map[string]func(Work) error
|
|
provers map[string]func(subject string, body json.RawMessage) (any, error)
|
|
}
|
|
|
|
type listener struct {
|
|
n int
|
|
pattern string
|
|
handle func(Envelope) error
|
|
}
|
|
|
|
// Envelope is one event as the mesh carries it: its type, the machine it came from, its body as JSON,
|
|
// and the ADR 0042 headers (x-event-id, x-source, x-node, x-time …).
|
|
type Envelope struct {
|
|
Key string `json:"key"`
|
|
Node string `json:"node"`
|
|
Body json.RawMessage `json:"body"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
func (c *channel) say(m map[string]any) {
|
|
m["jsonrpc"] = "2.0"
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
_ = c.enc.Encode(m)
|
|
}
|
|
|
|
// ask sends the runtime a request and waits for its answer.
|
|
func (c *channel) ask(method string, params any, timeout time.Duration) (json.RawMessage, error) {
|
|
return c.askAs(c.newID(), method, params, timeout)
|
|
}
|
|
|
|
func (c *channel) newID() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.next++
|
|
return "bundle-" + strconv.Itoa(c.next)
|
|
}
|
|
|
|
// askAs is ask under an id the caller chose, for a request whose id something is registered under
|
|
// before it is sent.
|
|
func (c *channel) askAs(id, method string, params any, timeout time.Duration) (json.RawMessage, error) {
|
|
c.mu.Lock()
|
|
answer := make(chan message, 1)
|
|
c.waiting[id] = answer
|
|
c.mu.Unlock()
|
|
c.say(map[string]any{"id": id, "method": method, "params": params})
|
|
select {
|
|
case m := <-answer:
|
|
if m.Error != nil {
|
|
return nil, &refused{message: m.Error.Message, code: m.Error.Code}
|
|
}
|
|
return m.Result, nil
|
|
case <-time.After(timeout):
|
|
c.mu.Lock()
|
|
delete(c.waiting, id)
|
|
c.mu.Unlock()
|
|
return nil, fmt.Errorf("the runtime did not answer %s in %s", method, timeout)
|
|
}
|
|
}
|
|
|
|
// refused is the runtime's error answer, as opposed to no answer: what it refused, it did not do.
|
|
type refused struct {
|
|
message string
|
|
code int
|
|
}
|
|
|
|
func (r *refused) Error() string { return r.message }
|
|
|
|
// Is makes a compare-and-set the runtime refused because another writer was first ErrChanged.
|
|
func (r *refused) Is(target error) bool { return target == ErrChanged && r.code == CodeChanged }
|
|
|
|
// ErrChanged is a compare-and-set that lost: the key was made or changed by another writer first (novox/hq
|
|
// ADR 0259). Told apart by the runtime's error code, never by its words.
|
|
var ErrChanged = errors.New("the key was written by another writer first")
|
|
|
|
// CodeChanged is the error code the runtime answers a lost compare-and-set with.
|
|
const CodeChanged = -32010
|
|
|
|
var (
|
|
current *channel
|
|
currentMu sync.Mutex
|
|
)
|
|
|
|
// Serve answers the runtime on stdin and stdout until stdin closes. An empty name serves as the
|
|
// module the runtime named in MESH_SERVED_MODULE.
|
|
func Serve(name string, tools []Tool) error {
|
|
return ServeOn(name, tools, os.Stdin, os.Stdout)
|
|
}
|
|
|
|
// ServeOn is Serve over any pair of streams, for a test.
|
|
func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
|
if name == "" {
|
|
name = os.Getenv(ServedAs)
|
|
}
|
|
if name == "" {
|
|
name = "bundle"
|
|
}
|
|
byName := map[string]Tool{}
|
|
for _, t := range tools {
|
|
byName[t.Name] = t
|
|
}
|
|
c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{},
|
|
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
|
|
currentMu.Lock()
|
|
current = c
|
|
currentMu.Unlock()
|
|
scanner := bufio.NewScanner(in)
|
|
scanner.Buffer(make([]byte, 1<<20), 1<<24)
|
|
for scanner.Scan() {
|
|
var m message
|
|
if err := json.Unmarshal(scanner.Bytes(), &m); err != nil {
|
|
continue
|
|
}
|
|
// The runtime's answer to something this bundle asked.
|
|
if m.Method == "" && m.ID != nil {
|
|
var key string
|
|
if json.Unmarshal(*m.ID, &key) == nil {
|
|
c.mu.Lock()
|
|
answer, ok := c.waiting[key]
|
|
delete(c.waiting, key)
|
|
c.mu.Unlock()
|
|
if ok {
|
|
answer <- m
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
id := any(nil)
|
|
if m.ID != nil {
|
|
id = *m.ID
|
|
}
|
|
switch m.Method {
|
|
case "initialize":
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"protocolVersion": Protocol,
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": name, "version": "1"}}})
|
|
case "notifications/initialized":
|
|
case "ping":
|
|
if id != nil {
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}
|
|
case "tools/list":
|
|
listed := make([]map[string]any, 0, len(tools))
|
|
for _, t := range tools {
|
|
schema := t.Input
|
|
if schema == nil {
|
|
schema = map[string]any{"type": "object", "properties": map[string]any{}}
|
|
} else if _, isSchema := schema["type"]; !isSchema {
|
|
schema = map[string]any{"type": "object", "properties": schema}
|
|
}
|
|
listed = append(listed, map[string]any{"name": t.Name, "description": t.Description, "inputSchema": schema})
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"tools": listed}})
|
|
case "tools/call":
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, &p)
|
|
t, ok := byName[p.Name]
|
|
if !ok {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": fmt.Sprintf("%s has no tool %s", name, p.Name)}})
|
|
continue
|
|
}
|
|
// Beside the loop, never in it: a tool that emits waits for the runtime's answer, which
|
|
// arrives on the line this loop reads next — and calls do not queue behind a slow one.
|
|
go func(id any, t Tool, args map[string]any) {
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
result, err := t.Run(args)
|
|
if err != nil {
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}})
|
|
return
|
|
}
|
|
text, _ := json.Marshal(result)
|
|
c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}})
|
|
}(id, t, p.Arguments)
|
|
case "mesh/event":
|
|
// One event the runtime read from this module's consumer (ADR 0198), answered once every
|
|
// handler it is for has taken it; an error is answered as one, so the runtime does not
|
|
// acknowledge it and the bus delivers it again. Beside the loop, like a tool call.
|
|
var p struct {
|
|
Envelope *Envelope `json:"envelope"`
|
|
}
|
|
if json.Unmarshal(m.Params, &p) != nil || p.Envelope == nil || p.Envelope.Key == "" {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/event carries no envelope"}})
|
|
continue
|
|
}
|
|
c.mu.Lock()
|
|
matching := []listener{}
|
|
for _, l := range c.listeners {
|
|
if TopicMatches(l.pattern, p.Envelope.Key) {
|
|
matching = append(matching, l)
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
go func(id any, env Envelope, ls []listener) {
|
|
for _, l := range ls {
|
|
if err := l.handle(env); err != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}(id, *p.Envelope, matching)
|
|
case "mesh/state":
|
|
// One change to a state this bundle watches (ADR 0201), named by the watch's id, answered
|
|
// once its handler has taken it. Beside the loop, like a tool call.
|
|
var p struct {
|
|
Watch string `json:"watch"`
|
|
Change *StateChange `json:"change"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, &p)
|
|
c.mu.Lock()
|
|
handle := c.watches[p.Watch]
|
|
c.mu.Unlock()
|
|
if handle == nil || p.Change == nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/state names no watch of this bundle"}})
|
|
continue
|
|
}
|
|
go func(id any, change StateChange) {
|
|
if err := handle(change); err != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}(id, *p.Change)
|
|
case "mesh/work":
|
|
// One piece of a seat's work this bundle takes (novox/hq ADR 0259): taken by an answer that is
|
|
// not an error, offered again after one. Beside the loop, like a tool call.
|
|
var p struct {
|
|
Work *Work `json:"work"`
|
|
}
|
|
if json.Unmarshal(m.Params, &p) != nil || p.Work == nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/work carries no work"}})
|
|
continue
|
|
}
|
|
c.mu.Lock()
|
|
handle := c.takers[p.Work.Worker]
|
|
if handle == nil && len(c.takers) == 1 {
|
|
for _, h := range c.takers {
|
|
handle = h
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
go func(id any, w Work) {
|
|
if handle == nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": "nothing here takes " + w.Subject}})
|
|
return
|
|
}
|
|
if err := handle(w); err != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
c.say(map[string]any{"id": id, "result": map[string]any{}})
|
|
}(id, *p.Work)
|
|
case "mesh/proof":
|
|
// One proof this bundle answers (novox/hq ADR 0259): what the handler returns is the answer; an
|
|
// error is answered in words, never as silence.
|
|
var p struct {
|
|
Subject string `json:"subject"`
|
|
Body json.RawMessage `json:"body"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, &p)
|
|
c.mu.Lock()
|
|
var handle func(string, json.RawMessage) (any, error)
|
|
for pattern, h := range c.provers {
|
|
if subjectMatches(pattern, p.Subject) {
|
|
handle = h
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
go func(id any, subject string, body json.RawMessage) {
|
|
if handle == nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": "nothing here answers " + subject}})
|
|
return
|
|
}
|
|
out, err := handle(subject, body)
|
|
if err != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
|
return
|
|
}
|
|
c.say(map[string]any{"id": id, "result": out})
|
|
}(id, p.Subject, p.Body)
|
|
default:
|
|
if id != nil {
|
|
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32601, "message": "no " + m.Method}})
|
|
}
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func live() (*channel, error) {
|
|
currentMu.Lock()
|
|
defer currentMu.Unlock()
|
|
if current == nil {
|
|
return nil, errors.New("before Serve: a bundle reaches the bus through the runtime that launched it")
|
|
}
|
|
return current, nil
|
|
}
|
|
|
|
// Subscribe asks the runtime for this module's events whose type matches the pattern (`*` one
|
|
// segment, `#` any), and hands each to handle (ADR 0198).
|
|
//
|
|
// **Taking an event** (see the repository's README): handle returning nil takes the event — the
|
|
// runtime acknowledges it once every matching handler did; an error is the one way to have it offered
|
|
// again, after a few seconds and a bounded number of times, after which the bus gives up and the
|
|
// controller raises a condition. So return an error only when the work was not done, never for an
|
|
// answer that went right but read oddly; and since delivery is at-least-once, make the work safe to
|
|
// do twice, or skip an event whose x-event-id was already done.
|
|
//
|
|
// A subscribe the runtime refused leaves no handler behind, so asking again — as a module does until
|
|
// the runtime takes it — does not hand one event to the same handler twice. (One the runtime did not
|
|
// answer in time is kept, as the runtime may have bound it.) Call it from a goroutine
|
|
// started before Serve, or from a tool, once Serve runs.
|
|
func Subscribe(pattern string, handle func(Envelope) error) error {
|
|
c, err := live()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
c.subscribed++
|
|
n := c.subscribed
|
|
c.listeners = append(c.listeners, listener{n: n, pattern: pattern, handle: handle})
|
|
c.mu.Unlock()
|
|
_, err = c.ask("mesh/subscribe", map[string]any{"pattern": pattern}, 30*time.Second)
|
|
// Removed only when the runtime answered that it refused: one that did not answer in time may
|
|
// have bound the consumer, and a handler removed then would have its events taken unhandled.
|
|
var no *refused
|
|
if errors.As(err, &no) {
|
|
c.mu.Lock()
|
|
for i, l := range c.listeners {
|
|
if l.n == n {
|
|
c.listeners = append(c.listeners[:i:i], c.listeners[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Ask calls a tool on the mesh as this bundle's module, through the runtime (ADR 0198), and returns
|
|
// its result as JSON. The key is `<module>.<tool>` as the runtime calls it.
|
|
func Ask(key string, body any) (json.RawMessage, error) {
|
|
c, err := live()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.ask("mesh/ask", map[string]any{"key": key, "body": body}, 60*time.Second)
|
|
}
|
|
|
|
// TopicMatches says whether an event's type matches a pattern: `*` is one dot-separated segment, `#`
|
|
// (or `**`) any number, as the TypeScript SDK matches.
|
|
func TopicMatches(pattern, key string) bool {
|
|
return matchFrom(strings.Split(pattern, "."), 0, strings.Split(key, "."), 0)
|
|
}
|
|
|
|
func matchFrom(p []string, pi int, k []string, ki int) bool {
|
|
if pi == len(p) {
|
|
return ki == len(k)
|
|
}
|
|
if p[pi] == "#" || p[pi] == "**" {
|
|
for skip := ki; skip <= len(k); skip++ {
|
|
if matchFrom(p, pi+1, k, skip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if ki == len(k) || (p[pi] != "*" && p[pi] != k[ki]) {
|
|
return false
|
|
}
|
|
return matchFrom(p, pi+1, k, ki+1)
|
|
}
|
|
|
|
// EmitOptions is the provenance an emitter may attach (ADR 0042).
|
|
type EmitOptions struct {
|
|
CausationID string
|
|
Schema string
|
|
}
|
|
|
|
// Emit asks the runtime to publish an event as this bundle's module, and returns once the bus has
|
|
// accepted it (ADR 0193). The envelope and headers are the TypeScript SDK's exactly: the source and
|
|
// node come from MESH_MODULE and MESH_NODE, which the runtime sets.
|
|
func Emit(eventType string, body any, opts ...EmitOptions) error {
|
|
currentMu.Lock()
|
|
c := current
|
|
currentMu.Unlock()
|
|
if c == nil {
|
|
return errors.New("emit before Serve: a bundle emits through the runtime that launched it")
|
|
}
|
|
source, node := os.Getenv("MESH_MODULE"), os.Getenv("MESH_NODE")
|
|
if source == "" {
|
|
source = "unknown"
|
|
}
|
|
if node == "" {
|
|
node = "unknown"
|
|
}
|
|
headers := map[string]string{
|
|
"x-event-id": eventID(), "x-source": source, "x-node": node,
|
|
"x-time": time.Now().UTC().Format(time.RFC3339Nano), "content-type": "application/json",
|
|
}
|
|
for _, o := range opts {
|
|
if o.CausationID != "" {
|
|
headers["x-causation-id"] = o.CausationID
|
|
}
|
|
if o.Schema != "" {
|
|
headers["x-schema"] = o.Schema
|
|
}
|
|
}
|
|
_, err := c.ask("mesh/publish", map[string]any{"key": eventType, "node": node, "body": body, "headers": headers}, 30*time.Second)
|
|
return err
|
|
}
|
|
|
|
// eventID is a random UUID, version 4, as the TypeScript SDK's randomUUID.
|
|
func eventID() string {
|
|
var b [16]byte
|
|
_, _ = rand.Read(b[:])
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
h := hex.EncodeToString(b[:])
|
|
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
|
}
|