Merge branch 'feat/47-G-fix11-a3' into feat/47-G-join11
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/busretry"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
// The gate's reach through the runner's own connection, in this one place (design 46 7-approval-mark §7, §13, §14): its
|
||||
// state approvals, its publish of an ask under the marked module's name, and its read of the router's record.
|
||||
|
||||
// approvalsState is the runner's state approvals over its connection: a lost compare-and-set is stdio.ErrChanged, as
|
||||
// operator-channel's client reads it.
|
||||
type approvalsState struct {
|
||||
conn *bus.Conn
|
||||
module string
|
||||
}
|
||||
|
||||
func (s approvalsState) Get(key string) (*stdio.StateEntry, error) {
|
||||
e, err := s.conn.StateGet(s.module, approval.StateName, key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil
|
||||
}
|
||||
|
||||
func (s approvalsState) Create(key string, value any) (uint64, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return changedAs(s.conn.StateCreate(s.module, approval.StateName, key, raw))
|
||||
}
|
||||
|
||||
func (s approvalsState) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return changedAs(s.conn.StateUpdate(s.module, approval.StateName, key, raw, revision))
|
||||
}
|
||||
|
||||
func (s approvalsState) Put(key string, value any) (uint64, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.conn.StatePut(s.module, approval.StateName, key, raw)
|
||||
}
|
||||
|
||||
func (s approvalsState) Keys() ([]string, error) {
|
||||
return s.conn.StateKeys(s.module, approval.StateName)
|
||||
}
|
||||
|
||||
func changedAs(rev uint64, err error) (uint64, error) {
|
||||
if errors.Is(err, bus.ErrStateChanged) {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
// Bound says the runner's membership binds its state approvals now (§13): a membership issued later binds it with no restart.
|
||||
func (s approvalsState) Bound() bool {
|
||||
m := s.conn.Membership(s.module)
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
for _, st := range m.State {
|
||||
if st.Name == approval.StateName && st.Writes {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// publishAsk says an accept on a seat under the module's name, on the runner's own account (§7).
|
||||
func publishAsk(conn *bus.Conn) func(module, subject string, body any, id string) error {
|
||||
return func(module, subject string, body any, id string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = conn.SeatPublish(module, subject, raw, id)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// routerRecord reads the router's record `<module>.<id>` of the seat's state asks, on the runner's own account (§14).
|
||||
func routerRecord(conn *bus.Conn) func(module, id string) (*stdio.StateEntry, error) {
|
||||
return func(module, id string) (*stdio.StateEntry, error) {
|
||||
e, err := conn.SeatRecord(module, "", id)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// boundState is the runner's key-ttl state bound (lead answers A-157, A-158).
|
||||
type boundState struct {
|
||||
conn *bus.Conn
|
||||
module string
|
||||
}
|
||||
|
||||
// BoundState is the runner's state its bound keys live in.
|
||||
const BoundState = "bound"
|
||||
|
||||
func (b boundState) CreateTTL(key string, value any, ttl time.Duration) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = b.conn.StateCreateTTL(b.module, BoundState, key, raw, ttl)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b boundState) Delete(key string) error { return b.conn.StateDelete(b.module, BoundState, key) }
|
||||
|
||||
func (b boundState) Watch(pattern string, expired func(key string)) (func(), error) {
|
||||
return b.conn.StateWatch(b.module, BoundState, pattern, func(c bus.StateChange) error {
|
||||
if c.Op == "expire" {
|
||||
expired(c.Key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// warranted asks mesh-hand-acts.warranted for one acted ask, each try a kept call open while open says so (design 46
|
||||
// 7-approval-mark §16; lead answer A-157).
|
||||
func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error)) error {
|
||||
return func(asker, ask string, open func() (bool, error)) error {
|
||||
_, err := busretry.DoKept("mesh-hand-acts.warranted of "+asker+"."+ask,
|
||||
busretry.Open{Key: asker + "." + ask, Open: func(string) (bool, error) { return open() }, Logf: log.Printf},
|
||||
func() (bus.Answered, error) {
|
||||
return conn.Ask("seat:"+handacts.Seat+"."+handacts.VerbWarranted, handacts.WarrantedInput{Asker: asker, Ask: ask},
|
||||
"mesh.seat."+handacts.Seat+".tool."+handacts.VerbWarranted)
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// hearOne waits on the runner's connection for the first event on the subjects whose body match takes (design 34
|
||||
// every-event §36: a marked wait's ask's decided).
|
||||
func hearOne(conn *bus.Conn) func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) {
|
||||
return func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) {
|
||||
_, ok, _, err := conn.HearFirst(ctx, subjects, within, match)
|
||||
return ok, err
|
||||
}
|
||||
}
|
||||
|
||||
// gateTools is the gate as mesh_approvals reads it, with its state's fill read from the bucket (§13, §18).
|
||||
type gateTools struct {
|
||||
*approval.Gate
|
||||
state approvalsState
|
||||
}
|
||||
|
||||
func (t gateTools) Fill() (uint64, int64, error) {
|
||||
info, err := t.state.conn.StateInfo(t.state.module, approval.StateName)
|
||||
if err != nil || info == nil {
|
||||
return 0, 0, fmt.Errorf("the state %s's fill cannot be read: %v", approval.StateName, err)
|
||||
}
|
||||
return info.Bytes, info.MaxBytes, nil
|
||||
}
|
||||
@@ -25,7 +25,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/alive"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/console"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/firstview"
|
||||
@@ -51,7 +54,7 @@ func main() {
|
||||
cred := credential(until)
|
||||
conn := connectPatiently(cred, until)
|
||||
// And the seat list it describes the seats by, read through the seat mesh-catalog (novox/hq ADR 0636 §4).
|
||||
_ = startSeatList(conn)
|
||||
seatList := startSeatList(conn)
|
||||
|
||||
served, err := runtime.ServedModulesFrom(os.Getenv(runtime.ToolModules), cred.Module)
|
||||
if err != nil {
|
||||
@@ -69,6 +72,28 @@ func main() {
|
||||
defer ledgers.Stop()
|
||||
opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer))
|
||||
}
|
||||
// The indexer's and the assigner's states the console indexes the mesh by, and the gate reads its marks from, come to it as
|
||||
// first views, asked of the owners' own tools and followed on their views families, never read from their state (novox/hq
|
||||
// ADR 0615 §1; design 46 7-approval-mark §5).
|
||||
source, stopViews := firstview.Start(context.Background(), conn, log.Printf)
|
||||
defer stopViews()
|
||||
gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog,
|
||||
Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module},
|
||||
Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module},
|
||||
Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) }),
|
||||
// The runner's own build, the one a marked wait's act binds (design 34 every-event §35).
|
||||
Build: os.Getenv(runtime.FingerprintWord), Hear: hearOne(conn)})
|
||||
// A failed read or write of the state approvals is made again at the next membership change the runner hears (A-157).
|
||||
conn.OnMembership(func(bus.Membership) { go gate.StateAgain() })
|
||||
opts = append(opts, runtime.WithGate(gate), runtime.WithSeatDefinitions(definitionsOf(seatList)))
|
||||
// A run still unrecorded is asked again at each start and when the hand-act seat's holder is heard again (lead answer A-157).
|
||||
if stopBounds, err := gate.WatchBounds(); err != nil {
|
||||
log.Printf("[mesh-tools] the gate's bound keys are not watched (%v): an unanswered hand-act is said in the log only", err)
|
||||
} else {
|
||||
defer stopBounds()
|
||||
}
|
||||
source.OnSeatHolder(handacts.Seat, gate.Recover)
|
||||
go gate.Recover()
|
||||
stop, err := runtime.Run(conn, served, envs, log.Printf, opts...)
|
||||
if err != nil {
|
||||
log.Fatalf("node-tools: %v", err)
|
||||
@@ -90,16 +115,14 @@ func main() {
|
||||
}
|
||||
var up *console.Listening
|
||||
if listen != "" {
|
||||
// The indexer's and the assigner's states the console indexes the mesh by come to it as first views, asked of the owners'
|
||||
// own tools and followed on their views families, never read from their state (novox/hq ADR 0615 §1).
|
||||
source, stopViews := firstview.Start(context.Background(), conn, log.Printf)
|
||||
defer stopViews()
|
||||
node := cred.Node
|
||||
if node == "" {
|
||||
node = "?"
|
||||
}
|
||||
who := node + "." + cred.Module
|
||||
up, err = console.Serve(console.NewSurface(conn, source, who), listen)
|
||||
surface := console.NewSurface(conn, source, who)
|
||||
surface.Approvals = gateTools{Gate: gate, state: approvalsState{conn: conn, module: cred.Module}}
|
||||
up, err = console.Serve(surface, listen)
|
||||
if err != nil {
|
||||
log.Fatalf("node-tools: %v", err)
|
||||
}
|
||||
|
||||
@@ -71,3 +71,14 @@ func startSeatListOn(b seatlist.Bus, bound <-chan time.Time, logf func(string, .
|
||||
}
|
||||
return reader
|
||||
}
|
||||
|
||||
// definitionsOf is the seat definitions the reader holds, by seat, which carry the verbs' marks (design 46 7-approval-mark
|
||||
// §2, §5); none while the reader did not start.
|
||||
func definitionsOf(reader *seatlist.Reader) func() map[string]seatdef.Definition {
|
||||
return func() map[string]seatdef.Definition {
|
||||
if reader == nil {
|
||||
return nil
|
||||
}
|
||||
return reader.Definitions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ require (
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves.
|
||||
type Call struct {
|
||||
// Address is the address as called: `<module>.<tool>` or `<seat>.<verb>`.
|
||||
Address string
|
||||
// Module is the module whose bundle serves it; Seat the seat of a verb, empty for a tool; Name the tool or the verb.
|
||||
Module, Seat, Name string
|
||||
// Fingerprint is the build the bundle serves, from its composed environment (§5).
|
||||
Fingerprint string
|
||||
// Caller is the caller the bus proved.
|
||||
Caller string
|
||||
// Args are the call's arguments, a JSON object.
|
||||
Args json.RawMessage
|
||||
// Description is the tool's or verb's own description, whose first line the ask explains it by.
|
||||
Description string
|
||||
// Own says it is one of the runner's own tools served under the module's name (its ledger repairs), not its bundle's.
|
||||
Own bool
|
||||
}
|
||||
|
||||
// MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9).
|
||||
const MostArguments = 16 * 1024
|
||||
|
||||
// arguments are a call's arguments by name; a body that is not an object is refused.
|
||||
func arguments(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||
args := map[string]json.RawMessage{}
|
||||
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||
return args, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("its arguments are not a JSON object: %v", err)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// refusedUnasked says why a marked call is refused at once, asking nothing (§9): its arguments hold a secret's shape, or are
|
||||
// more than MostArguments.
|
||||
func refusedUnasked(raw json.RawMessage) string {
|
||||
if len(raw) > MostArguments {
|
||||
return fmt.Sprintf("its arguments are %d bytes, more than the %d a call that needs the operator's approval may carry", len(raw), MostArguments)
|
||||
}
|
||||
if r, ok := outward.CheckSecret(string(raw)); !ok {
|
||||
return fmt.Sprintf("its arguments hold %s, which no ask may carry", r.What)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ActOf is the act a marked call binds (§8): `address`, `node`, `module` and `tool` or `seat` and `verb`, `build`, and
|
||||
// `arg.<name>` for each argument given, a string as itself and any other value as its JSON in RFC 8785's canonical form.
|
||||
func ActOf(c Call, node string) (operatorchannelclient.Act, error) {
|
||||
act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint}
|
||||
if c.Seat == "" {
|
||||
act["module"], act["tool"] = c.Module, c.Name
|
||||
} else {
|
||||
act["seat"], act["verb"] = c.Seat, c.Name
|
||||
}
|
||||
args, err := arguments(c.Args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, raw := range args {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
act["arg."+name] = s
|
||||
continue
|
||||
}
|
||||
canon, err := Canonical(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err)
|
||||
}
|
||||
act["arg."+name] = canon
|
||||
}
|
||||
return act, nil
|
||||
}
|
||||
|
||||
// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16
|
||||
// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names.
|
||||
func Canonical(raw json.RawMessage) (string, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := canonicalValue(&b, v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func canonicalValue(b *strings.Builder, v any) error {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
b.WriteString("null")
|
||||
case bool:
|
||||
b.WriteString(strconv.FormatBool(x))
|
||||
case json.Number:
|
||||
f, err := strconv.ParseFloat(string(x), 64)
|
||||
if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
||||
return fmt.Errorf("the number %s is not a double", x)
|
||||
}
|
||||
b.WriteString(es6Number(f))
|
||||
case string:
|
||||
canonicalString(b, x)
|
||||
case []any:
|
||||
b.WriteByte('[')
|
||||
for i, e := range x {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
if err := canonicalValue(b, e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.WriteByte(']')
|
||||
case map[string]any:
|
||||
names := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) })
|
||||
b.WriteByte('{')
|
||||
for i, k := range names {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
canonicalString(b, k)
|
||||
b.WriteByte(':')
|
||||
if err := canonicalValue(b, x[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("a value of type %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3).
|
||||
func es6Number(f float64) string {
|
||||
if f == 0 {
|
||||
return "0"
|
||||
}
|
||||
abs := math.Abs(f)
|
||||
if abs >= 1e-6 && abs < 1e21 {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07
|
||||
mant, exp, _ := strings.Cut(s, "e")
|
||||
sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0")
|
||||
return mant + "e" + sign + digits
|
||||
}
|
||||
|
||||
func canonicalString(b *strings.Builder, s string) {
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '"':
|
||||
b.WriteString(`\"`)
|
||||
case r == '\\':
|
||||
b.WriteString(`\\`)
|
||||
case r == '\b':
|
||||
b.WriteString(`\b`)
|
||||
case r == '\f':
|
||||
b.WriteString(`\f`)
|
||||
case r == '\n':
|
||||
b.WriteString(`\n`)
|
||||
case r == '\r':
|
||||
b.WriteString(`\r`)
|
||||
case r == '\t':
|
||||
b.WriteString(`\t`)
|
||||
case r < 0x20:
|
||||
fmt.Fprintf(b, `\u%04x`, r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
func utf16Less(a, b string) bool {
|
||||
ua, ub := utf16Of(a), utf16Of(b)
|
||||
for i := 0; i < len(ua) && i < len(ub); i++ {
|
||||
if ua[i] != ub[i] {
|
||||
return ua[i] < ub[i]
|
||||
}
|
||||
}
|
||||
return len(ua) < len(ub)
|
||||
}
|
||||
|
||||
func utf16Of(s string) []uint16 {
|
||||
out := make([]uint16, 0, utf8.RuneCountInString(s))
|
||||
for _, r := range s {
|
||||
if r >= 0x10000 {
|
||||
r -= 0x10000
|
||||
out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF)))
|
||||
} else {
|
||||
out = append(out, uint16(r))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10).
|
||||
func Fingerprint(digest string) string {
|
||||
hex := strings.TrimPrefix(digest, "sha256:")
|
||||
if len(hex) > 24 {
|
||||
hex = hex[:24]
|
||||
}
|
||||
var groups []string
|
||||
for len(hex) > 4 {
|
||||
groups, hex = append(groups, hex[:4]), hex[4:]
|
||||
}
|
||||
return strings.Join(append(groups, hex), " ")
|
||||
}
|
||||
|
||||
// The bounds of the arguments shown in an ask (§11).
|
||||
const (
|
||||
ShownBytes, ShownLines = 2000, 40
|
||||
PartBytes, PartLines = 1200, 20
|
||||
)
|
||||
|
||||
// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole:
|
||||
// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at
|
||||
// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL.
|
||||
func shown(raw json.RawMessage) (string, bool) {
|
||||
args, _ := arguments(raw)
|
||||
names := make([]string, 0, len(args))
|
||||
for n := range args {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
whole := true
|
||||
var lines []string
|
||||
for _, n := range names {
|
||||
var value string
|
||||
if json.Unmarshal(args[n], &value) != nil {
|
||||
value = string(args[n])
|
||||
}
|
||||
if r, ok := outward.Check(value); !ok {
|
||||
value, whole = shapeOf(r), false
|
||||
}
|
||||
lines = append(lines, n+": "+value)
|
||||
}
|
||||
text := strings.Join(lines, "\n")
|
||||
if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines {
|
||||
if len(all) > PartLines {
|
||||
all = all[:PartLines]
|
||||
}
|
||||
text = strings.Join(all, "\n")
|
||||
if len(text) > PartBytes {
|
||||
text = cutBytes(text, PartBytes)
|
||||
}
|
||||
text += "\nNOT SHOWN IN FULL"
|
||||
whole = false
|
||||
}
|
||||
return text, whole
|
||||
}
|
||||
|
||||
// shapeOf is what an argument outward.Check refuses is shown as.
|
||||
func shapeOf(r outward.Refusal) string {
|
||||
switch r.Class {
|
||||
case "path":
|
||||
return "‹path›"
|
||||
case "address":
|
||||
return "‹address›"
|
||||
}
|
||||
return "‹withheld›"
|
||||
}
|
||||
|
||||
// cutBytes is s at most n bytes, cut at a character's end.
|
||||
func cutBytes(s string, n int) string {
|
||||
for n > 0 && !utf8.RuneStart(s[n]) {
|
||||
n--
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// mem is the runner's state approvals as a fixture (meshtest.State).
|
||||
type mem = mt.State
|
||||
|
||||
func newMem() *mem { return mt.NewState() }
|
||||
|
||||
// world is a gate on a fixture mesh: the catalog half, the router's records as the router writes them (mesh-catalog's
|
||||
// messenger, asks.go), the asks published, the clock.
|
||||
type world struct {
|
||||
g *Gate
|
||||
state *mem
|
||||
cat *fakeCatalog
|
||||
records map[string]seat.AsksByAskerIDValue // by `<module>.<id>`
|
||||
takes bool // the router takes an ask as it is published
|
||||
asked []seat.Ask
|
||||
now time.Time
|
||||
runs int
|
||||
findings *findings
|
||||
bounds *bounds
|
||||
// holders is the assigner's view of the seats' holders, by key; holdersErr says it is not answered yet.
|
||||
holders map[string][]byte
|
||||
holdersErr error
|
||||
// warranted is what mesh-hand-acts.warranted answers, and recorded each ask it answered for.
|
||||
warranted error
|
||||
recorded []string
|
||||
}
|
||||
|
||||
// bounds is the runner's key-ttl state bound as a fixture: expire hands a key's expiry to the watch.
|
||||
type bounds struct {
|
||||
keys map[string]time.Duration
|
||||
expired func(string)
|
||||
}
|
||||
|
||||
func (b *bounds) CreateTTL(key string, _ any, ttl time.Duration) error { b.keys[key] = ttl; return nil }
|
||||
func (b *bounds) Delete(key string) error { delete(b.keys, key); return nil }
|
||||
func (b *bounds) Watch(_ string, expired func(string)) (func(), error) {
|
||||
b.expired = expired
|
||||
return func() {}, nil
|
||||
}
|
||||
func (b *bounds) expire(key string) {
|
||||
delete(b.keys, key)
|
||||
b.expired(key)
|
||||
}
|
||||
|
||||
func newWorld() *world {
|
||||
shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}})
|
||||
w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{},
|
||||
bounds: &bounds{keys: map[string]time.Duration{}},
|
||||
holders: map[string][]byte{"operator-channel": []byte(`{"seat":"operator-channel","scope":"mesh","holders":[{"module":"messenger"}]}`)},
|
||||
now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC),
|
||||
cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell},
|
||||
newest: map[string]catalogseat.CatalogEntry{"shell": shell},
|
||||
defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell",
|
||||
Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}}
|
||||
w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, Bounds: w.bounds,
|
||||
Go: func(f func()) { f() },
|
||||
Holder: HoldersIn(func() (map[string][]byte, error) { return w.holders, w.holdersErr }),
|
||||
Warranted: func(asker, ask string, open func() (bool, error)) error {
|
||||
if still, _ := open(); !still {
|
||||
return nil
|
||||
}
|
||||
if w.warranted == nil {
|
||||
w.recorded = append(w.recorded, asker+"."+ask)
|
||||
}
|
||||
return w.warranted
|
||||
},
|
||||
Now: func() time.Time { return w.now },
|
||||
After: func(d time.Duration) <-chan time.Time {
|
||||
w.now = w.now.Add(d)
|
||||
c := make(chan time.Time, 1)
|
||||
c <- w.now
|
||||
return c
|
||||
},
|
||||
Publish: func(module, subject string, body any, id string) error {
|
||||
if subject != seat.AcceptSubjectAsk(module) {
|
||||
return nil
|
||||
}
|
||||
a := body.(seat.Ask)
|
||||
w.asked = append(w.asked, a)
|
||||
if w.takes {
|
||||
w.records[module+"."+a.ID] = seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open",
|
||||
Headline: a.Headline, Expires: a.Expires, Opened: operatorchannelclient.FormatTime(w.now), OfferedOn: []string{"telegram"}}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Record: func(module, id string) (*stdio.StateEntry, error) {
|
||||
rec, ok := w.records[module+"."+id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
raw, _ := json.Marshal(rec)
|
||||
return &stdio.StateEntry{Key: module + "." + id, Value: raw}, nil
|
||||
}})
|
||||
_ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"})
|
||||
return w
|
||||
}
|
||||
|
||||
// decide writes the router's record of the newest ask as the router writes a person's choice: its state and warrant.
|
||||
func (w *world) decide(option string, at time.Time) seat.Ask {
|
||||
a := w.asked[len(w.asked)-1]
|
||||
key := "shell." + a.ID
|
||||
rec := w.records[key]
|
||||
rec.State = "chosen"
|
||||
rec.Warrant = &seat.Warrant{Ask: a.ID, Asker: "shell", Outcome: operatorchannelclient.OutcomeChosen, Option: option,
|
||||
Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(at), AskDigest: operatorchannelclient.Digest(a),
|
||||
By: &seat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
w.records[key] = rec
|
||||
return a
|
||||
}
|
||||
|
||||
// do makes a call through the gate, counting the runs.
|
||||
func (w *world) do(c Call) (any, error) {
|
||||
return w.g.Run(c, func() (any, error) { w.runs++; return "done", nil })
|
||||
}
|
||||
|
||||
// call is a fixture call of the marked tool wipe, or of another tool of the module.
|
||||
func call(name, args string) Call {
|
||||
return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args),
|
||||
Description: "Wipes a scratch directory.\nMore words."}
|
||||
}
|
||||
|
||||
// theActAndItsAsk is §8's and §11's rows: the act's fields, and how an ask shows a call's arguments.
|
||||
func theActAndItsAsk(t *testing.T) {
|
||||
base := call("wipe", `{"dir":"scratch","n":1}`)
|
||||
digest := func(c Call) string {
|
||||
act, err := ActOf(c, "n1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, _ := operatorchannelclient.ActDigest(act)
|
||||
return d
|
||||
}
|
||||
act, _ := ActOf(base, "n1")
|
||||
want := operatorchannelclient.Act{"address": "shell.wipe", "node": "n1", "module": "shell", "tool": "wipe", "build": "src2:1",
|
||||
"arg.dir": "scratch", "arg.n": "1"}
|
||||
if len(act) != len(want) {
|
||||
t.Fatalf("act %v", act)
|
||||
}
|
||||
for k, v := range want {
|
||||
if act[k] != v {
|
||||
t.Errorf("act[%s] = %q, want %q", k, act[k], v)
|
||||
}
|
||||
}
|
||||
seen := map[string]string{digest(base): "the call"}
|
||||
for name, c := range map[string]Call{
|
||||
"an omitted argument": call("wipe", `{"dir":"scratch"}`),
|
||||
"another number": call("wipe", `{"dir":"scratch","n":2}`),
|
||||
"an object": call("wipe", `{"dir":{"name":"scratch"},"n":1}`),
|
||||
"a changed build": func() Call { c := base; c.Fingerprint = "src2:2"; return c }(),
|
||||
"another node's seat": {Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1"},
|
||||
} {
|
||||
d := digest(c)
|
||||
if other, dup := seen[d]; dup {
|
||||
t.Errorf("%s gives the digest of %s", name, other)
|
||||
}
|
||||
seen[d] = name
|
||||
}
|
||||
if got, _ := Canonical(json.RawMessage(`{"b":[1.0,2.5e-7,1e21],"a":"é<"}`)); got != `{"a":"é<","b":[1,2.5e-7,1e+21]}` {
|
||||
t.Errorf("canonical form %s", got)
|
||||
}
|
||||
// §11: a path, an address and a long argument are shown in their shape and cut.
|
||||
text, whole := shown(json.RawMessage(`{"dir":"/home/someone/scratch","host":"10.0.0.7","ok":"yes"}`))
|
||||
if whole || text != "dir: ‹path›\nhost: ‹address›\nok: yes" {
|
||||
t.Errorf("shown %q whole %v", text, whole)
|
||||
}
|
||||
long, _ := json.Marshal(map[string]string{"text": strings.Repeat("word ", 500)})
|
||||
if text, whole := shown(long); whole || !strings.HasSuffix(text, "NOT SHOWN IN FULL") || len(text) > PartBytes+len("\nNOT SHOWN IN FULL") {
|
||||
t.Errorf("a long argument shown whole %v, %d bytes", whole, len(text))
|
||||
}
|
||||
// §10: the headline, cut with … to 60 characters, ending Part shown.
|
||||
for _, tc := range []struct{ name, node, want string }{
|
||||
{"wipe", "n1", "Run wipe on n1?"},
|
||||
{strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"},
|
||||
{strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"},
|
||||
} {
|
||||
if got := headlineOf("Run", tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 {
|
||||
t.Errorf("headline %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := headlineOf("Run", "wipe", "n1", false); got != "Run wipe on n1? Part shown" {
|
||||
t.Errorf("headline %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Design 46 7-approval-mark §22: the refusals and answers of a marked call, on the fixture mesh, the router's record and
|
||||
// the warrant written as the router writes them.
|
||||
func TestTheRefusalsAndAnswers(t *testing.T) {
|
||||
t.Run("§8, §10, §11: the act's fields give each its digest, and the ask shows the arguments", theActAndItsAsk)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
run func(t *testing.T, w *world)
|
||||
}{
|
||||
{"§9: a secret in the arguments is refused asking nothing", func(t *testing.T, w *world) {
|
||||
_, err := w.g.firstCall(call("wipe", `{"token":"ghp_0123456789abcdefghijABCDEFGHIJ012345"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "which no ask may carry") || len(w.asked) != 0 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§9: more than 16 KiB of arguments is refused asking nothing", func(t *testing.T, w *world) {
|
||||
big, _ := json.Marshal(map[string]string{"text": strings.Repeat("a b ", 5000)})
|
||||
_, err := w.g.firstCall(call("wipe", string(big)))
|
||||
if err == nil || !strings.Contains(err.Error(), "more than the 16384") || len(w.asked) != 0 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§10, §12: the published ask's fields, and Not run with the router's record", func(t *testing.T, w *world) {
|
||||
_, err := w.g.firstCall(call("wipe", `{"dir":"scratch"}`))
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") ||
|
||||
!strings.Contains(err.Error(), "make the same call again within 30 minutes of the approval; the ask expires at ") {
|
||||
t.Fatalf("answer %v", err)
|
||||
}
|
||||
if len(w.asked) != 1 {
|
||||
t.Fatalf("asked %d", len(w.asked))
|
||||
}
|
||||
a := w.asked[0]
|
||||
act, _ := ActOf(call("wipe", `{"dir":"scratch"}`), "n1")
|
||||
d, _ := operatorchannelclient.ActDigest(act)
|
||||
if a.About != d || a.Who != "operator" || a.OnExpiry != OnExpiry || a.Headline != "Run wipe on n1?" ||
|
||||
a.Expires != operatorchannelclient.FormatTime(time.Date(2026, 11, 12, 11, 0, 0, 0, time.UTC)) || len(a.Options) != 2 ||
|
||||
a.Options[0].ID != "approve" || a.Options[1].ID != "decline" || a.Options[0].Binds != d || a.Options[1].Binds != d ||
|
||||
a.Options[1].Level != "approve" {
|
||||
t.Errorf("ask %+v", a)
|
||||
}
|
||||
if !strings.HasPrefix(a.Explanation, "Wipes a scratch directory.\nOn n1, called through the mesh's tools, which cannot tell who called.\ndir: scratch\nFingerprint ") {
|
||||
t.Errorf("explanation %q", a.Explanation)
|
||||
}
|
||||
var k operatorchannelclient.Kept
|
||||
_ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k)
|
||||
if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.Vals["n1.act."+d]) != `"`+a.ID+`"` {
|
||||
t.Errorf("kept %+v, act key %s", k, w.state.Vals["n1.act."+d])
|
||||
}
|
||||
}},
|
||||
{"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) {
|
||||
w.takes = false
|
||||
start := w.now
|
||||
_, err := w.g.firstCall(call("wipe", `{}`))
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > RecordWithin {
|
||||
t.Fatalf("answer %v after %s", err, w.now.Sub(start))
|
||||
}
|
||||
}},
|
||||
{"§10, §17: a second identical call publishes nothing and is told it still waits", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
|
||||
_, err := w.do(call("wipe", `{"dir":"scratch"}`))
|
||||
if len(w.asked) != 1 || err == nil || !strings.HasPrefix(err.Error(), "Not run: still waiting for the operator's answer") || w.runs != 0 {
|
||||
t.Fatalf("asked %d, %v, runs %d", len(w.asked), err, w.runs)
|
||||
}
|
||||
}},
|
||||
{"§17: an ask the router holds back as the fourth open one still waits", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
a := w.asked[0]
|
||||
rec := w.records["shell."+a.ID]
|
||||
rec.State, rec.HeldSince = "held-back", operatorchannelclient.FormatTime(w.now)
|
||||
w.records["shell."+a.ID] = rec
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "still waiting") || len(w.asked) != 1 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§14, §15: approved, the same call runs once; made again it is already run and asks anew", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
|
||||
w.decide(OptionApprove, w.now.Add(time.Minute))
|
||||
w.now = w.now.Add(2 * time.Minute)
|
||||
if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got.(Answered).Answer != "done" || w.runs != 1 ||
|
||||
!strings.HasPrefix(got.(Answered).Approval, "run on the operator's approval via telegram at 2026-11-12T10:01:00Z, ask ") {
|
||||
t.Fatalf("%v %v, runs %d", got, err, w.runs)
|
||||
}
|
||||
_, err := w.do(call("wipe", `{"dir":"scratch"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "was already run; asked again") || w.runs != 1 || len(w.asked) != 2 {
|
||||
t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§15: two calls at the same moment run it once", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
w.decide(OptionApprove, w.now)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
runs := 0
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = w.g.Run(call("wipe", `{}`), func() (any, error) { mu.Lock(); runs++; mu.Unlock(); return "done", nil })
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if runs != 1 {
|
||||
t.Fatalf("ran %d times", runs)
|
||||
}
|
||||
}},
|
||||
{"§14: approved for other arguments runs nothing", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
|
||||
a := w.decide(OptionApprove, w.now)
|
||||
rec := w.records["shell."+a.ID]
|
||||
other, _ := ActOf(call("wipe", `{"dir":"home"}`), "n1")
|
||||
d, _ := operatorchannelclient.ActDigest(other)
|
||||
a.Options[0].Binds = d // the ask the person was shown bound another act
|
||||
rec.Warrant.AskDigest = operatorchannelclient.Digest(a)
|
||||
w.records["shell."+a.ID] = rec
|
||||
var k operatorchannelclient.Kept
|
||||
_ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k)
|
||||
k.Ask = a
|
||||
raw, _ := json.Marshal(k)
|
||||
w.state.Vals["n1.ask."+a.ID] = raw
|
||||
if _, err := w.do(call("wipe", `{"dir":"scratch"}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "nothing is done") {
|
||||
t.Fatalf("%v, runs %d", err, w.runs)
|
||||
}
|
||||
}},
|
||||
{"§14: approved 31 minutes ago asks anew", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
w.decide(OptionApprove, w.now)
|
||||
w.now = w.now.Add(31 * time.Minute)
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "approved too long ago") || w.runs != 0 || len(w.asked) != 2 {
|
||||
t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§14, §17: declined, the same call is not asked again for 10 minutes", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
w.decide(OptionDecline, w.now)
|
||||
w.now = w.now.Add(9 * time.Minute)
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "declined") || len(w.asked) != 1 || w.runs != 0 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
w.now = w.now.Add(2 * time.Minute)
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "asked again") || len(w.asked) != 2 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§14, §17: expired asks anew", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
a := w.asked[0]
|
||||
rec := w.records["shell."+a.ID]
|
||||
rec.State = "expired"
|
||||
w.records["shell."+a.ID] = rec
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "ended expired; asked again") || len(w.asked) != 2 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§14: another asker's warrant runs nothing", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
a := w.decide(OptionApprove, w.now)
|
||||
rec := w.records["shell."+a.ID]
|
||||
rec.Warrant.Asker = "mail"
|
||||
w.records["shell."+a.ID] = rec
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "its warrant does not hold") {
|
||||
t.Fatalf("%v, runs %d", err, w.runs)
|
||||
}
|
||||
}},
|
||||
{"§17: the router's refusal is said with its words", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
a := w.asked[0]
|
||||
rec := w.records["shell."+a.ID]
|
||||
rec.State, rec.Answer = "refused", "the asker has too many open asks"
|
||||
w.records["shell."+a.ID] = rec
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the asker has too many open asks") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}},
|
||||
{"§20: a runner's own tool marked is refused unasked, never run", func(t *testing.T, w *world) {
|
||||
c := call("wipe", `{}`)
|
||||
c.Own = true
|
||||
if _, err := w.do(c); err == nil || w.runs != 0 || len(w.asked) != 0 {
|
||||
t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§13: with no binding, an unmarked call runs and a marked one is refused", func(t *testing.T, w *world) {
|
||||
w.g.d.State = nil
|
||||
if got, err := w.do(call("tidy", `{}`)); err != nil || got != "done" {
|
||||
t.Fatalf("unmarked: %v %v", got, err)
|
||||
}
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "binds no state approvals") || w.runs != 1 {
|
||||
t.Fatalf("marked: %v, runs %d", err, w.runs)
|
||||
}
|
||||
}},
|
||||
{"§13: an ask over 256 KiB is refused naming the cause", func(t *testing.T, w *world) {
|
||||
c := call("wipe", `{}`)
|
||||
c.Description = strings.Repeat("w", 300*1024)
|
||||
if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "more than the 262144 a value of the state approvals holds") || len(w.asked) != 0 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§3, §2: a seat verb its seat marks is asked for its holder", func(t *testing.T, w *world) {
|
||||
c := Call{Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1", Args: json.RawMessage(`{"command":"ls"}`)}
|
||||
if _, err := w.do(c); err == nil || !strings.HasPrefix(err.Error(), "Not run") || len(w.asked) != 1 || w.asked[0].Headline != "Run node-login-shell.execute on n1?" {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§16: the run is recorded once; unanswered within five minutes it is a finding, cleared when warranted answers", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
a := w.decide(OptionApprove, w.now)
|
||||
stop, _ := w.g.WatchBounds()
|
||||
defer stop()
|
||||
w.warranted = errors.New("no responders")
|
||||
if _, err := w.do(call("wipe", `{}`)); err != nil || w.runs != 1 || len(w.recorded) != 0 {
|
||||
t.Fatalf("%v, runs %d, recorded %v", err, w.runs, w.recorded)
|
||||
}
|
||||
if w.bounds.keys["n1.unanswered."+a.ID] != UnansweredAfter || w.bounds.keys["n1.unrecorded."+a.ID] != KeptFor-SaidBefore {
|
||||
t.Fatalf("bounds %v", w.bounds.keys)
|
||||
}
|
||||
w.bounds.expire("n1.unanswered." + a.ID)
|
||||
if w.findings.raised[keys.EscapeName("n1."+a.ID)] != KindCallUnanswered {
|
||||
t.Fatalf("findings %v", w.findings.raised)
|
||||
}
|
||||
w.warranted = nil
|
||||
// At a start each acted, unrecorded ask is pending in the runner's findings-stated until warranted is made again
|
||||
// (design 47 G §384).
|
||||
pendingKey := views.FindingKey("machine", keys.EscapeName("n1."+a.ID), KindCallUnanswered)
|
||||
warranted := w.g.d.Warranted
|
||||
var pendingAtCall bool
|
||||
w.g.d.Warranted = func(asker, ask string, open func() (bool, error)) error {
|
||||
_, pendingAtCall = w.findings.pends[pendingKey]
|
||||
return warranted(asker, ask, open)
|
||||
}
|
||||
w.g.Recover() // a start, or the hand-act seat's holder heard again
|
||||
w.g.Recover()
|
||||
if !pendingAtCall || len(w.findings.pended) != 1 || len(w.findings.pends) != 0 || w.findings.pended[0] != pendingKey {
|
||||
t.Fatalf("pending at the call %v, pended %v, pending after %v", pendingAtCall, w.findings.pended, w.findings.pends)
|
||||
}
|
||||
if len(w.recorded) != 1 || w.recorded[0] != "shell."+a.ID || len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 {
|
||||
t.Fatalf("recorded %v, findings %v, bounds %v", w.recorded, w.findings.raised, w.bounds.keys)
|
||||
}
|
||||
var k operatorchannelclient.Kept
|
||||
_ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k)
|
||||
if !k.Recorded || !k.Acted {
|
||||
t.Fatalf("kept %+v", k)
|
||||
}
|
||||
}},
|
||||
{"§19: with no holder of operator-channel in the view, a marked call is refused asking nothing", func(t *testing.T, w *world) {
|
||||
w.holders["operator-channel"] = []byte(`{"seat":"operator-channel","scope":"mesh","holders":[]}`)
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "no module holds operator-channel") || len(w.asked) != 0 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
w.holdersErr = errors.New("the first view has not been answered yet")
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || len(w.asked) != 0 {
|
||||
t.Fatalf("an unread view: %v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§19: a publish that fails and a record that cannot be read refuse, running nothing", func(t *testing.T, w *world) {
|
||||
pub := w.g.d.Publish
|
||||
w.g.d.Publish = func(string, string, any, string) error { return errors.New("the bus refused it") }
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the ask could not be raised") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
w.g.d.Publish = pub
|
||||
w.state.Vals = map[string]json.RawMessage{}
|
||||
_, _ = w.do(call("wipe", `{}`))
|
||||
w.g.d.Record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") }
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the router's record of its ask") || w.runs != 0 {
|
||||
t.Fatalf("%v, runs %d", err, w.runs)
|
||||
}
|
||||
}},
|
||||
{"§5, §19: a served entry that cannot be read refuses every marked call on that runtime", func(t *testing.T, w *world) {
|
||||
c := call("wipe", `{}`)
|
||||
c.Fingerprint = "src2:unknown"
|
||||
if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "cannot be read") || len(w.asked) != 0 {
|
||||
t.Fatalf("%v, asked %d", err, len(w.asked))
|
||||
}
|
||||
}},
|
||||
{"§19: the marks unread, a call waits within 5 seconds and is refused naming mesh_first_views", func(t *testing.T, w *world) {
|
||||
w.cat.ready = errors.New("the catalog half has no first view of main yet")
|
||||
waited := time.Duration(0)
|
||||
w.g.d.Wait = func(d time.Duration) error { waited = d; return w.cat.ready }
|
||||
if _, err := w.do(call("tidy", `{}`)); err == nil || !strings.Contains(err.Error(), "mesh_first_views") || waited != RecordWithin || w.runs != 0 {
|
||||
t.Fatalf("%v, waited %s", err, waited)
|
||||
}
|
||||
w.g.d.Wait = func(time.Duration) error { w.cat.ready = nil; return nil }
|
||||
if _, err := w.do(call("tidy", `{}`)); err != nil || w.runs != 1 {
|
||||
t.Fatalf("read within the wait: %v, runs %d", err, w.runs)
|
||||
}
|
||||
}},
|
||||
{"§19: the state failing refuses, raises approvals-unreadable after five minutes, and clears when read again", func(t *testing.T, w *world) {
|
||||
stop, _ := w.g.WatchBounds()
|
||||
defer stop()
|
||||
w.state.Fail = errors.New("the bus is unreachable")
|
||||
if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "cannot be read") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if w.bounds.keys["n1.unreadable.approvals"] != UnreadableAfter || len(w.findings.raised) != 0 {
|
||||
t.Fatalf("bounds %v, findings %v", w.bounds.keys, w.findings.raised)
|
||||
}
|
||||
w.bounds.expire("n1.unreadable.approvals")
|
||||
if w.findings.raised[keys.EscapeName("n1.approvals")] != KindApprovalsUnreadable {
|
||||
t.Fatalf("findings %v", w.findings.raised)
|
||||
}
|
||||
w.state.Fail = nil
|
||||
w.g.StateAgain() // a membership change heard, no further call
|
||||
if len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 {
|
||||
t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys)
|
||||
}
|
||||
}},
|
||||
{"§18: the asks of the last day, one read whole, the marks applied with their sources, a record again and a cancel", func(t *testing.T, w *world) {
|
||||
_, _ = w.do(call("wipe", `{"dir":"scratch"}`))
|
||||
list, err := w.g.List()
|
||||
if err != nil || len(list) != 1 || list[0].State != "asked" || list[0].Address != "shell.wipe" || list[0].Fingerprint == "" {
|
||||
t.Fatalf("%+v %v", list, err)
|
||||
}
|
||||
whole, err := w.g.Read(list[0].Ask)
|
||||
if err != nil || string(whole.Arguments) != `{"dir":"scratch"}` || !strings.HasPrefix(whole.Digest, "sha256:") {
|
||||
t.Fatalf("%+v %v", whole, err)
|
||||
}
|
||||
applied := w.g.Applied()
|
||||
if len(applied) != 1 || applied[0].Tools["wipe"] != "entry shell src2:1" || applied[0].Verbs["node-login-shell.execute"] != "seat node-login-shell" || applied[0].Revision != "7" {
|
||||
t.Fatalf("applied %+v", applied)
|
||||
}
|
||||
if ok, src := w.g.Describe("shell", "", "wipe"); !ok || src != "entry shell src2:1" {
|
||||
t.Errorf("describe %v %q", ok, src)
|
||||
}
|
||||
if ok, src := w.g.Describe("", "node-login-shell", "execute"); !ok || src != "seat node-login-shell" {
|
||||
t.Errorf("describe a seat verb %v %q", ok, src)
|
||||
}
|
||||
if err := w.g.Again(list[0].Ask); err == nil {
|
||||
t.Error("an ask not run was asked its hand-act again")
|
||||
}
|
||||
cancelled := ""
|
||||
w.g.d.Publish = func(module, subject string, body any, id string) error { cancelled = subject + " " + id; return nil }
|
||||
if err := w.g.Cancel(list[0].Ask); err != nil || cancelled != "mesh.seat.operator-channel.accept.cancel.shell cancel."+list[0].Ask {
|
||||
t.Fatalf("cancel %v %q", err, cancelled)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// StateName is the runner's own state its asks and claims are kept in, per machine (§13).
|
||||
const StateName = "approvals"
|
||||
|
||||
// Store is the runner's state `approvals` (§13): a key's value, a create that refuses a key that exists and an update by
|
||||
// compare-and-set, each failing with stdio.ErrChanged when another write came first; a put, and its keys.
|
||||
type Store interface {
|
||||
Get(key string) (*stdio.StateEntry, error)
|
||||
Create(key string, value any) (uint64, error)
|
||||
Update(key string, value any, revision uint64) (uint64, error)
|
||||
Put(key string, value any) (uint64, error)
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// The bounds of an ask (§10, §12, §13, §14, §17).
|
||||
const (
|
||||
AskLasts = 60 * time.Minute
|
||||
RecordWithin = 5 * time.Second
|
||||
RunWithin = 30 * time.Minute
|
||||
DeclinedFor = 10 * time.Minute
|
||||
MostKeptValue = 256 * 1024
|
||||
OnExpiry = "nothing runs; call again to ask anew"
|
||||
)
|
||||
|
||||
// The options every ask offers (§10), both at level approve, both binding the act's digest.
|
||||
const (
|
||||
OptionApprove = "approve"
|
||||
OptionDecline = "decline"
|
||||
)
|
||||
|
||||
// client is operator-channel's client asking under the marked module's name, its asks kept in the runner's state under
|
||||
// this machine's keys, its publish and the router's record on the runner's own account (§7, §10).
|
||||
func (g *Gate) client(module string) *operatorchannelclient.Client {
|
||||
return &operatorchannelclient.Client{Module: module, State: g.d.State, KeyPrefix: g.d.Node + ".", Now: g.d.Now,
|
||||
Publish: func(subject string, body any, id string) error { return g.d.Publish(module, subject, body, id) },
|
||||
Record: func(id string) (*stdio.StateEntry, error) { return g.d.Record(module, id) }}
|
||||
}
|
||||
|
||||
// actKey is the key naming the newest ask of an act (§13).
|
||||
func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + digest }
|
||||
|
||||
// askKey is the key the client keeps an ask under (§13).
|
||||
func (g *Gate) askKey(id string) string { return g.d.Node + ".ask." + id }
|
||||
|
||||
// newID is an ask's id: 128 random bits.
|
||||
func newID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// askFor is the ask a marked call raises (§10, §11).
|
||||
func (g *Gate) askFor(c Call, digest, id string) seat.Ask {
|
||||
args, whole := shown(c.Args)
|
||||
name := c.Name
|
||||
if c.Seat != "" {
|
||||
name = c.Seat + "." + c.Name
|
||||
}
|
||||
headline := headlineOf("Run", name, g.d.Node, whole)
|
||||
first, _, _ := strings.Cut(strings.TrimSpace(c.Description), "\n")
|
||||
if first == "" {
|
||||
first = name
|
||||
}
|
||||
lines := []string{first, "On " + g.d.Node + ", called through the mesh's tools, which cannot tell who called."}
|
||||
if args != "" {
|
||||
lines = append(lines, args)
|
||||
}
|
||||
if !whole {
|
||||
lines = append(lines, "The whole is read with mesh_approvals on "+g.d.Node+", whose fingerprint must be the one on the phone.")
|
||||
}
|
||||
lines = append(lines, "Fingerprint "+Fingerprint(digest))
|
||||
return seat.Ask{ID: id, About: digest, Who: operatorchannelclient.Operator, Headline: headline,
|
||||
Explanation: strings.Join(lines, "\n"), Expires: operatorchannelclient.FormatTime(g.d.Now().Add(AskLasts)), OnExpiry: OnExpiry,
|
||||
Options: []seat.Option{
|
||||
{ID: OptionApprove, Label: "Approve", Does: "the same call, made again within 30 minutes, runs once",
|
||||
Level: operatorchannelclient.Approve, Binds: digest},
|
||||
{ID: OptionDecline, Label: "Decline", Does: "nothing runs; the same call is not asked again for 10 minutes",
|
||||
Level: operatorchannelclient.Approve, Binds: digest}}}
|
||||
}
|
||||
|
||||
// headlineOf is "<verb> <names> on <node>?", "<verb> <names>?" when that is longer than the router allows, and the names cut
|
||||
// with "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). verb is "Run"
|
||||
// for a call and "Wait on" for a wait (design 34 every-event §35).
|
||||
func headlineOf(verb, name, node string, whole bool) string {
|
||||
suffix := ""
|
||||
if !whole {
|
||||
suffix = " Part shown"
|
||||
}
|
||||
max := operatorchannelclient.HeadlineLength
|
||||
fits := func(s string) bool { return len([]rune(s)) <= max }
|
||||
if h := verb + " " + name + " on " + node + "?" + suffix; fits(h) {
|
||||
return h
|
||||
}
|
||||
if h := verb + " " + name + "?" + suffix; fits(h) {
|
||||
return h
|
||||
}
|
||||
room := max - len([]rune(verb+" …?"+suffix))
|
||||
r := []rune(name)
|
||||
if room < 1 {
|
||||
room = 1
|
||||
}
|
||||
if len(r) > room {
|
||||
r = r[:room]
|
||||
}
|
||||
return verb + " " + string(r) + "…?" + suffix
|
||||
}
|
||||
|
||||
// firstCall is a marked call whose act has no ask open: refused at once when its arguments may not be asked (§9), else
|
||||
// asked (§10, §12).
|
||||
func (g *Gate) firstCall(c Call) (any, error) {
|
||||
if why := refusedUnasked(c.Args); why != "" {
|
||||
return nil, g.refused(c, "", why)
|
||||
}
|
||||
act, err := ActOf(c, g.d.Node)
|
||||
if err != nil {
|
||||
return nil, g.refused(c, "", err.Error())
|
||||
}
|
||||
digest, err := operatorchannelclient.ActDigest(act)
|
||||
if err != nil {
|
||||
return nil, g.refused(c, "", err.Error())
|
||||
}
|
||||
return nil, g.raise(c, act, digest, "")
|
||||
}
|
||||
|
||||
// raise raises the ask of a marked call whose act has none open (§10): kept in the runner's state with the act and the whole
|
||||
// arguments, published under the module's name, its act's key naming it; then the 5 seconds for the router's record and
|
||||
// the first answer (§12), always an error, never a result.
|
||||
func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince string) error {
|
||||
id := newID()
|
||||
if err := g.keepAsk(c, act, digest, g.askFor(c, digest, id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.firstAnswer(c, id, digest, markedSince)
|
||||
}
|
||||
|
||||
// keepAsk raises an ask and keeps it in the runner's state with the act and the whole arguments, its act's key naming it
|
||||
// (§10, §13); refused, as an error, when it cannot be.
|
||||
func (g *Gate) keepAsk(c Call, act operatorchannelclient.Act, digest string, a seat.Ask) error {
|
||||
cl := g.client(c.Module)
|
||||
if size := keptSize(a, act, c.Args); size > MostKeptValue {
|
||||
return g.refused(c, digest, fmt.Sprintf("its ask would keep %d bytes, more than the %d a value of the state %s holds", size, MostKeptValue, StateName))
|
||||
}
|
||||
if err := cl.Ask(a); err != nil {
|
||||
return g.refused(c, digest, "the ask could not be raised: "+err.Error())
|
||||
}
|
||||
if err := g.keepCall(a.ID, act, c.Args); err != nil {
|
||||
g.stateFailed(err)
|
||||
return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error())
|
||||
}
|
||||
if _, err := g.d.State.Put(g.actKey(digest), a.ID); err != nil {
|
||||
g.stateFailed(err)
|
||||
return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keptSize is the bytes the kept ask would take.
|
||||
func keptSize(a seat.Ask, act operatorchannelclient.Act, args json.RawMessage) int {
|
||||
raw, _ := json.Marshal(operatorchannelclient.Kept{Ask: a, Act: act, Arguments: args})
|
||||
return len(raw)
|
||||
}
|
||||
|
||||
// keepCall writes the act and the whole arguments beside the ask the client kept (§13).
|
||||
func (g *Gate) keepCall(id string, act operatorchannelclient.Act, args json.RawMessage) error {
|
||||
e, err := g.d.State.Get(g.askKey(id))
|
||||
if err != nil || e == nil {
|
||||
return fmt.Errorf("the kept ask %s cannot be read: %v", id, err)
|
||||
}
|
||||
var k operatorchannelclient.Kept
|
||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
||||
return err
|
||||
}
|
||||
k.Act, k.Arguments = act, args
|
||||
_, err = g.d.State.Update(g.askKey(id), k, e.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
// firstAnswer waits up to RecordWithin for the router's record of the ask and says the call was not run (§12).
|
||||
func (g *Gate) firstAnswer(c Call, id, digest, markedSince string) error {
|
||||
rec, _ := g.awaitRecord(c.Module, id)
|
||||
since := ""
|
||||
if markedSince != "" {
|
||||
since = "; marked since revision " + markedSince
|
||||
}
|
||||
if rec == nil {
|
||||
return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make the same call again later%s",
|
||||
id, Fingerprint(digest), since)
|
||||
}
|
||||
return fmt.Errorf("Not run: this needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make the same call "+
|
||||
"again within 30 minutes of the approval; the ask expires at %s%s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened,
|
||||
id, Fingerprint(digest), rec.Expires, since)
|
||||
}
|
||||
|
||||
// record is the router's record of an ask under the module's name, nil when it holds none.
|
||||
func (g *Gate) record(module, id string) (*seat.AsksByAskerIDValue, error) {
|
||||
e, err := g.d.Record(module, id)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var rec seat.AsksByAskerIDValue
|
||||
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
||||
return nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// awaitRecord reads the router's record of a new ask until it is there, within RecordWithin: the seat answers a key's
|
||||
// value and says nothing when the router takes an ask, so it is read again after a growing pause (§12).
|
||||
func (g *Gate) awaitRecord(module, id string) (*seat.AsksByAskerIDValue, error) {
|
||||
until := g.d.Now().Add(RecordWithin)
|
||||
pause := 50 * time.Millisecond
|
||||
for {
|
||||
rec, err := g.record(module, id)
|
||||
if rec != nil || g.d.Now().Add(pause).After(until) {
|
||||
return rec, err
|
||||
}
|
||||
<-g.d.After(pause)
|
||||
pause *= 2
|
||||
}
|
||||
}
|
||||
|
||||
// refused is a marked call refused, run nothing, written in the runner's log with the ask's fingerprint and never its
|
||||
// arguments (§19).
|
||||
func (g *Gate) refused(c Call, digest, why string) error {
|
||||
g.d.Logf("[mesh-tools] %s on %s is refused (fingerprint %s): %s", c.Address, g.d.Node, orNone(Fingerprint(digest)), why)
|
||||
return fmt.Errorf("Not run: %s needs the operator's approval and %s", c.Address, why)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
assignerseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
)
|
||||
|
||||
// AskedOn is the seat an ask is raised on, whose holder must be there for a marked call to be asked (§19).
|
||||
const AskedOn = "operator-channel"
|
||||
|
||||
// UnreadableAfter is how long the state approvals fails before the runner's finding says so (§19).
|
||||
const UnreadableAfter = 5 * time.Minute
|
||||
|
||||
// unreadableKey is the bound key whose expiry raises `approvals-unreadable` (lead answer A-157).
|
||||
func (g *Gate) unreadableKey() string { return g.d.Node + ".unreadable." + StateName }
|
||||
|
||||
// held says why no marked call can be asked now, or "" (§19): no module holds operator-channel, as the assigner's view
|
||||
// of the seat's holders says, or that view is not read.
|
||||
func (g *Gate) held() string {
|
||||
if g.d.Holder == nil {
|
||||
return "the tool runner reads no view of the seats' holders, so it cannot tell that a module holds " + AskedOn
|
||||
}
|
||||
ok, err := g.d.Holder(AskedOn)
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Sprintf("whether a module holds %s cannot be read: %v", AskedOn, err)
|
||||
case !ok:
|
||||
return "no module holds " + AskedOn + ", so nobody can be asked"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stateFailed notes that the state approvals could not be read or written: no marked call runs on this machine until it
|
||||
// can, the failed read or write is made again at the next marked call and at the next membership change (A-157), and
|
||||
// five minutes on the runner's finding says it (§19).
|
||||
func (g *Gate) stateFailed(err error) {
|
||||
g.mu.Lock()
|
||||
first := g.failingSince.IsZero()
|
||||
if first {
|
||||
g.failingSince = g.d.Now()
|
||||
}
|
||||
g.mu.Unlock()
|
||||
if first {
|
||||
g.d.Logf("[mesh-tools] the state %s on %s cannot be read or written (%v): no marked call runs here until it can", StateName, g.d.Node, err)
|
||||
if g.d.Bounds != nil {
|
||||
if e := g.d.Bounds.CreateTTL(g.unreadableKey(), map[string]string{"since": g.d.Now().UTC().Format(time.RFC3339)}, UnreadableAfter); e != nil {
|
||||
g.d.Logf("[mesh-tools] the bound of the state %s's failure is not kept: %v", StateName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stateRead notes that the state approvals was read or written: a failure noted before ends, its finding cleared.
|
||||
func (g *Gate) stateRead() {
|
||||
g.mu.Lock()
|
||||
was := !g.failingSince.IsZero()
|
||||
g.failingSince = time.Time{}
|
||||
g.mu.Unlock()
|
||||
if !was {
|
||||
return
|
||||
}
|
||||
if g.d.Bounds != nil {
|
||||
_ = g.d.Bounds.Delete(g.unreadableKey())
|
||||
}
|
||||
g.finding(StateName, KindApprovalsUnreadable, false, "the state "+StateName+" is read again", "")
|
||||
}
|
||||
|
||||
// failing says the state approvals is failing now.
|
||||
func (g *Gate) failing() bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return !g.failingSince.IsZero()
|
||||
}
|
||||
|
||||
// StateAgain makes the failed read of the state approvals again: at the next membership change the runner hears (A-157).
|
||||
func (g *Gate) StateAgain() {
|
||||
if g.d.State == nil || !g.failing() {
|
||||
return
|
||||
}
|
||||
if _, err := g.d.State.Keys(); err != nil {
|
||||
return
|
||||
}
|
||||
g.stateRead()
|
||||
}
|
||||
|
||||
// unreadableExpired is the bound key of a failing state expiring: the finding raised while the state still fails.
|
||||
func (g *Gate) unreadableExpired() {
|
||||
g.mu.Lock()
|
||||
since := g.failingSince
|
||||
g.mu.Unlock()
|
||||
if since.IsZero() {
|
||||
return
|
||||
}
|
||||
g.finding(StateName, KindApprovalsUnreadable, true, fmt.Sprintf("the state %s on %s cannot be read or written since %s: no marked call "+
|
||||
"runs here until it can", StateName, g.d.Node, since.UTC().Format(time.RFC3339)), "approvals unreadable on "+g.d.Node)
|
||||
}
|
||||
|
||||
// HoldersIn is Holder over the assigner's view of the seats' holders as the runner holds it (§19): view answers the view's
|
||||
// values by key, an error while its first view is not answered; a seat is held when its key names a holder.
|
||||
func HoldersIn(view func() (map[string][]byte, error)) func(seat string) (bool, error) {
|
||||
return func(seat string) (bool, error) {
|
||||
values, err := view()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
raw, ok := values[assignerseat.SeatHoldersBySeatKey(seat)]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
var h assignerseat.SeatHolders
|
||||
if err := json.Unmarshal(raw, &h); err != nil {
|
||||
return false, fmt.Errorf("the holders of %s cannot be read: %w", seat, err)
|
||||
}
|
||||
return len(h.Holders) > 0, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The runner's findings of the gate (design 46 7-approval-mark §4, §16, §19), written through its held writer.
|
||||
const (
|
||||
KindMarkUnserved = "mark-unserved"
|
||||
KindCallUnanswered = "call-unanswered"
|
||||
KindApprovalsUnreadable = "approvals-unreadable"
|
||||
)
|
||||
|
||||
// Kinds are the finding kinds the gate writes through the runner's writer.
|
||||
var Kinds = []string{KindMarkUnserved, KindCallUnanswered, KindApprovalsUnreadable}
|
||||
|
||||
// FindingWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding).
|
||||
type FindingWriter interface {
|
||||
Write(ctx context.Context, finding condseat.Finding) error
|
||||
Clear(ctx context.Context, scope, id, kind, why string) error
|
||||
}
|
||||
|
||||
// Deps is what the gate reaches the mesh through: the runner's connection in its one place, fakes in a test.
|
||||
type Deps struct {
|
||||
// Node is the machine the runner serves; Runner its own module's name, the subject of its findings.
|
||||
Node, Runner string
|
||||
// Catalog is the catalog half the marks are read from.
|
||||
Catalog Catalog
|
||||
// Wait waits, within the bound, for the catalog half's first view, and is nil once it is read or why not.
|
||||
Wait func(within time.Duration) error
|
||||
// Findings is the runner's held writer; nil says a finding in the log only.
|
||||
Findings FindingWriter
|
||||
Logf func(string, ...any)
|
||||
Now func() time.Time
|
||||
// After is the clock's pause; nil is time.After.
|
||||
After func(time.Duration) <-chan time.Time
|
||||
// State is the runner's state approvals; nil when its membership binds none, so every marked call is refused (§13).
|
||||
State Store
|
||||
// Publish says an accept on a seat under the module's name, on the runner's own account (§7).
|
||||
Publish func(module, subject string, body any, id string) error
|
||||
// Record reads the router's record of an ask, `<module>.<id>` in the seat's state asks, on the runner's own account (§14).
|
||||
Record func(module, id string) (*stdio.StateEntry, error)
|
||||
// Warranted asks `mesh-hand-acts.warranted` {asker, ask}, each try a kept call open while open says so (§16, A-157).
|
||||
Warranted func(asker, ask string, open func() (bool, error)) error
|
||||
// Bounds is the runner's key-ttl state bound (A-157, A-158); nil keeps no bound.
|
||||
Bounds Bounds
|
||||
// Holder says whether a module holds a seat, from the assigner's view of the seats' holders the runner follows (§19).
|
||||
Holder func(seat string) (bool, error)
|
||||
// Build is the tool runner's own fingerprint, read from its MESH_FINGERPRINT at its start: the build a marked wait's
|
||||
// act binds; empty while unknown, when every marked wait is refused (design 34 every-event §35).
|
||||
Build string
|
||||
// Hear waits within the bound for the first event on the subjects whose body match takes, or the caller's going: a
|
||||
// marked wait's ask's `decided` (every-event §36); false when none came.
|
||||
Hear func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error)
|
||||
// Go runs what goes on beside the call (a run's recording); nil is a goroutine.
|
||||
Go func(func())
|
||||
}
|
||||
|
||||
// Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20).
|
||||
type Gate struct {
|
||||
d Deps
|
||||
mu sync.Mutex
|
||||
// registered is each module's tools as its bundle registered them at its last load.
|
||||
registered map[string]map[string]bool
|
||||
// unserved is each module whose marks name a tool its bundle does not register, with those tools.
|
||||
unserved map[string][]string
|
||||
// unmarkedAt is each caller's address it last called unmarked, so a mark added since is named (§12).
|
||||
unmarkedAt map[string]bool
|
||||
// inFlight is each ask whose recording runs now.
|
||||
inFlight map[string]bool
|
||||
// failingSince is when the state approvals began to fail; zero while it is read.
|
||||
failingSince time.Time
|
||||
// served is each module this runner serves, with the build its bundle serves, as its last load said (§18).
|
||||
served map[string]string
|
||||
}
|
||||
|
||||
// New is a gate over d.
|
||||
func New(d Deps) *Gate {
|
||||
if d.Logf == nil {
|
||||
d.Logf = func(string, ...any) {}
|
||||
}
|
||||
if d.Now == nil {
|
||||
d.Now = time.Now
|
||||
}
|
||||
if d.After == nil {
|
||||
d.After = time.After
|
||||
}
|
||||
if d.Go == nil {
|
||||
d.Go = func(f func()) { go f() }
|
||||
}
|
||||
return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}}
|
||||
}
|
||||
|
||||
// Unserved is, of the module's marked tools, those its bundle does not register (§4): such a bundle is not loaded, its
|
||||
// finding `mark-unserved` raised; a bundle that serves them, or marks that no longer name them, clear it. Before the marks
|
||||
// are read nothing is known, and every marked call is refused until they are (§19).
|
||||
func (g *Gate) Unserved(module, fingerprint string, registered []string) []string {
|
||||
set := map[string]bool{}
|
||||
for _, t := range registered {
|
||||
set[t] = true
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.registered[module] = set
|
||||
if g.served == nil {
|
||||
g.served = map[string]string{}
|
||||
}
|
||||
g.served[module] = fingerprint
|
||||
g.mu.Unlock()
|
||||
if g.d.Catalog == nil || g.d.Catalog.Ready() != nil {
|
||||
return nil
|
||||
}
|
||||
return g.checkServed(Derive(g.d.Catalog, module, fingerprint))
|
||||
}
|
||||
|
||||
// checkServed raises or clears the module's `mark-unserved` as its marks and its bundle's registrations stand now, and is
|
||||
// the tools it marks and does not register.
|
||||
func (g *Gate) checkServed(m Marks) []string {
|
||||
g.mu.Lock()
|
||||
set := g.registered[m.Module]
|
||||
var missing []string
|
||||
for t := range m.Tools {
|
||||
if !set[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
was := g.unserved[m.Module]
|
||||
if len(missing) > 0 {
|
||||
g.unserved[m.Module] = missing
|
||||
} else {
|
||||
delete(g.unserved, m.Module)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
switch {
|
||||
case len(missing) > 0 && strings.Join(was, ",") != strings.Join(missing, ","):
|
||||
g.finding(m.Module, KindMarkUnserved, true, fmt.Sprintf("%s's marks (%s, catalog revision %s) name %s, which its bundle on %s does not "+
|
||||
"register: no tool of %s is served here", m.Module, sourceOf(m, missing[0]), m.Revision, strings.Join(missing, ", "), g.d.Node, m.Module),
|
||||
fmt.Sprintf("%s is not served on %s", m.Module, g.d.Node))
|
||||
case len(missing) == 0 && len(was) > 0:
|
||||
g.finding(m.Module, KindMarkUnserved, false, "a bundle that serves every marked tool, or marks without the tool, is loaded", "")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func sourceOf(m Marks, tool string) string { return m.Tools[tool] }
|
||||
|
||||
// finding writes or clears one of the gate's findings: scope machine, its id `<node>.<what>` escaped as a key's name is,
|
||||
// about the runner's module on this machine (lead answer A-154).
|
||||
func (g *Gate) finding(what, kind string, raised bool, summary, headline string) {
|
||||
id := keys.EscapeName(g.d.Node + "." + what)
|
||||
if g.d.Findings == nil {
|
||||
g.d.Logf("[mesh-tools] %s %s: %s", kind, id, summary)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if raised {
|
||||
err = g.d.Findings.Write(context.Background(), condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id,
|
||||
Severity: condseat.FindingSeverityWarning, Source: "approval", Observer: g.d.Runner,
|
||||
Subject: condseat.Subject{Scope: "module", ID: g.d.Runner, Machine: g.d.Node}, Summary: summary, Headline: headline,
|
||||
At: g.d.Now().UTC()})
|
||||
} else {
|
||||
err = g.d.Findings.Clear(context.Background(), scopeMachine, id, kind, summary)
|
||||
}
|
||||
if err != nil {
|
||||
g.d.Logf("[mesh-tools] the finding %s of %s is not kept: %v", kind, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const scopeMachine = "machine"
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package approval is the tool runner's gate on a call that needs the operator's approval (design 46 7-approval-mark): the
|
||||
// marks it reads from the catalog half, the act it digests, the ask it raises on the seat operator-channel under the
|
||||
// marked module's name, the judging of the same call again on the router's record, the claim, the recording in the
|
||||
// hand-act log, and the refusals when a part of the check is missing (§5, §8 to §20). No module's name is written here.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
)
|
||||
|
||||
// Catalog is what the marks are read from: the catalog half the runner follows (design 46 7-approval-mark §5, design 47 G
|
||||
// §196, §443) and the seat definitions it carries (§2).
|
||||
type Catalog interface {
|
||||
// Ready is nil once the catalog half's first view is read, else says what it waits for.
|
||||
Ready() error
|
||||
// Revision is the catalog revision the marks are read at, as the runner names it.
|
||||
Revision() string
|
||||
// Served is the entry of the build a bundle serves, by the fingerprint its composed environment carries; false when
|
||||
// none is read (not yet, or not found), with why.
|
||||
Served(fingerprint string) (catalogseat.CatalogEntry, bool, string)
|
||||
// Newest is the module's entry at the newest revision; false when the catalog half lists none.
|
||||
Newest(module string) (catalogseat.CatalogEntry, bool)
|
||||
// Definitions are the seat definitions the catalog half carries, by seat.
|
||||
Definitions() map[string]seatdef.Definition
|
||||
// Entries are the newest entry of every module in every followed branch's half, a marked wait's marks (lead answer
|
||||
// A-168).
|
||||
Entries() []catalogseat.CatalogEntry
|
||||
}
|
||||
|
||||
// Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and
|
||||
// restricted streams, each with its source (§18: its module's entry in the catalog half, or its seat's definition).
|
||||
type Marks struct {
|
||||
Module string
|
||||
// Tools are its marked tools by name, Verbs its marked seat verbs as `<seat>.<verb>`, Subjects the bus subjects of its
|
||||
// marked events and restricted streams (design 34 every-event §33); each to its source.
|
||||
Tools, Verbs, Subjects map[string]string
|
||||
// Revision is the catalog revision read; Served the served entry's fingerprint, empty when it could not be read.
|
||||
Revision, Served string
|
||||
// Unread says why the served entry could not be read: every marked call is then refused (§5, §19).
|
||||
Unread string
|
||||
}
|
||||
|
||||
// Marked says whether a tool or a seat verb of the module needs the operator's approval, and its source.
|
||||
func (m Marks) Marked(seat, name string) (bool, string) {
|
||||
if seat == "" {
|
||||
src, ok := m.Tools[name]
|
||||
return ok, src
|
||||
}
|
||||
src, ok := m.Verbs[seat+"."+name]
|
||||
return ok, src
|
||||
}
|
||||
|
||||
// Any says the module marks anything at all.
|
||||
func (m Marks) Any() bool { return len(m.Tools)+len(m.Verbs)+len(m.Subjects) > 0 }
|
||||
|
||||
// marked is what an entry's manifest says of its marks: `needs-approval`, its events and its streams.
|
||||
type marked struct {
|
||||
NeedsApproval []string `json:"needs-approval"`
|
||||
Emits []string `json:"emits"`
|
||||
// Streams are read for their events and whether each carries a `grant`, and no other word, so a word added to a
|
||||
// stream's declaration later never hides its marks.
|
||||
Streams []struct {
|
||||
Events []string `json:"events"`
|
||||
Grant json.RawMessage `json:"grant"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// Derive is a module's marks from its served entry and its newest one, unioned while they differ so a mark is never lost
|
||||
// before or after the module moves (§5, ADR 0286 §3), and the seat definitions' verb marks for every verb an entry serves,
|
||||
// which no holder unmarks (§2, §3). fingerprint is the build its bundle serves; empty, or an entry not read, leaves the
|
||||
// marks of the newest entry and the seats, with Unread saying why.
|
||||
func Derive(c Catalog, module, fingerprint string) Marks {
|
||||
m := Marks{Module: module, Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{},
|
||||
Revision: c.Revision()}
|
||||
defs := c.Definitions()
|
||||
var entries []catalogseat.CatalogEntry
|
||||
if fingerprint == "" {
|
||||
m.Unread = "its bundle's environment carries no fingerprint, so the build it serves cannot be read"
|
||||
} else if e, ok, why := c.Served(fingerprint); ok {
|
||||
entries, m.Served = append(entries, e), fingerprint
|
||||
} else {
|
||||
m.Unread = fmt.Sprintf("the catalog half's entry of %s cannot be read: %s", fingerprint, why)
|
||||
}
|
||||
if e, ok := c.Newest(module); ok && (len(entries) == 0 || e.Fp != entries[0].Fp) {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
for _, e := range entries {
|
||||
m.add(e, defs)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// add takes one entry's marks into m: its `needs-approval` (a tool, a verb it serves or an event it declares), the seat
|
||||
// definitions' marks of every verb it serves, and every stream it declares with a `grant`, each with its source.
|
||||
func (m *Marks) add(e catalogseat.CatalogEntry, defs map[string]seatdef.Definition) {
|
||||
source := "entry " + e.Module + " " + orNone(e.Fp)
|
||||
var said marked
|
||||
if raw, err := json.Marshal(e.Manifest); err == nil {
|
||||
_ = json.Unmarshal(raw, &said)
|
||||
}
|
||||
serves := map[string]bool{}
|
||||
for _, sv := range e.Serves {
|
||||
serves[sv] = true
|
||||
seat, verb, _ := strings.Cut(sv, ".")
|
||||
for _, v := range defs[seat].Verbs {
|
||||
if v.Name == verb && v.NeedsApproval {
|
||||
m.Verbs[sv] = "seat " + seat
|
||||
}
|
||||
}
|
||||
}
|
||||
events := map[string]bool{}
|
||||
for _, ev := range said.Emits {
|
||||
events[ev] = true
|
||||
}
|
||||
for _, s := range said.Streams {
|
||||
for _, ev := range s.Events {
|
||||
events[ev] = true
|
||||
}
|
||||
}
|
||||
for _, name := range said.NeedsApproval {
|
||||
switch {
|
||||
case serves[name]:
|
||||
if _, bySeat := m.Verbs[name]; !bySeat {
|
||||
m.Verbs[name] = source
|
||||
}
|
||||
case events[name]:
|
||||
for _, s := range eventSubjects(e.Module, name) {
|
||||
m.Subjects[s] = source
|
||||
}
|
||||
default:
|
||||
m.Tools[name] = source
|
||||
}
|
||||
}
|
||||
for _, s := range said.Streams {
|
||||
if len(s.Grant) == 0 || string(s.Grant) == "null" {
|
||||
continue
|
||||
}
|
||||
wire := make([]string, 0, len(s.Events))
|
||||
for _, ev := range s.Events {
|
||||
wire = append(wire, buscompose.WirePattern(ev))
|
||||
}
|
||||
for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() {
|
||||
m.Subjects[sub] = source
|
||||
if !strings.HasSuffix(sub, ">") {
|
||||
m.Subjects[sub+".>"] = source
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its
|
||||
// own stream, each also with every subject below it.
|
||||
func eventSubjects(module, event string) []string {
|
||||
ev := buscompose.WirePattern(event)
|
||||
out := []string{"mesh.mod." + module + ".event." + ev, buscompose.ModuleStreamSubject(module, ev)}
|
||||
if !strings.HasSuffix(ev, ">") {
|
||||
out = append(out, out[0]+".>", out[1]+".>")
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// keysOf is a set's names, sorted; nil when empty.
|
||||
func keysOf(set map[string]string) []string {
|
||||
var out []string
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
)
|
||||
|
||||
// fakeCatalog is a catalog half as a fixture: entries by fingerprint, the newest by module, the seat definitions.
|
||||
type fakeCatalog struct {
|
||||
ready error
|
||||
revision string
|
||||
byFp map[string]catalogseat.CatalogEntry
|
||||
newest map[string]catalogseat.CatalogEntry
|
||||
defs map[string]seatdef.Definition
|
||||
}
|
||||
|
||||
func (c *fakeCatalog) Ready() error { return c.ready }
|
||||
func (c *fakeCatalog) Revision() string { return c.revision }
|
||||
func (c *fakeCatalog) Served(fp string) (catalogseat.CatalogEntry, bool, string) {
|
||||
e, ok := c.byFp[fp]
|
||||
return e, ok, "not-found"
|
||||
}
|
||||
func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) {
|
||||
e, ok := c.newest[m]
|
||||
return e, ok
|
||||
}
|
||||
func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs }
|
||||
func (c *fakeCatalog) Entries() []catalogseat.CatalogEntry {
|
||||
var out []catalogseat.CatalogEntry
|
||||
for _, e := range c.newest {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry {
|
||||
return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest}
|
||||
}
|
||||
|
||||
// Design 46 7-approval-mark §5, §3, §4 and design 34 every-event §33: the marks a module's calls are gated by, derived from
|
||||
// fixture catalog halves and served entries.
|
||||
func TestTheMarks(t *testing.T) {
|
||||
login := map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell",
|
||||
Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}, {Name: "sessions"}}}}
|
||||
v1 := entry("src2:1", []string{"node-login-shell.execute", "node-login-shell.sessions"},
|
||||
map[string]any{"needs-approval": []any{"wipe"}})
|
||||
v2 := entry("src2:2", []string{"node-login-shell.execute", "node-login-shell.sessions"},
|
||||
map[string]any{"needs-approval": []any{"erase", "node-login-shell.sessions"}})
|
||||
mail := catalogseat.CatalogEntry{Module: "mail", Fp: "src2:m", Manifest: map[string]any{
|
||||
"needs-approval": []any{"mail.received"}, "emits": []any{"mail.received"},
|
||||
"streams": []any{map[string]any{"name": "MAIL", "events": []any{"mail.**"}, "grant": map[string]any{"config": "readers", "events": "mail.{key}.**"}}}}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cat *fakeCatalog
|
||||
module string
|
||||
fp string
|
||||
tools []string
|
||||
verbs map[string]string
|
||||
subjects []string
|
||||
unread bool
|
||||
}{
|
||||
{name: "a seat's mark stands for every holder, and the holder's own adds up", cat: &fakeCatalog{revision: "7",
|
||||
byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login},
|
||||
module: "shell", fp: "src2:1", tools: []string{"wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell"}},
|
||||
{name: "a holder's needs-approval cannot unmark its seat's verb, and marks another", cat: &fakeCatalog{revision: "8",
|
||||
byFp: map[string]catalogseat.CatalogEntry{"src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login},
|
||||
module: "shell", fp: "src2:2", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell",
|
||||
"node-login-shell.sessions": "entry shell src2:2"}},
|
||||
{name: "across a version change the union stands: a dropped mark is asked while the old bundle runs", cat: &fakeCatalog{revision: "9",
|
||||
byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1, "src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login},
|
||||
module: "shell", fp: "src2:1", tools: []string{"erase", "wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell",
|
||||
"node-login-shell.sessions": "entry shell src2:2"}},
|
||||
{name: "a served entry that cannot be read leaves the newest marks, every marked call refused", cat: &fakeCatalog{revision: "9",
|
||||
newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login},
|
||||
module: "shell", fp: "src2:1", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell",
|
||||
"node-login-shell.sessions": "entry shell src2:2"}, unread: true},
|
||||
{name: "a bundle with no fingerprint has no served entry", cat: &fakeCatalog{revision: "9", defs: login},
|
||||
module: "shell", unread: true},
|
||||
{name: "a marked event and a stream with a grant give their subjects and each .>", cat: &fakeCatalog{revision: "3",
|
||||
byFp: map[string]catalogseat.CatalogEntry{"src2:m": mail}, newest: map[string]catalogseat.CatalogEntry{"mail": mail}},
|
||||
module: "mail", fp: "src2:m", subjects: []string{"mesh.mod.mail.event.mail.received", "mesh.mod.mail.event.mail.received.>",
|
||||
"mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := Derive(tc.cat, tc.module, tc.fp)
|
||||
if got := keysOf(m.Tools); !reflect.DeepEqual(got, tc.tools) {
|
||||
t.Errorf("tools %v, want %v", got, tc.tools)
|
||||
}
|
||||
if tc.verbs == nil {
|
||||
tc.verbs = map[string]string{}
|
||||
}
|
||||
if !reflect.DeepEqual(m.Verbs, tc.verbs) {
|
||||
t.Errorf("verbs %v, want %v", m.Verbs, tc.verbs)
|
||||
}
|
||||
if got := keysOf(m.Subjects); !reflect.DeepEqual(got, tc.subjects) {
|
||||
t.Errorf("subjects %v, want %v", got, tc.subjects)
|
||||
}
|
||||
if (m.Unread != "") != tc.unread || m.Revision != tc.cat.revision {
|
||||
t.Errorf("unread %q, revision %q", m.Unread, m.Revision)
|
||||
}
|
||||
})
|
||||
}
|
||||
// §4: a bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised; a bundle that
|
||||
// serves it clears it, and a catalog without the mark too.
|
||||
t.Run("mark-unserved raised and cleared", func(t *testing.T) {
|
||||
cat := &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login}
|
||||
w := &findings{}
|
||||
g := New(Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: w})
|
||||
if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != KindMarkUnserved {
|
||||
t.Fatalf("unserved %v, findings %v", got, w.raised)
|
||||
}
|
||||
if got := g.Unserved("shell", "src2:1", []string{"wipe"}); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" {
|
||||
t.Fatalf("a bundle serving the mark: unserved %v, findings %v", got, w.raised)
|
||||
}
|
||||
_ = g.Unserved("shell", "src2:1", nil)
|
||||
cat.byFp["src2:1"], cat.newest["shell"] = entry("src2:1", nil, nil), entry("src2:1", nil, nil)
|
||||
if got := g.Unserved("shell", "src2:1", nil); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" {
|
||||
t.Fatalf("a catalog without the mark: unserved %v, findings %v", got, w.raised)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// findings is the runner's held writer as a fixture: each finding raised now, by id.
|
||||
type findings struct {
|
||||
raised map[string]string
|
||||
// pends are the keys pending in the held writer's statement; pended every key ever pended.
|
||||
pends map[string]views.PendingKey
|
||||
pended []string
|
||||
}
|
||||
|
||||
func (f *findings) Pend(p views.PendingKey) {
|
||||
if f.pends == nil {
|
||||
f.pends = map[string]views.PendingKey{}
|
||||
}
|
||||
f.pends[p.Key] = p
|
||||
f.pended = append(f.pended, p.Key)
|
||||
}
|
||||
func (f *findings) Judged(key string) { delete(f.pends, key) }
|
||||
|
||||
func (f *findings) Write(_ context.Context, x condseat.Finding) error {
|
||||
if f.raised == nil {
|
||||
f.raised = map[string]string{}
|
||||
}
|
||||
f.raised[x.ID] = x.Kind
|
||||
return nil
|
||||
}
|
||||
func (f *findings) Clear(_ context.Context, _, id, _, _ string) error {
|
||||
delete(f.raised, id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// The bounds of a run's recording (§13, §16; lead answers A-157, A-158).
|
||||
const (
|
||||
UnansweredAfter = 5 * time.Minute
|
||||
KeptFor = 48 * time.Hour
|
||||
SaidBefore = time.Hour
|
||||
)
|
||||
|
||||
// Bounds is the runner's key-ttl state `bound` (lead answers A-157, A-158): a key created with its own time to live, which
|
||||
// the bus removes when it passes, every watch hearing the removal as an expiry, so the gate's waits need no timer.
|
||||
type Bounds interface {
|
||||
CreateTTL(key string, value any, ttl time.Duration) error
|
||||
Delete(key string) error
|
||||
// Watch hands expired every key under the pattern whose time to live passed, until the stop is called.
|
||||
Watch(pattern string, expired func(key string)) (stop func(), err error)
|
||||
}
|
||||
|
||||
// Answered is a call run on the operator's approval: the tool's own answer and the line naming the approval (§16), in the
|
||||
// form a process holder's `gated-ended` says it (design 47 G §677; lead answer A-158).
|
||||
type Answered struct {
|
||||
Answer any `json:"answer"`
|
||||
Approval string `json:"approval"`
|
||||
}
|
||||
|
||||
// The bound keys of a run (lead answer A-158; §13).
|
||||
func (g *Gate) unansweredKey(id string) string { return g.d.Node + ".unanswered." + id }
|
||||
func (g *Gate) unrecordedKey(id string) string { return g.d.Node + ".unrecorded." + id }
|
||||
|
||||
// ran is a call run on the operator's approval: answered with the tool's answer and the approval's line, and its warrant
|
||||
// recorded in the hand-act log (§16).
|
||||
func (g *Gate) ran(c Call, id string, rec *seat.AsksByAskerIDValue, answer any, err error) (any, error) {
|
||||
channel := ""
|
||||
at := ""
|
||||
if w := rec.Warrant; w != nil {
|
||||
channel, at = w.Channel, w.At
|
||||
}
|
||||
if channel == "" {
|
||||
channel = strings.Join(rec.OfferedOn, ", ")
|
||||
}
|
||||
line := fmt.Sprintf("run on the operator's approval via %s at %s, ask %s", orNone(channel), at, id)
|
||||
g.d.Logf("[mesh-tools] %s on %s %s", c.Address, g.d.Node, line)
|
||||
g.bindRecording(c.Module, id)
|
||||
g.d.Go(func() { g.recordRun(c.Module, id) })
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (%s)", err, line)
|
||||
}
|
||||
return Answered{Answer: answer, Approval: line}, nil
|
||||
}
|
||||
|
||||
// bindRecording keeps the run's two bound keys: one that raises `call-unanswered` when `warranted` has not answered within
|
||||
// five minutes, one that says an unrecorded run an hour before its key's 48 hours end (§13, §16).
|
||||
func (g *Gate) bindRecording(module, id string) {
|
||||
if g.d.Bounds == nil {
|
||||
return
|
||||
}
|
||||
for key, ttl := range map[string]time.Duration{g.unansweredKey(id): UnansweredAfter, g.unrecordedKey(id): KeptFor - SaidBefore} {
|
||||
if err := g.d.Bounds.CreateTTL(key, map[string]string{"asker": module, "ask": id}, ttl); err != nil {
|
||||
g.d.Logf("[mesh-tools] the bound %s of the run of ask %s is not kept: %v", key, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordRun calls `mesh-hand-acts.warranted` for one acted ask, each try a kept call open while the ask lacks `recorded`
|
||||
// (§16, A-157), and on its answer writes `recorded` and clears what waited on it. One recording per ask at a time.
|
||||
func (g *Gate) recordRun(module, id string) {
|
||||
if !g.recording(id, true) {
|
||||
return
|
||||
}
|
||||
defer g.recording(id, false)
|
||||
if g.d.Warranted == nil {
|
||||
g.d.Logf("[mesh-tools] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id)
|
||||
return
|
||||
}
|
||||
err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) })
|
||||
g.judged(id) // made again: its answer, or the bound of its key, judges it from now
|
||||
if err != nil {
|
||||
g.d.Logf("[mesh-tools] mesh-hand-acts.warranted has not answered for ask %s (%v): asked again when the hand-act seat's "+
|
||||
"holder is heard again and at the next start", id, err)
|
||||
return
|
||||
}
|
||||
if err := g.markRecorded(id); err != nil {
|
||||
g.d.Logf("[mesh-tools] ask %s is recorded in the hand-act log and its mark recorded is not kept: %v", id, err)
|
||||
return
|
||||
}
|
||||
if g.d.Bounds != nil {
|
||||
_ = g.d.Bounds.Delete(g.unansweredKey(id))
|
||||
_ = g.d.Bounds.Delete(g.unrecordedKey(id))
|
||||
}
|
||||
g.finding(id, KindCallUnanswered, false, "mesh-hand-acts.warranted answered for ask "+id, "")
|
||||
}
|
||||
|
||||
// recording claims or releases an ask's recording: false when one runs already.
|
||||
func (g *Gate) recording(id string, start bool) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.inFlight == nil {
|
||||
g.inFlight = map[string]bool{}
|
||||
}
|
||||
if start {
|
||||
if g.inFlight[id] {
|
||||
return false
|
||||
}
|
||||
g.inFlight[id] = true
|
||||
return true
|
||||
}
|
||||
delete(g.inFlight, id)
|
||||
return true
|
||||
}
|
||||
|
||||
// kept is the ask the client keeps under its id, with its revision; nil when there is none.
|
||||
func (g *Gate) kept(id string) (*operatorchannelclient.Kept, uint64, error) {
|
||||
e, err := g.d.State.Get(g.askKey(id))
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var k operatorchannelclient.Kept
|
||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return &k, e.Revision, nil
|
||||
}
|
||||
|
||||
// unrecorded says the ask is acted on and its run not yet recorded: the key that keeps `warranted` open (§16).
|
||||
func (g *Gate) unrecorded(id string) (bool, error) {
|
||||
k, _, err := g.kept(id)
|
||||
if err != nil || k == nil {
|
||||
return false, err
|
||||
}
|
||||
return k.Acted && !k.Recorded, nil
|
||||
}
|
||||
|
||||
// markRecorded writes `recorded` on the kept ask by compare-and-set, again when another write came first.
|
||||
func (g *Gate) markRecorded(id string) error {
|
||||
for {
|
||||
k, rev, err := g.kept(id)
|
||||
if err != nil || k == nil {
|
||||
return fmt.Errorf("the kept ask cannot be read: %v", err)
|
||||
}
|
||||
if k.Recorded {
|
||||
return nil
|
||||
}
|
||||
k.Recorded = true
|
||||
if _, err := g.d.State.Update(g.askKey(id), k, rev); err == nil {
|
||||
return nil
|
||||
} else if !isChanged(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recover calls `warranted` again for every acted, unrecorded ask this machine's keys hold within their 48 hours (§16): at
|
||||
// each start, and when the hand-act seat's holder is heard again (A-157).
|
||||
func (g *Gate) Recover() {
|
||||
if g.d.State == nil {
|
||||
return
|
||||
}
|
||||
keys, err := g.d.State.Keys()
|
||||
if err != nil {
|
||||
g.d.Logf("[mesh-tools] the acted, unrecorded asks cannot be read (%v): read again at the next start or holder heard", err)
|
||||
return
|
||||
}
|
||||
prefix := g.d.Node + ".ask."
|
||||
for _, key := range keys {
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimPrefix(key, prefix)
|
||||
k, _, err := g.kept(id)
|
||||
if err != nil || k == nil || !k.Acted || k.Recorded || g.d.Now().Sub(k.Asked) > KeptFor {
|
||||
continue
|
||||
}
|
||||
module := moduleOf(k)
|
||||
g.pend(id)
|
||||
g.d.Go(func() { g.recordRun(module, id) })
|
||||
}
|
||||
}
|
||||
|
||||
// moduleOf is the module an ask was asked under: the warrant's asker.
|
||||
func moduleOf(k *operatorchannelclient.Kept) string {
|
||||
if k.Answer != nil {
|
||||
return k.Answer.Asker
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WatchBounds hears the run's bound keys expire (A-157): `call-unanswered` raised when `warranted` has not answered within
|
||||
// five minutes; an unrecorded run said in the log an hour before its key's 48 hours end (§13).
|
||||
func (g *Gate) WatchBounds() (func(), error) {
|
||||
if g.d.Bounds == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
return g.d.Bounds.Watch(g.d.Node+".**", func(key string) {
|
||||
if key == g.unreadableKey() {
|
||||
g.unreadableExpired()
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(key, g.d.Node+".")
|
||||
kind, id, _ := strings.Cut(rest, ".")
|
||||
if open, err := g.unrecorded(id); err != nil || !open {
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case "unanswered":
|
||||
g.finding(id, KindCallUnanswered, true, fmt.Sprintf("mesh-hand-acts.warranted has not answered for ask %s, run on the "+
|
||||
"operator's approval on %s, within %s: the run is not yet in the hand-act log", id, g.d.Node, UnansweredAfter),
|
||||
"a run's hand-act is unrecorded on "+g.d.Node)
|
||||
case "unrecorded":
|
||||
g.d.Logf("[mesh-tools] ERROR the run of ask %s on %s is still unrecorded an hour before its key's 48 hours end", id, g.d.Node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pender is the runner's held writer's pending (views.HeldWriter's Pend and Judged): the keys it cannot judge yet since its
|
||||
// start, named in its `findings-stated` (design 47 G §384).
|
||||
type Pender interface {
|
||||
Pend(views.PendingKey)
|
||||
Judged(key string)
|
||||
}
|
||||
|
||||
// pend names an acted, unrecorded ask's `call-unanswered` in the runner's `findings-stated` until its `warranted` is made
|
||||
// again (§16; design 47 G §384), its bound the five minutes the finding waits.
|
||||
func (g *Gate) pend(id string) {
|
||||
if p, ok := g.d.Findings.(Pender); ok {
|
||||
p.Pend(views.PendingKey{Key: g.unansweredFinding(id), Since: g.d.Now().UTC(), Bound: UnansweredAfter.String()})
|
||||
}
|
||||
}
|
||||
|
||||
// judged ends that pending.
|
||||
func (g *Gate) judged(id string) {
|
||||
if p, ok := g.d.Findings.(Pender); ok {
|
||||
p.Judged(g.unansweredFinding(id))
|
||||
}
|
||||
}
|
||||
|
||||
// unansweredFinding is the key of an ask's `call-unanswered` in the runner's state findings (lead answer A-154).
|
||||
func (g *Gate) unansweredFinding(id string) string {
|
||||
return views.FindingKey(scopeMachine, keys.EscapeName(g.d.Node+"."+id), KindCallUnanswered)
|
||||
}
|
||||
|
||||
// isChanged says a compare-and-set lost to another write.
|
||||
func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) }
|
||||
@@ -0,0 +1,184 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// Run is a call through the gate (§20): an unmarked call runs; a marked one runs only on the operator's approval of that
|
||||
// one call, judged on the router's record and claimed once (§14, §15), and is otherwise answered as an error saying why
|
||||
// (§12, §17, §19). run is called at most once.
|
||||
func (g *Gate) Run(c Call, run func() (any, error)) (any, error) {
|
||||
m, err := g.marksOf(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if missing := g.unservedOf(c.Module); len(missing) > 0 {
|
||||
return nil, fmt.Errorf("Not run: %s's marks name %s, which its bundle on %s does not register, so none of its tools is "+
|
||||
"served here (design 46 7-approval-mark §4)", c.Module, strings.Join(missing, ", "), g.d.Node)
|
||||
}
|
||||
marked, _ := m.Marked(c.Seat, c.Name)
|
||||
since := g.markedSince(c, marked, m.Revision)
|
||||
if !marked {
|
||||
return run()
|
||||
}
|
||||
if c.Own {
|
||||
return nil, g.refused(c, "", "it is one of the tool runner's own tools, which no ask covers")
|
||||
}
|
||||
if m.Unread != "" {
|
||||
return nil, g.refused(c, "", m.Unread)
|
||||
}
|
||||
if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() {
|
||||
return nil, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in")
|
||||
}
|
||||
if why := refusedUnasked(c.Args); why != "" {
|
||||
return nil, g.refused(c, "", why)
|
||||
}
|
||||
if why := g.held(); why != "" {
|
||||
return nil, g.refused(c, "", why)
|
||||
}
|
||||
act, err := ActOf(c, g.d.Node)
|
||||
if err != nil {
|
||||
return nil, g.refused(c, "", err.Error())
|
||||
}
|
||||
digest, err := operatorchannelclient.ActDigest(act)
|
||||
if err != nil {
|
||||
return nil, g.refused(c, "", err.Error())
|
||||
}
|
||||
named, err := g.d.State.Get(g.actKey(digest))
|
||||
if err != nil {
|
||||
g.stateFailed(err)
|
||||
return nil, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error())
|
||||
}
|
||||
g.stateRead()
|
||||
if named == nil {
|
||||
return nil, g.raise(c, act, digest, since)
|
||||
}
|
||||
var id string
|
||||
if err := json.Unmarshal(named.Value, &id); err != nil || id == "" {
|
||||
return nil, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName))
|
||||
}
|
||||
return g.judge(c, act, digest, id, since, run)
|
||||
}
|
||||
|
||||
// Binding is a Store that says whether the runner's membership binds the state now (§13).
|
||||
type Binding interface{ Bound() bool }
|
||||
|
||||
// marksOf is the module's marks as the catalog half has them now; before its first view, the call waits for it within
|
||||
// RecordWithin and is then judged, refused naming mesh_first_views when it is still unread (§19).
|
||||
func (g *Gate) marksOf(c Call) (Marks, error) {
|
||||
if g.d.Catalog == nil {
|
||||
return Marks{}, g.refused(c, "", "the tool runner reads no catalog half, so its marks are not read")
|
||||
}
|
||||
if err := g.d.Catalog.Ready(); err != nil {
|
||||
if g.d.Wait != nil {
|
||||
err = g.d.Wait(RecordWithin)
|
||||
}
|
||||
if err != nil {
|
||||
return Marks{}, fmt.Errorf("Not run: the marks of %s are not read yet (%v); mesh_first_views says what the tool runner "+
|
||||
"waits for", c.Module, err)
|
||||
}
|
||||
}
|
||||
m := Derive(g.d.Catalog, c.Module, c.Fingerprint)
|
||||
g.checkServed(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (g *Gate) unservedOf(module string) []string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.unserved[module]
|
||||
}
|
||||
|
||||
// markedSince is the revision a call was marked at when the same caller last called it unmarked (§12), empty otherwise.
|
||||
func (g *Gate) markedSince(c Call, marked bool, revision string) string {
|
||||
key := c.Caller + " " + c.Address
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.unmarkedAt == nil {
|
||||
g.unmarkedAt = map[string]bool{}
|
||||
}
|
||||
was := g.unmarkedAt[key]
|
||||
if marked {
|
||||
delete(g.unmarkedAt, key)
|
||||
if was {
|
||||
return revision
|
||||
}
|
||||
return ""
|
||||
}
|
||||
g.unmarkedAt[key] = true
|
||||
return ""
|
||||
}
|
||||
|
||||
// judge is the same call made again, judged on the router's own record of its act's newest ask (§14 to §17).
|
||||
func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since string, run func() (any, error)) (any, error) {
|
||||
rec, err := g.record(c.Module, id)
|
||||
if err != nil {
|
||||
return nil, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error())
|
||||
}
|
||||
if rec == nil {
|
||||
return nil, g.firstAnswer(c, id, digest, since) // §17: not yet taken by the router
|
||||
}
|
||||
again := func(why string) (any, error) {
|
||||
err := g.raise(c, act, digest, since)
|
||||
return nil, fmt.Errorf("The ask %s %s; asked again. %w", id, why, err)
|
||||
}
|
||||
switch rec.State {
|
||||
case "open", "held-back":
|
||||
return nil, fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s",
|
||||
id, Fingerprint(digest), rec.Expires)
|
||||
case "refused":
|
||||
return nil, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer))
|
||||
case "chosen":
|
||||
default: // expired, cancelled, replaced: each asks again (§15, §17)
|
||||
return again("ended " + rec.State)
|
||||
}
|
||||
w := rec.Warrant
|
||||
if w == nil {
|
||||
return nil, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant")
|
||||
}
|
||||
at, _ := operatorchannelclient.Time(w.At)
|
||||
if w.Option == OptionDecline {
|
||||
if until := at.Add(DeclinedFor); g.d.Now().Before(until) {
|
||||
return nil, fmt.Errorf("Not run: the operator declined ask %s; the same call is not asked again until %s",
|
||||
id, operatorchannelclient.FormatTime(until))
|
||||
}
|
||||
return again("was declined more than 10 minutes ago")
|
||||
}
|
||||
if g.d.Now().After(at.Add(RunWithin)) {
|
||||
return again("was approved too long ago (more than 30 minutes)")
|
||||
}
|
||||
var answer any
|
||||
var ran error
|
||||
h, err := g.client(c.Module).Take(*w, func(o seat.Option, _ seat.Warrant) error {
|
||||
if err := operatorchannelclient.Performs(o, act); err != nil {
|
||||
ran = err
|
||||
return err
|
||||
}
|
||||
answer, ran = run()
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case h == operatorchannelclient.Done:
|
||||
return again("was already run")
|
||||
case h == operatorchannelclient.Refused:
|
||||
return nil, g.refused(c, digest, "its warrant does not hold: "+errText(err))
|
||||
case h == "" && err != nil:
|
||||
g.stateFailed(err)
|
||||
return nil, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error())
|
||||
case err != nil: // the act about to run is not the one the option bound: nothing runs, the warrant stays used
|
||||
return nil, g.refused(c, digest, err.Error())
|
||||
}
|
||||
return g.ran(c, id, rec, answer, ran)
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return "no reason given"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The mesh MCP server's mesh_approvals reads and steers what the gate asked on this machine (§18).
|
||||
|
||||
// Listed is one ask of the last day as mesh_approvals lists it: its state and its act's fingerprint.
|
||||
type Listed struct {
|
||||
Ask string `json:"ask"`
|
||||
Address string `json:"address"`
|
||||
Module string `json:"module"`
|
||||
Asked time.Time `json:"asked"`
|
||||
State string `json:"state"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// Whole is one ask read whole: its arguments and the act's digest.
|
||||
type Whole struct {
|
||||
Listed
|
||||
Digest string `json:"digest"`
|
||||
Act map[string]string `json:"act"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
Recorded bool `json:"recorded"`
|
||||
}
|
||||
|
||||
// stateOf is a kept ask's state as the runner knows it.
|
||||
func stateOf(acted, recorded bool, ended string) string {
|
||||
switch {
|
||||
case acted && recorded:
|
||||
return "run, recorded"
|
||||
case acted:
|
||||
return "run, not yet recorded"
|
||||
case ended != "":
|
||||
return ended
|
||||
}
|
||||
return "asked"
|
||||
}
|
||||
|
||||
// List is the calls asked on this machine in the last day, newest first (§18).
|
||||
func (g *Gate) List() ([]Listed, error) {
|
||||
if g.d.State == nil {
|
||||
return nil, fmt.Errorf("the tool runner's membership binds no state %s", StateName)
|
||||
}
|
||||
keys, err := g.d.State.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := g.d.Node + ".ask."
|
||||
out := []Listed{}
|
||||
for _, key := range keys {
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
w, err := g.Read(strings.TrimPrefix(key, prefix))
|
||||
if err != nil || g.d.Now().Sub(w.Asked) > 24*time.Hour {
|
||||
continue
|
||||
}
|
||||
out = append(out, w.Listed)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Asked.After(out[j].Asked) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Read is one ask whole: its arguments and the act's digest (§18).
|
||||
func (g *Gate) Read(id string) (Whole, error) {
|
||||
if g.d.State == nil {
|
||||
return Whole{}, fmt.Errorf("the tool runner's membership binds no state %s", StateName)
|
||||
}
|
||||
k, _, err := g.kept(id)
|
||||
if err != nil {
|
||||
return Whole{}, err
|
||||
}
|
||||
if k == nil {
|
||||
return Whole{}, fmt.Errorf("no ask %s is kept on %s", id, g.d.Node)
|
||||
}
|
||||
module := k.Act["module"]
|
||||
if module == "" {
|
||||
module = moduleOf(k)
|
||||
}
|
||||
return Whole{Listed: Listed{Ask: id, Address: k.Act["address"], Module: module, Asked: k.Asked,
|
||||
State: stateOf(k.Acted, k.Recorded, k.Ended), Fingerprint: Fingerprint(k.Ask.About)},
|
||||
Digest: k.Ask.About, Act: k.Act, Arguments: k.Arguments, Recorded: k.Recorded}, nil
|
||||
}
|
||||
|
||||
// Applied are the marks the gate applies on this machine, each module's marked tools and verbs with their source and the
|
||||
// catalog revision read (§18).
|
||||
func (g *Gate) Applied() []Marks {
|
||||
g.mu.Lock()
|
||||
modules := make([]string, 0, len(g.served))
|
||||
for m := range g.served {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
sort.Strings(modules)
|
||||
var out []Marks
|
||||
if g.d.Catalog == nil {
|
||||
return out
|
||||
}
|
||||
for _, m := range modules {
|
||||
if marks := Derive(g.d.Catalog, m, g.servedFingerprint(m)); marks.Any() {
|
||||
out = append(out, marks)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Gate) servedFingerprint(module string) string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.served[module]
|
||||
}
|
||||
|
||||
// Describe says whether a tool or a seat verb of a module is marked, and its source, for mesh_describe (§18): the served
|
||||
// build's marks where this machine serves the module, else the newest entry's and its seat's.
|
||||
func (g *Gate) Describe(module, seat, name string) (bool, string) {
|
||||
if g.d.Catalog == nil || g.d.Catalog.Ready() != nil {
|
||||
return false, ""
|
||||
}
|
||||
if seat != "" && module == "" {
|
||||
for s, d := range g.d.Catalog.Definitions() {
|
||||
if s != seat {
|
||||
continue
|
||||
}
|
||||
for _, v := range d.Verbs {
|
||||
if v.Name == name && v.NeedsApproval {
|
||||
return true, "seat " + seat
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
return Derive(g.d.Catalog, module, g.servedFingerprint(module)).Marked(seat, name)
|
||||
}
|
||||
|
||||
// Again asks `warranted` again for one acted, unrecorded ask, by hand, in the kept-call form of §16 (§18); the caller
|
||||
// records the hand act first.
|
||||
func (g *Gate) Again(id string) error {
|
||||
w, err := g.Read(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(w.State, "run, not yet") {
|
||||
return fmt.Errorf("the ask %s is %s: only a run not yet recorded is asked again", id, w.State)
|
||||
}
|
||||
g.recordRun(w.Module, id)
|
||||
if again, _ := g.Read(id); !again.Recorded {
|
||||
return fmt.Errorf("mesh-hand-acts.warranted has not answered for ask %s yet", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel takes an open ask back through operator-channel's client (§18); the caller records the hand act first.
|
||||
func (g *Gate) Cancel(id string) error {
|
||||
w, err := g.Read(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.State != "asked" {
|
||||
return fmt.Errorf("the ask %s is %s, not open", id, w.State)
|
||||
}
|
||||
return g.client(w.Module).Cancel(id)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// Wait is one mesh_wait call as the gate judges it (design 34 every-event §34 to §36): the event names given, the subjects
|
||||
// they became (after `from`), the filter and the timeout.
|
||||
type Wait struct {
|
||||
Names []string
|
||||
From string
|
||||
Subjects []string
|
||||
Filter map[string]any
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Waited is a wait through the gate (every-event §34 to §36). An unmarked wait is heard at once by hear, given how long it
|
||||
// may wait. A marked one is heard only on the operator's warrant for that one wait, its timeout running from then; with
|
||||
// none it subscribes to nothing, its ask raised once under the runner's own module's name, and is answered as an error at
|
||||
// its timeout, when its caller goes, or as soon as the router's record says the ask was chosen, never in a hot loop. c is
|
||||
// the call as the console makes it: its address, its tool's name, its caller and its arguments as given.
|
||||
func (g *Gate) Waited(ctx context.Context, c Call, w Wait, hear func(within time.Duration) (any, error)) (any, error) {
|
||||
deadline := g.d.Now().Add(w.Timeout)
|
||||
// A wait from a seat is never marked, and a seat's event never is (§32, §34).
|
||||
if w.From == "seat" || !outsideSeats(w.Subjects) {
|
||||
return hear(w.Timeout)
|
||||
}
|
||||
if g.d.Catalog == nil {
|
||||
return nil, g.holdRefused(ctx, deadline, "this wait is refused: the tool runner reads no catalog half, so the marks are not read")
|
||||
}
|
||||
if err := g.d.Catalog.Ready(); err != nil {
|
||||
// Before the marks are read nothing is asked: the wait waits for the first view within its own timeout and is
|
||||
// then judged, and is refused at its timeout when it does not come (§34).
|
||||
if g.d.Wait != nil {
|
||||
err = g.waitCatalog(ctx, w.Timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, g.holdRefused(ctx, deadline, fmt.Sprintf("this wait is refused at its timeout: the marks are not yet read (%v); "+
|
||||
"mesh_first_views says what the tool runner waits for", err))
|
||||
}
|
||||
}
|
||||
if !overlapsAny(w.Subjects, g.waitMarks()) {
|
||||
return hear(deadline.Sub(g.d.Now()))
|
||||
}
|
||||
c.Module, c.Seat, c.Fingerprint = g.d.Runner, "", g.d.Build
|
||||
if c.Fingerprint == "" {
|
||||
return nil, g.holdRefused(ctx, deadline, "this wait needs the operator's approval, and the tool runner's own fingerprint is "+
|
||||
"unknown (its MESH_FINGERPRINT was not set at its start), so no warrant can bind its build")
|
||||
}
|
||||
if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in").Error())
|
||||
}
|
||||
if why := refusedUnasked(c.Args); why != "" {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error())
|
||||
}
|
||||
if why := g.held(); why != "" {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error())
|
||||
}
|
||||
act, err := ActOf(c, g.d.Node)
|
||||
if err != nil {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error())
|
||||
}
|
||||
digest, err := operatorchannelclient.ActDigest(act)
|
||||
if err != nil {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error())
|
||||
}
|
||||
named, err := g.d.State.Get(g.actKey(digest))
|
||||
if err != nil {
|
||||
g.stateFailed(err)
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()).Error())
|
||||
}
|
||||
g.stateRead()
|
||||
anew := func() (any, error) {
|
||||
id := newID()
|
||||
if err := g.keepAsk(c, act, digest, g.askForWait(c, w, digest, id)); err != nil {
|
||||
return nil, g.holdRefused(ctx, deadline, err.Error())
|
||||
}
|
||||
return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, true)
|
||||
}
|
||||
if named == nil {
|
||||
return anew()
|
||||
}
|
||||
var id string
|
||||
if err := json.Unmarshal(named.Value, &id); err != nil || id == "" {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)).Error())
|
||||
}
|
||||
rec, err := g.record(c.Module, id)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()).Error())
|
||||
case rec == nil, rec.State == "open", rec.State == "held-back":
|
||||
return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, false)
|
||||
case rec.State == "refused":
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)).Error())
|
||||
case rec.State != "chosen":
|
||||
return anew() // expired, cancelled, replaced: asked again (§15, §17)
|
||||
}
|
||||
wt := rec.Warrant
|
||||
if wt == nil {
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant").Error())
|
||||
}
|
||||
at, _ := operatorchannelclient.Time(wt.At)
|
||||
if wt.Option == OptionDecline {
|
||||
if g.d.Now().Before(at.Add(DeclinedFor)) {
|
||||
return nil, g.holdRefused(ctx, deadline, declinedWords(id, at))
|
||||
}
|
||||
return anew()
|
||||
}
|
||||
if g.d.Now().After(at.Add(RunWithin)) {
|
||||
return anew()
|
||||
}
|
||||
var answer any
|
||||
var ran error
|
||||
h, err := g.client(c.Module).Take(*wt, func(o seat.Option, _ seat.Warrant) error {
|
||||
if err := operatorchannelclient.Performs(o, act); err != nil {
|
||||
ran = err
|
||||
return err
|
||||
}
|
||||
// On the warrant it subscribes, and the wait's timeout runs from then (§35).
|
||||
answer, ran = hear(w.Timeout)
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case h == operatorchannelclient.Done:
|
||||
return anew() // already run: one warrant is one wait (§35)
|
||||
case h == operatorchannelclient.Refused:
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its warrant does not hold: "+errText(err)).Error())
|
||||
case h == "" && err != nil:
|
||||
g.stateFailed(err)
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()).Error())
|
||||
case err != nil:
|
||||
return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, err.Error()).Error())
|
||||
}
|
||||
return g.ran(c, id, rec, answer, ran)
|
||||
}
|
||||
|
||||
// outsideSeats says a subject is not a seat's: only such a subject can be marked (§32, §34).
|
||||
func outsideSeats(subjects []string) bool {
|
||||
for _, s := range subjects {
|
||||
if !strings.HasPrefix(s, "mesh.seat.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitMarks are the marked subjects of every module the catalog half lists (§33): the newest entry of every followed
|
||||
// branch, and the served entry of each module this runner serves (lead answer A-168), so a mark is never lost.
|
||||
func (g *Gate) waitMarks() map[string]string {
|
||||
m := Marks{Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}}
|
||||
defs := g.d.Catalog.Definitions()
|
||||
for _, e := range g.d.Catalog.Entries() {
|
||||
m.add(e, defs)
|
||||
}
|
||||
g.mu.Lock()
|
||||
served := make(map[string]string, len(g.served))
|
||||
for module, fp := range g.served {
|
||||
served[module] = fp
|
||||
}
|
||||
g.mu.Unlock()
|
||||
for _, fp := range served {
|
||||
if e, ok, _ := g.d.Catalog.Served(fp); ok {
|
||||
m.add(e, defs)
|
||||
}
|
||||
}
|
||||
return m.Subjects
|
||||
}
|
||||
|
||||
// overlapsAny says a subject a wait subscribes overlaps a marked subject (§34).
|
||||
func overlapsAny(subjects []string, marked map[string]string) bool {
|
||||
for _, s := range subjects {
|
||||
for mk := range marked {
|
||||
if buscompose.Overlaps(s, mk) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitCatalog waits for the catalog half's first view within the bound, or until the caller goes.
|
||||
func (g *Gate) waitCatalog(ctx context.Context, within time.Duration) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- g.d.Wait(within) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// pause waits until the deadline or the caller's going.
|
||||
func (g *Gate) pause(ctx context.Context, deadline time.Time) {
|
||||
if left := deadline.Sub(g.d.Now()); left > 0 {
|
||||
select {
|
||||
case <-g.d.After(left):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// holdRefused is a refused marked wait, said at its timeout or when its caller goes, never at once, so a watcher's loop
|
||||
// is never a hot one (§36).
|
||||
func (g *Gate) holdRefused(ctx context.Context, deadline time.Time, words string) error {
|
||||
g.pause(ctx, deadline)
|
||||
if !strings.HasPrefix(words, "Not run") {
|
||||
words = "Not run: " + words
|
||||
}
|
||||
return fmt.Errorf("%s", words)
|
||||
}
|
||||
|
||||
// holdAsk holds a marked wait whose ask is open, subscribing to nothing but its ask's `decided` (§36): it answers, as an
|
||||
// error, at its timeout, when its caller goes, or once the router's record says the ask was chosen, the event only waking it.
|
||||
func (g *Gate) holdAsk(ctx context.Context, deadline time.Time, module, id, digest string, first bool) error {
|
||||
for {
|
||||
left := deadline.Sub(g.d.Now())
|
||||
heard := false
|
||||
if left > 0 && ctx.Err() == nil {
|
||||
var err error
|
||||
if g.d.Hear != nil {
|
||||
heard, err = g.d.Hear(ctx, []string{seat.SubjectDecided(module)}, left, func(body json.RawMessage) bool {
|
||||
var w seat.Warrant
|
||||
return json.Unmarshal(body, &w) == nil && w.Ask == id
|
||||
})
|
||||
}
|
||||
if g.d.Hear == nil || err != nil {
|
||||
g.pause(ctx, deadline)
|
||||
}
|
||||
}
|
||||
rec, _ := g.record(module, id)
|
||||
if heard && (rec == nil || rec.State != "chosen") {
|
||||
continue // the event only wakes it: the record says nothing chosen yet
|
||||
}
|
||||
return g.waitWords(rec, id, digest, first)
|
||||
}
|
||||
}
|
||||
|
||||
// waitWords are §12's and §17's words for a marked wait not run, "this wait" for "the call" (§36).
|
||||
func (g *Gate) waitWords(rec *seat.AsksByAskerIDValue, id, digest string, first bool) error {
|
||||
switch {
|
||||
case rec == nil:
|
||||
return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make this wait again later", id, Fingerprint(digest))
|
||||
case rec.State == "chosen" && rec.Warrant != nil && rec.Warrant.Option == OptionDecline:
|
||||
at, _ := operatorchannelclient.Time(rec.Warrant.At)
|
||||
return fmt.Errorf("%s", declinedWords(id, at))
|
||||
case rec.State == "chosen", first:
|
||||
return fmt.Errorf("Not run: this wait needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make this wait "+
|
||||
"again within 30 minutes of the approval; the ask expires at %s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened,
|
||||
id, Fingerprint(digest), rec.Expires)
|
||||
case rec.State == "open", rec.State == "held-back":
|
||||
return fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s",
|
||||
id, Fingerprint(digest), rec.Expires)
|
||||
}
|
||||
return fmt.Errorf("Not run: the ask %s ended %s; make this wait again to ask anew", id, rec.State)
|
||||
}
|
||||
|
||||
func declinedWords(id string, at time.Time) string {
|
||||
return fmt.Sprintf("Not run: the operator declined ask %s; this wait is not asked again until %s", id,
|
||||
operatorchannelclient.FormatTime(at.Add(DeclinedFor)))
|
||||
}
|
||||
|
||||
// askForWait is a marked wait's ask (§35): "Wait on <names> on <node>?", shortened as §10 says, and an explanation naming
|
||||
// every event name one per line, the filter, and the bound as the timeout in minutes, then the act's fingerprint.
|
||||
func (g *Gate) askForWait(c Call, w Wait, digest, id string) seat.Ask {
|
||||
a := g.askFor(c, digest, id)
|
||||
filter, whole := "none", true
|
||||
if len(w.Filter) > 0 {
|
||||
raw, _ := json.Marshal(w.Filter)
|
||||
filter = string(raw)
|
||||
if r, ok := outward.Check(filter); !ok {
|
||||
filter, whole = shapeOf(r), false
|
||||
}
|
||||
}
|
||||
a.Headline = headlineOf("Wait on", strings.Join(w.Names, ", "), g.d.Node, whole)
|
||||
lines := append([]string{"Wait on " + g.d.Node + ", called through the mesh's tools, which cannot tell who called, for:"}, w.Names...)
|
||||
lines = append(lines, "Filter: "+filter, fmt.Sprintf("Timeout: %s minutes", minutes(w.Timeout)), "Fingerprint "+Fingerprint(digest))
|
||||
a.Explanation = strings.Join(lines, "\n")
|
||||
a.Options[0].Does = "the same wait, made again within 30 minutes, subscribes once, its timeout running from then"
|
||||
a.Options[1].Does = "nothing is heard; the same wait is not asked again for 10 minutes"
|
||||
return a
|
||||
}
|
||||
|
||||
// minutes is a timeout in minutes, as few digits as say it.
|
||||
func minutes(d time.Duration) string {
|
||||
return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.2f", d.Minutes()), "0"), ".")
|
||||
}
|
||||
@@ -75,9 +75,6 @@ type Membership struct {
|
||||
// Hears are the module streams the module hears, each through a durable consumer on that stream the
|
||||
// runtime binds beside the one on EVENTS (ADR 0363 §8).
|
||||
Hears []HeardIssued `json:"hears,omitempty"`
|
||||
// Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2):
|
||||
// the tool runner's alone, composed from its manifest with the grant that lets it subscribe them.
|
||||
Waits []WaitIssued `json:"waits,omitempty"`
|
||||
// Bindings are the module's bus state in the new form, by local name (novox/hq ADR 0595 §1, mesh-sdk
|
||||
// membership.BusBinding): its object stores, streams, work queues and ledgers, its own alone (bindings.go).
|
||||
Bindings []membership.BusBinding `json:"bindings,omitempty"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,38 +17,42 @@ import (
|
||||
// subscribes the subjects when called and unsubscribes them before it answers, so an event published
|
||||
// between two calls is not heard — a session reads the state from its owner after every wake (§4).
|
||||
|
||||
// WaitIssued is one event family the mesh issued this account to wait on (ADR 0367 §2): the name a session
|
||||
// asks for, and the subject it lands on. Only the tool runner's membership carries any.
|
||||
type WaitIssued struct {
|
||||
Event string `json:"event"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// Heard is one event a wait heard: the subject it arrived on, its body and when it arrived.
|
||||
// Heard is one event a wait heard: the subject it arrived on, its body, when it arrived, and when it was published, the
|
||||
// event's own header `x-time` as its emitter wrote it, empty for an event that carries none (design 34 every-event §12).
|
||||
type Heard struct {
|
||||
Subject string
|
||||
Body json.RawMessage
|
||||
At time.Time
|
||||
Subject string
|
||||
Body json.RawMessage
|
||||
At time.Time
|
||||
Published string
|
||||
}
|
||||
|
||||
// HearFirst subscribes the subjects for this call alone and answers the first message whose body match
|
||||
// accepts (every one, when match is nil), or ok false once `within` has passed. Every subscription is
|
||||
// closed before it returns. A subscription the bus refuses is said as an error naming the subject, at once;
|
||||
// a caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for nobody.
|
||||
func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, err error) {
|
||||
// closed before it returns. A subscription the bus refuses is noted and the others go on waiting; refused lists
|
||||
// those noted by the time it answers (design 34 every-event §16). When the bus refused every subscription of the
|
||||
// call, it still waits out `within` and then answers the refusal, so a caller looping on it asks once a timeout and
|
||||
// never at once again. A caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for
|
||||
// nobody.
|
||||
func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, refused []string, err error) {
|
||||
if len(subjects) == 0 {
|
||||
return Heard{}, false, fmt.Errorf("a wait names at least one subject")
|
||||
return Heard{}, false, nil, fmt.Errorf("a wait names at least one subject")
|
||||
}
|
||||
var unique []string
|
||||
for _, subject := range subjects {
|
||||
if !slices.Contains(unique, subject) {
|
||||
unique = append(unique, subject)
|
||||
}
|
||||
}
|
||||
got := make(chan Heard, 1)
|
||||
refused := make(chan string, len(subjects))
|
||||
refusals := make(chan string, len(unique))
|
||||
var subs []*nats.Subscription
|
||||
defer func() {
|
||||
for _, s := range subs {
|
||||
_ = s.Unsubscribe()
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, subject := range subjects {
|
||||
delete(c.waits[subject], refused)
|
||||
for _, subject := range unique {
|
||||
delete(c.waits[subject], refusals)
|
||||
if len(c.waits[subject]) == 0 {
|
||||
delete(c.waits, subject)
|
||||
}
|
||||
@@ -58,14 +63,14 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur
|
||||
if c.waits == nil {
|
||||
c.waits = map[string]map[chan string]bool{}
|
||||
}
|
||||
for _, subject := range subjects {
|
||||
for _, subject := range unique {
|
||||
if c.waits[subject] == nil {
|
||||
c.waits[subject] = map[chan string]bool{}
|
||||
}
|
||||
c.waits[subject][refused] = true
|
||||
c.waits[subject][refusals] = true
|
||||
}
|
||||
c.mu.Unlock()
|
||||
for _, subject := range subjects {
|
||||
for _, subject := range unique {
|
||||
sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
body := json.RawMessage(msg.Data)
|
||||
if !json.Valid(body) {
|
||||
@@ -76,31 +81,39 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur
|
||||
return
|
||||
}
|
||||
select {
|
||||
case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC()}:
|
||||
case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC(), Published: msg.Header.Get("x-time")}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return Heard{}, false, fmt.Errorf("could not wait on %s: %w", subject, err)
|
||||
return Heard{}, false, nil, fmt.Errorf("could not wait on %s: %w", subject, err)
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
// The bus has every subscription — or has said which it refused — once this returns.
|
||||
if err := c.nc.FlushTimeout(5 * time.Second); err != nil {
|
||||
return Heard{}, false, fmt.Errorf("the bus did not take the wait: %w", err)
|
||||
return Heard{}, false, nil, fmt.Errorf("the bus did not take the wait: %w", err)
|
||||
}
|
||||
timer := time.NewTimer(within)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case h := <-got:
|
||||
return h, true, nil
|
||||
case subject := <-refused:
|
||||
return Heard{}, false, fmt.Errorf("the bus refused this account a subscription on %s: it is not in the "+
|
||||
"account's wait grant (novox/hq ADR 0367 §2)", subject)
|
||||
case <-timer.C:
|
||||
return Heard{}, false, nil
|
||||
case <-ctx.Done():
|
||||
return Heard{}, false, ctx.Err()
|
||||
for {
|
||||
select {
|
||||
case h := <-got:
|
||||
return h, true, refused, nil
|
||||
case subject := <-refusals:
|
||||
if !slices.Contains(refused, subject) {
|
||||
refused = append(refused, subject)
|
||||
}
|
||||
case <-timer.C:
|
||||
if len(refused) == len(unique) {
|
||||
return Heard{}, false, refused, fmt.Errorf("the bus refused this account a subscription on %s: the "+
|
||||
"grants part grants this machine's tool runner every event subject (novox/hq ADR 0670 §1), so its "+
|
||||
"grant is not composed yet; read the state from its owner meanwhile", strings.Join(refused, ", "))
|
||||
}
|
||||
return Heard{}, false, refused, nil
|
||||
case <-ctx.Done():
|
||||
return Heard{}, false, refused, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,24 @@ package bus_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0367 §2, §5: the bus refuses the tool runner's account any subject its wait grant does not
|
||||
// name — another asker's `decided` among them — and a wait on one says the bus refused it, at once.
|
||||
// novox/hq ADR 0670 §1 (design 34 every-event §12, §16): a subscription the bus refuses is noted and the others go on
|
||||
// waiting, a heard event carries its own time; a wait whose every subscription is refused answers only after its
|
||||
// timeout, in words naming the grants part's grant of every event subject, never a wait grant (E-2).
|
||||
func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) {
|
||||
granted := "mesh.seat.operator-channel.event.decided.claude-code"
|
||||
other := "mesh.seat.operator-channel.event.decided.shop"
|
||||
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true,
|
||||
Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{
|
||||
Publish: &server.SubjectPermission{Allow: []string{"$JS.API.DIRECT.GET.>", "mesh.>"}},
|
||||
@@ -33,33 +35,65 @@ func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) {
|
||||
t.Cleanup(s.Shutdown)
|
||||
c := dial(t, s.ClientURL(), "node-tools", "desk", "desk.node-tools")
|
||||
|
||||
// Every subscription refused: answered after the timeout, never at once, in §16's words.
|
||||
start := time.Now()
|
||||
_, ok, err := c.HearFirst(context.Background(), []string{"mesh.seat.operator-channel.event.decided.shop"}, 5*time.Second, nil)
|
||||
if ok || err == nil || !strings.Contains(err.Error(), "refused") {
|
||||
t.Errorf("a wait on another asker's decided: heard %v, %v", ok, err)
|
||||
_, ok, refused, err := c.HearFirst(context.Background(), []string{other}, 300*time.Millisecond, nil)
|
||||
if took := time.Since(start); took < 300*time.Millisecond || took > 3*time.Second {
|
||||
t.Errorf("a wait refused whole answered after %s, want its timeout of 300ms", took)
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Errorf("the refusal took %s to be said", time.Since(start))
|
||||
if ok || err == nil || !strings.Contains(err.Error(), "the bus refused this account a subscription on "+other) ||
|
||||
!strings.Contains(err.Error(), "every event subject (novox/hq ADR 0670 §1)") ||
|
||||
strings.Contains(err.Error(), "wait grant") || strings.Contains(err.Error(), "0367") ||
|
||||
len(refused) != 1 || refused[0] != other {
|
||||
t.Errorf("a wait on another asker's decided: heard %v, refused %v, %v", ok, refused, err)
|
||||
}
|
||||
|
||||
done := make(chan bus.Heard, 1)
|
||||
go func() {
|
||||
h, ok, err := c.HearFirst(context.Background(), []string{granted}, 5*time.Second, func(body json.RawMessage) bool { return true })
|
||||
if ok && err == nil {
|
||||
done <- h
|
||||
}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if err := c.Say(granted, []byte(`{"ask":"a1"}`)); err != nil {
|
||||
// One refused beside one granted: the granted one hears, with the event's own time, and the refused one is listed.
|
||||
pub, err := nats.Connect(s.ClientURL(), nats.UserInfo("desk.node-tools", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case h := <-done:
|
||||
if h.Subject != granted || string(h.Body) != `{"ask":"a1"}` {
|
||||
t.Errorf("heard %+v", h)
|
||||
t.Cleanup(pub.Close)
|
||||
for _, published := range []string{"2026-11-12T03:00:00.5Z", ""} {
|
||||
type result struct {
|
||||
h bus.Heard
|
||||
refused []string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
h, ok, refused, err := c.HearFirst(context.Background(), []string{other, granted}, 5*time.Second, nil)
|
||||
if !ok && err == nil {
|
||||
err = errors.New("timed out")
|
||||
}
|
||||
done <- result{h, refused, err}
|
||||
}()
|
||||
// The event is said again until the wait, subscribed in its own time, hears it.
|
||||
say := time.NewTicker(20 * time.Millisecond)
|
||||
var r result
|
||||
heard:
|
||||
for {
|
||||
select {
|
||||
case r = <-done:
|
||||
break heard
|
||||
case <-say.C:
|
||||
msg := nats.NewMsg(granted)
|
||||
msg.Data = []byte(`{"ask":"a1"}`)
|
||||
if published != "" {
|
||||
msg.Header.Set("x-time", published)
|
||||
}
|
||||
if err := pub.PublishMsg(msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
say.Stop()
|
||||
if r.err != nil || r.h.Subject != granted || string(r.h.Body) != `{"ask":"a1"}` || r.h.Published != published {
|
||||
t.Errorf("heard %+v, %v; want published %q", r.h, r.err, published)
|
||||
}
|
||||
if len(r.refused) > 1 || len(r.refused) == 1 && r.refused[0] != other {
|
||||
t.Errorf("refused %v, want at most %s", r.refused, other)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("a wait on the granted subject heard nothing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +104,7 @@ func TestAWaitEndsWhenItsCallerIsGone(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
time.AfterFunc(200*time.Millisecond, cancel)
|
||||
start := time.Now()
|
||||
_, ok, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil)
|
||||
_, ok, _, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil)
|
||||
if ok || !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("a cancelled wait answered %v, %v", ok, err)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -103,12 +104,13 @@ func discovery() []map[string]any {
|
||||
}), "description": "Ask the tool runner's four first views again, now, and say what each answers. A hand act, recorded in " +
|
||||
"the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."},
|
||||
waitTool(),
|
||||
approvalsTool(),
|
||||
}
|
||||
}
|
||||
|
||||
func isDiscovery(name string) bool {
|
||||
switch name {
|
||||
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait:
|
||||
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait, verbApprovals:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -925,6 +927,21 @@ func schemaWithoutNode(raw json.RawMessage) map[string]any {
|
||||
}
|
||||
|
||||
// answerText is a discovery verb's answer as MCP content: JSON, indented.
|
||||
// callFailed is why a call failed, as mesh_call says it: a marked tool or verb that reaches no responder names `withheld`
|
||||
// and the grants part's read (design 46 7-approval-mark §6), and a target known only from an earlier round says so.
|
||||
func (s *Surface) callFailed(t target, err error) string {
|
||||
why := whyItFailed(t.Address, err)
|
||||
if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) {
|
||||
// A marked subject withheld from a machine whose tool runner does not honour the mark (§6).
|
||||
why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " +
|
||||
"approval-mark/1: mesh-grants.grants_user_list names it under withheld"
|
||||
}
|
||||
if t.Stale != nil {
|
||||
why += " (" + t.Stale.say() + ")"
|
||||
}
|
||||
return why
|
||||
}
|
||||
|
||||
func answerText(v any) map[string]any {
|
||||
var b strings.Builder
|
||||
enc := json.NewEncoder(&b)
|
||||
@@ -949,6 +966,9 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
|
||||
if name == verbWait {
|
||||
return s.waitAnswer(ctx, args)
|
||||
}
|
||||
if name == verbApprovals {
|
||||
return s.approvalsAnswer(args)
|
||||
}
|
||||
x, err := s.index()
|
||||
if err != nil {
|
||||
return failure("the mesh's discovery failed: " + err.Error())
|
||||
@@ -1109,6 +1129,10 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
|
||||
if t.Stale != nil {
|
||||
described["found"] = t.Stale.say()
|
||||
}
|
||||
if ok, source := s.markedTool(t); ok {
|
||||
described["needs-approval"] = map[string]any{"source": source,
|
||||
"says": "the first call asks the operator and runs nothing; the same call again within 30 minutes of the Approve runs once"}
|
||||
}
|
||||
return answerText(described)
|
||||
|
||||
case verbCall:
|
||||
@@ -1128,11 +1152,7 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any
|
||||
got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing)
|
||||
}
|
||||
if err != nil {
|
||||
why := whyItFailed(t.Address, err)
|
||||
if t.Stale != nil {
|
||||
why += " (" + t.Stale.say() + ")"
|
||||
}
|
||||
return failure(why)
|
||||
return failure(s.callFailed(t, err))
|
||||
}
|
||||
result, unread := resolveSent(s.conn, got.Result)
|
||||
content := []map[string]any{{"type": "text", "text": pretty(result)}}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
)
|
||||
|
||||
// verbApprovals is the mesh MCP server's tool that reads and steers what the gate asked on this machine (design 46
|
||||
// 7-approval-mark §18).
|
||||
const verbApprovals = "mesh_approvals"
|
||||
|
||||
// Approvals is the gate of this machine's tool runner, as mesh_approvals and mesh_describe read it.
|
||||
type Approvals interface {
|
||||
List() ([]approval.Listed, error)
|
||||
Read(id string) (approval.Whole, error)
|
||||
Applied() []approval.Marks
|
||||
Describe(module, seat, name string) (bool, string)
|
||||
Again(id string) error
|
||||
Cancel(id string) error
|
||||
// Fill is the state approvals' bytes against its cap, every machine's keys together.
|
||||
Fill() (bytes uint64, max int64, err error)
|
||||
}
|
||||
|
||||
func approvalsTool() map[string]any {
|
||||
str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
||||
return map[string]any{"name": verbApprovals, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"ask": str("an ask's id: its whole arguments and the act's digest"),
|
||||
"record": str("an ask's id: ask mesh-hand-acts.warranted again for its run, recorded in the hand-act log first"),
|
||||
"cancel": str("an ask's id: take that open ask back, recorded in the hand-act log first"),
|
||||
"why": str("why, for record and cancel, kept in the hand-act log"),
|
||||
}}, "description": "The calls that needed the operator's approval on this machine: with no argument, those asked in " +
|
||||
"the last day, newest first, with their state and fingerprint, the marks the tool runner applies (each module's " +
|
||||
"marked tools and verbs with their source and the catalog revision read) and its state's fill; `ask` reads one " +
|
||||
"whole, `record` asks its hand-act again, `cancel` takes an open ask back."}
|
||||
}
|
||||
|
||||
// approvalsAnswer is mesh_approvals.
|
||||
func (s *Surface) approvalsAnswer(args map[string]any) map[string]any {
|
||||
if s.Approvals == nil {
|
||||
return failure("this tool runner has no approval gate")
|
||||
}
|
||||
str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) }
|
||||
why := str("why")
|
||||
if why == "" {
|
||||
why = "asked by hand with no reason given"
|
||||
}
|
||||
act := func(id, what, cause string, do func(string) error) map[string]any {
|
||||
// A hand act, recorded through the seat mesh-hand-acts before it is done (§18); one that cannot be recorded is refused.
|
||||
log.Printf("[mesh-tools] hand act: %s %s ask %s: %s", s.who, what, id, why)
|
||||
if err := s.recordAct(map[string]any{"what": what + " ask " + id, "why": why, "cause": cause, "verb": verbApprovals}); err != nil {
|
||||
return failure("the hand act was not recorded through mesh-hand-acts, so nothing was done: " + err.Error())
|
||||
}
|
||||
if err := do(id); err != nil {
|
||||
return failure(err.Error())
|
||||
}
|
||||
return answerText(map[string]any{"done": what + " ask " + id, "recorded": "mesh-hand-acts.hand-act"})
|
||||
}
|
||||
switch {
|
||||
case str("record") != "":
|
||||
return act(str("record"), "asked the hand-act of", "approval-record", s.Approvals.Again)
|
||||
case str("cancel") != "":
|
||||
return act(str("cancel"), "cancelled", "approval-cancel", s.Approvals.Cancel)
|
||||
case str("ask") != "":
|
||||
w, err := s.Approvals.Read(str("ask"))
|
||||
if err != nil {
|
||||
return failure(err.Error())
|
||||
}
|
||||
return answerText(w)
|
||||
}
|
||||
asks, err := s.Approvals.List()
|
||||
out := map[string]any{"asks": asks, "marks": s.Approvals.Applied()}
|
||||
if err != nil {
|
||||
out["asks"], out["asks_why"] = []approval.Listed{}, "the state approvals cannot be read: "+err.Error()
|
||||
}
|
||||
if b, max, err := s.Approvals.Fill(); err != nil {
|
||||
out["fill_why"] = err.Error()
|
||||
} else {
|
||||
out["fill"] = map[string]any{"bytes": b, "max_bytes": max, "of": "every machine's keys together"}
|
||||
}
|
||||
return answerText(out)
|
||||
}
|
||||
|
||||
// marked is what mesh_describe and a call that reached no one say of a marked tool or verb (§6, §18).
|
||||
func (s *Surface) marked(module, seat, name string) (bool, string) {
|
||||
if s.Approvals == nil {
|
||||
return false, ""
|
||||
}
|
||||
return s.Approvals.Describe(module, seat, name)
|
||||
}
|
||||
|
||||
// markedTool is marked for a resolved address.
|
||||
func (s *Surface) markedTool(t target) (bool, string) {
|
||||
if t.Seat {
|
||||
return s.marked("", t.Tool.Module, t.Tool.Name)
|
||||
}
|
||||
return s.marked(t.Tool.Module, "", t.Tool.Name)
|
||||
}
|
||||
@@ -8,19 +8,28 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0367 §5: the tool runner's manifest names the families a session waits on today, one by one,
|
||||
// and the asker's `decided` for the claude-code module alone. Adding one is a reviewed change to this file
|
||||
// (§6), so this list changes with it. The delivery's transitions are waited on under the gate's name alone (ADR 0450 §8, issues 1350
|
||||
// and 1406): today's module is forgotten.
|
||||
func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) {
|
||||
// The tool runner's manifest names no events to wait on (novox/hq ADR 0670 §2): a session waits on any event by its
|
||||
// name. It holds each change of design 34 every-event §11 (E-1): no `waits`, `operator-channel` in `uses` for the asks of
|
||||
// marked waits, the per-machine state `approvals` in the old `state` section with its 48 hours and none in `requires`
|
||||
// (design 46 7-approval-mark §13), and the finding stream (design 47 G §387).
|
||||
func TestTheManifestNamesNoWaitsAndKeepsItsApprovals(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := keys["waits"]; ok {
|
||||
t.Error("the manifest still carries waits (novox/hq ADR 0670 §2)")
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Waits []string `json:"waits"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Module string `json:"module"`
|
||||
Uses []string `json:"uses"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
Requires []json.RawMessage `json:"requires"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Streams []struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
@@ -29,20 +38,33 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Module != "node-tools" || !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
|
||||
t.Errorf("%s uses %v, want operator-channel (design 46 7-approval-mark §7)", m.Module, m.Uses)
|
||||
}
|
||||
type state struct {
|
||||
Name string `json:"name"`
|
||||
PerMachine bool `json:"per-machine"`
|
||||
TTLSeconds int `json:"ttl-seconds"`
|
||||
}
|
||||
var approvals []state
|
||||
for _, r := range m.State {
|
||||
var s state
|
||||
if json.Unmarshal(r, &s) == nil && s.Name == "approvals" {
|
||||
approvals = append(approvals, s)
|
||||
}
|
||||
}
|
||||
if want := []state{{Name: "approvals", PerMachine: true, TTLSeconds: 172800}}; !reflect.DeepEqual(approvals, want) {
|
||||
t.Errorf("the old state section's approvals is %+v, want %+v (design 46 7-approval-mark §13)", approvals, want)
|
||||
}
|
||||
for _, r := range m.Requires {
|
||||
if strings.Contains(string(r), `"approvals"`) {
|
||||
t.Errorf("approvals is asked in requires too: %s", r)
|
||||
}
|
||||
}
|
||||
if len(m.Streams) != 1 || m.Streams[0].Name != "node-tools-findings" ||
|
||||
!reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) {
|
||||
t.Errorf("the finding stream is %+v, want node-tools-findings with the three finding events (design 47 G §387)", m.Streams)
|
||||
}
|
||||
want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status",
|
||||
"mesh-self-check.checked", "operator-channel.decided.claude-code"}
|
||||
if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) {
|
||||
t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want)
|
||||
}
|
||||
for _, w := range m.Waits {
|
||||
if strings.ContainsAny(w, "*>") {
|
||||
t.Errorf("a family named by a wildcard: %s", w)
|
||||
}
|
||||
}
|
||||
// A wait is no consumer (ADR 0367 §2): what it consumes is the assigner's two views families (ADR 0615 §5, issue 1966
|
||||
// TLS-5), the events of the catalog half's branch follower (design 47 G §196, §614: `catalog-revision`,
|
||||
// `registration-changed`, and the gate's `source-moved`; never the indexer's `views.modules` or `views.manifests`), the two
|
||||
|
||||
@@ -63,6 +63,8 @@ type Surface struct {
|
||||
Flat bool
|
||||
// Record, when set, records a hand act in place of the seat mesh-hand-acts' verb (a test's).
|
||||
Record func(args map[string]any) error
|
||||
// Approvals is this machine's approval gate, read by mesh_approvals and mesh_describe; nil when the runner has none.
|
||||
Approvals Approvals
|
||||
}
|
||||
|
||||
// MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape
|
||||
|
||||
@@ -11,18 +11,33 @@ import (
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529).
|
||||
func TestAToolsOwn503IsNotNothingServes(t *testing.T) {
|
||||
// A marked tool or verb that reaches no responder names withheld and the grants part's read (design 46 7-approval-mark §6);
|
||||
// an unmarked one, or a marked one failing otherwise, does not. A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says
|
||||
// nothing serves.
|
||||
func TestARealNoRespondersStillSaysNothingServes(t *testing.T) {
|
||||
got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable"))
|
||||
if strings.Contains(got, "nothing serves") || !strings.HasPrefix(got, "scanner.scan failed: ") || !strings.Contains(got, "503") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARealNoRespondersStillSaysNothingServes(t *testing.T) {
|
||||
for _, err := range []error{bus.ErrNoResponders, fmt.Errorf("ask: %w", bus.ErrNoResponders), nats.ErrNoResponders} {
|
||||
if got := whyItFailed("a.b", err); !strings.HasPrefix(got, "nothing serves a.b") {
|
||||
t.Fatalf("%v: got %q", err, got)
|
||||
}
|
||||
}
|
||||
s := &Surface{Approvals: &fakeApprovals{marked: "shell.wipe"}}
|
||||
for _, c := range []struct {
|
||||
target target
|
||||
err error
|
||||
hinted bool
|
||||
}{
|
||||
{target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, bus.ErrNoResponders, true},
|
||||
{target{Address: "node-login-shell.execute", Seat: true, Tool: Tool{Module: "node-login-shell", Name: "execute"}}, bus.ErrNoResponders, false},
|
||||
{target{Address: "shell.tidy", Tool: Tool{Module: "shell", Name: "tidy"}}, bus.ErrNoResponders, false},
|
||||
{target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, errors.New("its own failure"), false},
|
||||
} {
|
||||
got := s.callFailed(c.target, c.err)
|
||||
if hinted := strings.Contains(got, "approval-mark/1: mesh-grants.grants_user_list names it under withheld"); hinted != c.hinted {
|
||||
t.Errorf("%s (%v): %q", c.target.Address, c.err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package console
|
||||
|
||||
// mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367).
|
||||
//
|
||||
// **Only the families the tool runner was issued** (§2, §6): its membership names them, composed by the
|
||||
// mesh from the tool runner's manifest together with the grant that lets this account subscribe them.
|
||||
// A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one.
|
||||
// **Any event, by its name** (novox/hq ADR 0670 §1, ADR 0671 §1; design 34 every-event §13, §26): the grants part
|
||||
// grants this machine's tool runner every event subject, a seat's, a module's and a module stream's, and a name
|
||||
// becomes its subjects through mesh-sdk's buscompose.EventSubjects; no membership and no manifest names a list.
|
||||
// **An event only wakes the session** (§4): what it says is read again from the verb that owns it.
|
||||
|
||||
import (
|
||||
@@ -13,9 +13,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
)
|
||||
|
||||
// WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last
|
||||
@@ -25,20 +31,29 @@ var (
|
||||
WaitLongest = 10 * time.Minute
|
||||
)
|
||||
|
||||
// waitMostNames is the most event names one call gives (design 34 every-event §13, §26): each name is six
|
||||
// subscriptions for the call, so 16 names are 96, far above what a watcher names.
|
||||
const waitMostNames = 16
|
||||
|
||||
func waitTool() map[string]any {
|
||||
return map[string]any{"name": verbWait, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"subjects": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "one or more event families to wait on, by name, e.g. `mesh-knowledge.reviewed`; only " +
|
||||
"those the mesh granted this machine's tool runner — a refusal names them"},
|
||||
"description": fmt.Sprintf("one or more event names, `<seat or module>.<event>`, such as "+
|
||||
"`mesh-delivery-view.delivered`, at most %d; an event whose subject goes on after the name (an asker, a "+
|
||||
"kind, a module) is heard by the name too", waitMostNames)},
|
||||
"from": map[string]any{"type": "string", "enum": []string{"seat", "module", "stream"},
|
||||
"description": "narrows every name to a seat's events, a module's or a module stream's, for an emitter " +
|
||||
"whose name is more than one of them (optional)"},
|
||||
"filter": map[string]any{"type": "object",
|
||||
"description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367} (optional)"},
|
||||
"description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367}: the filter narrows by the body's fields (optional)"},
|
||||
"timeout": map[string]any{"type": "number",
|
||||
"description": fmt.Sprintf("seconds to wait: %d when absent, %d at most", int(WaitDefault.Seconds()), int(WaitLongest.Seconds()))},
|
||||
}, "required": []string{"subjects"}},
|
||||
"description": "Wait for the first event of the named families that matches the filter, and answer it with its " +
|
||||
"family, body and time — or say it timed out. Call it in a loop from a background command, and after every " +
|
||||
"answer read the state from the verb that owns it: an event only wakes you, and one published between two " +
|
||||
"waits is not heard (novox/hq ADR 0367)."}
|
||||
"description": "Wait for the first event of the named events that matches the filter, and answer it with its " +
|
||||
"name, where it came from, body and times — or say it timed out. Call it in a loop from a background command, " +
|
||||
"and after every answer read the state from the verb that owns it: an event only wakes you, and one " +
|
||||
"published between two waits is not heard (novox/hq ADR 0367). An operator's answer is read only with " +
|
||||
"claude_code_answer, never from the event."}
|
||||
}
|
||||
|
||||
// waitAnswer answers one mesh_wait call.
|
||||
@@ -56,51 +71,195 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin
|
||||
return failure("mesh_wait's filter is an object of body fields to the values they must equal")
|
||||
}
|
||||
|
||||
issued := map[string]string{} // name → subject
|
||||
if m := s.conn.Membership(s.conn.Module()); m != nil {
|
||||
for _, w := range m.Waits {
|
||||
issued[w.Event] = w.Subject
|
||||
}
|
||||
from, _ := args["from"].(string)
|
||||
if args["from"] != nil && from != "seat" && from != "module" && from != "stream" {
|
||||
return failure(fmt.Sprintf("mesh_wait's from is seat, module or stream, not %v", args["from"]))
|
||||
}
|
||||
if len(issued) == 0 {
|
||||
return failure("this machine's tool runner was issued no event families to wait on: the mesh grants them from " +
|
||||
"the tool runner's manifest (novox/hq ADR 0367 §2), and its membership names none yet")
|
||||
if len(names) > waitMostNames {
|
||||
return failure(fmt.Sprintf("mesh_wait names at most %d events in one call, not %d: each name is six "+
|
||||
"subscriptions for the call (design 34 every-event §13)", waitMostNames, len(names)))
|
||||
}
|
||||
byName := make([]string, 0, len(issued))
|
||||
for name := range issued {
|
||||
byName = append(byName, name)
|
||||
}
|
||||
sort.Strings(byName)
|
||||
family := map[string]string{} // subject → name
|
||||
var subjects []string
|
||||
known, unchecked := s.emitters()
|
||||
var subjects, undeclared []string
|
||||
for _, name := range names {
|
||||
subject, granted := issued[name]
|
||||
if !granted {
|
||||
return failure(fmt.Sprintf("mesh_wait may not wait on %q: this machine's tool runner is granted %s alone. "+
|
||||
"A family is added only by a reviewed change to the tool runner's manifest (node-tools' module.json, "+
|
||||
"novox/hq ADR 0367 §6); no argument, call or setting adds one", name, strings.Join(byName, ", ")))
|
||||
subs, err := buscompose.EventSubjects(name, from)
|
||||
if err != nil {
|
||||
return failure("mesh_wait may not wait on it: " + err.Error())
|
||||
}
|
||||
if _, twice := family[subject]; !twice {
|
||||
subjects = append(subjects, subject)
|
||||
// A misspelt emitter is refused, never waited on for ever; a known emitter's undeclared event is waited on and
|
||||
// its timed-out answer says so (design 34 every-event §14).
|
||||
if unchecked == "" {
|
||||
emitter, event, _ := strings.Cut(name, ".")
|
||||
seatEvents, isSeat := known.seats[emitter]
|
||||
moduleEvents, isModule := known.modules[emitter]
|
||||
isSeat = isSeat && from != "module" && from != "stream"
|
||||
isModule = isModule && from != "seat"
|
||||
if !isSeat && !isModule {
|
||||
return failure(fmt.Sprintf("mesh_wait may not wait on %q: %s is neither a seat nor a module of %s",
|
||||
name, emitter, known.source))
|
||||
}
|
||||
if !(isSeat && declares(seatEvents, event)) && !(isModule && declares(moduleEvents, event)) {
|
||||
undeclared = append(undeclared, name)
|
||||
}
|
||||
}
|
||||
for _, subject := range subs {
|
||||
if !slices.Contains(subjects, subject) {
|
||||
subjects = append(subjects, subject)
|
||||
}
|
||||
}
|
||||
family[subject] = name
|
||||
}
|
||||
|
||||
heard, ok, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) })
|
||||
listen := func(within time.Duration) (any, error) {
|
||||
return s.hear(ctx, names, subjects, undeclared, known.source, unchecked, filter, within)
|
||||
}
|
||||
// A wait whose subjects overlap a marked event's runs only on the operator's warrant for that one wait (design 34
|
||||
// every-event §34 to §36), judged by the runner's approval gate.
|
||||
var answer any
|
||||
if g, ok := s.Approvals.(waitGate); ok {
|
||||
given := map[string]any{}
|
||||
for _, k := range []string{"subjects", "from", "filter", "timeout"} {
|
||||
if v, has := args[k]; has {
|
||||
given[k] = v
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(given)
|
||||
answer, err = g.Waited(ctx, approval.Call{Address: verbWait, Name: verbWait, Caller: s.who, Args: raw},
|
||||
approval.Wait{Names: names, From: from, Subjects: subjects, Filter: filter, Timeout: timeout}, listen)
|
||||
} else {
|
||||
answer, err = listen(timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return failure("mesh_wait could not wait: " + err.Error())
|
||||
return failure(err.Error())
|
||||
}
|
||||
if !ok {
|
||||
return answerText(map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names,
|
||||
"read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"})
|
||||
}
|
||||
return answerText(map[string]any{
|
||||
"heard": map[string]any{"subject": family[heard.Subject], "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)},
|
||||
"read": "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)",
|
||||
})
|
||||
return answerText(answer)
|
||||
}
|
||||
|
||||
// waitNames is the families a call names: a list of names, or one name.
|
||||
// waitGate is the runner's approval gate as mesh_wait goes through it (design 34 every-event §34 to §36).
|
||||
type waitGate interface {
|
||||
Waited(ctx context.Context, c approval.Call, w approval.Wait, hear func(within time.Duration) (any, error)) (any, error)
|
||||
}
|
||||
|
||||
// hear subscribes the wait's subjects for within and answers the first matching event, or that it timed out.
|
||||
func (s *Surface) hear(ctx context.Context, names, subjects, undeclared []string, source, unchecked string, filter map[string]any,
|
||||
timeout time.Duration) (map[string]any, error) {
|
||||
heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) })
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mesh_wait could not wait: %w", err)
|
||||
}
|
||||
var answer map[string]any
|
||||
if !ok {
|
||||
answer = map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names,
|
||||
"read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"}
|
||||
if len(undeclared) > 0 {
|
||||
answer["undeclared"] = fmt.Sprintf("no declaration names %s: no seat definition and no module's entry in %s "+
|
||||
"declares that event", strings.Join(undeclared, ", "), source)
|
||||
}
|
||||
} else {
|
||||
name, from, _ := buscompose.EventName(heard.Subject)
|
||||
h := map[string]any{"subject": name, "from": from, "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)}
|
||||
if heard.Published != "" {
|
||||
h["published"] = heard.Published
|
||||
}
|
||||
read := "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)"
|
||||
if strings.HasPrefix(name, "operator-channel.decided") {
|
||||
read = "an event only wakes you: the operator's answer is read only with claude_code_answer, never from " +
|
||||
"the event's body (novox/hq ADR 0670 §5)"
|
||||
}
|
||||
answer = map[string]any{"heard": h, "read": read}
|
||||
}
|
||||
if unchecked != "" {
|
||||
answer["unchecked"] = "the names were not checked: " + unchecked
|
||||
}
|
||||
// A subscription the bus refused while others stood is said (design 34 every-event §16).
|
||||
if len(refused) > 0 {
|
||||
answer["refused"] = refused
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait
|
||||
// checks an event name's emitter against it (design 34 every-event §14).
|
||||
type halfReader interface {
|
||||
Halves() (map[string]meshcatalogclient.Half, error)
|
||||
}
|
||||
|
||||
// knownEmitters is what the catalog half says emits what: each seat's declared events and each module's, and the
|
||||
// source a refusal names, with its revisions.
|
||||
type knownEmitters struct {
|
||||
seats, modules map[string][]string
|
||||
source string
|
||||
}
|
||||
|
||||
// emitters is the catalog half's emitters, or why they are not checked: the half not yet read (its first view,
|
||||
// mesh_first_views), or no half held at all.
|
||||
func (s *Surface) emitters() (known knownEmitters, unchecked string) {
|
||||
hr, ok := s.src.(halfReader)
|
||||
if !ok {
|
||||
return known, "this tool runner holds no catalog half"
|
||||
}
|
||||
halves, err := hr.Halves()
|
||||
if err != nil {
|
||||
return known, "the catalog half is not yet read (" + err.Error() + "); its first view is in mesh_first_views"
|
||||
}
|
||||
seatEvents := map[string][]string{} // a seat's events, as its definition in the seat list declares them
|
||||
for _, st := range catalogue.Seats() {
|
||||
seatEvents[st.Name] = st.Emits
|
||||
}
|
||||
known.seats, known.modules = map[string][]string{}, map[string][]string{}
|
||||
branches := make([]string, 0, len(halves))
|
||||
for b := range halves {
|
||||
branches = append(branches, b)
|
||||
}
|
||||
slices.Sort(branches)
|
||||
var revisions []string
|
||||
for _, b := range branches {
|
||||
half := halves[b]
|
||||
revisions = append(revisions, fmt.Sprintf("revision %d of %s", half.Revision.Revision, b))
|
||||
for _, d := range half.Revision.Definitions {
|
||||
known.seats[d.Seat] = seatEvents[d.Seat]
|
||||
}
|
||||
for m, entry := range half.Entries {
|
||||
if _, taken := known.modules[m]; !taken {
|
||||
known.modules[m] = eventsDeclared(entry.Manifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
known.source = "the catalog half (mesh-catalog.revision, " + strings.Join(revisions, "; ") + ")"
|
||||
return known, ""
|
||||
}
|
||||
|
||||
// eventsDeclared is the events a module's manifest declares: its `emits` and its streams' `events`.
|
||||
func eventsDeclared(manifest map[string]any) []string {
|
||||
var out []string
|
||||
add := func(list any) {
|
||||
items, _ := list.([]any)
|
||||
for _, x := range items {
|
||||
if e, ok := x.(string); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
add(manifest["emits"])
|
||||
streams, _ := manifest["streams"].([]any)
|
||||
for _, st := range streams {
|
||||
if m, ok := st.(map[string]any); ok {
|
||||
add(m["events"])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declares says whether one of the declared events is the event a name gives, or one its name is followed by.
|
||||
func declares(declared []string, event string) bool {
|
||||
for _, d := range declared {
|
||||
if buscompose.Overlaps(d, event) || buscompose.Overlaps(d, event+".>") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitNames is the event names a call gives: a list of names, or one name.
|
||||
func waitNames(given any) ([]string, error) {
|
||||
var names []string
|
||||
switch v := given.(type) {
|
||||
@@ -110,7 +269,7 @@ func waitNames(given any) ([]string, error) {
|
||||
for _, x := range v {
|
||||
name, ok := x.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-knowledge.reviewed; %v is not one", x)
|
||||
return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-delivery-view.delivered; %v is not one", x)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
@@ -119,7 +278,7 @@ func waitNames(given any) ([]string, error) {
|
||||
names[i] = strings.TrimSpace(names[i])
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("mesh_wait names at least one event family in `subjects`, such as mesh-knowledge.reviewed")
|
||||
return nil, fmt.Errorf("mesh_wait names at least one event in `subjects`, such as mesh-delivery-view.delivered")
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
@@ -3,32 +3,40 @@ package console
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
opseat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// waitingRuntime is the tool runner on a machine, issued the families ADR 0367 §5 names, as the controller
|
||||
// composes them from its manifest: the surface over its connection, and the mesh to publish on.
|
||||
// waitingRuntime is the tool runner on a machine, issued no waits (novox/hq ADR 0670 §2): the surface over its
|
||||
// connection, and the mesh to publish on.
|
||||
func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
|
||||
t.Helper()
|
||||
mesh := mt.New(t)
|
||||
m := mt.MembershipOf("node-tools", "desk", false, nil)
|
||||
m.Waits = []bus.WaitIssued{
|
||||
{Event: "mesh-knowledge.reviewed", Subject: "mesh.mod.mesh-knowledge.event.reviewed"},
|
||||
{Event: "operator-channel.decided.claude-code", Subject: "mesh.seat.operator-channel.event.decided.claude-code"},
|
||||
}
|
||||
mesh.Issue(t, m)
|
||||
mesh.Issue(t, mt.MembershipOf("node-tools", "desk", false, nil))
|
||||
conn := connect(t, "node-tools", "desk")
|
||||
mt.Until(t, func() error {
|
||||
if got := conn.Membership("node-tools"); got == nil || len(got.Waits) == 0 {
|
||||
if conn.Membership("node-tools") == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return nil
|
||||
@@ -36,6 +44,25 @@ func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) {
|
||||
return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh
|
||||
}
|
||||
|
||||
// halfSource is a Source that also holds a catalog half, as the tool runner's first views do.
|
||||
type halfSource struct {
|
||||
Source
|
||||
halves map[string]meshcatalogclient.Half
|
||||
}
|
||||
|
||||
func (h halfSource) Halves() (map[string]meshcatalogclient.Half, error) { return h.halves, nil }
|
||||
|
||||
// listening waits until the surface's connection holds n more subscriptions than before: a wait has subscribed.
|
||||
func listening(t *testing.T, s *Surface, before, n int) {
|
||||
t.Helper()
|
||||
mt.Until(t, func() error {
|
||||
if s.conn.Subscriptions() < before+n {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// callWait is one tools/call of mesh_wait, answered as an agent host reads it.
|
||||
func callWait(s *Surface, args map[string]any) (text string, isError bool) {
|
||||
raw, _ := json.Marshal(map[string]any{"name": verbWait, "arguments": args})
|
||||
@@ -50,8 +77,9 @@ func callWait(s *Surface, args map[string]any) (text string, isError bool) {
|
||||
return strings.Join(parts, "\n"), isErr
|
||||
}
|
||||
|
||||
// novox/hq ADR 0367 §1: mesh_wait answers the first event that matches — by its family and its filter — with
|
||||
// its subject, body and time, within a second of its publication, and ignores the others.
|
||||
// novox/hq ADR 0367 §1, ADR 0670 §1 (design 34 every-event §13, §15, §19): mesh_wait answers the first event that
|
||||
// matches — by its name and its filter — with no membership naming it: its whole name, the namespace it arrived on, its
|
||||
// body, when it arrived and when it was published, within a second of its publication, and ignores the others.
|
||||
func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
|
||||
s, mesh := waitingRuntime(t)
|
||||
type answer struct {
|
||||
@@ -59,18 +87,39 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
|
||||
isErr bool
|
||||
at time.Time
|
||||
}
|
||||
type heardAnswer struct {
|
||||
Heard struct {
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
Body map[string]any `json:"body"`
|
||||
At time.Time `json:"at"`
|
||||
Published *string `json:"published"`
|
||||
} `json:"heard"`
|
||||
Read string `json:"read"`
|
||||
}
|
||||
nc, err := nats.Connect(mt.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
done := make(chan answer, 1)
|
||||
before := s.conn.Subscriptions()
|
||||
go func() {
|
||||
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"},
|
||||
"filter": map[string]any{"kind": "decision", "number": 367}, "timeout": 20})
|
||||
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-delivery-view.delivered"},
|
||||
"filter": map[string]any{"module": "netcheck"}, "timeout": 20})
|
||||
done <- answer{text, isErr, time.Now()}
|
||||
}()
|
||||
// Once it listens: another record's verdict, another family's event, then the one it waits for.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 366, "what": "approved"})
|
||||
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.proposed", map[string]any{"kind": "decision", "number": 367})
|
||||
// Once it listens: another module's delivery, another event, then the one it waits for, with its own time.
|
||||
listening(t, s, before, 6)
|
||||
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.delivered", map[string]any{"module": "grafana"})
|
||||
mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.planned", map[string]any{"module": "netcheck"})
|
||||
msg := nats.NewMsg("mesh.mod.mesh-delivery-view.event.delivered")
|
||||
msg.Header.Set("x-time", "2026-11-12T03:00:00.5Z")
|
||||
msg.Data = []byte(`{"module":"netcheck"}`)
|
||||
published := time.Now()
|
||||
mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 367, "what": "approved"})
|
||||
if err := nc.PublishMsg(msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got answer
|
||||
select {
|
||||
case got = <-done:
|
||||
@@ -83,32 +132,32 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) {
|
||||
if got.at.Sub(published) > time.Second {
|
||||
t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published))
|
||||
}
|
||||
var heard struct {
|
||||
Heard struct {
|
||||
Subject string `json:"subject"`
|
||||
Body map[string]any `json:"body"`
|
||||
At time.Time `json:"at"`
|
||||
} `json:"heard"`
|
||||
}
|
||||
var heard heardAnswer
|
||||
if err := json.Unmarshal([]byte(got.text), &heard); err != nil {
|
||||
t.Fatalf("not an answer: %s", got.text)
|
||||
}
|
||||
if heard.Heard.Subject != "mesh-knowledge.reviewed" || heard.Heard.Body["number"] != float64(367) || heard.Heard.At.IsZero() {
|
||||
t.Errorf("answered %+v, want record 367's verdict with its time", heard.Heard)
|
||||
if h := heard.Heard; h.Subject != "mesh-delivery-view.delivered" || h.From != "module" || h.Body["module"] != "netcheck" ||
|
||||
h.At.IsZero() || h.Published == nil || *h.Published != "2026-11-12T03:00:00.5Z" || !strings.Contains(heard.Read, "only wakes you") {
|
||||
t.Errorf("answered %s, want netcheck's delivery from a module with its times", got.text)
|
||||
}
|
||||
// And the subject it is granted on the asker's own `decided`, by the name a session asks for.
|
||||
// And a seat's `decided` of one asker, heard by `operator-channel.decided`: its whole name, from the seat, with no
|
||||
// time of its own, and the answer read only with claude_code_answer.
|
||||
before = s.conn.Subscriptions()
|
||||
go func() {
|
||||
text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided.claude-code", "timeout": 20})
|
||||
text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided", "timeout": 20})
|
||||
done <- answer{text, isErr, time.Now()}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
listening(t, s, before, 6)
|
||||
// A seat's event, said by the router on core NATS here: no stream of the test's bus carries the seat.
|
||||
if err := connect(t, "messenger", "").Say("mesh.seat.operator-channel.event.decided.claude-code", []byte(`{"ask":"a1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case got = <-done:
|
||||
if got.isErr || !strings.Contains(got.text, `"subject": "operator-channel.decided.claude-code"`) {
|
||||
heard = heardAnswer{}
|
||||
_ = json.Unmarshal([]byte(got.text), &heard)
|
||||
if h := heard.Heard; got.isErr || h.Subject != "operator-channel.decided.claude-code" || h.From != "seat" ||
|
||||
h.Published != nil || !strings.Contains(heard.Read, "claude_code_answer") {
|
||||
t.Errorf("the asker's decided: %s", got.text)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
@@ -125,7 +174,8 @@ func TestMeshWaitSaysItTimedOutAndKeepsNothing(t *testing.T) {
|
||||
if took := time.Since(start); took < time.Second || took > 3*time.Second {
|
||||
t.Errorf("a one-second wait took %s", took)
|
||||
}
|
||||
if isErr || !strings.Contains(text, `"timed-out": true`) {
|
||||
// Before the catalog half is read, names are not checked, and the answer says so (design 34 every-event §14).
|
||||
if isErr || !strings.Contains(text, `"timed-out": true`) || !strings.Contains(text, `"unchecked"`) {
|
||||
t.Errorf("a wait that heard nothing: %s", text)
|
||||
}
|
||||
if after := s.conn.Subscriptions(); after != before {
|
||||
@@ -148,41 +198,313 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0367 §2 and §6: an event outside the granted families is refused, naming the families and how
|
||||
// one is added; no argument — a wildcard, another asker's `decided`, a subject spelled out — widens it.
|
||||
func TestMeshWaitRefusesAnEventOutsideTheGrantedFamilies(t *testing.T) {
|
||||
s, _ := waitingRuntime(t)
|
||||
for _, subjects := range []any{
|
||||
[]any{"mesh-knowledge.proposed"},
|
||||
[]any{"mesh-knowledge.reviewed", "gitea.forge.push"},
|
||||
[]any{"mesh-knowledge.*"},
|
||||
[]any{"operator-channel.decided.shop"},
|
||||
[]any{"operator-channel.decided.*"},
|
||||
[]any{"mesh.mod.mesh-knowledge.event.reviewed"},
|
||||
[]any{">"},
|
||||
[]any{},
|
||||
// novox/hq ADR 0670 §1, §2 (design 34 every-event §13, §16, §19): mesh_wait refuses loudly and never loops: a name with
|
||||
// a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a
|
||||
// wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant.
|
||||
func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) {
|
||||
s, mesh := waitingRuntime(t)
|
||||
var seventeen []any
|
||||
for i := 0; i < 17; i++ {
|
||||
seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i))
|
||||
}
|
||||
for _, c := range []struct {
|
||||
args map[string]any
|
||||
says string
|
||||
}{
|
||||
{map[string]any{"subjects": []any{"mesh-knowledge.*"}}, "[A-Za-z0-9_-]+"},
|
||||
{map[string]any{"subjects": []any{"operator-channel.decided.>"}}, "[A-Za-z0-9_-]+"},
|
||||
{map[string]any{"subjects": []any{"mesh-knowledge"}}, "fewer than two tokens"},
|
||||
{map[string]any{"subjects": seventeen}, "at most 16"},
|
||||
{map[string]any{"subjects": []any{}}, "at least one event"},
|
||||
{map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "from": "bench"}, "seat, module or stream"},
|
||||
} {
|
||||
text, isErr := callWait(s, map[string]any{"subjects": subjects, "timeout": 1})
|
||||
if !isErr {
|
||||
t.Errorf("%v was waited on: %s", subjects, text)
|
||||
continue
|
||||
c.args["timeout"] = 1
|
||||
start := time.Now()
|
||||
text, isErr := callWait(s, c.args)
|
||||
if !isErr || !strings.Contains(text, c.says) || strings.Contains(text, "manifest") {
|
||||
t.Errorf("%v: %s, want refused saying %q", c.args, text, c.says)
|
||||
}
|
||||
if len(subjects.([]any)) > 0 && (!strings.Contains(text, "mesh-knowledge.reviewed") || !strings.Contains(text, "manifest")) {
|
||||
t.Errorf("%v refused without naming the families and how one is added: %s", subjects, text)
|
||||
if took := time.Since(start); took > 500*time.Millisecond {
|
||||
t.Errorf("%v was refused after %s, want at once", c.args, took)
|
||||
}
|
||||
}
|
||||
|
||||
// E §14: against the catalog half, a misspelt emitter is refused naming the half and its revision, a known emitter's
|
||||
// undeclared event is waited on and its timeout says so, and a declared one says nothing of it.
|
||||
half := NewSurface(s.conn, halfSource{busSource{s.conn}, map[string]meshcatalogclient.Half{"main": {
|
||||
Revision: catalogseat.CatalogRevision{Revision: 7, Definitions: []catalogseat.CatalogRevisionDefinitionsItem{{Seat: "mesh-catalog"}}},
|
||||
Entries: map[string]catalogseat.CatalogEntry{"mesh-delivery-view": {Module: "mesh-delivery-view",
|
||||
Manifest: map[string]any{"emits": []any{"delivered"}}}},
|
||||
}}}, "desk.node-tools")
|
||||
for _, c := range []struct {
|
||||
name, says string
|
||||
refused bool
|
||||
}{
|
||||
{"mesh-delivry-view.delivered", "mesh-delivry-view is neither a seat nor a module of the catalog half (mesh-catalog.revision, revision 7 of main)", true},
|
||||
{"mesh-delivery-view.planned", "no declaration names mesh-delivery-view.planned", false},
|
||||
{"mesh-catalog.catalog-revision", "", false},
|
||||
} {
|
||||
text, isErr := callWait(half, map[string]any{"subjects": []any{c.name}, "timeout": 1})
|
||||
if isErr != c.refused || c.says != "" && !strings.Contains(text, c.says) ||
|
||||
c.says == "" && (strings.Contains(text, "undeclared") || strings.Contains(text, "unchecked")) {
|
||||
t.Errorf("%s against the catalog half: %s", c.name, text)
|
||||
}
|
||||
}
|
||||
|
||||
// The bus refusing every subscription of the call: answered after the timeout, in §16's words.
|
||||
b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true,
|
||||
Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{
|
||||
Subscribe: &server.SubjectPermission{Allow: []string{"_INBOX.desk.node-tools.>", "mesh.assignment.desk.*"}},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go b.Start()
|
||||
if !b.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(b.Shutdown)
|
||||
conn, err := bus.Connect(bus.Credential{URL: b.ClientURL(), Module: "node-tools", Node: "desk", User: "desk.node-tools", Password: "pw"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Logf = func(string, ...any) {}
|
||||
t.Cleanup(conn.Close)
|
||||
start := time.Now()
|
||||
text, isErr := callWait(NewSurface(conn, nil, "desk.node-tools"), map[string]any{"subjects": []any{"mesh-delivery-view.delivered"}, "timeout": 1})
|
||||
if took := time.Since(start); took < time.Second || took > 3*time.Second {
|
||||
t.Errorf("a wait the bus refused whole answered after %s, want its timeout of 1s", took)
|
||||
}
|
||||
if !isErr || !strings.Contains(text, "the bus refused this account a subscription on mesh.seat.mesh-delivery-view.event.delivered") ||
|
||||
!strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") {
|
||||
t.Errorf("a wait the bus refused whole: %s", text)
|
||||
}
|
||||
// Design 34 every-event §37: a wait whose subjects overlap a marked event's, or a stream's with a `grant`, runs only on the
|
||||
// operator's warrant for that one wait; with none it subscribes nothing, asks once and answers at its timeout or the choice.
|
||||
markedWaits(t, s, mesh)
|
||||
}
|
||||
|
||||
// markedWaits are §37's rows, against a gate on the test bus, the router's records written there as the router writes them.
|
||||
func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) {
|
||||
gw := newGatedWaits(t, s, mesh, "src2:runner")
|
||||
g := gw.surface
|
||||
took := func(args map[string]any) (string, bool, time.Duration) {
|
||||
start := time.Now()
|
||||
text, isErr := callWait(g, args)
|
||||
return text, isErr, time.Since(start)
|
||||
}
|
||||
// A fixture module's marked event, no warrant: nothing of it subscribed (only its ask's decided), asked once, §12's
|
||||
// words at its timeout; the same wait again asks nothing new.
|
||||
before := g.conn.Subscriptions()
|
||||
go func() {
|
||||
listening(t, g, before, 1)
|
||||
mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 1})
|
||||
}()
|
||||
text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1})
|
||||
if !isErr || !strings.Contains(text, "this wait needs the operator's approval. Asked on telegram") || gw.asks() != 1 ||
|
||||
d < time.Second || strings.Contains(text, "heard") {
|
||||
t.Errorf("a marked event's wait, no warrant: %s after %s, %d asks", text, d, gw.asks())
|
||||
}
|
||||
if text, isErr, _ = took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr ||
|
||||
!strings.Contains(text, "still waiting") || gw.asks() != 1 {
|
||||
t.Errorf("the same marked wait again: %s, %d asks", text, gw.asks())
|
||||
}
|
||||
// A stream with a `grant` is marked whole; a name with the node after a marked event's name is marked.
|
||||
for i, name := range []string{"mail.inbox", "mail.message.desk"} {
|
||||
if text, isErr, _ := took(map[string]any{"subjects": []any{name}, "timeout": 1}); !isErr || !strings.Contains(text, "Not run") || gw.asks() != 2+i {
|
||||
t.Errorf("%s: %s, %d asks", name, text, gw.asks())
|
||||
}
|
||||
}
|
||||
// The same names from a seat ask nothing.
|
||||
if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "from": "seat", "timeout": 1}); isErr ||
|
||||
!strings.Contains(text, `"timed-out": true`) || gw.asks() != 3 {
|
||||
t.Errorf("from a seat: %s, %d asks", text, gw.asks())
|
||||
}
|
||||
// The most timeout a wait takes, its ask chosen while it waits: answered at the choice; the same call made then
|
||||
// subscribes and hears the event published after it, answered with its approval; a warrant for other names, another
|
||||
// filter or another timeout is refused, asked anew.
|
||||
long := map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 600}
|
||||
done := make(chan string, 1)
|
||||
go func() { text, _, d := took(long); done <- fmt.Sprintf("%s after %s", text, d) }()
|
||||
mt.Until(t, func() error {
|
||||
if gw.asks() < 4 {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return nil
|
||||
})
|
||||
gw.approve(t)
|
||||
select {
|
||||
case got := <-done:
|
||||
if !strings.Contains(got, "make this wait again within 30 minutes") {
|
||||
t.Errorf("a long wait chosen while it waits: %s", got)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("a long marked wait was not answered at the choice")
|
||||
}
|
||||
before = g.conn.Subscriptions()
|
||||
go func() {
|
||||
listening(t, g, before, 6)
|
||||
mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 2})
|
||||
}()
|
||||
if text, isErr, _ := took(long); isErr || !strings.Contains(text, `"approval": "run on the operator's approval`) ||
|
||||
!strings.Contains(text, `"subject": "mail.message"`) {
|
||||
t.Errorf("the same wait on its warrant: %s", text)
|
||||
}
|
||||
for _, other := range []map[string]any{
|
||||
{"subjects": []any{"mail.message", "mail.inbox"}, "filter": map[string]any{"n": 2}, "timeout": 1},
|
||||
{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 3}, "timeout": 1},
|
||||
{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 2},
|
||||
} {
|
||||
asked := gw.asks()
|
||||
if text, isErr, _ := took(other); !isErr || !strings.Contains(text, "Not run") || gw.asks() != asked+1 {
|
||||
t.Errorf("another wait than the warrant's %v: %s", other, text)
|
||||
}
|
||||
}
|
||||
// Before the catalog half is read nothing is asked: judged when its first view comes within the timeout, refused at
|
||||
// the timeout when it does not.
|
||||
gw.cat.mu.Lock()
|
||||
gw.cat.ready = errors.New("no first view yet")
|
||||
gw.cat.mu.Unlock()
|
||||
asked := gw.asks()
|
||||
if text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr ||
|
||||
!strings.Contains(text, "the marks are not yet read") || gw.asks() != asked || d < time.Second {
|
||||
t.Errorf("before the catalog half is read: %s after %s", text, d)
|
||||
}
|
||||
gw.cat.came = make(chan chan struct{})
|
||||
go func() { close(<-gw.cat.came) }()
|
||||
if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 9}, "timeout": 1}); !isErr ||
|
||||
!strings.Contains(text, "this wait needs the operator's approval") || gw.asks() != asked+1 {
|
||||
t.Errorf("the catalog half read within the timeout: %s", text)
|
||||
}
|
||||
// While the runner's own fingerprint is unknown a marked wait is refused, saying so.
|
||||
unknown := newGatedWaits(t, s, mesh, "")
|
||||
if text, isErr := callWait(unknown.surface, map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr ||
|
||||
!strings.Contains(text, "own fingerprint is unknown") || unknown.asks() != 0 {
|
||||
t.Errorf("the runner's fingerprint unknown: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool runner issued no families waits on nothing, and says so.
|
||||
func TestMeshWaitWithNoFamiliesIssuedWaitsOnNothing(t *testing.T) {
|
||||
mt.New(t)
|
||||
s := NewSurface(connect(t, "node-tools", "desk"), nil, "desk.node-tools")
|
||||
text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1})
|
||||
if !isErr || !strings.Contains(text, "no event families") {
|
||||
t.Errorf("a runtime issued no families: %s", text)
|
||||
// gatedWaits is the surface with the runner's approval gate: the fixture module mail marks its event message and declares
|
||||
// the stream inbox with a grant; the router takes each ask as it is published, its records on the test bus.
|
||||
type gatedWaits struct {
|
||||
surface *Surface
|
||||
cat *waitCatalog
|
||||
records nats.KeyValue
|
||||
mu sync.Mutex
|
||||
asked []opseat.Ask
|
||||
say *bus.Conn
|
||||
}
|
||||
|
||||
func newGatedWaits(t *testing.T, s *Surface, mesh *mt.Mesh, build string) *gatedWaits {
|
||||
gw := &gatedWaits{cat: &waitCatalog{entries: []catalogseat.CatalogEntry{{Module: "mail", Fp: "src2:mail", Manifest: map[string]any{
|
||||
"emits": []any{"message"}, "needs-approval": []any{"message"},
|
||||
"streams": []any{map[string]any{"name": "inbox", "events": []any{"inbox.*"}, "grant": map[string]any{"subscribe": []any{"x"}}}}}}}},
|
||||
records: mesh.Bucket(t, "operator-channel-asks"), say: connect(t, "messenger", "")}
|
||||
gate := approval.New(approval.Deps{Node: "desk", Runner: "node-tools", Build: build, Catalog: gw.cat, Wait: gw.cat.wait,
|
||||
State: mt.NewState(), Holder: func(string) (bool, error) { return true, nil },
|
||||
Hear: func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) {
|
||||
_, ok, _, err := s.conn.HearFirst(ctx, subjects, within, match)
|
||||
return ok, err
|
||||
},
|
||||
Publish: func(module, subject string, body any, id string) error {
|
||||
a, ok := body.(opseat.Ask)
|
||||
if subject != opseat.AcceptSubjectAsk(module) || !ok {
|
||||
return nil
|
||||
}
|
||||
gw.mu.Lock()
|
||||
gw.asked = append(gw.asked, a)
|
||||
gw.mu.Unlock()
|
||||
gw.write(t, opseat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", Headline: a.Headline,
|
||||
Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}})
|
||||
return nil
|
||||
},
|
||||
Record: func(module, id string) (*stdio.StateEntry, error) {
|
||||
e, err := gw.records.Get(module + "." + id)
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stdio.StateEntry{Key: e.Key(), Value: e.Value(), Revision: e.Revision()}, nil
|
||||
}})
|
||||
gw.surface = NewSurface(s.conn, busSource{s.conn}, "desk.node-tools")
|
||||
gw.surface.Approvals = gateApprovals{gate}
|
||||
return gw
|
||||
}
|
||||
|
||||
func (gw *gatedWaits) write(t *testing.T, rec opseat.AsksByAskerIDValue) {
|
||||
raw, _ := json.Marshal(rec)
|
||||
if _, err := gw.records.Put(rec.Key, raw); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *gatedWaits) asks() int {
|
||||
gw.mu.Lock()
|
||||
defer gw.mu.Unlock()
|
||||
return len(gw.asked)
|
||||
}
|
||||
|
||||
// approve writes the router's record of the newest ask as a person's Approve, and says its decided.
|
||||
func (gw *gatedWaits) approve(t *testing.T) {
|
||||
gw.mu.Lock()
|
||||
a := gw.asked[len(gw.asked)-1]
|
||||
gw.mu.Unlock()
|
||||
gw.write(t, opseat.AsksByAskerIDValue{Asker: "node-tools", Key: "node-tools." + a.ID, State: "chosen", Expires: a.Expires,
|
||||
OfferedOn: []string{"telegram"}, Warrant: &opseat.Warrant{Ask: a.ID, Asker: "node-tools", Outcome: operatorchannelclient.OutcomeChosen,
|
||||
Option: approval.OptionApprove, Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(time.Now()),
|
||||
AskDigest: operatorchannelclient.Digest(a), Channel: "telegram",
|
||||
By: &opseat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}})
|
||||
if err := gw.say.Say(opseat.SubjectDecided("node-tools"), []byte(`{"ask":"`+a.ID+`"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// gateApprovals is the gate as the surface holds it: mesh_approvals' reads unused here, mesh_wait's gate.
|
||||
type gateApprovals struct{ *approval.Gate }
|
||||
|
||||
func (gateApprovals) Fill() (uint64, int64, error) { return 0, 0, nil }
|
||||
|
||||
// waitCatalog is a catalog half as a fixture: its entries, read or not; with came set, its first view comes once a wait
|
||||
// waits for it (waiting is told on it, then it closes).
|
||||
type waitCatalog struct {
|
||||
entries []catalogseat.CatalogEntry
|
||||
mu sync.Mutex
|
||||
ready error
|
||||
came chan chan struct{}
|
||||
}
|
||||
|
||||
func (c *waitCatalog) Ready() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.ready
|
||||
}
|
||||
func (c *waitCatalog) wait(within time.Duration) error {
|
||||
if c.Ready() == nil {
|
||||
return nil
|
||||
}
|
||||
if c.came != nil {
|
||||
view := make(chan struct{})
|
||||
c.came <- view
|
||||
<-view
|
||||
c.mu.Lock()
|
||||
c.ready = nil
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
<-time.After(within) // the bound of the runner's own wait for the first view
|
||||
return c.Ready()
|
||||
}
|
||||
func (c *waitCatalog) Revision() string { return "7" }
|
||||
func (c *waitCatalog) Served(string) (catalogseat.CatalogEntry, bool, string) {
|
||||
return catalogseat.CatalogEntry{}, false, "not-found"
|
||||
}
|
||||
func (c *waitCatalog) Newest(string) (catalogseat.CatalogEntry, bool) {
|
||||
return catalogseat.CatalogEntry{}, false
|
||||
}
|
||||
func (c *waitCatalog) Definitions() map[string]seatdef.Definition { return nil }
|
||||
func (c *waitCatalog) Entries() []catalogseat.CatalogEntry { return c.entries }
|
||||
|
||||
// The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded.
|
||||
func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
|
||||
var names []string
|
||||
@@ -194,7 +516,7 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
want := []string{"mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"}
|
||||
want := []string{"mesh_approvals", "mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Errorf("announced %v, want %v", names, want)
|
||||
}
|
||||
@@ -207,8 +529,49 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) {
|
||||
t.Errorf("mesh_wait's schema has no %s: %s", word, schema)
|
||||
}
|
||||
}
|
||||
// Design 46 7-approval-mark §18: mesh_approvals lists, and records a hand act before it cancels; refused without a record.
|
||||
gate := &fakeApprovals{}
|
||||
var recorded []string
|
||||
s := &Surface{who: "n1.node-tools", Approvals: gate, Record: func(a map[string]any) error {
|
||||
if a["why"] == "unrecordable" {
|
||||
return errors.New("mesh-hand-acts does not answer")
|
||||
}
|
||||
recorded = append(recorded, a["what"].(string))
|
||||
return nil
|
||||
}}
|
||||
if out := s.discover(context.Background(), verbApprovals, map[string]any{}); out["isError"] == true || !strings.Contains(fmtAnswer(out), "every machine") {
|
||||
t.Errorf("the list %v", out)
|
||||
}
|
||||
if out := s.discover(context.Background(), verbApprovals, map[string]any{"cancel": "a1", "why": "no longer needed"}); out["isError"] == true ||
|
||||
gate.cancelled != "a1" || len(recorded) != 1 || recorded[0] != "cancelled ask a1" {
|
||||
t.Errorf("a cancel %v, recorded %v", out, recorded)
|
||||
}
|
||||
if out := s.discover(context.Background(), verbApprovals, map[string]any{"cancel": "a2", "why": "unrecordable"}); out["isError"] != true || gate.cancelled != "a1" {
|
||||
t.Errorf("a cancel without a record %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func fmtAnswer(out map[string]any) string { raw, _ := json.Marshal(out); return string(raw) }
|
||||
|
||||
// fakeApprovals is a gate as mesh_approvals reads it.
|
||||
type fakeApprovals struct {
|
||||
cancelled string
|
||||
// marked is the one `<module>.<tool>` its Describe says is marked.
|
||||
marked string
|
||||
}
|
||||
|
||||
func (f *fakeApprovals) List() ([]approval.Listed, error) {
|
||||
return []approval.Listed{{Ask: "a1", State: "asked"}}, nil
|
||||
}
|
||||
func (f *fakeApprovals) Read(id string) (approval.Whole, error) { return approval.Whole{}, nil }
|
||||
func (f *fakeApprovals) Applied() []approval.Marks { return nil }
|
||||
func (f *fakeApprovals) Describe(module, _, name string) (bool, string) {
|
||||
return f.marked != "" && f.marked == module+"."+name, "entry " + module
|
||||
}
|
||||
func (f *fakeApprovals) Again(string) error { return nil }
|
||||
func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil }
|
||||
func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil }
|
||||
|
||||
// A client that hangs up or cancels ends its wait at once: the subscription is closed and nothing is left
|
||||
// waiting for the timeout (the review of issue 745). Over the console's own HTTP, as an agent host calls it.
|
||||
func TestMeshWaitEndsWhenItsClientHangsUp(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package firstview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
)
|
||||
|
||||
// Findings is the runner's held finding writer, the one the gate writes its findings through too (design 46 7-approval-mark
|
||||
// §4, §16, §19); nil when it was not made.
|
||||
func (v *Views) Findings() approval.FindingWriter {
|
||||
if v.deps.Findings == nil {
|
||||
return nil
|
||||
}
|
||||
return v.deps.Findings
|
||||
}
|
||||
|
||||
// ApprovalCatalog is the catalog half as the gate reads its marks (design 46 7-approval-mark §5): the entries the branch
|
||||
// follower holds, an entry by fingerprint asked of `mesh-catalog.entry` when no branch lists it, and the seat definitions
|
||||
// the seat list holds (defs).
|
||||
func (v *Views) ApprovalCatalog(defs func() map[string]seatdef.Definition) approval.Catalog {
|
||||
return approvalCatalog{v: v, defs: defs}
|
||||
}
|
||||
|
||||
type approvalCatalog struct {
|
||||
v *Views
|
||||
defs func() map[string]seatdef.Definition
|
||||
}
|
||||
|
||||
func (c approvalCatalog) Ready() error {
|
||||
if c.v.branches == nil {
|
||||
return errNotFollowed
|
||||
}
|
||||
return c.v.branches.Ready()
|
||||
}
|
||||
|
||||
func (c approvalCatalog) Revision() string {
|
||||
if c.v.branches == nil {
|
||||
return ""
|
||||
}
|
||||
for _, branch := range c.v.branches.Followed() {
|
||||
if cat, ok := c.v.branches.Catalog(branch); ok {
|
||||
if half, ok := cat.View(); ok {
|
||||
return strconv.FormatInt(half.Revision.Revision, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c approvalCatalog) Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) {
|
||||
if c.v.branches == nil {
|
||||
return catalogseat.CatalogEntry{}, false, errNotFollowed.Error()
|
||||
}
|
||||
e, state := c.v.branches.Resolve(context.Background(), fingerprint)
|
||||
return e, state == meshcatalogclient.EntryAnswered, string(state)
|
||||
}
|
||||
|
||||
func (c approvalCatalog) Newest(module string) (catalogseat.CatalogEntry, bool) {
|
||||
if c.v.branches == nil {
|
||||
return catalogseat.CatalogEntry{}, false
|
||||
}
|
||||
e, ok, err := c.v.branches.Entry(module)
|
||||
return e, ok && err == nil
|
||||
}
|
||||
|
||||
func (c approvalCatalog) Definitions() map[string]seatdef.Definition {
|
||||
if c.defs == nil {
|
||||
return nil
|
||||
}
|
||||
return c.defs()
|
||||
}
|
||||
|
||||
func (c approvalCatalog) Entries() []catalogseat.CatalogEntry {
|
||||
halves, err := c.v.Halves()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []catalogseat.CatalogEntry
|
||||
for _, half := range halves {
|
||||
for _, e := range half.Entries {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type notFollowed struct{}
|
||||
|
||||
func (notFollowed) Error() string { return "the catalog half is not followed by this runner" }
|
||||
|
||||
var errNotFollowed error = notFollowed{}
|
||||
|
||||
// heardOne wakes every wait on the picture.
|
||||
func (v *Views) heardOne() {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.heard != nil {
|
||||
close(v.heard)
|
||||
}
|
||||
v.heard = make(chan struct{})
|
||||
}
|
||||
|
||||
// heardNext is closed at the next event heard.
|
||||
func (v *Views) heardNext() <-chan struct{} {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.heard == nil {
|
||||
v.heard = make(chan struct{})
|
||||
}
|
||||
return v.heard
|
||||
}
|
||||
|
||||
// WaitCatalog waits within the bound for the catalog half's first view of every watched branch (design 46 7-approval-mark
|
||||
// §19), waking at each event the runner hears and at each first view answered; nil once it is read, else why not.
|
||||
func (v *Views) WaitCatalog(within time.Duration) error {
|
||||
c := approvalCatalog{v: v}
|
||||
timer := time.NewTimer(within)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
next := v.heardNext()
|
||||
err := c.Ready()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ready []<-chan struct{}
|
||||
if v.branches != nil {
|
||||
for _, branch := range v.branches.Followed() {
|
||||
if cat, ok := v.branches.Catalog(branch); ok {
|
||||
select {
|
||||
case <-cat.Ready(): // read already: waits on nothing
|
||||
default:
|
||||
ready = append(ready, cat.Ready())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !waitAny(next, timer.C, ready) {
|
||||
return c.Ready()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitAny waits for next or one of ready, true, or for the bound, false.
|
||||
func waitAny(next <-chan struct{}, bound <-chan time.Time, ready []<-chan struct{}) bool {
|
||||
woke := make(chan struct{}, 1)
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
for _, r := range ready {
|
||||
go func(r <-chan struct{}) {
|
||||
select {
|
||||
case <-r:
|
||||
woke <- struct{}{}
|
||||
case <-stop:
|
||||
}
|
||||
}(r)
|
||||
}
|
||||
select {
|
||||
case <-next:
|
||||
case <-woke:
|
||||
case <-bound:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OnSeatHolder calls f whenever the assigner's seat-holders view changes for the seat (lead answer A-157: the gate asks
|
||||
// mesh-hand-acts.warranted again when the hand-act seat's holder is heard again).
|
||||
func (v *Views) OnSeatHolder(seat string, f func()) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.onKey == nil {
|
||||
v.onKey = map[string][]func(){}
|
||||
}
|
||||
k := "seat-holders " + seat
|
||||
v.onKey[k] = append(v.onKey[k], f)
|
||||
}
|
||||
|
||||
// heardKey calls the hearers of a key of an owner's state just changed: a seat's holders, under the seat's own key and
|
||||
// its `<seat>.<node>` keys.
|
||||
func (v *Views) heardKey(state, key string) {
|
||||
seat, _, _ := strings.Cut(key, ".")
|
||||
v.mu.Lock()
|
||||
fs := append([]func(){}, v.onKey[state+" "+seat]...)
|
||||
v.mu.Unlock()
|
||||
for _, f := range fs {
|
||||
go f()
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
@@ -66,6 +67,10 @@ type Views struct {
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
deps Deps
|
||||
// heard is closed and made again at every event heard, so a wait for the picture wakes on events, never on a timer.
|
||||
heard chan struct{}
|
||||
// onKey are the hearers of one key of an owner's state, by `<state> <key>` (OnSeatHolder).
|
||||
onKey map[string][]func()
|
||||
}
|
||||
|
||||
// FindingsState is the runner's own state the findings are written in.
|
||||
@@ -120,7 +125,7 @@ func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps {
|
||||
}
|
||||
w, err := views.NewHeldWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{
|
||||
Asked: condseat.SubjectFindingsAsked, Module: me, Node: conn.Node(), State: findingsState{conn: conn, module: me}, JS: js, Logf: logf,
|
||||
Kinds: []string{views.UnansweredKind}})
|
||||
Kinds: append([]string{views.UnansweredKind}, approval.Kinds...)})
|
||||
if err != nil {
|
||||
logf("[mesh-tools] the runner's findings are not written (%v): the first views' findings are in the log only", err)
|
||||
return d
|
||||
@@ -204,8 +209,10 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View
|
||||
return v, func() { stop(); cancel(); v.wg.Wait(); stopFindings() }
|
||||
}
|
||||
|
||||
// hear applies one views event; a gap asks the first view once more.
|
||||
// hear applies one views event; a gap asks the first view once more. Every event heard wakes what waits on the picture
|
||||
// (heardOne).
|
||||
func (v *Views) hear(env bus.Envelope) error {
|
||||
defer v.heardOne()
|
||||
v.mu.Lock()
|
||||
hs := append([]*adoption(nil), v.hearers[env.Key]...)
|
||||
v.mu.Unlock()
|
||||
@@ -225,6 +232,7 @@ func (v *Views) hear(env bus.Envelope) error {
|
||||
if f == nil || f.Owner != ev.Owner {
|
||||
return nil
|
||||
}
|
||||
defer v.heardKey(ev.State, ev.Key)
|
||||
if gap, why := f.Apply(ev); gap {
|
||||
v.logf("[mesh-tools] a gap in %s's %s (%s): its first view %s is asked once more", f.Owner, f.State, why, f.Tool)
|
||||
go func() { _ = f.Again(v.ctx, "a gap: "+why) }()
|
||||
@@ -437,6 +445,25 @@ func (w wiring) Drop(owner, state string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Halves is the catalog half of each followed branch, by branch: its revision, with the seat definitions it was judged
|
||||
// with, and its entries; mesh_wait checks an event name's emitter against them (design 34 every-event §14). An error says
|
||||
// the half has no first view yet, never that no module exists.
|
||||
func (v *Views) Halves() (map[string]meshcatalogclient.Half, error) {
|
||||
if v.branches == nil {
|
||||
return nil, fmt.Errorf("the tool runner does not follow the catalog half")
|
||||
}
|
||||
if err := v.branches.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]meshcatalogclient.Half{}
|
||||
for _, branch := range v.branches.Followed() {
|
||||
if c, ok := v.branches.Catalog(branch); ok {
|
||||
out[branch], _ = c.View() // a branch at none-yet is ready and empty (§614)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Modules is every module the catalog half holds, by name, with its entry: the entries of the watched branches' newest trunk
|
||||
// revisions, the branch followed first naming a module more than one hold (design 47 G §196, §594). An error says the half has
|
||||
// no first view yet, never that no module exists.
|
||||
|
||||
@@ -158,6 +158,10 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 })
|
||||
// And the halves by branch, with their revision, which mesh_wait names checking an emitter (design 34 every-event §14).
|
||||
if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 2 {
|
||||
t.Errorf("the halves: %+v, %v", h, err)
|
||||
}
|
||||
|
||||
// Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again.
|
||||
asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package meshtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// State is a module's state as a fixture, the shape a gate's Store takes: Fail makes every read and write fail, as a bus
|
||||
// refusing them; Vals are its values by key.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
Vals map[string]json.RawMessage
|
||||
revs map[string]uint64
|
||||
n uint64
|
||||
Fail error
|
||||
}
|
||||
|
||||
// NewState is an empty State.
|
||||
func NewState() *State { return &State{Vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} }
|
||||
|
||||
func (m *State) Get(k string) (*stdio.StateEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return nil, m.Fail
|
||||
}
|
||||
v, ok := m.Vals[k]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
|
||||
}
|
||||
func (m *State) Create(k string, v any) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return 0, m.Fail
|
||||
}
|
||||
if _, ok := m.Vals[k]; ok {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return m.put(k, v)
|
||||
}
|
||||
func (m *State) Update(k string, v any, rev uint64) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return 0, m.Fail
|
||||
}
|
||||
if rev == 0 || m.revs[k] != rev {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return m.put(k, v)
|
||||
}
|
||||
func (m *State) Put(k string, v any) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return 0, m.Fail
|
||||
}
|
||||
return m.put(k, v)
|
||||
}
|
||||
func (m *State) Keys() ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return nil, m.Fail
|
||||
}
|
||||
var out []string
|
||||
for k := range m.Vals {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
func (m *State) put(k string, v any) (uint64, error) {
|
||||
raw, ok := v.(json.RawMessage)
|
||||
if !ok {
|
||||
raw, _ = json.Marshal(v)
|
||||
}
|
||||
m.n++
|
||||
m.Vals[k], m.revs[k] = raw, m.n
|
||||
return m.n, nil
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
|
||||
var m struct {
|
||||
Serves map[string]map[string]any `json:"serves"`
|
||||
Moved bool `json:"moved"`
|
||||
State []string `json:"state"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
Reads []string `json:"reads"`
|
||||
Provides []struct {
|
||||
Name string `json:"name"`
|
||||
@@ -172,9 +172,18 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) {
|
||||
// Every bucket the sdk's helper reaches is a state the manifest keeps or reads, by the sdk's names: the provider's
|
||||
// own and the seat mesh-provisioner's provisions and plans (issue 1954 L6-T1); the controller's assignments no more.
|
||||
declared := map[string]bool{}
|
||||
for _, n := range append(m.State, m.Reads...) {
|
||||
for _, n := range m.Reads {
|
||||
declared[n] = true
|
||||
}
|
||||
for _, r := range m.State { // an old state entry is its name, or an object naming it (approvals, per machine)
|
||||
var e struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(r, &e.Name) != nil {
|
||||
_ = json.Unmarshal(r, &e)
|
||||
}
|
||||
declared[e.Name] = true
|
||||
}
|
||||
for bucket, want := range map[string]string{
|
||||
provider.BucketOf(Module): "bound",
|
||||
provisions.ProvisionsBucketOf(Provisioner): "mesh-provisioner.provisions",
|
||||
|
||||
@@ -2,7 +2,6 @@ package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -140,20 +139,7 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) {
|
||||
t.Fatalf("ping of the instance: %v", err)
|
||||
}
|
||||
var ping micro.Ping
|
||||
if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" {
|
||||
if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" {
|
||||
t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Design 47 G §462: the call that reads a sent object is listed under the bus state capability, and the ledger calls keep theirs.
|
||||
func TestTheSentObjectCallBelongsToTheBusStateCapability(t *testing.T) {
|
||||
for c, method := range map[string]string{CapBusStateCalls: buscalls.ObjectsSentGet} {
|
||||
found := false
|
||||
for _, m := range callFamilies[c] {
|
||||
found = found || m == method
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s does not list %s", c, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ const (
|
||||
CapLedgerCalls = "ledger-calls"
|
||||
// CapPutSessions is the put and get sessions' word (design 47 G §518, §622), mesh-sdk's.
|
||||
CapPutSessions = stdio.CapPutSessions
|
||||
// CapApprovalMark says the runner honours the approval mark: its gate sits on every handler it serves (design 46
|
||||
// 7-approval-mark §6, §20), so the grants part composes no deny of its modules' marked subjects.
|
||||
CapApprovalMark = "approval-mark/1"
|
||||
)
|
||||
|
||||
// callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer.
|
||||
@@ -36,7 +39,7 @@ var callFamilies = map[string][]string{
|
||||
// CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat
|
||||
// message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5).
|
||||
func CallCapabilities() []string {
|
||||
out := []string{stdio.CapSeatSettleDelay}
|
||||
out := []string{stdio.CapSeatSettleDelay, CapApprovalMark}
|
||||
for c := range callFamilies {
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
@@ -24,7 +27,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
|
||||
mesh := mt.New(t)
|
||||
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}}))
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {})
|
||||
stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -62,13 +65,20 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// The subject that names no caller is still served, for one release (retired by hq issue 464), and says so:
|
||||
// the module is named no caller, whatever the body claims.
|
||||
// deskDefined is the seat list's definition of the fixture seat node-desk, its verb who taking its class's default.
|
||||
var deskDefined = WithSeatDefinitions(func() map[string]seatdef.Definition {
|
||||
return map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who"}}}}
|
||||
})
|
||||
|
||||
// The subject that names no caller is still served for a module's tool, for one release (retired by hq issue 464), and says
|
||||
// so: the module is named no caller, whatever the body claims. A seat's verb refuses it before the bundle runs (design 47 G
|
||||
// §610; lead answers A-165, A-179), and refuses every caller while the seat's definition is unread, names no such verb or
|
||||
// opens it to none.
|
||||
func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
|
||||
mesh := mt.New(t)
|
||||
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}}))
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {})
|
||||
stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -91,8 +101,29 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
|
||||
}
|
||||
return err
|
||||
})
|
||||
if strings.HasPrefix(subject, "mesh.seat.") {
|
||||
if !strings.Contains(reply.Error, "node-desk.who takes no unproven caller") || reply.Result.Call != "" {
|
||||
t.Errorf("%s: an unproven caller of a seat's verb was answered %+v (error %q), want refused before the bundle", subject, reply.Result, reply.Error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reply.Error != "" || reply.Result.Caller != "" {
|
||||
t.Errorf("%s: the module was named caller %q (error %q), want none", subject, reply.Result.Caller, reply.Error)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
defs map[string]seatdef.Definition
|
||||
says string
|
||||
}{
|
||||
{nil, "is not yet read"},
|
||||
{map[string]seatdef.Definition{"node-desk": {Seat: "node-desk"}}, "names no verb who"},
|
||||
{map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Callers: &seatdef.Callers{List: []string{}}}}}}, "open to no caller"},
|
||||
{map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Class: seatdef.ClassAct}}}}, ""},
|
||||
} {
|
||||
defs := c.defs
|
||||
err := (&consumers{definitions: func() map[string]seatdef.Definition { return defs }}).admitted("node-desk", "who", "shanks.node-tools")
|
||||
if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) {
|
||||
t.Errorf("a proven caller with the definitions %v: %v, want %q", c.defs, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/launch"
|
||||
)
|
||||
|
||||
// FingerprintWord is the word of a module's composed environment that names the build its bundle serves (design 47 G §424).
|
||||
const FingerprintWord = "MESH_FINGERPRINT"
|
||||
|
||||
// Gate is the approval gate the runner serves through (design 46 7-approval-mark §4, §20; node-tools/internal/approval).
|
||||
type Gate interface {
|
||||
// Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding.
|
||||
Unserved(module, fingerprint string, registered []string) []string
|
||||
// Run is a call through the gate: run is called only when the call may run now, once.
|
||||
Run(call approval.Call, run func() (any, error)) (any, error)
|
||||
}
|
||||
|
||||
// gatedRun is how a handler calls a tool: through the gate where there is one.
|
||||
type gatedRun func(call approval.Call, t launch.Tool, caller string) (any, error)
|
||||
|
||||
// gated calls the tool through the gate, or straight where the runner has none.
|
||||
func (c *consumers) gated(call approval.Call, t launch.Tool, caller string) (any, error) {
|
||||
run := func() (any, error) { return t.Run(caller, call.Args) }
|
||||
if c.gate == nil {
|
||||
return run()
|
||||
}
|
||||
return c.gate.Run(call, run)
|
||||
}
|
||||
|
||||
// WithGate serves every call through the approval gate.
|
||||
func WithGate(g Gate) Option { return func(c *consumers) { c.gate = g } }
|
||||
|
||||
var _ Gate = (*approval.Gate)(nil)
|
||||
|
||||
// WithSeatDefinitions refuses, where a seat's verb is answered, a caller outside the verb's effective callers, before the
|
||||
// bundle runs (design 47 G §610; lead answers A-165, A-179): defs are the seat definitions the runner's seat list holds.
|
||||
// Without it a verb is answered for every caller, as before.
|
||||
func WithSeatDefinitions(defs func() map[string]seatdef.Definition) Option {
|
||||
return func(c *consumers) { c.definitions = defs }
|
||||
}
|
||||
|
||||
// admitted says why a seat's verb is refused to a caller, nil when it is answered: an unproven caller is refused, and so is
|
||||
// every caller while the seat's definition is not read, names no such verb, or the verb's effective callers are empty; a
|
||||
// proven caller is among them, since the grants part composes a verb's call subject only for its effective callers (A-179).
|
||||
func (c *consumers) admitted(seat, verb, caller string) error {
|
||||
if c.definitions == nil {
|
||||
return nil
|
||||
}
|
||||
if caller == "" {
|
||||
return fmt.Errorf("Not run: %s.%s takes no unproven caller: call it on its subject that names its caller, "+
|
||||
"mesh.seat.%s.call.%s.<caller>", seat, verb, seat, verb)
|
||||
}
|
||||
def, ok := c.definitions()[seat]
|
||||
if !ok {
|
||||
return fmt.Errorf("Not run: the definition of the seat %s is not yet read by this tool runner's seat list, so no caller "+
|
||||
"of %s.%s is admitted", seat, seat, verb)
|
||||
}
|
||||
for _, v := range def.Verbs {
|
||||
if v.Name == verb {
|
||||
if len(v.EffectiveCallers()) == 0 {
|
||||
return fmt.Errorf("Not run: %s.%s is open to no caller", seat, verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("Not run: the seat %s's definition names no verb %s, so no caller is admitted", seat, verb)
|
||||
}
|
||||
|
||||
// unservedMarks is, of the module's marked tools, those its bundle's registrations under its own name do not register.
|
||||
func (r *runner) unservedMarks(module string, regs []launch.Registration) []string {
|
||||
if r.events.gate == nil {
|
||||
return nil
|
||||
}
|
||||
var registered []string
|
||||
for _, reg := range regs {
|
||||
if reg.Module == module {
|
||||
for _, t := range reg.Tools {
|
||||
registered = append(registered, t.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r.events.gate.Unserved(module, r.fingerprintOf(module), registered)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -204,9 +205,13 @@ func TestABundleToolNamedAsALedgerRepairIsRefused(t *testing.T) {
|
||||
// no other name; none answers "the runtime answers no ...".
|
||||
func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) {
|
||||
m, _, _ := aLedger(t)
|
||||
if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" {
|
||||
if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" {
|
||||
t.Fatalf("the runner states %s", got)
|
||||
}
|
||||
// Design 47 G §462: the call that reads a sent object is listed under the bus state capability.
|
||||
if !slices.Contains(callFamilies[CapBusStateCalls], buscalls.ObjectsSentGet) {
|
||||
t.Errorf("%s does not list %s", CapBusStateCalls, buscalls.ObjectsSentGet)
|
||||
}
|
||||
for capability, methods := range callFamilies {
|
||||
for _, method := range methods {
|
||||
t.Run(capability+" "+method, func(t *testing.T) {
|
||||
|
||||
@@ -16,7 +16,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/launch"
|
||||
)
|
||||
@@ -230,6 +233,10 @@ type runner struct {
|
||||
of map[string]*loadedModule
|
||||
verbs map[string]func() // each module's `tools` verb, where it is answered
|
||||
|
||||
// fingerprintOf is the build a module's bundle serves: the fingerprint its composed environment carries (design 47 G
|
||||
// §424, §618), never the runner's own.
|
||||
fingerprintOf func(module string) string
|
||||
|
||||
seatsAgain func()
|
||||
stops []func() // the watch, the announcement and the seats, in the order they began
|
||||
}
|
||||
@@ -263,6 +270,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
|
||||
|
||||
base := os.Environ()
|
||||
r.envFor = func(module string) []string { return BundleEnv(base, envs[module], module, node) }
|
||||
r.fingerprintOf = func(module string) string { return envs[module][FingerprintWord] }
|
||||
|
||||
// The module's events, for every child of it that subscribes (ADR 0198): one consumer per module,
|
||||
// bound the first time any of its children subscribes, each event handed to every child that did.
|
||||
@@ -298,7 +306,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
|
||||
}
|
||||
logf("%s", line)
|
||||
conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() })
|
||||
stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf)
|
||||
stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.fingerprintOf, logf)
|
||||
r.seatsAgain = again
|
||||
r.stops = append(r.stops, stopSeats)
|
||||
|
||||
@@ -409,6 +417,13 @@ func (r *runner) load(s Served) *loadedModule {
|
||||
fail(clashed)
|
||||
continue
|
||||
}
|
||||
// A bundle whose marks name a tool it does not register is not loaded: no tool of its module is served here, rather
|
||||
// than one served unmarked that the manifest meant to mark (design 46 7-approval-mark §4).
|
||||
if unserved := r.unservedMarks(module, child.Registrations); len(unserved) > 0 {
|
||||
child.Stop()
|
||||
fail(fmt.Sprintf("its marks name %s, which it does not register (design 46 7-approval-mark §4)", strings.Join(unserved, ", ")))
|
||||
continue
|
||||
}
|
||||
l.children = append(l.children, child.Stop)
|
||||
for _, reg := range child.Registrations {
|
||||
got := registration{module: reg.Module, owner: module, tools: reg.Tools}
|
||||
@@ -452,8 +467,11 @@ func (r *runner) serve(l *loadedModule) {
|
||||
for _, t := range reg.tools {
|
||||
t := t
|
||||
// Told who called, from the subject the call arrived on, never from its body (novox/hq issue 365).
|
||||
// Through the approval gate, before the bundle or the runner's own tool (design 46 7-approval-mark §20).
|
||||
module, own := reg.module, reg.repairs
|
||||
stop, err := r.conn.HandleCalled(reg.module+"."+t.Name, func(caller string, body json.RawMessage) (any, error) {
|
||||
return t.Run(caller, argsOf(body))
|
||||
return r.events.gated(approval.Call{Address: module + "." + t.Name, Module: module, Name: t.Name, Own: own,
|
||||
Fingerprint: r.fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller)
|
||||
})
|
||||
if err != nil {
|
||||
r.logf("[mesh-tools] cannot serve %s.%s: %v", reg.module, t.Name, err)
|
||||
@@ -691,7 +709,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string {
|
||||
// the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again
|
||||
// whenever a membership changes, and when again is called because a bundle was loaded again. Whether
|
||||
// this machine holds the seat is the bus's to decide. It answers its stop and again.
|
||||
func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf func(string, ...any)) (func(), func()) {
|
||||
func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun,
|
||||
admitted func(seat, verb, caller string) error, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) {
|
||||
var mu sync.Mutex
|
||||
var stops []func()
|
||||
stopped := false
|
||||
@@ -730,8 +749,15 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration,
|
||||
logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb)
|
||||
continue
|
||||
}
|
||||
// Its callers checked, then through the approval gate, before the bundle (design 47 G §610, A-179; design 46
|
||||
// 7-approval-mark §20).
|
||||
module, v := module, v
|
||||
stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, body json.RawMessage) (any, error) {
|
||||
return t.Run(caller, argsOf(body))
|
||||
if err := admitted(v.Seat, v.Verb, caller); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb,
|
||||
Fingerprint: fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller)
|
||||
})
|
||||
if err != nil {
|
||||
logf("[mesh-tools] cannot serve %s's %s on %s: %v", v.Seat, v.Verb, v.Subject, err)
|
||||
@@ -772,6 +798,10 @@ type consumers struct {
|
||||
calls callsInFlight
|
||||
// sessions are the bundles' put and get sessions (sessions.go).
|
||||
sessions sessionTable
|
||||
// gate is the approval gate every call is served through (gate.go); nil serves every call as before.
|
||||
gate Gate
|
||||
// definitions are the seat definitions a seat verb's callers are read from (gate.go); nil admits every caller.
|
||||
definitions func() map[string]seatdef.Definition
|
||||
}
|
||||
|
||||
type moduleEvents struct {
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/launch"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
@@ -192,10 +194,49 @@ func TestEachBundleIsGivenItsOwnEnvironment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193).
|
||||
// unservingGate is an approval gate whose marks of each module name the tools given, so a bundle not registering them is
|
||||
// refused at the load (design 46 7-approval-mark §4).
|
||||
type unservingGate map[string][]string
|
||||
|
||||
func (g unservingGate) Unserved(module, _ string, registered []string) []string {
|
||||
var missing []string
|
||||
for _, tool := range g[module] {
|
||||
if !slices.Contains(registered, tool) {
|
||||
missing = append(missing, tool)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
func (unservingGate) Run(_ approval.Call, run func() (any, error)) (any, error) { return run() }
|
||||
|
||||
// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). A bundle whose marks name a
|
||||
// tool it does not register is refused at the load, serving no tool of its module, and one that registers it serves
|
||||
// (design 46 7-approval-mark §4).
|
||||
func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) {
|
||||
mt.NeedBundles(t)
|
||||
mesh := mt.New(t)
|
||||
{
|
||||
mesh.Issue(t, mt.MembershipOf("marked", "anchor", true, nil))
|
||||
mesh.Issue(t, mt.MembershipOf("served", "anchor", true, nil))
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
asker := connect(t, "console", "workstation")
|
||||
logs := &mt.Logs{}
|
||||
stop, err := Run(nodeTools, []Served{{"marked", []string{mt.Fixture("caller-echo.py")}}, {"served", []string{mt.Fixture("caller-echo.py")}}},
|
||||
nil, logs.Logf, WithGate(unservingGate{"marked": {"wipe"}, "served": {"who"}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !logs.Has("marked's bundle", "failed to load: its marks name wipe, which it does not register (design 46 7-approval-mark §4)") {
|
||||
t.Errorf("a bundle not serving its marked tool was not refused at the load:\n%s", logs.All())
|
||||
}
|
||||
if _, err := call(t, asker, "served.who@anchor", map[string]any{}); err != nil {
|
||||
t.Errorf("a bundle serving its marked tool: %v", err)
|
||||
}
|
||||
if _, err := asker.Ask("marked.who@anchor", map[string]any{}, ""); !errors.Is(err, bus.ErrNoResponders) {
|
||||
t.Errorf("a refused bundle's tool answered: %v", err)
|
||||
}
|
||||
stop()
|
||||
}
|
||||
mt.NeedBundles(t)
|
||||
mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil))
|
||||
mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil))
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
"invokes": [
|
||||
"*"
|
||||
],
|
||||
"waits": [
|
||||
"mesh-knowledge.reviewed",
|
||||
"mesh-delivery-gate.transition",
|
||||
"gitea.forge.status",
|
||||
"mesh-self-check.checked",
|
||||
"operator-channel.decided.claude-code"
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -68,7 +64,12 @@
|
||||
},
|
||||
"state": [
|
||||
"bound",
|
||||
"findings"
|
||||
"findings",
|
||||
{
|
||||
"name": "approvals",
|
||||
"per-machine": true,
|
||||
"ttl-seconds": 172800
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-catalog.catalog-revision",
|
||||
|
||||
Reference in New Issue
Block a user