Files
mesh-sdk/go/process/serve.go
T
jschoubben ba6c2a5207 process: AnswerSeat answers a seat's verbs over the bus with the proved caller
A process answers its seat's verbs through one call: caller-named and
unproven subjects, one queue group per seat, reply {result, error, node}.
The rule that a process answers no call (issue 1171 AC3) is replaced by one
that AnswerSeat is the only way to serve. A compare-and-set write on a
process's own state already exists (State.Create, Update).

Builds: design 47 G A-107 (G-20-lead-answers)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:50:15 +01:00

114 lines
3.7 KiB
Go

package process
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/calladdress"
)
// SeatHandler answers one call of a seat's verb: caller is the bus user the subject the call arrived on names
// (the bus server enforces it, so it is a fact, never read from the body), or "" when the call came on the subject
// that names none (an unproven caller); body is the call's arguments. A returned error is the call's failure.
type SeatHandler func(caller string, body json.RawMessage) (any, error)
// callerUser is the bus user in a caller token: each `~` is a dot (mesh-tools node-tools/internal/bus/caller.go
// UserOfToken), "" for a token that is not a bus user's name.
var callerUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
func userOfToken(token string) string {
user := strings.ReplaceAll(token, "~", ".")
if !callerUser.MatchString(user) {
return ""
}
return user
}
// AnswerSeat answers the verbs of a mesh seat this process's module holds, over the bus, as the tool runner
// answers a bundle's (novox/hq design 47 G, lead answer A-107): on `mesh.seat.<seat>.call.<verb>.*`, handing the
// handler the caller its last token names, and on `mesh.seat.<seat>.tool.<verb>`, which names none. Every instance
// of the seat's holder subscribes in one queue group per seat, so a call reaches one of them. The reply is
// {result, error, node}, as Call reads it. The returned stop ends every subscription. Nothing is subscribed when
// any verb is malformed or a subscription is refused.
func (c *Conn) AnswerSeat(seat string, verbs map[string]SeatHandler) (stop func(), err error) {
if len(verbs) == 0 {
return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat)
}
names := make([]string, 0, len(verbs))
for verb, h := range verbs {
if h == nil {
return nil, fmt.Errorf("AnswerSeat(%q): the verb %s has no handler", seat, verb)
}
if _, err := calladdress.Parse("seat:" + seat + "." + verb); err != nil {
return nil, err
}
names = append(names, verb)
}
sort.Strings(names)
queue := "serve.seat." + seat
var subs []*nats.Subscription
undo := func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
}
for _, verb := range names {
h := verbs[verb]
base := "mesh.seat." + seat
for _, subject := range []string{base + ".call." + verb + ".*", base + ".tool." + verb} {
subject := subject
sub, err := c.nc.QueueSubscribe(subject, queue, func(msg *nats.Msg) { go c.answerCall(msg, subject, h) })
if err != nil {
undo()
return nil, fmt.Errorf("answering %s: %w", subject, err)
}
subs = append(subs, sub)
}
}
if err := c.nc.Flush(); err != nil {
undo()
return nil, err
}
return undo, nil
}
// answerCall runs h for one call and replies {result, error, node}; a panic in h is the call's error, never silence.
func (c *Conn) answerCall(msg *nats.Msg, pattern string, h SeatHandler) {
caller := ""
if strings.HasSuffix(pattern, ".*") {
caller = userOfToken(msg.Subject[strings.LastIndex(msg.Subject, ".")+1:])
}
var reply struct {
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Node string `json:"node,omitempty"`
}
func() {
defer func() {
if r := recover(); r != nil {
reply.Error = fmt.Sprintf("%s failed: %v", msg.Subject, r)
}
}()
result, err := h(caller, json.RawMessage(msg.Data))
switch {
case err != nil:
reply.Error = err.Error()
case result == nil:
reply.Result = json.RawMessage("null")
default:
reply.Result = result
}
}()
reply.Node = c.account.Node
body, err := json.Marshal(reply)
if err != nil {
body, _ = json.Marshal(map[string]string{"error": "the answer is not JSON: " + err.Error(), "node": c.account.Node})
}
_ = msg.Respond(body)
}