package process import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/busretry" ) // CallTimeout bounds a call to a tool or a seat verb (mesh-tools node-tools/internal/bus/bus.go:35, // RequestTimeout): what a module already expects. var CallTimeout = 30 * time.Second // Call invokes a tool or a seat verb as this process's own account (novox/hq ADR 0436 clause 4), on the // caller-named subject, falling back to the subject that names no caller when nothing answers it — // exactly the provisioner client's own pattern (mesh-catalog modules/mesh-provisioner-client/cmd/ // mesh-provisioner-client/secrets.go:59-61, 93-97, 119, 125-133). key is "seat:." for a seat's // verb, or "." for a module's own tool, as a process's bus.invokes names it. The reply is // {result, error, node} (mesh-tools node-tools/internal/bus/bus.go answerOf); a non-empty error is the // call's failure. It offers no way to answer a call — a process reaches the bus only as itself, and // never serves anything. func (c *Conn) Call(key string, body any) (json.RawMessage, error) { called, fallback, err := callSubjects(key, c.callerToken()) if err != nil { return nil, err } return c.callOn(called, fallback, body) } // CallOn is Call to the tool or seat verb of one named machine (novox/hq ADR 0469 §3; issue 1470): the tool // runner of that node alone answers, on mesh.mod..call... (a seat verb: // mesh.seat..call...), falling back to the subject that names no caller // (mesh-tools node-tools/internal/bus/caller.go:15-19). node is one subject token: an empty one, or one // holding '.', '*', '>' or white space, is refused before anything is published. func (c *Conn) CallOn(node, key string, body any) (json.RawMessage, error) { if err := checkNode(node); err != nil { return nil, err } called, fallback, err := callSubjectsOn(key, node, c.callerToken()) if err != nil { return nil, err } return c.callOn(called, fallback, body) } // callOn asks called, then fallback when nothing answers it; while the bus itself answers nothing (a user-list reload // drops the account's imports for a few seconds, busretry) both are asked again, within busretry.Bound. A call nobody // answers on a bus that answers is not asked again: its callee is not running. A call that may have been delivered (a // timeout, any answer) is never asked again. func (c *Conn) callOn(called, fallback string, body any) (json.RawMessage, error) { data, err := json.Marshal(body) if err != nil { return nil, err } return callWith(c.request, c.blackedOut, called, fallback, data) } // callWith is callOn over a request and a blackout probe. Only the transport's own errors can count as undelivered: a // callee's answer is *busretry.Answered, whatever its words, so a call that reached its callee is never asked again. func callWith(request func(string, []byte) (json.RawMessage, error), blackout func() bool, called, fallback string, data []byte) (json.RawMessage, error) { return busretry.Do("the call on "+fallback, func() (json.RawMessage, error) { result, err := request(called, data) if errors.Is(err, nats.ErrNoResponders) { result, err = request(fallback, data) } return result, err }, blackout) } // answered is the error a callee answered with: delivered, never asked again (busretry.Answered). func answered(words string) error { return &busretry.Answered{Words: words} } // blackedOut says the bus answers this account nothing at all: its JetStream API, which every account imports, has no // responder or says it is not enabled. func (c *Conn) blackedOut() bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() msg, err := c.nc.RequestWithContext(ctx, "$JS.API.INFO", nil) if err != nil { return busretry.Undelivered(err) } return strings.Contains(string(msg.Data), `"err_code":10039`) || strings.Contains(string(msg.Data), "jetstream not enabled") } // callerToken is this account's user name as the last token of a call it publishes, each dot written ~ // (mesh-tools node-tools/internal/bus/caller.go:43, CallerToken). func (c *Conn) callerToken() string { return strings.ReplaceAll(c.account.User, ".", "~") } // callSubjects is the caller-named subject key is called on, and its plain fallback (mesh-tools // node-tools/internal/bus/caller.go CalledSubject, toolShape, calledPattern). func callSubjects(key, token string) (called, fallback string, err error) { return callSubjectsOn(key, "", token) } // checkNode refuses a node name that is not one subject token. func checkNode(node string) error { if node == "" || strings.ContainsAny(node, ".*> \t\r\n") { return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>' or space)", node) } return nil } // callSubjectsOn is callSubjects for one named node ("" names none): the node is a token between the tool and // the caller on the called subject, and the last one of the fallback. func callSubjectsOn(key, node, token string) (called, fallback string, err error) { at := "" if node != "" { at = "." + node } if rest, ok := strings.CutPrefix(key, "seat:"); ok { seat, verb, ok := strings.Cut(rest, ".") if !ok || seat == "" || verb == "" { return "", "", fmt.Errorf("%q names no seat and verb (seat:.)", key) } return "mesh.seat." + seat + ".call." + verb + at + "." + token, "mesh.seat." + seat + ".tool." + verb + at, nil } module, tool, ok := strings.Cut(key, ".") if !ok || module == "" || tool == "" { return "", "", fmt.Errorf("%q is not ., and does not start seat:.", key) } return "mesh.mod." + module + ".call." + tool + at + "." + token, "mesh.mod." + module + ".tool." + tool + at, nil } // request asks one subject once and reads its reply ({result, error, node}); a reply naming an error is // the call's failure, not a transport one. func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) { ctx, cancel := context.WithTimeout(context.Background(), CallTimeout) defer cancel() msg, err := c.nc.RequestWithContext(ctx, subject, data) if err != nil { return nil, err } var r struct { Result json.RawMessage `json:"result"` Error string `json:"error"` Node string `json:"node"` } if err := json.Unmarshal(msg.Data, &r); err != nil { return nil, fmt.Errorf("%s answered something that is not a seat's answer: %w", subject, err) } if r.Error != "" { return nil, answered(r.Error) } return r.Result, nil }