The controller nox forwarded every line to is unassigned (issue 1126), so nox answered nothing but enrol open. Each line now calls the seat verb or module tool that owns its job through this machine's console (mesh_call); a line no seat owns says so and names what to use, never failing silently. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
145 lines
5.2 KiB
Go
145 lines
5.2 KiB
Go
package main
|
|
|
|
// How mesh-cli reaches the mesh now that the controller is unassigned (novox/hq issue 1126): through this machine's
|
|
// console, the mesh MCP server the module node-tools serves on loopback (provision mcp-endpoint, ADR 0152: the
|
|
// machine's login is the authority). mesh-cli calls `mesh_call` with a seat verb's or a module tool's address, and the
|
|
// console puts the call on the bus, the way every agent session on the machine does. mesh-cli holds no credential and
|
|
// keeps no bus account of its own.
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// consoleFile is where the module writes the console's address, from the mcp-endpoint it requires; a variable so a
|
|
// test can name its own.
|
|
var consoleFile = "/etc/mesh-cli/console"
|
|
|
|
// defaultConsole is the console's address when the file is not there yet: the port node-tools declares it serves.
|
|
const defaultConsole = "http://127.0.0.1:4270/mcp"
|
|
|
|
// callWithin bounds one call: the console's own bound on a call is 30 seconds, and a wait is longer.
|
|
var callWithin = 2 * time.Minute
|
|
|
|
// maxAnswer bounds what is read of the console's answer.
|
|
const maxAnswer = 16 << 20
|
|
|
|
// consoleAddress is where the console listens, and where that was read from, for what is said when it does not answer.
|
|
func consoleAddress() (string, string) {
|
|
if v := strings.TrimSpace(os.Getenv("MESH_CONSOLE")); v != "" {
|
|
return v, "MESH_CONSOLE"
|
|
}
|
|
if raw, err := os.ReadFile(consoleFile); err == nil {
|
|
if v := strings.TrimSpace(string(raw)); v != "" && !strings.Contains(v, "${") {
|
|
return v, consoleFile
|
|
}
|
|
}
|
|
return defaultConsole, "the console's declared port (" + consoleFile + " is not there)"
|
|
}
|
|
|
|
// answer is what the console said of one call: its text, and whether the console marked it an error (a refusal, a
|
|
// tool's failure, nothing serving, no answer in time).
|
|
type answer struct {
|
|
Text string
|
|
IsError bool
|
|
}
|
|
|
|
// errNoConsole is a console that did not answer at all: nothing was called.
|
|
var errNoConsole = errors.New("no console")
|
|
|
|
// consoleTool asks the console one of its own tools (mesh_call, mesh_search, mesh_describe) and answers what it said.
|
|
func consoleTool(ctx context.Context, tool string, arguments map[string]any) (answer, error) {
|
|
url, from := consoleAddress()
|
|
body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": map[string]any{"name": tool, "arguments": arguments}})
|
|
if err != nil {
|
|
return answer{}, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, callWithin)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return answer{}, fmt.Errorf("the console's address %q (from %s) is not usable: %v", url, from, err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return answer{}, ctx.Err()
|
|
}
|
|
return answer{}, fmt.Errorf("%w: nothing answers at %s (from %s), so nothing was called. The console is the "+
|
|
"module node-tools on this machine: `systemctl status` of its unit, or call from another machine: %v",
|
|
errNoConsole, url, from, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer))
|
|
if err != nil {
|
|
return answer{}, fmt.Errorf("the console's answer could not be read: %v; the call may have run", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return answer{}, fmt.Errorf("the console at %s answered %s: %s", url, resp.Status, strings.TrimSpace(string(raw)))
|
|
}
|
|
return decodeAnswer(raw)
|
|
}
|
|
|
|
// decodeAnswer reads the console's JSON-RPC answer, given as JSON or as a server-sent event's data.
|
|
func decodeAnswer(raw []byte) (answer, error) {
|
|
payload := bytes.TrimSpace(raw)
|
|
if !bytes.HasPrefix(payload, []byte("{")) {
|
|
sc := bufio.NewScanner(bytes.NewReader(raw))
|
|
sc.Buffer(make([]byte, 0, 64<<10), maxAnswer)
|
|
payload = nil
|
|
for sc.Scan() {
|
|
if d, ok := strings.CutPrefix(sc.Text(), "data:"); ok {
|
|
payload = []byte(strings.TrimSpace(d))
|
|
}
|
|
}
|
|
}
|
|
var rpc struct {
|
|
Result *struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
} `json:"result"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(payload, &rpc); err != nil {
|
|
return answer{}, fmt.Errorf("the console's answer is not one mesh-cli reads (%v); the call may have run", err)
|
|
}
|
|
if rpc.Error != nil {
|
|
return answer{Text: rpc.Error.Message, IsError: true}, nil
|
|
}
|
|
if rpc.Result == nil {
|
|
return answer{}, errors.New("the console answered nothing; the call may have run")
|
|
}
|
|
var parts []string
|
|
for _, c := range rpc.Result.Content {
|
|
if c.Type == "text" || c.Type == "" {
|
|
parts = append(parts, c.Text)
|
|
}
|
|
}
|
|
return answer{Text: strings.Join(parts, "\n"), IsError: rpc.Result.IsError}, nil
|
|
}
|
|
|
|
// meshCall calls one address through the console.
|
|
func meshCall(ctx context.Context, address string, args map[string]any) (answer, error) {
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
return consoleTool(ctx, "mesh_call", map[string]any{"address": address, "arguments": args})
|
|
}
|