The Go SDK subscribes and asks through the runtime (hq ADR 0198)

Subscribe(pattern, handler) asks the runtime for the module's events and answers each mesh/event
only after the matching handlers returned nil, so the runtime acknowledges it on the bus then and
not before; Ask(key, body) calls a tool as the module. Topic matching is the TypeScript SDK's.
This commit is contained in:
jochen
2026-10-03 23:19:54 +02:00
parent 7b728a8f9f
commit 81ca305ddd
3 changed files with 181 additions and 5 deletions
+107 -4
View File
@@ -21,6 +21,7 @@ import (
"io"
"os"
"strconv"
"strings"
"sync"
"time"
)
@@ -54,10 +55,25 @@ type message struct {
// 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
mu sync.Mutex
enc *json.Encoder
next int
waiting map[string]chan message
listeners []listener
}
type listener struct {
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) {
@@ -189,6 +205,34 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
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)
default:
if id != nil {
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32601, "message": "no " + m.Method}})
@@ -198,6 +242,65 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
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). The runtime acknowledges an event on the bus
// only after every matching handler returned nil; an error has it delivered again. 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.listeners = append(c.listeners, listener{pattern: pattern, handle: handle})
c.mu.Unlock()
_, err = c.ask("mesh/subscribe", map[string]any{"pattern": pattern}, 30*time.Second)
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