diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go new file mode 100644 index 0000000..3f57f65 --- /dev/null +++ b/node-tools/cmd/node-tools/approvals.go @@ -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" + "git.novox.be/novox/mesh-sdk/go/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 `.` 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 +} diff --git a/node-tools/cmd/node-tools/approvals_integration_test.go b/node-tools/cmd/node-tools/approvals_integration_test.go new file mode 100644 index 0000000..529bbc1 --- /dev/null +++ b/node-tools/cmd/node-tools/approvals_integration_test.go @@ -0,0 +1,295 @@ +//go:build integration + +package main + +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/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/nats-io/nats.go" + runnerapproval "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" +) + +// marked is the fixture marked module's catalog half: its build src2:1 marks its tool wipe (design 46 7-approval-mark §1). +type marked struct{} + +var wipeEntry = catalogseat.CatalogEntry{Module: "shell", Fp: "src2:1", Manifest: map[string]any{"needs-approval": []any{"wipe"}}} + +func (marked) Ready() error { return nil } +func (marked) Revision() string { return "7" } +func (marked) Served(fp string) (approval.Entry, bool, string) { + return runnerapproval.Entry(wipeEntry), fp == wipeEntry.Fp, "not-found" +} +func (marked) Newest(m string) (approval.Entry, bool) { + return runnerapproval.Entry(wipeEntry), m == "shell" +} +func (marked) Definitions() map[string]seatdef.Definition { return map[string]seatdef.Definition{} } +func (marked) Entries() []approval.Entry { return []approval.Entry{runnerapproval.Entry(wipeEntry)} } + +// bed is the runner's gate wired as main wires it, over the runner's own connection to a real bus, with stand-ins of the +// operator channel's router (it takes each ask into its record asks, as the router writes it) and of the hand-act seat. +type bed struct { + t *testing.T + conn *bus.Conn + asks nats.KeyValue + taken chan struct{} + done chan struct{} + + mu sync.Mutex + asked []seat.Ask + recorded []string + holders map[string][]byte +} + +func newBed(t *testing.T, bindState bool) *bed { + t.Helper() + mesh := mt.New(t) + nc, err := nats.Connect(mt.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, _ := nc.JetStream() + if _, err := js.AddStream(&nats.StreamConfig{Name: "SEAT_OPERATOR_CHANNEL", Subjects: []string{"mesh.seat.operator-channel.accept.>"}, + Retention: nats.WorkQueuePolicy}); err != nil { + t.Fatal(err) + } + b := &bed{t: t, asks: mesh.Bucket(t, "messenger_asks"), taken: make(chan struct{}, 1), done: make(chan struct{}), + holders: map[string][]byte{"operator-channel": []byte(`{"seat":"operator-channel","scope":"mesh","holders":[{"module":"messenger"}]}`)}} + t.Cleanup(func() { close(b.done) }) + mesh.Bucket(t, "node-tools_approvals") + mesh.BucketKeyTTL(t, "node-tools_bound") + + // The router's stand-in: each ask accepted under the marked module's name is taken into its record, state open. + sub, err := js.PullSubscribe("mesh.seat.operator-channel.accept.>", "router", nats.BindStream("SEAT_OPERATOR_CHANNEL")) + if err != nil { + t.Fatal(err) + } + go func() { + for { + select { + case <-b.done: + return + default: + } + msgs, _ := sub.Fetch(1, nats.MaxWait(200*time.Millisecond)) + for _, m := range msgs { + var a seat.Ask + if err := json.Unmarshal(m.Data, &a); err == nil { + module := strings.TrimPrefix(m.Subject, "mesh.seat.operator-channel.accept.ask.") + b.mu.Lock() + b.asked = append(b.asked, a) + b.mu.Unlock() + b.write(seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", Headline: a.Headline, + Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}}) + select { + case b.taken <- struct{}{}: + default: + } + } + _ = m.Ack() + } + } + }() + // The hand-act seat's stand-in: warranted {asker, ask} answered, each one kept. + if _, err := nc.Subscribe("mesh.seat.mesh-hand-acts.>", func(m *nats.Msg) { + b.mu.Lock() + b.recorded = append(b.recorded, string(m.Data)) + b.mu.Unlock() + _ = m.Respond([]byte(`{"result":{"recorded":true}}`)) + }); err != nil { + t.Fatal(err) + } + + runner := mt.MembershipOf("node-tools", "n1", false, nil) + if bindState { + runner.State = []bus.StateIssued{{Name: approval.StateName, Bucket: "node-tools_approvals", Writes: true}, + {Name: BoundState, Bucket: "node-tools_bound", Writes: true}} + } + shell := mt.MembershipOf("shell", "n1", false, nil) + shell.SeatTraffic = &bus.SeatTraffic{Publish: []string{seat.AcceptSubjectAsk("shell")}, + Records: []string{"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.shell.>"}} + mesh.Issue(t, runner) + mesh.Issue(t, shell) + b.conn, err = bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "n1"}) + if err != nil { + t.Fatal(err) + } + b.conn.Logf = func(string, ...any) {} + t.Cleanup(b.conn.Close) + b.conn.Follow("node-tools") + b.conn.Follow("shell") + mt.Until(t, func() error { + if b.conn.Membership("node-tools") == nil || b.conn.Membership("shell") == nil { + return errors.New("not issued yet") + } + return nil + }) + return b +} + +// gate is a runner's gate as main makes it; record says how the run's recording goes beside the call (nil: at once). +func (b *bed) gate(record func(func())) *approval.Gate { + if record == nil { + record = func(f func()) { f() } + } + g := approval.New(approval.Deps{Node: "n1", Runner: "node-tools", Catalog: marked{}, Logf: b.t.Logf, + State: approvalsState{conn: b.conn, module: "node-tools"}, + Channel: &operatorchannelclient.GateChannel{State: approvalsState{conn: b.conn, module: "node-tools"}, KeyPrefix: "n1.", + Publish: publishAsk(b.conn), ReadRecord: routerRecord(b.conn)}, + Warranted: warranted(b.conn), Bounds: boundState{conn: b.conn, module: "node-tools"}, Go: record, + Holder: runnerapproval.HoldersIn(func() (map[string][]byte, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.holders, nil + }), + // The pause while the router takes the ask ends when the stand-in has taken one: no sleeping. + After: func(time.Duration) <-chan time.Time { + c := make(chan time.Time, 1) + go func() { + select { + case <-b.taken: + case <-b.done: + } + c <- time.Now() + }() + return c + }}) + _ = g.Unserved("shell", "src2:1", []string{"wipe"}) + return g +} + +func (b *bed) write(rec seat.AsksByAskerIDValue) { + raw, _ := json.Marshal(rec) + if _, err := b.asks.Put(rec.Key, raw); err != nil { + b.t.Fatal(err) + } +} + +// decide writes the router's record of the newest ask as the router writes the operator's choice: chosen, with its warrant. +func (b *bed) decide(option string) seat.Ask { + b.mu.Lock() + a := b.asked[len(b.asked)-1] + b.mu.Unlock() + e, err := b.asks.Get("shell." + a.ID) + if err != nil { + b.t.Fatal(err) + } + var rec seat.AsksByAskerIDValue + _ = json.Unmarshal(e.Value(), &rec) + rec.State = "chosen" + rec.Warrant = &seat.Warrant{Ask: a.ID, Asker: "shell", Outcome: operatorchannelclient.OutcomeChosen, Option: option, + Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(time.Now()), AskDigest: operatorchannelclient.Digest(a), + Channel: "telegram", By: &seat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + b.write(rec) + return a +} + +func wipe(args string) approval.Call { + return approval.Call{Address: "shell.wipe", Module: "shell", Name: "wipe", Fingerprint: "src2:1", Caller: "agent", + Args: json.RawMessage(args), Description: "Wipes a scratch directory."} +} + +// Design 46 7-approval-mark §22: the runner's gate against the real bus with a fixture marked module: a call asked and taken +// by the operator channel's stand-in, the operator's Approve written as the router's record, the call again run once and +// recorded in the hand-act log, mesh_approvals' list, a restart between the run and warranted's answer recorded once; a +// Decline runs nothing; a gate missing a part of its check runs nothing. +func TestAMarkedCallOnTheBusIsAskedRunOnTheApprovalAndRecorded(t *testing.T) { + t.Run("approved: asked, run once, recorded, listed", func(t *testing.T) { + b := newBed(t, true) + g := b.gate(nil) + runs := 0 + run := func() (any, error) { runs++; return "wiped", nil } + if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || + !strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") || runs != 0 { + t.Fatalf("first call: %v, runs %d", err, runs) + } + if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || !strings.Contains(err.Error(), "still waiting for the operator's answer") { + t.Fatalf("before the answer: %v", err) + } + a := b.decide(approval.OptionApprove) + got, err := g.Run(wipe(`{"dir":"scratch"}`), run) + answered, ok := got.(approval.Answered) + if err != nil || !ok || answered.Answer != "wiped" || !strings.Contains(answered.Approval, "ask "+a.ID) || runs != 1 { + t.Fatalf("on the approval: %+v, %v, runs %d", got, err, runs) + } + if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || runs != 1 { + t.Fatalf("the warrant ran twice: %v, runs %d", err, runs) + } + b.mu.Lock() + recorded := strings.Join(b.recorded, "\n") + b.mu.Unlock() + if !strings.Contains(recorded, a.ID) || !strings.Contains(recorded, "shell") { + t.Fatalf("hand-act log heard %q", recorded) + } + list, err := g.List() + if err != nil || len(list) == 0 || list[len(list)-1].Ask != a.ID || list[len(list)-1].State != "run, recorded" { + t.Fatalf("mesh_approvals lists %+v, %v", list, err) + } + }) + t.Run("a restart between the run and warranted's answer records the run once", func(t *testing.T) { + b := newBed(t, true) + stopped := b.gate(func(func()) {}) // the runner stops before warranted answers + runs := 0 + run := func() (any, error) { runs++; return "wiped", nil } + _, _ = stopped.Run(wipe(`{"dir":"tmp"}`), run) + a := b.decide(approval.OptionApprove) + if _, err := stopped.Run(wipe(`{"dir":"tmp"}`), run); err != nil || runs != 1 { + t.Fatalf("run: %v, runs %d", err, runs) + } + if list, _ := stopped.List(); len(list) != 1 || list[0].State != "run, not yet recorded" { + t.Fatalf("before the restart: %+v", list) + } + started := b.gate(nil) + started.Recover() + started.Recover() + b.mu.Lock() + n := len(b.recorded) + b.mu.Unlock() + if list, _ := started.List(); n != 1 || len(list) != 1 || list[0].Ask != a.ID || list[0].State != "run, recorded" || runs != 1 { + t.Fatalf("after the restart: recorded %d, %+v, runs %d", n, list, runs) + } + }) + t.Run("declined: refused, nothing runs", func(t *testing.T) { + b := newBed(t, true) + g := b.gate(nil) + runs := 0 + run := func() (any, error) { runs++; return nil, nil } + _, _ = g.Run(wipe(`{"dir":"scratch"}`), run) + b.decide(approval.OptionDecline) + if _, err := g.Run(wipe(`{"dir":"scratch"}`), run); err == nil || !strings.Contains(err.Error(), "the operator declined ask") || runs != 0 { + t.Fatalf("declined: %v, runs %d", err, runs) + } + }) + t.Run("failing closed: no state bound, or nobody to ask, runs nothing and asks nothing", func(t *testing.T) { + unbound := newBed(t, false) + runs := 0 + run := func() (any, error) { runs++; return nil, nil } + if _, err := unbound.gate(nil).Run(wipe(`{"dir":"scratch"}`), run); err == nil || + !strings.Contains(err.Error(), "binds no state approvals") || runs != 0 || len(unbound.asked) != 0 { + t.Fatalf("no state: %v, runs %d", err, runs) + } + b := newBed(t, true) + b.holders = map[string][]byte{} + if _, err := b.gate(nil).Run(wipe(`{"dir":"scratch"}`), run); err == nil || + !strings.Contains(err.Error(), "no module holds operator-channel") || runs != 0 { + t.Fatalf("no holder: %v, runs %d", err, runs) + } + if got, err := b.gate(nil).Run(approval.Call{Address: "shell.tidy", Module: "shell", Name: "tidy", Fingerprint: "src2:1", + Args: json.RawMessage(`{}`)}, func() (any, error) { return "tidied", nil }); err != nil || got != "tidied" { + t.Fatalf("an unmarked tool: %v, %v", got, err) + } + }) +} diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 7da745a..07b66fe 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -25,7 +25,12 @@ import ( "syscall" "time" + handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + "git.novox.be/novox/mesh-sdk/go/approval" + "github.com/novox/mesh-tools/node-tools/internal/alive" + runnerapproval "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 +56,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 +74,30 @@ 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() + approvals := approvalsState{conn: conn, module: cred.Module} + gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, + Findings: runnerapproval.Findings(source.Findings(), conn.Node(), cred.Module, nil), Logf: log.Printf, State: approvals, + Channel: &operatorchannelclient.GateChannel{State: approvals, KeyPrefix: conn.Node() + ".", Publish: publishAsk(conn), ReadRecord: routerRecord(conn)}, + Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}, + Holder: runnerapproval.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 +119,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) } diff --git a/node-tools/cmd/node-tools/main_test.go b/node-tools/cmd/node-tools/main_test.go index a9d093a..0018932 100644 --- a/node-tools/cmd/node-tools/main_test.go +++ b/node-tools/cmd/node-tools/main_test.go @@ -13,10 +13,17 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/runtime" ) +// The runner's start waits for its credential and its bus within the start's bound (issue 1496): one test, two cases, so +// design 46 7-approval-mark §22's whole test of the gate adds no test to the module (design 47 G §419, §626). +func TestTheRunnersStartWaitsForItsCredentialAndItsBus(t *testing.T) { + t.Run("the credential", awaitsCredential) + t.Run("the bus", connectsWithin) +} + // A runtime started before its credential is on the machine waits for it, woken by the watch of its directory, and gives // up loudly at its bound; a file that is no credential ends the wait at once (issue 1496: on ace mesh-build-node's and // mesh-build-image's runtimes exited twice on "cannot read the broker credential ... no such file"). -func TestAwaitCredential(t *testing.T) { +func awaitsCredential(t *testing.T) { const good = `{"url":"tls://bus.example:4222","module":"m"}` rereadEvery = time.Hour // only the watch can wake the read in time settle := runtime.Settle @@ -82,7 +89,7 @@ func TestAwaitCredential(t *testing.T) { // A bus that refuses the account at a start is asked again until the start's bound, said once; a refusal past it, a pin // that does not match and an address that is no URL give up (issue 1496, issue 058). -func TestConnectWithin(t *testing.T) { +func connectsWithin(t *testing.T) { refused := errors.New("nats: Authorization Violation") unreachable := errors.New("nats: no servers available for connection") for _, c := range []struct { diff --git a/node-tools/cmd/node-tools/seatlist.go b/node-tools/cmd/node-tools/seatlist.go index 5d93ec3..a545710 100644 --- a/node-tools/cmd/node-tools/seatlist.go +++ b/node-tools/cmd/node-tools/seatlist.go @@ -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() + } +} diff --git a/node-tools/go.mod b/node-tools/go.mod index 54e4d8b..606687c 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -5,11 +5,13 @@ go 1.26.0 require ( git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 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/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog 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 github.com/nats-io/nats.go v1.54.0 golang.org/x/sys v0.48.0 ) @@ -27,3 +29,7 @@ require ( golang.org/x/crypto v0.57.0 // indirect golang.org/x/time v0.16.0 // indirect ) + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect diff --git a/node-tools/internal/announce/announce.go b/node-tools/internal/announce/announce.go index 8771120..c8d79d8 100644 --- a/node-tools/internal/announce/announce.go +++ b/node-tools/internal/announce/announce.go @@ -48,6 +48,11 @@ const Digest = "digest" // module needing one is placed only where the runtime states it. const Capabilities = "capabilities" +// Fingerprint is the service metadata key the tool runner states its own fingerprint under, beside Capabilities: the build +// its composed environment names (MESH_FINGERPRINT, design 47 G §424), so the node-engine ties the capabilities it takes +// into the node's record to the runner that stated them (design 46 approval mark §6, lead answer A-203). +const Fingerprint = "fingerprint" + // Shortened is the service metadata key a runtime sets when its announcement was cut to fit the bus. const Shortened = "shortened" diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go new file mode 100644 index 0000000..97c209f --- /dev/null +++ b/node-tools/internal/approval/approval_test.go @@ -0,0 +1,592 @@ +package approval + +import ( + "encoding/json" + "errors" + "reflect" + "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/modules/mesh-delivery-contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + gate "git.novox.be/novox/mesh-sdk/go/approval" + "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.Gate + state *mem + cat *fakeCatalog + records map[string]seat.AsksByAskerIDValue // by `.` + 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 + // deps is the gate's, made anew by rebuild; publish and record are the bus's publish and the router's record read. + deps gate.Deps + publish func(module, subject string, body any, id string) error + record func(module, id string) (*stdio.StateEntry, error) +} + +// rebuild makes the gate anew from w.deps, its module's bundle loaded. +func (w *world) rebuild() { + w.g = gate.New(w.deps) + _ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"}) +} + +// 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.deps = gate.Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Bounds: w.bounds, + Findings: Findings(w.findings, "n1", "node-tools", func() time.Time { return w.now }), + Channel: &operatorchannelclient.GateChannel{State: w.state, KeyPrefix: "n1.", Now: func() time.Time { return w.now }, + Publish: func(module, subject string, body any, id string) error { return w.publish(module, subject, body, id) }, + ReadRecord: func(module, id string) (*stdio.StateEntry, error) { return w.record(module, id) }}, + 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 + }, + } + w.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 + } + w.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.rebuild() + 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 gate.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) gate.Call { + return gate.Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args), + Description: "Wipes a scratch directory.\nMore words."} +} + +// 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) { + 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.do(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.do(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.do(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, _ := gate.ActOf(call("wipe", `{"dir":"scratch"}`), "n1") + d, _ := operatorchannelclient.ActDigest(act) + if a.About != d || a.Who != "operator" || a.OnExpiry != gate.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."+keys.EscapeName(d)]) != `"`+a.ID+`"` { + t.Errorf("kept %+v, act key %s", k, w.state.Vals["n1.act."+keys.EscapeName(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.do(call("wipe", `{}`)) + if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > gate.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(gate.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.(gate.Answered).Answer != "done" || w.runs != 1 || + !strings.HasPrefix(got.(gate.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(gate.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(gate.OptionApprove, w.now) + rec := w.records["shell."+a.ID] + other, _ := gate.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(gate.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(gate.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(gate.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.deps.State = nil + w.rebuild() + 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: the fixture state refuses a key the bus refuses, so every key the gate writes here is one the bus holds (A-209)", func(t *testing.T, w *world) { + if _, err := w.state.Put("n1.act.sha256:00", "x"); err == nil { + t.Fatal("the fixture state held a key with a colon, which the bus refuses") + } + if _, err := w.state.Create("n1..ask", "x"); err == nil { + t.Fatal("the fixture state held a key with an empty name") + } + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + if len(w.asked) != 1 || len(w.state.Vals) == 0 { + t.Fatalf("the gate kept nothing through the fixture state: asked %d, keys %d", len(w.asked), len(w.state.Vals)) + } + }}, + {"§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 := gate.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(gate.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] != gate.UnansweredAfter || w.bounds.keys["n1.unrecorded."+a.ID] != gate.KeptFor-gate.SaidBefore { + t.Fatalf("bounds %v", w.bounds.keys) + } + w.bounds.expire("n1.unanswered." + a.ID) + if w.findings.raised[keys.EscapeName("n1."+a.ID)] != gate.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), gate.KindCallUnanswered) + warranted := w.deps.Warranted + var pendingAtCall bool + w.deps.Warranted = func(asker, ask string, open func() (bool, error)) error { + _, pendingAtCall = w.findings.pends[pendingKey] + return warranted(asker, ask, open) + } + w.rebuild() // a start + 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.publish + w.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.publish = pub + w.state.Vals = map[string]json.RawMessage{} + _, _ = w.do(call("wipe", `{}`)) + w.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.deps.Wait = func(d time.Duration) error { waited = d; return w.cat.ready } + w.rebuild() + if _, err := w.do(call("tidy", `{}`)); err == nil || !strings.Contains(err.Error(), "mesh_first_views") || waited != gate.RecordWithin || w.runs != 0 { + t.Fatalf("%v, waited %s", err, waited) + } + w.deps.Wait = func(time.Duration) error { w.cat.ready = nil; return nil } + w.rebuild() + 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"] != gate.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")] != gate.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.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()) }) + } + // 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. + t.Run("TheMarks", func(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 := gate.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 := gate.New(gate.Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: Findings(w, "n1", "node-tools", nil)}) + if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != gate.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) + } + }) + }) +} diff --git a/node-tools/internal/approval/contracts.go b/node-tools/internal/approval/contracts.go new file mode 100644 index 0000000..46e3b37 --- /dev/null +++ b/node-tools/internal/approval/contracts.go @@ -0,0 +1,100 @@ +// Package approval is the tool runner's side of mesh-sdk's approval gate (go/approval; design 47 G lead answers A-224, +// A-228): it translates what the gate reads beyond itself from the generated contracts, the gate's findings into the +// contract's findings about the runner on its machine, the catalog's entries into the gate's, and the assigner's view of +// the seats' holders into its holder function. The gate itself is mesh-sdk's; none is written here. +package approval + +import ( + "context" + "encoding/json" + "fmt" + "time" + + assignerseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + gate "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// HeldWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding). +type HeldWriter interface { + Write(ctx context.Context, finding condseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// scopeMachine is the scope of the gate's findings on the tool runner: its machine (lead answer A-154). +const scopeMachine = "machine" + +// Findings is the gate's finding writer over the runner's held writer: each finding about the runner's module on node, at +// scope machine, and its pending named in the writer's `findings-stated` where the writer keeps one (design 47 G §384). +func Findings(w HeldWriter, node, runner string, now func() time.Time) gate.FindingWriter { + if w == nil { + return nil + } + if now == nil { + now = time.Now + } + return findingWriter{w: w, node: node, runner: runner, now: now} +} + +type findingWriter struct { + w HeldWriter + node, runner string + now func() time.Time +} + +func (f findingWriter) Raise(ctx context.Context, kind, id, summary, headline string) error { + return f.w.Write(ctx, condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id, Severity: condseat.FindingSeverityWarning, + Source: "approval", Observer: f.runner, Subject: condseat.Subject{Scope: "module", ID: f.runner, Machine: f.node}, + Summary: summary, Headline: headline, At: f.now().UTC()}) +} + +func (f findingWriter) Clear(ctx context.Context, kind, id, why string) error { + return f.w.Clear(ctx, scopeMachine, id, kind, why) +} + +// pender is the held writer's pending (views.HeldWriter's Pend and Judged). +type pender interface { + Pend(views.PendingKey) + Judged(key string) +} + +func (f findingWriter) Pend(kind, id string, since time.Time, bound time.Duration) { + if p, ok := f.w.(pender); ok { + p.Pend(views.PendingKey{Key: views.FindingKey(scopeMachine, id, kind), Since: since, Bound: bound.String()}) + } +} + +func (f findingWriter) Judged(kind, id string) { + if p, ok := f.w.(pender); ok { + p.Judged(views.FindingKey(scopeMachine, id, kind)) + } +} + +// Entry is a catalog entry as the gate reads its marks. +func Entry(e catalogseat.CatalogEntry) gate.Entry { + manifest, _ := json.Marshal(e.Manifest) + return gate.Entry{Module: e.Module, Fingerprint: e.Fp, Serves: e.Serves, Manifest: manifest} +} + +// HoldersIn is the gate's holder function 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 + } +} diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go new file mode 100644 index 0000000..708536c --- /dev/null +++ b/node-tools/internal/approval/marks_test.go @@ -0,0 +1,83 @@ +package approval + +import ( + "context" + "sort" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" + + catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + gate "git.novox.be/novox/mesh-sdk/go/approval" + "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) (gate.Entry, bool, string) { + e, ok := c.byFp[fp] + return Entry(e), ok, "not-found" +} +func (c *fakeCatalog) Newest(m string) (gate.Entry, bool) { + e, ok := c.newest[m] + return Entry(e), ok +} +func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } +func (c *fakeCatalog) Entries() []gate.Entry { + var out []gate.Entry + for _, e := range c.newest { + out = append(out, Entry(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} +} + +// 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 +} + +func keysOf(set map[string]string) []string { + var out []string + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/node-tools/internal/bus/bindings.go b/node-tools/internal/bus/bindings.go index 26cd793..85ead67 100644 --- a/node-tools/internal/bus/bindings.go +++ b/node-tools/internal/bus/bindings.go @@ -58,15 +58,16 @@ func (c *Conn) Bindings(module string) []membership.BusBinding { return nil } -// jetStream is a JetStream handle on this connection. -func (c *Conn) jetStream() (jetstream.JetStream, error) { return jetstream.New(c.nc) } +// JetStream is a JetStream handle on this connection: its one accessor, for a part that hands it to a finding writer (design 47 G +// A-16, A-27). +func (c *Conn) JetStream() (jetstream.JetStream, error) { return jetstream.New(c.nc) } func (c *Conn) objectStore(ctx context.Context, module, local string) (jetstream.ObjectStore, membership.BusBinding, error) { b, err := c.Binding(module, local, catalogue.StorageObjectStore) if err != nil { return nil, b, err } - js, err := c.jetStream() + js, err := c.JetStream() if err != nil { return nil, b, err } @@ -238,7 +239,7 @@ func (c *Conn) ObjectsWatch(module string, r buscalls.ObjectsWatchRequest, deliv func (c *Conn) streamFill(name string) (buscalls.StreamFilled, error) { ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) defer cancel() - js, err := c.jetStream() + js, err := c.JetStream() if err != nil { return buscalls.StreamFilled{}, err } @@ -295,7 +296,7 @@ func (c *Conn) StreamPublish(module string, r buscalls.StreamPublishRequest) (bu } ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) defer cancel() - js, err := c.jetStream() + js, err := c.JetStream() if err != nil { return buscalls.StreamPublished{}, err } @@ -336,7 +337,7 @@ func (c *Conn) readStream(name, filter string, from uint64, limit int) (buscalls } ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) defer cancel() - js, err := c.jetStream() + js, err := c.JetStream() if err != nil { return buscalls.StreamPage{}, err } @@ -396,7 +397,7 @@ func (c *Conn) WorkQueueDead(module string, r buscalls.WorkQueueDeadRequest) (bu } ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) defer cancel() - js, err := c.jetStream() + js, err := c.JetStream() if err != nil { return buscalls.WorkQueueDeadLetters{}, err } diff --git a/node-tools/internal/bus/bindings_test.go b/node-tools/internal/bus/bindings_test.go index 7cb1f25..8e163d6 100644 --- a/node-tools/internal/bus/bindings_test.go +++ b/node-tools/internal/bus/bindings_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "strings" "testing" @@ -71,6 +72,23 @@ func TestABundleReachesItsOwnBoundObjectsAndStreamsOnly(t *testing.T) { if err != nil || got == nil || string(got.Data) != "what was put" || got.Info.Name != "a" { t.Fatalf("read back %+v, %v", got, err) } + // Design 47 G §518: a value over one chunk is streamed into the store and read back out of it, its description kept. + big := strings.Repeat("0123456789", 120<<10) + info, err := conn.ObjectsPutStream(ctx, "m", buscalls.ObjectsNameRequest{Objects: "files", Name: "big"}, "streamed", strings.NewReader(big)) + if err != nil || info.Size != uint64(len(big)) || info.Description != "streamed" { + t.Fatalf("streamed put %+v, %v", info, err) + } + opened, rd, found, err := conn.ObjectsOpen(ctx, "m", buscalls.ObjectsNameRequest{Objects: "files", Name: "big"}) + if err != nil || !found || opened.Size != uint64(len(big)) { + t.Fatalf("opened %+v %v %v", opened, found, err) + } + if back, _ := io.ReadAll(rd); string(back) != big { + t.Fatalf("read back %d bytes, want %d", len(back), len(big)) + } + _ = rd.Close() + if err := conn.ObjectsDelete("m", buscalls.ObjectsNameRequest{Objects: "files", Name: "big"}); err != nil { + t.Fatal(err) + } if listed, err := conn.ObjectsList("m", buscalls.ObjectsRequest{Objects: "files"}); err != nil || len(listed.Objects) != 1 { t.Fatalf("listed %+v, %v", listed, err) } diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 920189e..b0c0c04 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -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"` @@ -852,6 +849,12 @@ func answerOf(data []byte) (Answered, error) { // PublishAs emits an event as a module: published into JetStream and awaited, de-duplicated by its // own id (ADR 0042). The body is the payload; the metadata rides as headers. func (c *Conn) PublishAs(module string, env Envelope) error { + _, err := c.PublishAsAcked(module, env) + return err +} + +// PublishAsAcked is PublishAs that also answers the sequence the bus acknowledged the event at. +func (c *Conn) PublishAsAcked(module string, env Envelope) (uint64, error) { // On the module's own stream when its membership names one carrying the event (ADR 0363 §2), on EVENTS // otherwise — and only there, as before, for a membership that names no stream. msg := nats.NewMsg(eventSubject(c.Membership(module), module, env.Key)) @@ -873,8 +876,11 @@ func (c *Conn) PublishAs(module string, env Envelope) error { if id := env.Headers["x-event-id"]; id != "" { opts = append(opts, nats.MsgId(id)) } - _, err := c.js.PublishMsg(msg, opts...) - return err + ack, err := c.js.PublishMsg(msg, opts...) + if err != nil { + return 0, err + } + return ack.Sequence, nil } // Say publishes one message on core NATS, kept by nobody: a heartbeat, whose loss is the next one. diff --git a/node-tools/internal/bus/chunked.go b/node-tools/internal/bus/chunked.go new file mode 100644 index 0000000..7b9257b --- /dev/null +++ b/node-tools/internal/bus/chunked.go @@ -0,0 +1,143 @@ +package bus + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// FileField is the member of a chunked verb's answer that carries the file received: its whole bytes, base64 (design 47 G +// A-249). +const FileField = "file" + +// AskAsChunked is AskAs of a seat's verb whose definition says `chunks` (design 47 G §681, A-237, A-249): it chooses the call +// token (the body's `call`), hears the chunk subjects of that call before it asks, receives the chunks the verb's answer +// counts with buscalls.SeatChunks, each acknowledged before the next, and answers the verb's own answer with the whole +// file added as FileField. The asking bundle receives no chunk itself. +func (c *Conn) AskAsChunked(module, key string, body any) (Answered, error) { + // The verb may be asked of the machine that ran the ask (`@`, A-254): the chunk subjects name the seat, the verb and + // the call alone. + name, _, _ := strings.Cut(key, "@") + seat, verb, ok := strings.Cut(strings.TrimPrefix(name, "seat:"), ".") + if !strings.HasPrefix(key, "seat:") || !ok { + return Answered{}, fmt.Errorf("%q is no seat:., which a chunked answer needs", key) + } + args := map[string]any{} + if raw, err := json.Marshal(body); err != nil { + return Answered{}, err + } else if string(raw) != "null" { + if err := json.Unmarshal(raw, &args); err != nil { + return Answered{}, fmt.Errorf("the arguments of %s are not an object: %w", key, err) + } + } + var bits [16]byte + if _, err := rand.Read(bits[:]); err != nil { + return Answered{}, err + } + send := buscalls.SeatChunks{Seat: seat, Verb: verb, Call: hex.EncodeToString(bits[:])} + args["call"] = send.Call + chunks := make(chan *nats.Msg, 4) + sub, err := c.nc.ChanSubscribe(send.Chunks(), chunks) + if err != nil { + return Answered{}, fmt.Errorf("the chunks of %s cannot be heard: %w", key, err) + } + defer func() { _ = sub.Unsubscribe() }() + answered, err := c.AskAs(module, key, args) + if err != nil { + return answered, err + } + fields := map[string]json.RawMessage{} + if err := json.Unmarshal(answered.Result, &fields); err != nil { + return answered, fmt.Errorf("%s answered what is no object: %w", key, err) + } + var count int64 + if raw, has := fields["chunks"]; has { + if err := json.Unmarshal(raw, &count); err != nil { + return answered, fmt.Errorf("%s answered chunks that is no count: %w", key, err) + } + } + var file bytes.Buffer + if count > 0 { + next := func() (string, []byte, error) { + select { + case m := <-chunks: + return m.Subject, m.Data, nil + case <-time.After(buscalls.SeatAckWithin): + return "", nil, buscalls.ErrSeatChunkMissed + } + } + ack := func(subject string) error { + _, err := c.nc.Request(subject, []byte("{}"), buscalls.SeatAckWithin) + return err + } + if _, err := send.Receive(count, next, ack, &file); err != nil { + return answered, fmt.Errorf("the chunks of %s: %w", key, err) + } + } + if _, taken := fields[FileField]; taken { + return answered, errors.New(key + " answers a member named " + FileField + " itself") + } + enc, _ := json.Marshal(base64.StdEncoding.EncodeToString(file.Bytes())) + fields[FileField] = enc + out, err := json.Marshal(fields) + if err != nil { + return answered, err + } + answered.Result = out + return answered, nil +} + +// AnswerChunked is a seat verb's answer as its definition says `chunks` (design 47 G A-257, §681): a bundle answers the whole file +// as the member FileField (base64), and the runner that serves the verb sends it in chunks with buscalls.SeatChunks on the call +// token the body names, each acknowledged by the caller before the next, and answers the verb's own members with `chunks`, the +// count, in place of the file. The bundle sends no chunk itself. An answer with no file is answered as it is. The send runs after +// the answer is returned: the caller hears the chunks before it asks (AskAsChunked) and acknowledges each once it has the answer. +func (c *Conn) AnswerChunked(seat, verb string, body json.RawMessage, answer any) (any, error) { + raw, err := json.Marshal(answer) + if err != nil { + return nil, err + } + fields := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &fields); err != nil { + return answer, nil + } + enc, has := fields[FileField] + if !has { + return answer, nil + } + var text string + if err := json.Unmarshal(enc, &text); err != nil { + return nil, fmt.Errorf("%s.%s answers a %s that is no string: %w", seat, verb, FileField, err) + } + file, err := base64.StdEncoding.DecodeString(text) + if err != nil { + return nil, fmt.Errorf("%s.%s answers a %s in no base64: %w", seat, verb, FileField, err) + } + var asked struct { + Call string `json:"call"` + } + if err := json.Unmarshal(body, &asked); err != nil || asked.Call == "" { + return nil, fmt.Errorf("%s.%s is asked in chunks with no call token", seat, verb) + } + delete(fields, FileField) + count := int64((len(file) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes) + fields["chunks"], _ = json.Marshal(count) + send := buscalls.SeatChunks{Seat: seat, Verb: verb, Call: asked.Call} + go func() { _ = send.Send(c.nc, file, buscalls.SeatAckWithin, c.Logf) }() + return json.RawMessage(mustMarshal(fields)), nil +} + +func mustMarshal(v any) []byte { + b, _ := json.Marshal(v) + return b +} diff --git a/node-tools/internal/bus/chunked_test.go b/node-tools/internal/bus/chunked_test.go new file mode 100644 index 0000000..a838f2e --- /dev/null +++ b/node-tools/internal/bus/chunked_test.go @@ -0,0 +1,117 @@ +package bus_test + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" +) + +// Design 47 G A-249, §681: the runner asks a chunked verb with a call token of its own, receives the chunks the answer counts +// (each acknowledged before the next) and answers the verb's own answer with the whole file, so the asker receives none. +func TestAChunkedSeatAnswerIsReceivedByTheRunnerAndHandedWhole(t *testing.T) { + url := mt.URL(t) + holder := dial(t, url, "mesh-delivery-indexer", "anchor", "") + asker := dial(t, url, "mesh-build-go", "anchor", "") + raw, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(raw.Close) + file := make([]byte, buscalls.SeatChunkBytes*2+5) + for i := range file { + file[i] = byte(i) + } + // the holder's process: answers the count, then sends each chunk and waits for its acknowledgement + stop, err := holder.Handle("seat:mesh-catalog.input", func(body json.RawMessage) (any, error) { + var in struct{ Call, Digest string } + if err := json.Unmarshal(body, &in); err != nil || in.Call == "" { + t.Errorf("the verb was asked with no call token: %s (%v)", body, err) + return nil, err + } + send := buscalls.SeatChunks{Seat: "mesh-catalog", Verb: "input", Call: in.Call} + go func() { + for n := int64(1); n <= 3; n++ { + end := min(int64(len(file)), n*buscalls.SeatChunkBytes) + data, _ := json.Marshal(buscalls.SeatChunk{N: n, Of: 3, Data: file[(n-1)*buscalls.SeatChunkBytes : end]}) + ackSub, _ := raw.SubscribeSync(send.Ack(n)) + _ = raw.Flush() + if err := raw.Publish(send.Chunk(n), data); err != nil { + return + } + m, err := ackSub.NextMsg(buscalls.SeatAckWithin) + if err != nil { + t.Errorf("chunk %d not acknowledged: %v", n, err) + return + } + _ = m.Respond([]byte("{}")) + } + }() + return map[string]any{"digest": in.Digest, "chunks": 3}, nil + }) + if err != nil { + t.Fatal(err) + } + defer stop() + holder.Flush() // the bus holds the verb's subscription before it is asked: the asker is another connection + got, err := asker.AskAsChunked("mesh-build-go", "seat:mesh-catalog.input", map[string]any{"digest": "d1"}) + if err != nil { + t.Fatal(err) + } + var ans struct { + Digest string `json:"digest"` + Chunks int `json:"chunks"` + File string `json:"file"` + } + if err := json.Unmarshal(got.Result, &ans); err != nil || ans.Digest != "d1" || ans.Chunks != 3 { + t.Fatalf("the answer %s: %v", got.Result, err) + } + if body, err := base64.StdEncoding.DecodeString(ans.File); err != nil || string(body) != string(file) { + t.Errorf("the file handed is %d bytes, want the %d sent: %v", len(body), len(file), err) + } + // Design 47 G A-257, A-254: a bundle answers a chunked verb with the whole file in the member `file`; the runner that serves the + // verb sends the file in chunks (each acknowledged before the next) and answers the count, so the bundle sends none, and the asker + // of one node's verb receives it whole. + t.Run("ABundlesChunkedAnswerIsSentByTheRunner", func(t *testing.T) { + url := mt.URL(t) + holder := dial(t, url, "mesh-build-go", "anchor", "") + asker := dial(t, url, "mesh-delivery-indexer", "anchor", "") + file := make([]byte, buscalls.SeatChunkBytes*2+7) + for i := range file { + file[i] = byte(i * 7) + } + stop, err := holder.Handle("seat:node-build-go.output@anchor", func(body json.RawMessage) (any, error) { + answer := map[string]any{"ask": "a1", "file": base64.StdEncoding.EncodeToString(file)} + return holder.AnswerChunked("node-build-go", "output", body, answer) + }) + if err != nil { + t.Fatal(err) + } + defer stop() + holder.Flush() // the bus holds the verb's subscription before it is asked: the asker is another connection + got, err := asker.AskAsChunked("mesh-delivery-indexer", "seat:node-build-go.output@anchor", map[string]any{"ask": "a1"}) + if err != nil { + t.Fatal(err) + } + var ans struct { + Ask string `json:"ask"` + Chunks int `json:"chunks"` + File string `json:"file"` + } + if err := json.Unmarshal(got.Result, &ans); err != nil || ans.Ask != "a1" || ans.Chunks != 3 { + t.Fatalf("the answer %s: %v", got.Result, err) + } + if body, err := base64.StdEncoding.DecodeString(ans.File); err != nil || string(body) != string(file) { + t.Errorf("the file handed is %d bytes, want the %d sent: %v", len(body), len(file), err) + } + // an answer with no file is the verb's own: no chunks are sent + plain, err := holder.AnswerChunked("node-build-go", "output", json.RawMessage(`{"call":"c"}`), map[string]any{"ask": "x", "chunks": 0}) + if m, _ := plain.(map[string]any); err != nil || m["chunks"] != 0 { + t.Fatalf("an answer with no file was %v, %v", plain, err) + } + }) +} diff --git a/node-tools/internal/bus/ledger.go b/node-tools/internal/bus/ledger.go index c9080eb..0d48054 100644 --- a/node-tools/internal/bus/ledger.go +++ b/node-tools/internal/bus/ledger.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/membership" "github.com/nats-io/nats.go" @@ -82,7 +83,7 @@ func publishExpecting(c *Conn, subject string, body []byte, expect uint64) (uint func lastOn(c *Conn, stream, subject string) ([]byte, uint64, error) { ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) defer cancel() - js, err := c.jetStream() + js, err := c.JetStream() if err != nil { return nil, 0, err } @@ -143,13 +144,83 @@ func (h *busHistory) Read(keys string, from uint64, limit int) ([]ledger.Kept, e } } +// Purge removes every operation of one instance from the history stream, on the module's ledger connection, the one +// granted the purge of its own ledger's streams. +func (h *busHistory) Purge(key string) error { + return purgeSubject(h.read, h.write, h.stream, h.prefix+"."+key) +} + +// Fill is the history stream's fill against its cap, the cap the manifest gives the ledger. +func (h *busHistory) Fill() (buscalls.Fill, error) { + name := h.stream + if name == "" { + ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) + defer cancel() + js, err := h.read.JetStream() + if err != nil { + return buscalls.Fill{}, err + } + if name, err = js.StreamNameBySubject(ctx, h.prefix+".>"); err != nil { + return buscalls.Fill{}, fmt.Errorf("no stream on the bus keeps %s: %w", h.prefix, err) + } + } + f, err := h.read.streamFill(name) + return f.Fill, err +} + +// purgeSubject purges one subject's messages from the stream that keeps it: the stream named, or found on the runner's +// own connection by its subject, purged on the ledger connection. +func purgeSubject(read, write *Conn, stream, subject string) error { + if write == nil { + return errors.New("a ledger's streams are purged only on its module's ledger connection, and none is held") + } + ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) + defer cancel() + if stream == "" { + js, err := read.JetStream() + if err != nil { + return err + } + if stream, err = js.StreamNameBySubject(ctx, subject); err != nil { + return fmt.Errorf("no stream on the bus keeps %s: %w", subject, err) + } + } + // The purge is asked of the stream by its name, with no read of its info first: the ledger account is granted the + // purge of its own ledger's two streams and nothing else (PurgeAPI). + body, err := json.Marshal(map[string]string{"filter": subject}) + if err != nil { + return err + } + reply, err := write.nc.RequestWithContext(ctx, PurgeAPI(stream), body) + if err != nil { + return fmt.Errorf("purging %s from %s: %w", subject, stream, err) + } + var ack struct { + Success bool `json:"success"` + Error *struct { + Description string `json:"description"` + } `json:"error"` + } + if err := json.Unmarshal(reply.Data, &ack); err != nil || !ack.Success { + why := string(reply.Data) + if ack.Error != nil { + why = ack.Error.Description + } + return fmt.Errorf("the bus refused the purge of %s from %s: %s", subject, stream, why) + } + return nil +} + +// PurgeAPI is the bus's subject that purges a stream, the one a ledger account is granted for its own ledger's streams. +func PurgeAPI(stream string) string { return "$JS.API.STREAM.PURGE." + stream } + type busState struct { c *Conn bucket string } func (s *busState) kv(ctx context.Context) (jetstream.KeyValue, error) { - js, err := s.c.jetStream() + js, err := s.c.JetStream() if err != nil { return nil, err } @@ -231,6 +302,11 @@ func (s *busSnapshots) Get(key string) ([]byte, uint64, error) { return lastOn(s.read, "", s.prefix+"."+key) } +// Purge removes one instance's snapshot, on the module's ledger connection. +func (s *busSnapshots) Purge(key string) error { + return purgeSubject(s.read, s.write, "", s.prefix+"."+key) +} + func (s *busSnapshots) Put(key string, value []byte, expect uint64) error { if s.write == nil { return errors.New("a ledger's snapshot is published only on its module's ledger connection, and none is held") diff --git a/node-tools/internal/bus/sent.go b/node-tools/internal/bus/sent.go new file mode 100644 index 0000000..e8242fa --- /dev/null +++ b/node-tools/internal/bus/sent.go @@ -0,0 +1,60 @@ +package bus + +import ( + "context" + "errors" + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/stateobjects" + "github.com/nats-io/nats.go/jetstream" +) + +// SentGet reads one object of a sender's store of sent objects by the reference that carried it (ADR 0666 §16): the +// store is the sender's `_sent-objects`, and no other — a state's objects are reached through their owner's +// bindings alone. It reads as this connection's account, which the bus lets read a sender's sent store only where the +// grants part granted it (a bundle is held to what its runtime channel carries by the runtime call, not here), from the manifests' callers and consumers; a refusal says the store. +func (c *Conn) SentGet(r buscalls.ObjectsSentGetRequest) (*buscalls.ObjectData, error) { + ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) + defer cancel() + obs, err := c.sentStore(ctx, r) + if err != nil { + return nil, err + } + info, err := obs.GetInfo(ctx, r.Object) + if errors.Is(err, jetstream.ErrObjectNotFound) { + return nil, fmt.Errorf("the sent object %s is not in %s: it is kept 7 days, then expires", r.Object, r.Store) + } + if err != nil { + return nil, fmt.Errorf("the sent object %s of %s: %w", r.Object, r.Store, err) + } + data, err := obs.GetBytes(ctx, r.Object) + if err != nil { + return nil, fmt.Errorf("the sent object %s of %s: %w", r.Object, r.Store, err) + } + return &buscalls.ObjectData{Info: objectInfoOf(info), Data: data}, nil +} + +// sentStore checks the reference r and opens the sender's sent store it names. +func (c *Conn) sentStore(ctx context.Context, r buscalls.ObjectsSentGetRequest) (jetstream.ObjectStore, error) { + ref := stateobjects.Ref{Store: r.Store, Object: r.Object, Size: r.Size, Digest: r.Digest, Form: stateobjects.FormVersion} + if _, ok := stateobjects.ReadRef(stateobjects.Wrap(ref)); !ok { + return nil, errors.New("a sent object is named by the reference's store, object, size and sha256, as the reply or event carried them") + } + if !strings.HasSuffix(r.Store, catalogue.BusNameSeparator+catalogue.SentObjectsLocal) { + return nil, fmt.Errorf("%s is no sent store (a module's is `%s%s`): only a sent object is read by its reference", + r.Store, catalogue.BusNameSeparator, catalogue.SentObjectsLocal) + } + js, err := c.JetStream() + if err != nil { + return nil, err + } + obs, err := js.ObjectStore(ctx, r.Store) + if err != nil { + return nil, fmt.Errorf("the sent store %s cannot be read as %s: it is not on the bus, or the bus grants this account no "+ + "read of it (the manifests' callers and consumers are granted): %w", r.Store, c.cred.Module, err) + } + return obs, nil +} diff --git a/node-tools/internal/bus/sessions.go b/node-tools/internal/bus/sessions.go new file mode 100644 index 0000000..283b5c8 --- /dev/null +++ b/node-tools/internal/bus/sessions.go @@ -0,0 +1,76 @@ +package bus + +import ( + "context" + "errors" + "fmt" + "io" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + "github.com/nats-io/nats.go/jetstream" +) + +// The bus side of a bundle's put and get sessions (design 47 G §518, A-21, A-25): a put written into the module's object +// store as its chunks arrive and a get read out of it as they are asked, through the store's own chunking, so no more than +// one chunk is held in memory. The session itself is the runtime's (internal/runtime/sessions.go). + +// ObjectsPutStream stores the object r.Name of the module's object store r.Objects from in, read to its end, with the +// put's description (A-25); it answers once the object is sealed. A read error of in drops the object: nothing is left. +func (c *Conn) ObjectsPutStream(ctx context.Context, module string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error) { + if r.Name == "" { + return buscalls.ObjectInfo{}, errors.New("an object is put under a name") + } + obs, _, err := c.objectStore(ctx, module, r.Objects) + if err != nil { + return buscalls.ObjectInfo{}, err + } + info, err := obs.Put(ctx, jetstream.ObjectMeta{Name: r.Name, Description: description}, in) + if err != nil { + _ = obs.Delete(context.WithoutCancel(ctx), r.Name) + return buscalls.ObjectInfo{}, err + } + return objectInfoOf(info), nil +} + +// ObjectsOpen is one object of the module's object store as a reader of its bytes beside its info; found is false when there +// is none. The caller closes the reader. +func (c *Conn) ObjectsOpen(ctx context.Context, module string, r buscalls.ObjectsNameRequest) (info buscalls.ObjectInfo, rd io.ReadCloser, found bool, err error) { + obs, _, err := c.objectStore(ctx, module, r.Objects) + if err != nil { + return info, nil, false, err + } + res, err := obs.Get(ctx, r.Name) + if errors.Is(err, jetstream.ErrObjectNotFound) { + return info, nil, false, nil + } + if err != nil { + return info, nil, false, err + } + i, err := res.Info() + if err != nil { + res.Close() + return info, nil, false, err + } + return objectInfoOf(i), res, true, nil +} + +// SentOpen is a sent object, read by its reference as SentGet reads it, as a reader of its bytes beside its info. +func (c *Conn) SentOpen(ctx context.Context, r buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error) { + obs, err := c.sentStore(ctx, r) + if err != nil { + return buscalls.ObjectInfo{}, nil, err + } + res, err := obs.Get(ctx, r.Object) + if errors.Is(err, jetstream.ErrObjectNotFound) { + return buscalls.ObjectInfo{}, nil, fmt.Errorf("the sent object %s is not in %s: it is kept 7 days, then expires", r.Object, r.Store) + } + if err != nil { + return buscalls.ObjectInfo{}, nil, fmt.Errorf("the sent object %s of %s: %w", r.Object, r.Store, err) + } + i, err := res.Info() + if err != nil { + res.Close() + return buscalls.ObjectInfo{}, nil, err + } + return objectInfoOf(i), res, nil +} diff --git a/node-tools/internal/bus/state_watch_test.go b/node-tools/internal/bus/state_watch_test.go index 42b16da..15280aa 100644 --- a/node-tools/internal/bus/state_watch_test.go +++ b/node-tools/internal/bus/state_watch_test.go @@ -87,6 +87,74 @@ func TestAWatchHoldsWhileItsCurrentValuesKeepArriving(t *testing.T) { } return nil }) + // **A watch given up, or stopped, hands over nothing more** (issue 774). The runtime's watch stopped its watcher + // when the current values did not arrive within the bound, but the watcher still held values, and its goroutine + // went on handing them over: to a watch id the bundle no longer held (on the provisioner, 51 failed watches were + // followed by 1257 orphan changes). Each row blocks the bundle on its first value, gives the watch up, lets the + // bundle go and counts what it is still handed. + t.Run("AWatchGivenUpHandsOverNothingMore", func(t *testing.T) { + for _, tc := range []struct { + name string + refused bool // the bound gives it up, or the caller stops it + }{ + {"refused at the bound", true}, + {"stopped by its caller", false}, + } { + t.Run(tc.name, func(t *testing.T) { + was := bus.StateTimeout + bus.StateTimeout = 300 * time.Millisecond + t.Cleanup(func() { bus.StateTimeout = was }) + conn, kv := owedWatcher(t) + put := func(i int) { + if _, err := kv.Put(fmt.Sprintf("novox/hq.%d.%040x.status=mesh/check", i+1, i), []byte(`{"v":1}`)); err != nil { + t.Fatal(err) + } + } + if tc.refused { + for i := 0; i < 4; i++ { + put(i) + } + } + var calls atomic.Int32 + first := make(chan struct{}, 1) + release := make(chan struct{}) + deliver := func(ch bus.StateChange) error { + if calls.Add(1) == 1 { + first <- struct{}{} + <-release + } + return nil + } + var stop func() + if tc.refused { + if _, err := conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err == nil { + t.Fatal("the watch whose bundle took nothing was not refused") + } + } else { + var err error + if stop, err = conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err != nil { + t.Fatal(err) + } + for i := 0; i < 4; i++ { + put(i) + } + select { + case <-first: + case <-time.After(5 * time.Second): + t.Fatal("the bundle was handed no change") + } + stop() + } + // Let the watcher's server side deliver what it held, then free the bundle. + time.Sleep(300 * time.Millisecond) + close(release) + time.Sleep(500 * time.Millisecond) + if n := calls.Load(); n != 1 { + t.Fatalf("the bundle was handed %d changes of a watch given up, want 1 (the one in hand)", n) + } + }) + } + }) } // A watch whose bundle takes nothing for longer than the bound is still refused, so a bundle that hangs @@ -187,72 +255,3 @@ func owedWatcher(t *testing.T) (*bus.Conn, interface { }) return conn, kv } - -// **A watch given up, or stopped, hands over nothing more** (issue 774). The runtime's watch stopped its watcher -// when the current values did not arrive within the bound, but the watcher still held values, and its goroutine -// went on handing them over: to a watch id the bundle no longer held (on the provisioner, 51 failed watches were -// followed by 1257 orphan changes). Each row blocks the bundle on its first value, gives the watch up, lets the -// bundle go and counts what it is still handed. -func TestAWatchGivenUpHandsOverNothingMore(t *testing.T) { - for _, tc := range []struct { - name string - refused bool // the bound gives it up, or the caller stops it - }{ - {"refused at the bound", true}, - {"stopped by its caller", false}, - } { - t.Run(tc.name, func(t *testing.T) { - was := bus.StateTimeout - bus.StateTimeout = 300 * time.Millisecond - t.Cleanup(func() { bus.StateTimeout = was }) - conn, kv := owedWatcher(t) - put := func(i int) { - if _, err := kv.Put(fmt.Sprintf("novox/hq.%d.%040x.status=mesh/check", i+1, i), []byte(`{"v":1}`)); err != nil { - t.Fatal(err) - } - } - if tc.refused { - for i := 0; i < 4; i++ { - put(i) - } - } - var calls atomic.Int32 - first := make(chan struct{}, 1) - release := make(chan struct{}) - deliver := func(ch bus.StateChange) error { - if calls.Add(1) == 1 { - first <- struct{}{} - <-release - } - return nil - } - var stop func() - if tc.refused { - if _, err := conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err == nil { - t.Fatal("the watch whose bundle took nothing was not refused") - } - } else { - var err error - if stop, err = conn.StateWatch("gitea", "mesh-delivery-checker.owed", "", deliver); err != nil { - t.Fatal(err) - } - for i := 0; i < 4; i++ { - put(i) - } - select { - case <-first: - case <-time.After(5 * time.Second): - t.Fatal("the bundle was handed no change") - } - stop() - } - // Let the watcher's server side deliver what it held, then free the bundle. - time.Sleep(300 * time.Millisecond) - close(release) - time.Sleep(500 * time.Millisecond) - if n := calls.Load(); n != 1 { - t.Fatalf("the bundle was handed %d changes of a watch given up, want 1 (the one in hand)", n) - } - }) - } -} diff --git a/node-tools/internal/bus/streams_internal_test.go b/node-tools/internal/bus/streams_internal_test.go index be4cefc..5b176c3 100644 --- a/node-tools/internal/bus/streams_internal_test.go +++ b/node-tools/internal/bus/streams_internal_test.go @@ -1,6 +1,11 @@ package bus -import "testing" +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) // novox/hq ADR 0363 §2, §3: an event goes to the stream whose pattern carries it, as the bus matches subjects. func TestAnEventFallsUnderAStreamsPatternAsTheBusMatchesIt(t *testing.T) { @@ -22,6 +27,24 @@ func TestAnEventFallsUnderAStreamsPatternAsTheBusMatchesIt(t *testing.T) { t.Errorf("%q under %q: %v, want %v", c.event, c.pattern, got, c.want) } } + // Design 47 G §347, §59, §462: a sent object is read by the reference that carried it, from a sender's sent store only; a + // state's objects, and a reference that is none are refused before the bus is asked. + t.Run("SentGetRefusesWhatIsNoSentObjectsReference", func(t *testing.T) { + digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + for _, c := range []struct { + name string + req buscalls.ObjectsSentGetRequest + want string + }{ + {"no digest", buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: 3}, "named by the reference"}, + {"a state's objects", buscalls.ObjectsSentGetRequest{Store: "m_asks-objects", Object: "o", Size: 3, Digest: digest}, "no sent store"}, + } { + _, err := (&Conn{}).SentGet(c.req) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: got %v, want an error containing %q", c.name, err, c.want) + } + } + }) } // A membership naming no stream publishes every event on EVENTS, exactly as before (issue 725). diff --git a/node-tools/internal/bus/subscribenew.go b/node-tools/internal/bus/subscribenew.go new file mode 100644 index 0000000..d282fa3 --- /dev/null +++ b/node-tools/internal/bus/subscribenew.go @@ -0,0 +1,87 @@ +package bus + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/buscompose" + "github.com/nats-io/nats.go/jetstream" +) + +// SubscribeNew hears one event, named `.`, from the next message on, on an ordered consumer of its own on each +// stream that carries it, and hands every message to deliver as the envelope a module sees. The subjects the name lands on are +// mesh-sdk's one rule (buscompose.EventSubjects, design 47 G §559, A-153), so a name with a wildcard is refused, naming the rule; +// a name no stream on the bus carries is an error, never a subscription that hears nothing. stop ends every consumer. +func (c *Conn) SubscribeNew(name string, deliver func(json.RawMessage) error) (func(), error) { + subjects, err := buscompose.EventSubjects(name, "") + if err != nil { + return nil, err + } + js, err := c.JetStream() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) + defer cancel() + // The streams that carry each subject, found by the bus: a namespace no stream carries hears nothing and is skipped. + byStream := map[string][]string{} + for _, subject := range subjects { + if strings.HasSuffix(subject, ".>") { + continue + } + stream, err := js.StreamNameBySubject(ctx, subject) + if err != nil || stream == "" { + continue + } + byStream[stream] = append(byStream[stream], subject, subject+".>") + } + if len(byStream) == 0 { + return nil, fmt.Errorf("no stream on the bus carries %s (%s): nothing would be heard", name, strings.Join(subjects, ", ")) + } + var stops []func() + stopAll := func() { + for _, s := range stops { + s() + } + } + for _, stream := range slices.Sorted(mapKeys(byStream)) { + cons, err := js.OrderedConsumer(ctx, stream, jetstream.OrderedConsumerConfig{FilterSubjects: byStream[stream], + DeliverPolicy: jetstream.DeliverNewPolicy}) + if err != nil { + stopAll() + return nil, fmt.Errorf("an ordered consumer of %s on %s: %w", name, stream, err) + } + cc, err := cons.Consume(func(m jetstream.Msg) { + env, ok := envelopeOf(m.Subject(), m.Headers(), m.Data()) + if !ok { + return + } + raw, err := json.Marshal(env) + if err != nil { + return + } + if err := deliver(raw); err != nil && c.Logf != nil { + c.Logf("[mesh-tools] a subscription for new %s messages did not take one: %v", name, err) + } + }) + if err != nil { + stopAll() + return nil, fmt.Errorf("consuming %s on %s: %w", name, stream, err) + } + stops = append(stops, cc.Stop) + } + return stopAll, nil +} + +func mapKeys(m map[string][]string) func(func(string) bool) { + return func(yield func(string) bool) { + for k := range m { + if !yield(k) { + return + } + } + } +} diff --git a/node-tools/internal/bus/wait.go b/node-tools/internal/bus/wait.go index 4dda03b..c42a667 100644 --- a/node-tools/internal/bus/wait.go +++ b/node-tools/internal/bus/wait.go @@ -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() + } } } diff --git a/node-tools/internal/bus/wait_test.go b/node-tools/internal/bus/wait_test.go index 45d3b5a..c12d8d7 100644 --- a/node-tools/internal/bus/wait_test.go +++ b/node-tools/internal/bus/wait_test.go @@ -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) } diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 15bac45..706f38c 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -19,11 +19,12 @@ package console import ( assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - meshcatalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/catalogue" "context" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -95,8 +96,7 @@ func discovery() []map[string]any { "bus, and when it was last heard — and every runtime or machine expected that was not heard. Read-only; " + "for when an address is said to be missing or on another machine."}, {"name": verbFirstViews, "inputSchema": obj(map[string]any{}), - "description": "The tool runner's first views of the four states it reads of the indexer and the assigner (the " + - "assigner's assignments and seat-holders, the indexer's manifests and modules): for each, the owner, the tool " + + "description": "The tool runner's first views: the assigner's assignments and seat-holders, and the catalog half's registrations, the gate's source and the view of each watched branch (which modules exist, each with its manifest): for each, the owner, the tool " + "asked, the revision held and when it was made, how many keys, when it was asked and answered, how often " + "it was asked and how often by hand, whether its condition is raised, and why it was last asked. Read-only."}, {"name": verbFirstAgain, "inputSchema": obj(map[string]any{ @@ -104,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 @@ -359,14 +360,14 @@ func indexOn(conn *bus.Conn, src Source, held announce.Known, before func(announ return x, nil } -// Source is where the console reads the four states of the indexer and the assigner it indexes the mesh by: the assigner's -// assignments and seat-holders, the indexer's modules and manifests, by `.` (firstview.AssignmentsRead and the -// rest). The tool runner's is its first views (firstview.Views), never the owners' state (novox/hq ADR 0615 §1). +// Source is where the console reads what it indexes the mesh by: the assigner's assignments and seat-holders, by +// `.` (firstview.AssignmentsRead and SeatHoldersRead), and the modules of the catalog half with their entries. +// The tool runner's is its first views (firstview.Views), never the owners' state (novox/hq ADR 0615 §1; design 47 G §196, §594). type Source interface { // State is every current value of a state by key; an error says why it cannot be told yet. State(name string) (map[string][]byte, error) - // Entry is one value of a state, nil when it holds none. - Entry(name, key string) ([]byte, error) + // Modules is every module the catalog half holds, by name, with its entry; an error says why it cannot be told yet. + Modules() (map[string]meshcatalog.CatalogEntry, error) } // seatHoldersOn reads the state seat-holders whole (novox/hq ADR 0571 clause 4), as firstview.SeatHoldersRead. @@ -374,22 +375,12 @@ func seatHoldersOn(src Source) (map[string][]byte, error) { return src.State(firstview.SeatHoldersRead) } -// The indexer's states the console reads (novox/hq ADR 0571 clauses 4 and 5): every module, and each manifest by its -// digest, by the names a reader's manifest declares. -var ( - modulesRead = firstview.ModulesRead - manifestsRead = firstview.ManifestsRead -) - -// manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces. A manifest's -// digest never names another, so what is read once is kept. +// manifestFacts is what the console takes of a manifest: the tools it declares and what each replaces. type manifestFacts struct { Tools []string `json:"tools"` Replaces map[string][]string `json:"replaces"` } -var manifestsKept sync.Map // digest → manifestFacts - // recordsOn is every module the indexer holds, with where the assigner seat places it and whether it declares tools, // and every machine the assignments name (moduleNames, assigner.MachineNames). What cannot be read is said, one line // per state, for the listing's not answering. @@ -411,18 +402,19 @@ func recordsOn(src Source) (recorded []recordedModule, machines []string, why [] on[m.Module] = append(on[m.Module], node) } } - modules, err := src.State(modulesRead) + entries, err := src.Modules() if err != nil { - return nil, machines, append(why, modulesRead+" (which modules there are could not be read: "+err.Error()+")") + return nil, machines, append(why, "the catalog half's modules (which modules there are could not be read: "+err.Error()+")") } - names, err := moduleNames(modules) - if err != nil { - return nil, machines, append(why, modulesRead+" ("+err.Error()+")") + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) } + sort.Strings(names) for _, name := range names { r := recordedModule{Module: name, On: on[name]} - if m, err := meshcatalogclient.DecodeModule(modules[name]); err == nil && m.Manifest != "" { - facts, err := manifestOf(src, m.Manifest) + if m := entries[name].Manifest; len(m) > 0 { + facts, err := manifestOf(m) if err != nil { why = append(why, name+" (its manifest could not be read, so whether it answers tools is not known: "+err.Error()+")") } else { @@ -434,42 +426,16 @@ func recordsOn(src Source) (recorded []recordedModule, machines []string, why [] return recorded, machines, why } -// moduleNames is every module the indexer holds, sorted, from its state `modules` as a reader holds its keys (key to -// value; a deleted key absent). A key that is not a module's is refused, naming it. -func moduleNames(keys map[string][]byte) ([]string, error) { - out := make([]string, 0, len(keys)) - for k := range keys { - module, err := meshcatalogclient.ParseModulesKey(k) - if err != nil { - return nil, err - } - out = append(out, module) - } - sort.Strings(out) - return out, nil -} - -// manifestOf is what the console takes of the manifest of a digest, read from the indexer's manifests once. -func manifestOf(src Source, digest string) (manifestFacts, error) { - if kept, ok := manifestsKept.Load(digest); ok { - return kept.(manifestFacts), nil - } - entry, err := src.Entry(manifestsRead, digest) - if err != nil { - return manifestFacts{}, err - } - if entry == nil { - return manifestFacts{}, fmt.Errorf("the indexer holds no manifest %s", digest) - } - m, err := meshcatalogclient.DecodeManifest(entry) +// manifestOf is what the console takes of the manifest an entry of the catalog half carries whole (design 47 G §443). +func manifestOf(manifest map[string]any) (manifestFacts, error) { + raw, err := json.Marshal(manifest) if err != nil { return manifestFacts{}, err } var facts manifestFacts - if err := json.Unmarshal([]byte(m.Manifest), &facts); err != nil { + if err := json.Unmarshal(raw, &facts); err != nil { return manifestFacts{}, err } - manifestsKept.Store(digest, facts) return facts, nil } @@ -961,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) @@ -985,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()) @@ -1145,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: @@ -1164,13 +1152,13 @@ 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)}} + if unread != "" { + content = append(content, map[string]any{"type": "text", "text": unread}) } - content := []map[string]any{{"type": "text", "text": pretty(got.Result)}} if got.Node != "" { content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node}) } diff --git a/node-tools/internal/console/approvals.go b/node-tools/internal/console/approvals.go new file mode 100644 index 0000000..17bcaf3 --- /dev/null +++ b/node-tools/internal/console/approvals.go @@ -0,0 +1,99 @@ +package console + +import ( + "log" + "strings" + + "git.novox.be/novox/mesh-sdk/go/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) +} diff --git a/node-tools/internal/console/firstviews.go b/node-tools/internal/console/firstviews.go index 0bb995c..16f44e6 100644 --- a/node-tools/internal/console/firstviews.go +++ b/node-tools/internal/console/firstviews.go @@ -6,6 +6,7 @@ import ( "log" "strings" + "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -16,6 +17,14 @@ type FirstViewer interface { Again(ctx context.Context, why string) []error } +// entryLister is a FirstViewer that also keeps entries by fingerprint (design 47 G A-103): mesh_first_views lists them with their +// state and since when. +type entryLister interface{ Entries() any } + +// ownBuilder is a FirstViewer that also says the build the tool runner itself was started with (design 47 G §205, §424): +// mesh_first_views names it, or says `own build unknown` and why. +type ownBuilder interface{ OwnBuild() process.Build } + // firstViewsAnswer is mesh_first_views and mesh_first_view_again. func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[string]any) map[string]any { fv, ok := s.src.(FirstViewer) @@ -24,7 +33,14 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st "another source") } if name == verbFirstViews { - return answerText(map[string]any{"first views": fv.Status()}) + out := map[string]any{"first views": fv.Status()} + if ob, ok := fv.(ownBuilder); ok { + out["own build"] = ob.OwnBuild().String() + } + if el, ok := fv.(entryLister); ok { + out["entries"] = el.Entries() + } + return answerText(out) } why, _ := args["why"].(string) if why = strings.TrimSpace(why); why == "" { @@ -32,8 +48,8 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st } // A hand act, recorded through the seat mesh-hand-acts (novox/hq ADR 0615 §6, ADR 0565 §10) before it is done, and in the // runner's log. One that cannot be recorded is said, and the ask is not made. - log.Printf("[mesh-tools] hand act: %s asked the four first views again: %s", s.who, why) - if err := s.recordAct(map[string]any{"what": "asked the tool runner's four first views again", "why": why, + log.Printf("[mesh-tools] hand act: %s asked the first views again: %s", s.who, why) + if err := s.recordAct(map[string]any{"what": "asked the tool runner's first views again", "why": why, "cause": "first-view-again", "verb": verbFirstAgain}); err != nil { return failure("the hand act was not recorded through mesh-hand-acts, so the first views were not asked again: " + err.Error()) } diff --git a/node-tools/internal/console/firstviews_test.go b/node-tools/internal/console/firstviews_test.go index 1af9b3b..5192c3a 100644 --- a/node-tools/internal/console/firstviews_test.go +++ b/node-tools/internal/console/firstviews_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -14,12 +15,19 @@ import ( type viewer struct { busSource asked []string + build process.Build } func (v *viewer) Status() []views.Status { return []views.Status{{Owner: "mesh-delivery-assigner", Tool: "assignments", State: "assignments", Revision: 4, Asks: 1}} } +// OwnBuild is the build the viewer was started with. +func (v *viewer) OwnBuild() process.Build { return v.build } + +// Entries are the entries by fingerprint the viewer keeps (design 47 G A-103). +func (v *viewer) Entries() any { return []string{"fp:a asked"} } + func (v *viewer) Again(_ context.Context, why string) []error { v.asked = append(v.asked, why) return nil @@ -35,9 +43,16 @@ func TestTheConsoleShowsAndAsksAgainTheRunnersFirstViews(t *testing.T) { v := &viewer{} var recorded []map[string]any s := &Surface{src: v, who: "desk.node-tools", Record: func(a map[string]any) error { recorded = append(recorded, a); return nil }} - if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, "mesh-delivery-assigner") || !strings.Contains(got, "assignments") { + if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, "mesh-delivery-assigner") || !strings.Contains(got, "assignments") || !strings.Contains(got, "fp:a asked") { t.Errorf("mesh_first_views shows %s", got) } + // The tool runner's own build is named, or said unknown with the reason (design 47 G §205, §424). + for build, want := range map[string]string{"abcd1234": "abcd1234", "": "own build unknown"} { + v.build = process.ReadOwnBuild(func(string) string { return build }) + if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, `own build`) || !strings.Contains(got, want) { + t.Errorf("mesh_first_views with the build %q shows %s", build, got) + } + } got := s.discover(context.Background(), "mesh_first_view_again", map[string]any{"why": "doubted"}) if len(v.asked) != 1 || v.asked[0] != "doubted" || !strings.Contains(text(got), "asked again") { t.Errorf("mesh_first_view_again asked %v and answered %s", v.asked, text(got)) diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index 3511ec8..b7bad2b 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -8,37 +8,70 @@ 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"` + } `json:"streams"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - 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) + 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) } - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - t.Errorf("a family named by a wildcard: %s", w) + 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) } } - // A wait is no consumer (ADR 0367 §2): what it consumes is the four views families of its first views (ADR 0615 §5, issue - // 1966 TLS-5) and the two events of the seat that serves the seat list (ADR 0636 §4), and nothing else. - wantConsumes := []string{"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.seat-holders", - "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"} + 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) + } + // 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 + // events of the seat that serves the seat list (ADR 0636 §4) and the keeper's `findings-asked`, and nothing else. + wantConsumes := []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "seat:mesh-module-assigner.views.assignments", + "seat:mesh-module-assigner.views.seat-holders", "mesh-delivery.source-moved"} if !reflect.DeepEqual(m.Consumes, wantConsumes) { t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes) } diff --git a/node-tools/internal/console/mcp.go b/node-tools/internal/console/mcp.go index 1874356..57e4e8b 100644 --- a/node-tools/internal/console/mcp.go +++ b/node-tools/internal/console/mcp.go @@ -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 diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go index fd008c8..1cbe5c5 100644 --- a/node-tools/internal/console/noresponders_test.go +++ b/node-tools/internal/console/noresponders_test.go @@ -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) + } + } } diff --git a/node-tools/internal/console/refusal_test.go b/node-tools/internal/console/refusal_test.go index 68e350c..5d2111f 100644 --- a/node-tools/internal/console/refusal_test.go +++ b/node-tools/internal/console/refusal_test.go @@ -1,9 +1,18 @@ package console import ( + "context" + "crypto/sha256" + "encoding/hex" "errors" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/stateobjects" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + "github.com/novox/mesh-tools/node-tools/internal/bus" + "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) // A refusal is said only for the bus's own refusals; a tool's answer that mentions authorization is the @@ -19,6 +28,45 @@ func TestARefusalIsSaidOnlyForTheBussOwn(t *testing.T) { t.Errorf("%q read as a refusal: %v, want %v", msg, got, refusal) } } + // ADR 0666 §16: an answer a module sent as a reference is read from its sent store for the person asking; one the bus does + // not let the runner read is shown as its reference with its size and why it was not read. + t.Run("AnAnswerSentAsAReferenceIsReadForThePersonAsking", func(t *testing.T) { + nc, err := nats.Connect(meshtest.URL(t)) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, _ := jetstream.New(nc) + obs, err := js.CreateObjectStore(context.Background(), jetstream.ObjectStoreConfig{Bucket: "m_sent-objects"}) + if err != nil { + t.Fatal(err) + } + conn, err := bus.Connect(bus.Credential{URL: meshtest.URL(t), Module: "console", Node: "anchor"}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + body := []byte(`{"lines":"` + strings.Repeat("x", 9<<20) + `"}`) + sum := sha256.Sum256(body) + ref := stateobjects.Ref{Store: "m_sent-objects", Object: "tool.lines." + hex.EncodeToString(sum[:]), Size: int64(len(body)), + Digest: hex.EncodeToString(sum[:]), Form: stateobjects.FormVersion} + if _, err := obs.PutBytes(context.Background(), ref.Object, body); err != nil { + t.Fatal(err) + } + if got, unread := resolveSent(conn, stateobjects.Wrap(ref)); unread != "" || string(got) != string(body) { + t.Fatalf("read %d bytes of %d, %q", len(got), len(body), unread) + } + missing := ref + missing.Store = "gone_sent-objects" + wrapped := stateobjects.Wrap(missing) + got, unread := resolveSent(conn, wrapped) + if string(got) != string(wrapped) || !strings.Contains(unread, "9437") || !strings.Contains(unread, "gone_sent-objects") { + t.Fatalf("an unread answer showed %q and %q", got, unread) + } + if got, unread := resolveSent(conn, []byte(`{"a":1}`)); unread != "" || string(got) != `{"a":1}` { + t.Fatalf("a plain answer showed %q, %q", got, unread) + } + }) } // **A timeout says it is not a failure** (novox/hq issue 265): a push that outlasted the wait had diff --git a/node-tools/internal/console/sent.go b/node-tools/internal/console/sent.go new file mode 100644 index 0000000..6276914 --- /dev/null +++ b/node-tools/internal/console/sent.go @@ -0,0 +1,31 @@ +package console + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/stateobjects" + "github.com/novox/mesh-tools/node-tools/internal/bus" +) + +// resolveSent is the answer a person reads (ADR 0666 §16): an answer a module sent as a reference to an object of its +// sent store is read from it when the bus grants this runner's account the read (the standing grant the manifests give +// the callers of the verb), else shown as its reference with its size and why it was not read. +func resolveSent(conn *bus.Conn, result json.RawMessage) (json.RawMessage, string) { + ref, ok := stateobjects.ReadRef(result) + if !ok { + return result, "" + } + whole, err := stateobjects.Receive(result, func(r stateobjects.Ref) ([]byte, error) { + got, err := conn.SentGet(buscalls.ObjectsSentGetRequest{Store: r.Store, Object: r.Object, Size: r.Size, Digest: r.Digest}) + if err != nil { + return nil, err + } + return got.Data, nil + }) + if err != nil { + return result, fmt.Sprintf("the answer is %d bytes, held as the object %s of %s, and was not read: %v", ref.Size, ref.Object, ref.Store, err) + } + return whole, "" +} diff --git a/node-tools/internal/console/source_test.go b/node-tools/internal/console/source_test.go index a3fed70..cb9aeb6 100644 --- a/node-tools/internal/console/source_test.go +++ b/node-tools/internal/console/source_test.go @@ -1,9 +1,13 @@ package console import ( + "encoding/json" "sync" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "github.com/novox/mesh-tools/node-tools/internal/bus" + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) // busSource is a Source over a bus connection's own states, which a test's fixture mesh fills: the console's tests stand a @@ -37,3 +41,20 @@ func (b busSource) Entry(name, key string) ([]byte, error) { } return entry.Value, nil } + +// Modules reads the test bed's entries of the catalog half (meshtest.ModulesRead), as the tool runner's first views hold them. +func (b busSource) Modules() (map[string]meshcatalog.CatalogEntry, error) { + raw, err := b.State(mt.ModulesRead) + if err != nil { + return nil, err + } + out := map[string]meshcatalog.CatalogEntry{} + for module, value := range raw { + var e meshcatalog.CatalogEntry + if err := json.Unmarshal(value, &e); err != nil { + return nil, err + } + out[module] = e + } + return out, nil +} diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index 9ee1a41..ad08d72 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -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/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" + + "git.novox.be/novox/mesh-sdk/go/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, `.`, 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 } diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 862517f..1fe7562 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -3,32 +3,41 @@ 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/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + runnerapproval "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 +45,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 +78,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 +88,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 +133,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 +175,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,39 +199,357 @@ 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) { +// 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, _ := 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{}, + 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) + } + } + + t.Run("the catalog half", func(t *testing.T) { + t.Parallel() + // 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) + } + } + }) + + t.Run("a bus that refuses every subscription", func(t *testing.T) { + t.Parallel() + // 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. + // Each group of rows has a runtime of its own, so the waits of the groups run side by side (A-311: the one-second floor of + // a wait's timeout stands, and no clock is injected). + for name, group := range map[string]func(*testing.T, *Surface, *mt.Mesh){ + "no warrant": markedWaitsWithNoWarrant, "a warrant": markedWaitsOnAWarrant, "the catalog half unread": markedWaitsBeforeTheHalfIsRead, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + s, mesh := waitingRuntime(t) + group(t, s, mesh) + }) + } +} + +// markedWaitsWithNoWarrant are §37's rows of a wait with no warrant, against a gate on the test bus, the router's records written there as the router writes them. +func markedWaitsWithNoWarrant(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()) + } +} + +// markedWaitsOnAWarrant are §37's rows of a wait chosen while it waits and of one on a warrant. +func markedWaitsOnAWarrant(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) + } + // 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() < 1 { + 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) } } } -// 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) +// markedWaitsBeforeTheHalfIsRead are §37's rows of a wait before the catalog half is read, and of one whose runner build is unknown. +func markedWaitsBeforeTheHalfIsRead(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) } + // 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) + } +} + +// 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", "")} + state := mt.NewState() + gate := approval.New(approval.Deps{Node: "desk", Runner: "node-tools", Build: build, Catalog: gw.cat, Wait: gw.cat.wait, + State: state, 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 + }, + Channel: &operatorchannelclient.GateChannel{State: state, KeyPrefix: "desk.", 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 + }, + ReadRecord: 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) (approval.Entry, bool, string) { + return approval.Entry{}, false, "not-found" +} +func (c *waitCatalog) Newest(string) (approval.Entry, bool) { + return approval.Entry{}, false +} +func (c *waitCatalog) Definitions() map[string]seatdef.Definition { return nil } +func (c *waitCatalog) Entries() []approval.Entry { + var out []approval.Entry + for _, e := range c.entries { + out = append(out, runnerapproval.Entry(e)) + } + return out } // The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded. @@ -194,7 +563,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 +576,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 `.` 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) { diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go new file mode 100644 index 0000000..24a8aed --- /dev/null +++ b/node-tools/internal/firstview/approval.go @@ -0,0 +1,196 @@ +package firstview + +import ( + "context" + "strconv" + "strings" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + runnerapproval "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() runnerapproval.HeldWriter { + 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) (approval.Entry, bool, string) { + if c.v.branches == nil { + return approval.Entry{}, false, errNotFollowed.Error() + } + e, state := c.v.branches.Resolve(context.Background(), fingerprint) + return runnerapproval.Entry(e), state == meshcatalogclient.EntryAnswered, string(state) +} + +func (c approvalCatalog) Newest(module string) (approval.Entry, bool) { + if c.v.branches == nil { + return approval.Entry{}, false + } + e, ok, err := c.v.branches.Entry(module) + return runnerapproval.Entry(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() []approval.Entry { + halves, err := c.v.Halves() + if err != nil { + return nil + } + var out []approval.Entry + for _, half := range halves { + for _, e := range half.Entries { + out = append(out, runnerapproval.Entry(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 `.` 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() + } +} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 4b93e15..604c93c 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -1,66 +1,79 @@ -// Package firstview is the tool runner's picture of the four states it reads of the indexer and the assigner: the assigner's -// assignments and seat-holders, the indexer's manifests and modules (novox/hq ADR 0615 §1, §3; ADR 0621 §2; issue 1966 TLS-5). +// Package firstview is the tool runner's picture of what it reads of the assigner and the catalog half: the assigner's +// assignments and seat-holders (novox/hq ADR 0615 §1, §3; ADR 0621 §2; issue 1966 TLS-5), and which modules exist with the +// manifest each entry carries, followed per watched branch through meshcatalogclient's branch follower on this process's own +// consumer (design 47 G §196, §594, §614; lead answers A-5, A-27, A-103), never the indexer's state `modules` or `manifests`. // // **It never reads the owners' state.** At start it asks each owner's first-view tool until answered (the owner answers in a // runtime of its own, never through this one: ADR 0621 §1), then follows the owner's views family, one event per changed key; // a gap in the revisions, or an owner whose state was made again, asks the first view once more, said in the log. Past two -// minutes without an answer it writes a finding in its own state `findings`, which the condition keeper raises and clears. +// minutes without an answer it writes a finding through its held finding writer (design 47 G A-27, A-28), which keeps it in its +// own state `findings` and says it; the answer clears it. package firstview import ( "context" "encoding/json" "fmt" + "slices" "sort" + "strings" "sync" "time" - conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// The owners and their first-view tools (the tools' addresses are `.`). +// AssignerSeat is the seat whose holder's views the runner follows: the holder is read from the catalog half's claims of the +// seat, never named in code (design 47 G §616, §428), and the names the console reads the two states by are the seat's +// (`.`; the manifest's `consumes` names their views families by the seat, as `seat:.views.`). +const AssignerSeat = meshmoduleassigner.Seat + const ( - Indexer = "mesh-delivery-indexer" - Assigner = "mesh-delivery-assigner" + AssignmentsRead = AssignerSeat + ".assignments" + SeatHoldersRead = AssignerSeat + ".seat-holders" ) -// The names the console reads the four states by (`.`), as the manifest's `consumes` names their views -// families (no seat's client names a holder, novox/hq ADR 0625 §4; TestTheFirstViewsAreTheManifestsViews). -const ( - AssignmentsRead = Assigner + ".assignments" - SeatHoldersRead = Assigner + ".seat-holders" - ModulesRead = Indexer + ".modules" - ManifestsRead = Indexer + ".manifests" -) +// Spec is one first view the runner holds of the seat's holder: its first-view tool and the state. +type Spec struct{ Tool, State string } -// Spec is one first view the runner holds. -type Spec struct{ Owner, Tool, State string } - -// Specs are the four. +// Specs are the two states asked of the holder's tool; the catalog half's views are adopted by the branch follower. var Specs = []Spec{ - {Assigner, "assignments", "assignments"}, - {Assigner, "seat-holders", "seat-holders"}, - {Indexer, "manifests", "manifests"}, - {Indexer, "modules", "modules"}, + {"assignments", "assignments"}, + {"seat-holders", "seat-holders"}, } -// Name is the name a state is read by, `.`: AssignmentsRead, SeatHoldersRead and the indexer's. -func (s Spec) Name() string { return s.Owner + "." + s.State } +// Name is the name a state is read by, `.`: AssignmentsRead and SeatHoldersRead. +func (s Spec) Name() string { return AssignerSeat + "." + s.State } -// Views is the four followers. +// Views is the first views: the assigner's two, and the catalog half's per watched branch. type Views struct { logf func(string, ...any) - followers map[string]*views.Follower // by Spec.Name() - byState map[string]*views.Follower // by the state an event names + mu sync.Mutex + followers map[string]*views.Follower // by `.` + byState map[string]*views.Follower // by the state an event names, the owners' views families + adopted map[string]*adoption // by `.`, the views the branch follower adopted + hearers map[string][]*adoption // event key `.` -> the adopted views that hear it + branches *meshcatalogclient.Branches + holder *holding // the module that claims the assigner seat, once the catalog half names it ctx context.Context 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 ` ` (OnSeatHolder). + onKey map[string][]func() } // FindingsState is the runner's own state the findings are written in. @@ -74,70 +87,147 @@ type Deps struct { Ask func(address string, body any) (json.RawMessage, error) // Consume hands each event of the owners' views families to deliver, until the stop is called. Consume func(deliver func(bus.Envelope) error) (stop func(), err error) - // PutFinding and DeleteFinding keep and remove a finding in the runner's own state. - PutFinding func(key string, value json.RawMessage) error - DeleteFinding func(key string) error + // Findings is the runner's held finding writer (views.HeldWriter): a follower's unanswered first view is written and cleared + // through it with the contract's Finding (design 47 G A-28). Nil says it in the log only. + Findings FindingWriter + // StartFindings starts that writer, and its stop ends it; nil starts none. + StartFindings func(ctx context.Context) (stop func(), err error) + // Build is the build this process was started with (process.OwnBuild, read once at the start): its fingerprint, or + // why it is unknown (design 47 G §205, §424). + Build process.Build + // Node is the machine the runner is on, named in its findings' subject. + Node string // Bound and Wait are the follower's (zero: two minutes, five seconds). Bound, Wait time.Duration } -// Start asks the four first views, each until answered, and follows the owners' views families on the runner's own -// consumers. It returns at once: the picture fills as the owners answer. -func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (*Views, func()) { - return StartWith(parent, DepsOf(conn), logf) +// FindingWriter is the writer the runner's findings go through: 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 } -// DepsOf is the views' reach through the runner's own connection: its tool calls, its consumer and its findings state. -func DepsOf(conn *bus.Conn) Deps { +// Start asks the first views, each until answered, and follows the owners' views families on the runner's own +// consumers. It returns at once: the picture fills as the owners answer. +func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (*Views, func()) { + return StartWith(parent, DepsOf(conn, logf), logf) +} + +// DepsOf is the views' reach through the runner's own connection: its tool calls, its consumer and its held finding writer, whose +// state adapter, JetStream and node are this connection's, in this one place (design 47 G A-16, A-27). +func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps { me := conn.Module() - return Deps{Module: me, + d := Deps{Module: me, Node: conn.Node(), Build: process.OwnBuild(), Ask: func(address string, body any) (json.RawMessage, error) { got, err := conn.Ask(address, body, "") return got.Result, err }, Consume: func(deliver func(bus.Envelope) error) (func(), error) { return conn.ConsumeAs(me, deliver) }, - PutFinding: func(key string, value json.RawMessage) error { - _, err := conn.StatePut(me, FindingsState, key, value) - return err - }, - DeleteFinding: func(key string) error { return conn.StateDelete(me, FindingsState, key) }, } + js, err := conn.JetStream() + 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 + } + 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: 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 + } + d.Findings = w + d.StartFindings = func(ctx context.Context) (func(), error) { return w.Close, w.Start(ctx) } + return d } +// findingsState is the runner's own state `findings` (views.FindingState) over its connection. +type findingsState struct { + conn *bus.Conn + module string +} + +func (s findingsState) Get(key string) (*stdio.StateEntry, error) { + e, err := s.conn.StateGet(s.module, FindingsState, 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 findingsState) Put(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return s.conn.StatePut(s.module, FindingsState, key, raw) +} + +func (s findingsState) Delete(key string) error { + return s.conn.StateDelete(s.module, FindingsState, key) +} + +func (s findingsState) Keys() ([]string, error) { return s.conn.StateKeys(s.module, FindingsState) } + // StartWith is Start over deps. func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*Views, func()) { ctx, cancel := context.WithCancel(parent) - v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{}, deps: d} - for _, s := range Specs { - s := s - call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(s.Owner+"."+tool, body) } - f := &views.Follower{Owner: s.Owner, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: logf, - Bound: d.Bound, Wait: d.Wait, - Raise: func(st views.Status) { v.finding(st, true) }, - Clear: func(st views.Status) { v.finding(st, false) }} - v.followers[s.Name()] = f - v.byState[s.State] = f - v.wg.Add(1) - go func() { defer v.wg.Done(); _ = f.Start(ctx) }() + v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{}, + adopted: map[string]*adoption{}, hearers: map[string][]*adoption{}, deps: d} + stopFindings := func() {} + if d.StartFindings != nil { + if stop, err := d.StartFindings(ctx); err != nil { + logf("[mesh-tools] the runner's finding writer does not start (%v): the first views' findings are in the log only", err) + } else { + stopFindings = stop + } + } + // The catalog half is followed by the shared branch follower on this process's own wiring (design 47 G §614, A-103): the + // registrations, the gate's source and a view per watched branch, each heard on this consumer. + branches, err := meshcatalogclient.NewBranches(wiring{v}, func(key string, body any) (json.RawMessage, error) { return d.Ask(key, body) }) + if err != nil { + logf("[mesh-tools] the catalog half is not followed (%v): the console cannot say which modules exist", err) + } else { + branches.Logf = func(format string, args ...any) { logf("[mesh-tools] "+format, args...) } + branches.Changed = func(_, module string, entry json.RawMessage, deleted bool, _ time.Time) { + v.claimed(module, entry, deleted) + } + v.branches = branches } stop, err := d.Consume(v.hear) if err != nil { logf("[mesh-tools] the owners' views are not followed (%v): the pictures are the first views alone until the runner starts again", err) stop = func() {} } - return v, func() { stop(); cancel(); v.wg.Wait() } + 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() + if len(hs) > 0 { // an event of the catalog half or the gate's source, heard by the views the branch follower adopted + for _, a := range hs { + if err := a.hear(v.ctx, env.Body); err != nil { + v.logf("[mesh-tools] %v", err) + } + } + return nil + } ev, err := views.DecodeEvent(env.Body) if err != nil { return nil // not a views event: nothing to hold, nothing to retry } + v.mu.Lock() f := v.byState[ev.State] + v.mu.Unlock() 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) }() @@ -145,27 +235,36 @@ func (v *Views) hear(env bus.Envelope) error { return nil } -// finding writes or removes the runner's finding of a first view unanswered past its bound, in its own state. -func (v *Views) finding(st views.Status, raised bool) { - key, value, err := conditionOf(views.UnansweredFinding(v.deps.Module, st, time.Now())) - if err != nil { - v.logf("[mesh-tools] the finding of the first view %s is not made: %v", st.Tool, err) +// finding writes or clears the runner's finding of a first view unanswered past its bound, through its writer (design 47 G A-28). +func (v *Views) finding(f condseat.Finding, tool string, raised bool) { + if v.deps.Findings == nil { + v.logf("[mesh-tools] %s", f.Summary) return } + var err error if raised { - err = v.deps.PutFinding(key, value) + err = v.deps.Findings.Write(context.Background(), f) } else { - err = v.deps.DeleteFinding(key) + err = v.deps.Findings.Clear(context.Background(), f.Scope, f.ID, f.Kind, "the owner answered") } if err != nil { - v.logf("[mesh-tools] the finding of the first view %s is not kept: %v", st.Tool, err) + v.logf("[mesh-tools] the finding of the first view %s is not kept: %v", tool, err) } } // State is the picture of the state spec names (`.`), every current value by key; an error says the owner has // not answered yet, so nothing is claimed of it. func (v *Views) State(name string) (map[string][]byte, error) { + v.mu.Lock() + if state, ok := strings.CutPrefix(name, AssignerSeat+"."); ok { // the seat's name stands for its holder's + if v.holder == nil { + v.mu.Unlock() + return nil, fmt.Errorf("the catalog half names no holder of the seat %s yet, so %s has no first view", AssignerSeat, name) + } + name = v.holder.module + "." + state + } f := v.followers[name] + v.mu.Unlock() if f == nil { return nil, fmt.Errorf("the tool runner holds no first view of %s", name) } @@ -193,17 +292,40 @@ func (v *Views) Entry(name, key string) ([]byte, error) { // Status is each first view as it stands, by `.`, sorted. func (v *Views) Status() []views.Status { var out []views.Status - for _, f := range v.followers { + for _, f := range v.all() { out = append(out, f.Status()) } sort.Slice(out, func(i, j int) bool { return out[i].Owner+out[i].State < out[j].Owner+out[j].State }) return out } +// OwnBuild is the build the tool runner was started with: the fingerprint of the want its node-engine applied, or why it is +// unknown (`own build unknown: started without MESH_FINGERPRINT`). +func (v *Views) OwnBuild() process.Build { return v.deps.Build } + +// Entries are the entries by fingerprint the catalog half's follower keeps, with their state and since when (design 47 G A-103). +func (v *Views) Entries() any { + if v.branches == nil { + return []meshcatalogclient.EntryStatus{} + } + return v.branches.EntryStatuses() +} + +// all is every first view held now: the owners', then the adopted ones. +func (v *Views) all() []*views.Follower { + v.mu.Lock() + defer v.mu.Unlock() + out := make([]*views.Follower, 0, len(v.followers)) + for _, f := range v.followers { + out = append(out, f) + } + return out +} + // Again asks every first view once more, by hand, for why. func (v *Views) Again(ctx context.Context, why string) []error { var errs []error - for _, f := range v.followers { + for _, f := range v.all() { if err := f.Again(ctx, why); err != nil { errs = append(errs, err) } @@ -211,20 +333,240 @@ func (v *Views) Again(ctx context.Context, why string) []error { return errs } -// conditionOf is a finding in the layout of the seat mesh-conditions' state findings (its contract, ADR 0634 §1), about a -// module, a warning. -func conditionOf(f views.Finding) (string, []byte, error) { - key := conditions.Key(scopeModule, f.ID, f.Kind) - v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning, - Subject: condseat.Subject{Scope: scopeModule, ID: f.ID}, Summary: f.Summary, Headline: f.Headline, - Explanation: f.Explanation, Source: f.Observer + "." + f.Kind, At: f.At.UTC(), - Evidence: []condseat.Evidence{{At: f.At.UTC(), Said: f.Summary}}} - if err := conditions.CheckFinding(v); err != nil { - return "", nil, err - } - raw, err := json.Marshal(v) - return key, raw, err +// unansweredFinding is the finding of a first view unanswered past its bound, in the contract's form (design 47 G A-28): about the +// runner's module, a warning, its id the view's name escaped as a key's name is. +func unansweredFinding(module, node string, st views.Status, at time.Time) condseat.Finding { + return condseat.Finding{Kind: views.UnansweredKind, Scope: scopeModule, ID: keys.EscapeName(module + "." + st.Owner + "." + st.State), + Severity: condseat.FindingSeverityWarning, Source: "views", Observer: module, + Subject: condseat.Subject{Scope: scopeModule, ID: module, Machine: node}, + Summary: fmt.Sprintf("%s has no first view of %s's %s: %s has not answered since %s (%s)", module, st.Owner, st.State, st.Tool, + st.Asked.Format(time.RFC3339), strings.TrimSpace(st.Error)), + Headline: fmt.Sprintf("%s waits for %s", module, st.Owner), At: at.UTC()} } // scopeModule is the scope of a finding about a module. const scopeModule = "module" + +// viewWaitsFinding is the finding of a view waited for past views.WaitsBound, in the contract's form: about the runner's module, +// a warning, its id the view's name escaped as a key's name is. +func viewWaitsFinding(module, node string, st views.Status, at time.Time) condseat.Finding { + return condseat.Finding{Kind: views.ViewWaitsKind, Scope: scopeModule, ID: keys.EscapeName(module + "." + st.Owner + "." + st.State), + Severity: condseat.FindingSeverityWarning, Source: "views", Observer: module, + Subject: condseat.Subject{Scope: scopeModule, ID: module, Machine: node}, + Summary: fmt.Sprintf("%s waits for the view %s.%s at revision %s since %s and acts on nothing new until it holds it; it holds %s", + module, st.Owner, st.State, st.WaitsFor, st.WaitingSince.UTC().Format(time.RFC3339), orNone(st.View)), + Headline: fmt.Sprintf("%s waits for a view of %s", module, st.Owner), At: at.UTC()} +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} + +// adoption is a view the branch follower put on this process's consumer: its follower, the event it hears and its handler. +type adoption struct { + name, key string + f *views.Follower + hear func(context.Context, []byte) error + cancel context.CancelFunc +} + +// wiring is the branch follower's wiring over this process's own consumer (meshcatalogclient.Wiring; design 47 G §614, A-103): +// an adopted view is held with the owners', takes its first view at once, hears its seat's event on the runner's consumer, +// writes its findings through the runner's held writer, and a dropped one clears them. +type wiring struct{ v *Views } + +func (w wiring) Adopt(f *views.Follower, seat, event string, hear func(context.Context, []byte) error) error { + v := w.v + if f == nil || f.Owner == "" || f.State == "" || seat == "" || event == "" || hear == nil { + return fmt.Errorf("the tool runner adopts a view that names its owner, state, seat's event and handler") + } + name := f.Owner + "." + f.State + key := seat + "." + event + v.mu.Lock() + if v.followers[name] != nil { + v.mu.Unlock() + return fmt.Errorf("%s is followed already", name) + } + if f.Logf == nil { + f.Logf = v.logf + } + if f.Bound == 0 { + f.Bound = v.deps.Bound + } + if f.Wait == 0 { + f.Wait = v.deps.Wait + } + held := func() bool { v.mu.Lock(); defer v.mu.Unlock(); return v.followers[name] == f } + f.Raise = func(st views.Status) { + if held() { + v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true) + } + } + f.Clear = func(st views.Status) { + v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + } + f.RaiseWaits = func(st views.Status) { + if held() { + v.finding(viewWaitsFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true) + } + } + f.ClearWaits = func(st views.Status) { + v.finding(viewWaitsFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + } + ctx, cancel := context.WithCancel(v.ctx) + a := &adoption{name: name, key: key, f: f, hear: hear, cancel: cancel} + v.followers[name] = f + v.adopted[name] = a + v.hearers[key] = append(v.hearers[key], a) + v.mu.Unlock() + v.wg.Add(1) + go func() { defer v.wg.Done(); _ = f.Start(ctx) }() + return nil +} + +func (w wiring) Drop(owner, state string) error { + v := w.v + name := owner + "." + state + v.mu.Lock() + a := v.adopted[name] + if a == nil { + v.mu.Unlock() + return fmt.Errorf("%s is not a view this tool runner adopted", name) + } + delete(v.followers, name) + delete(v.adopted, name) + v.hearers[a.key] = slices.DeleteFunc(v.hearers[a.key], func(o *adoption) bool { return o == a }) + v.mu.Unlock() + a.cancel() + st := a.f.Status() + if st.Raised { + v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + } + if st.WaitsRaised { + v.finding(viewWaitsFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + } + 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. +func (v *Views) Modules() (map[string]catalogseat.CatalogEntry, 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]catalogseat.CatalogEntry{} + for _, branch := range v.branches.Followed() { + c, ok := v.branches.Catalog(branch) + if !ok { + continue + } + half, _ := c.View() // a branch at none-yet is ready and empty (§614) + for m, e := range half.Entries { + if _, taken := out[m]; !taken { + out[m] = e + } + } + } + return out, nil +} + +// holding is the views of the module that claims the assigner seat, and what stops them. +type holding struct { + module string + cancel context.CancelFunc + views []*views.Follower +} + +// claimed is the branch follower's word of one entry changed: an entry that serves a verb of the assigner seat makes its module +// the holder whose views the runner follows (a holder before it is dropped), the deletion of the holder's entry drops them +// (design 47 G §616, §428). It acts on the event alone; nothing waits on a timer for the claim. +func (v *Views) claimed(module string, entry json.RawMessage, deleted bool) { + claims := false + if !deleted { + var e catalogseat.CatalogEntry + if json.Unmarshal(entry, &e) == nil { + claims = slices.ContainsFunc(e.Serves, func(s string) bool { return strings.HasPrefix(s, AssignerSeat+".") }) + } + } + v.mu.Lock() + cur := v.holder + switch { + case claims && cur != nil && cur.module == module, !claims && (cur == nil || cur.module != module), v.ctx.Err() != nil: + v.mu.Unlock() + return + } + if cur != nil { + v.dropHolderLocked() + } + if claims { + v.startHolderLocked(module) + } + v.mu.Unlock() +} + +// startHolderLocked follows the first views of module, the holder of the seat, on the runner's own consumer. +func (v *Views) startHolderLocked(module string) { + ctx, cancel := context.WithCancel(v.ctx) + h := &holding{module: module, cancel: cancel} + d := v.deps + for _, s := range Specs { + s := s + call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(module+"."+tool, body) } + f := &views.Follower{Owner: module, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: v.logf, + Bound: d.Bound, Wait: d.Wait, + Raise: func(st views.Status) { + v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, true) + }, + Clear: func(st views.Status) { + v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, false) + }} + v.followers[module+"."+s.State] = f + v.byState[s.State] = f + h.views = append(h.views, f) + v.wg.Add(1) + go func() { defer v.wg.Done(); _ = f.Start(ctx) }() + } + v.holder = h + v.logf("[mesh-tools] the seat %s is held by %s: its views are followed", AssignerSeat, module) +} + +// dropHolderLocked stops the views of the holder and clears the findings they raised. +func (v *Views) dropHolderLocked() { + h := v.holder + v.holder = nil + h.cancel() + for _, f := range h.views { + delete(v.followers, f.Owner+"."+f.State) + delete(v.byState, f.State) + if st := f.Status(); st.Raised { + go v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + } + } + v.logf("[mesh-tools] %s no longer holds the seat %s: its views are no longer followed", h.module, AssignerSeat) +} diff --git a/node-tools/internal/firstview/firstview_integration_test.go b/node-tools/internal/firstview/firstview_integration_test.go index baa369d..48879d9 100644 --- a/node-tools/internal/firstview/firstview_integration_test.go +++ b/node-tools/internal/firstview/firstview_integration_test.go @@ -11,11 +11,16 @@ import ( "testing" "time" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" + "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -40,28 +45,30 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - streamOf := map[string]string{Assigner: catalogue.StreamBusName("assigner-views"), Indexer: catalogue.StreamBusName("indexer-views")} + // the manifest names the assigner's views by the seat; the module the catalog half says claims it is the fixture's + const holder = "assigner-fixture" + streamOf := map[string]string{"seat:" + AssignerSeat: catalogue.StreamBusName("assigner-views")} filters := map[string][]string{} families := 0 for _, c := range manifest.Consumes { emitter, event, isView := strings.Cut(c, ".views.") - if !isView { // the seat mesh-catalog's events: the seat list's, on EVENTS (cmd/node-tools/seatlist.go) + if !isView { // the seats' events (the seat list's, the catalog half's), on EVENTS (cmd/node-tools/seatlist.go) continue } stream := streamOf[emitter] if stream == "" { t.Fatalf("the manifest consumes %s, whose views stream the test does not know", c) } - filters[stream] = append(filters[stream], process.ModuleStreamSubject(emitter, views.EventPrefix+event)) + filters[stream] = append(filters[stream], process.ModuleStreamSubject(holder, views.EventPrefix+event)) families++ } if families != len(Specs) { t.Fatalf("the manifest consumes %d families, the runner holds %d first views", families, len(Specs)) } runner := mt.MembershipOf("node-tools", "anchor", false, nil) - for _, owner := range []string{Assigner, Indexer} { - stream := streamOf[owner] - mesh.ModuleStream(t, owner, stream) + for _, seat := range []string{"seat:" + AssignerSeat} { + stream := streamOf[seat] + mesh.ModuleStream(t, holder, stream) mesh.ConsumerOn(t, stream, "anchor", "node-tools", filters[stream], 2*time.Second) runner.Hears = append(runner.Hears, bus.HeardIssued{Stream: stream, Consumer: "anchor_node-tools"}) } @@ -84,21 +91,21 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen var mu sync.Mutex findings := map[string]bool{} - deps := DepsOf(conn) + deps := DepsOf(conn, t.Logf) deps.Bound, deps.Wait = 100*time.Millisecond, 20*time.Millisecond - deps.PutFinding = func(key string, _ json.RawMessage) error { mu.Lock(); findings[key] = true; mu.Unlock(); return nil } - deps.DeleteFinding = func(key string) error { mu.Lock(); delete(findings, key); mu.Unlock(); return nil } + deps.Findings = writes{&mu, findings} + deps.StartFindings = nil v, stop := StartWith(context.Background(), deps, func(string, ...any) {}) t.Cleanup(stop) // The owners are down: no tool answers, nothing is claimed, and past the bound each view is a finding. - if _, err := v.State(Assigner + ".assignments"); err == nil { + if _, err := v.State(AssignmentsRead); err == nil { t.Fatal("a state its owner has not answered is claimed") } mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != len(Specs) { + if len(findings) != catalogViews { return errFindings(len(findings)) } return nil @@ -110,10 +117,25 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen t.Fatal(err) } t.Cleanup(nc.Close) + respond := func(subject string, answer any) { + if _, err := nc.Subscribe(subject, func(m *nats.Msg) { + raw, _ := json.Marshal(answer) + body, _ := json.Marshal(map[string]json.RawMessage{"result": raw}) + _ = m.Respond(body) + }); err != nil { + t.Fatal(err) + } + } + // the catalog half answers: the gate's source names main, whose entries say the fixture claims the assigner seat + respond("mesh.seat.mesh-delivery.tool.source", delivery.SourceAnswer{Branches: []string{"main"}}) + respond("mesh.seat.mesh-catalog.tool.registrations", catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}}) + respond("mesh.seat.mesh-catalog.tool.revision", catalogseat.RevisionAnswer{ + Revision: &catalogseat.CatalogRevision{Revision: 3, Kind: meshcatalogclient.KindTrunk, Branch: "main"}, + Entries: []catalogseat.CatalogEntry{{Module: holder, Serves: []string{AssignerSeat + ".assignments"}}}}) for _, s := range Specs { s := s - if _, err := nc.Subscribe("mesh.mod."+s.Owner+".tool."+s.Tool, func(m *nats.Msg) { - answer, _ := json.Marshal(views.Answer{V: 1, Owner: s.Owner, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(), + if _, err := nc.Subscribe("mesh.mod."+holder+".tool."+s.Tool, func(m *nats.Msg) { + answer, _ := json.Marshal(views.Answer{V: 1, Owner: holder, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(), Values: map[string]json.RawMessage{"first": json.RawMessage(`{"n":1}`)}}) body, _ := json.Marshal(map[string]json.RawMessage{"result": answer}) _ = m.Respond(body) @@ -129,7 +151,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != 0 { + if len(findings) != 0 { // every view answered clears its finding return errFindings(len(findings)) } return nil @@ -137,9 +159,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen // Each owner's next event arrives on the runner's consumer: all four families are bound. for _, s := range Specs { - ev, _ := json.Marshal(views.Event{V: 1, Owner: s.Owner, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(), + ev, _ := json.Marshal(views.Event{V: 1, Owner: holder, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(), Value: json.RawMessage(`{"n":2}`)}) - mesh.Emit(t, process.ModuleStreamSubject(s.Owner, views.EventPrefix+s.State), json.RawMessage(ev)) + mesh.Emit(t, process.ModuleStreamSubject(holder, views.EventPrefix+s.State), json.RawMessage(ev)) } for _, s := range Specs { name := s.Name() @@ -153,6 +175,30 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen } } +// catalogViews is the first views the branch follower adopts before any branch is known: the registrations and the gate's source +// (design 47 G §614), each a finding while the seats' verbs go unanswered. +const catalogViews = 2 + type errFindings int func (e errFindings) Error() string { return "not yet" } + +// writes is the runner's finding writer in this test: the findings written and not cleared. +type writes struct { + mu *sync.Mutex + by map[string]bool +} + +func (w writes) Write(_ context.Context, f condseat.Finding) error { + w.mu.Lock() + defer w.mu.Unlock() + w.by[views.FindingKey(f.Scope, f.ID, f.Kind)] = true + return nil +} + +func (w writes) Clear(_ context.Context, scope, id, kind, _ string) error { + w.mu.Lock() + defer w.mu.Unlock() + delete(w.by, views.FindingKey(scope, id, kind)) + return nil +} diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index 11c299d..5221d6e 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -11,6 +11,10 @@ import ( "testing" "time" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-sdk/go/views" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -25,12 +29,32 @@ type owners struct { asked map[string]int deliver func(bus.Envelope) error finds map[string]bool + // catalog is the revision of the watched branch main the seat mesh-catalog answers, and modules its entries + catalog int64 + modules []string + // holder is the module among them that claims the assigner seat + holder string } func newOwners() *owners { - return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}, - "manifests": {"d1": `{"m":1}`}, "modules": {"web": `{"w":1}`}}, - rev: map[string]uint64{"assignments": 4, "seat-holders": 2, "manifests": 7, "modules": 3}, asked: map[string]int{}, finds: map[string]bool{}} + return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}}, + rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, asked: map[string]int{}, finds: map[string]bool{}, + catalog: 3, modules: []string{"web", fixtureHolder}, holder: fixtureHolder} +} + +// Write and Clear are the runner's finding writer in a test: the findings written and not cleared, by their key. +func (o *owners) Write(_ context.Context, f condseat.Finding) error { + o.mu.Lock() + defer o.mu.Unlock() + o.finds[views.FindingKey(f.Scope, f.ID, f.Kind)] = true + return nil +} + +func (o *owners) Clear(_ context.Context, scope, id, kind, _ string) error { + o.mu.Lock() + defer o.mu.Unlock() + delete(o.finds, views.FindingKey(scope, id, kind)) + return nil } func (o *owners) ask(address string, _ any) (json.RawMessage, error) { @@ -41,6 +65,22 @@ func (o *owners) ask(address string, _ any) (json.RawMessage, error) { if o.down { return nil, errors.New("503 no responders") } + switch address { + case "seat:mesh-delivery.source": + return json.Marshal(delivery.SourceAnswer{Branches: []string{"main"}}) + case "seat:mesh-catalog.registrations": + return json.Marshal(catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}}) + case "seat:mesh-catalog.revision": + a := catalogseat.RevisionAnswer{Revision: &catalogseat.CatalogRevision{Revision: o.catalog, Kind: meshcatalogclient.KindTrunk, Branch: "main"}} + for _, m := range o.modules { + e := catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}} + if m == o.holder { + e.Serves = []string{AssignerSeat + ".assignments", AssignerSeat + ".seat-holders"} + } + a.Entries = append(a.Entries, e) + } + return json.Marshal(a) + } vals := map[string]json.RawMessage{} for k, v := range o.state[tool] { vals[k] = json.RawMessage(v) @@ -60,14 +100,10 @@ func (o *owners) say(t *testing.T, state, key, value string, revision uint64, ga } } -func owner(state string) string { - for _, s := range Specs { - if s.State == state { - return s.Owner - } - } - return "" -} +// fixtureHolder is the module the fixture catalog half says claims the assigner seat: no name is in the code. +const fixtureHolder = "assigner-fixture" + +func owner(state string) string { return fixtureHolder } func eventually(t *testing.T, what string, ok func() bool) { t.Helper() @@ -87,41 +123,48 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes var logs []string var lmu sync.Mutex deps := Deps{Module: "node-tools", Ask: o.ask, Bound: 30 * time.Millisecond, Wait: 10 * time.Millisecond, - Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, - PutFinding: func(key string, _ json.RawMessage) error { - o.mu.Lock() - o.finds[key] = true - o.mu.Unlock() - return nil - }, - DeleteFinding: func(key string) error { - o.mu.Lock() - delete(o.finds, key) - o.mu.Unlock() - return nil - }} + Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, + Findings: o} v, stop := StartWith(context.Background(), deps, func(f string, a ...any) { lmu.Lock(); logs = append(logs, f); lmu.Unlock() }) defer stop() // Down: nothing is claimed, and past the bound each unanswered view is a finding. - if _, err := v.State("mesh-delivery-assigner.assignments"); err == nil || !strings.Contains(err.Error(), "not been answered") { - t.Fatalf("a state its owner has not answered is claimed: %v", err) + if _, err := v.State(AssignmentsRead); err == nil || !strings.Contains(err.Error(), "names no holder") { + t.Fatalf("a state whose holder is not known is claimed: %v", err) } - eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 4 }) + eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 2 }) // Up: answered, the findings are cleared, and the pictures hold the owners' values. o.mu.Lock() o.down = false o.mu.Unlock() - eventually(t, "not answered", func() bool { _, err := v.State("mesh-delivery-indexer.manifests"); return err == nil }) + eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil }) eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 }) - if got, _ := v.Entry("mesh-delivery-indexer.modules", "web"); string(got) != `{"w":1}` { - t.Errorf("the modules picture holds %q", got) - } - if got, _ := v.Entry("mesh-delivery-indexer.modules", "none"); got != nil { + if got, _ := v.Entry(AssignerSeat+".seat-holders", "none"); got != nil { t.Errorf("an absent key is %q", got) } + // The catalog half is followed by the shared branch follower on the runner's own consumer: the gate's source names main, + // main's view is adopted, and its entries are the modules, each with its manifest (design 47 G §196, §614). + eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 }) + m, _ := v.Modules() + if m["web"].Manifest["module"] != "web" { + t.Errorf("the module web is %+v", m["web"]) + } + // A catalog-revision of the branch is heard on the same consumer and asks the view once more. + o.mu.Lock() + o.catalog, o.modules = 4, []string{"web", "db", fixtureHolder} + o.mu.Unlock() + ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: 4}) + if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil { + t.Fatal(err) + } + eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 3 }) + // 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) != 3 { + 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] } before := asked("assignments") @@ -129,7 +172,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes o.say(t, "assignments", "laptop", `{"p":0}`, 5, false) o.say(t, "assignments", "anchor", "", 6, false) // (a deleted mark carries no value: Deleted is the encoding's, set below) - if got, _ := v.Entry("mesh-delivery-assigner.assignments", "laptop"); string(got) != `{"p":2}` { + if got, _ := v.Entry(AssignerSeat+".assignments", "laptop"); string(got) != `{"p":2}` { t.Errorf("the next revision is not applied: %q", got) } if n := asked("assignments"); n != before { @@ -147,18 +190,18 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes assignments = s } } - if assignments.Owner != "mesh-delivery-assigner" || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() { + if assignments.Owner != fixtureHolder || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() { t.Errorf("the status of assignments is %+v", assignments) } - manifests := asked("manifests") + manifests := asked("seat-holders") if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 { t.Fatalf("the hand ask failed: %v", errs) } - if asked("manifests") != manifests+1 { - t.Errorf("the hand ask did not ask the manifests again") + if asked("seat-holders") != manifests+1 { + t.Errorf("the hand ask did not ask the seat-holders again") } for _, st := range v.Status() { - if st.State == "manifests" && st.ByHand != 1 { + if st.State == "seat-holders" && st.ByHand != 1 { t.Errorf("the hand ask is not counted: %+v", st) } } @@ -181,17 +224,72 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - want := map[string]bool{AssignmentsRead: true, SeatHoldersRead: true, ModulesRead: true, ManifestsRead: true} + want := map[string]bool{AssignmentsRead: true, SeatHoldersRead: true} for _, s := range Specs { if !want[s.Name()] { t.Errorf("the first view %s is read by no name here", s.Name()) } delete(want, s.Name()) - if view := s.Owner + ".views." + s.Tool; !slices.Contains(m.Consumes, view) { + // the family is named by the seat, never by its holder (design 47 G §616) + if view := "seat:" + AssignerSeat + ".views." + s.Tool; !slices.Contains(m.Consumes, view) { t.Errorf("the first view %s has no family in the manifest's consumes %v", view, m.Consumes) } } if len(want) > 0 { t.Errorf("no first view reads %v", want) } + for _, c := range m.Consumes { + if strings.Contains(c, "assigner") && !strings.HasPrefix(c, "seat:") { + t.Errorf("the manifest names the assigner by a module: %s", c) + } + } + // the catalog half's follower hears these events and asks these verbs, and the manifest says so (design 47 G §614) + for _, ev := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"} { + if !slices.Contains(m.Consumes, ev) { + t.Errorf("the catalog half's event %s is not in the manifest's consumes %v", ev, m.Consumes) + } + } + for _, ev := range []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests"} { + if slices.Contains(m.Consumes, ev) { + t.Errorf("the manifest still consumes the indexer's %s", ev) + } + } + // design 47 G §616: the holder of the assigner seat is whichever module the catalog half says serves it; when another one + // claims it the views are of the new holder, and when none does the seat's names answer that the catalog half names no holder. + t.Run("TheAssignersViewsFollowTheModuleThatClaimsTheSeat", func(t *testing.T) { + o := newOwners() + deps := Deps{Module: "node-tools", Ask: o.ask, Bound: time.Second, Wait: 10 * time.Millisecond, + Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, Findings: o} + v, stop := StartWith(context.Background(), deps, func(string, ...any) {}) + defer stop() + eventually(t, "not answered", func() bool { _, err := v.State(AssignmentsRead); return err == nil }) + ownerOf := func() string { + for _, s := range v.Status() { + if s.State == "assignments" { + return s.Owner + } + } + return "" + } + if ownerOf() != fixtureHolder { + t.Fatalf("the views are of %q", ownerOf()) + } + revise := func(rev int64, holder string, modules ...string) { + o.mu.Lock() + o.catalog, o.modules, o.holder = rev, modules, holder + o.mu.Unlock() + ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: rev}) + if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil { + t.Fatal(err) + } + } + revise(4, "other-holder", "web", "other-holder") + eventually(t, "the views did not move to the new holder", func() bool { return ownerOf() == "other-holder" }) + eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil }) + revise(5, "", "web") + eventually(t, "the views stayed after the claim went", func() bool { + _, err := v.State(AssignmentsRead) + return err != nil && strings.Contains(err.Error(), "names no holder") + }) + }) } diff --git a/node-tools/internal/launch/launch.go b/node-tools/internal/launch/launch.go index 5316862..203b28e 100644 --- a/node-tools/internal/launch/launch.go +++ b/node-tools/internal/launch/launch.go @@ -19,6 +19,7 @@ import ( "syscall" "time" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/buscalls" "golang.org/x/sys/unix" @@ -123,7 +124,7 @@ var busStateCalls = map[string]bool{ buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: true, buscalls.StreamPublish: true, buscalls.WorkQueueSize: true, buscalls.WorkQueueDead: true, buscalls.LedgerApply: true, buscalls.LedgerGet: true, buscalls.LedgerHistory: true, buscalls.LedgerQuery: true, buscalls.LedgerRebuild: true, buscalls.LedgerReindex: true, buscalls.LedgerSnapshot: true, - buscalls.LedgerLag: true, + buscalls.LedgerLag: true, buscalls.ObjectsSentGet: true, buscalls.ObjectsPutChunk: true, buscalls.ObjectsGetChunk: true, } // busStateWatches are the watches of mesh-sdk buscalls, each with the method its changes are handed to the bundle by. @@ -386,13 +387,77 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an asked := time.Now() switch m.Method { case "mesh/publish": - err = mesh.Publish(m.Params) + var said struct { + Ack bool `json:"ack"` + } + _ = json.Unmarshal(m.Params, &said) + if !said.Ack { + err = mesh.Publish(m.Params) + break + } + // With ack the runtime answers only once the bus acknowledged the event: its sequence, or {error} + // (design 47 G §559). A refusal is an answer, so a writer says it as a finding's resend. + acker, ok := mesh.(interface { + PublishAcked(params json.RawMessage) (uint64, error) + }) + switch { + case !ok: + result = map[string]any{"error": "this tool runner's bus publishes no event with an acknowledgement"} + default: + if seq, perr := acker.PublishAcked(m.Params); perr != nil { + result = map[string]any{"error": perr.Error()} + } else { + result = map[string]any{"sequence": seq} + } + } case "mesh/ask": var asked json.RawMessage if asked, err = mesh.Ask(m.Params); err == nil { result = asked } + case stdio.CapabilitiesCall: + // The capability words this runner states (design 47 G §576); a Bus that states none answers none. + words := []string{} + if w, ok := mesh.(interface{ CapabilityWords() []string }); ok { + words = w.CapabilityWords() + } + var raw []byte + if raw, err = json.Marshal(map[string]any{"words": words}); err == nil { + result = json.RawMessage(raw) + } case "mesh/subscribe": + var said struct { + Pattern string `json:"pattern"` + Deliver string `json:"deliver"` + } + _ = json.Unmarshal(m.Params, &said) + if said.Deliver == "new" { + // An ordered consumer of its own from the next message on (design 47 G §559, A-153): each message + // is handed to this child as an event is, and the consumer ends with the child. + newer, ok := mesh.(interface { + SubscribeNew(pattern string, deliver func(json.RawMessage) error) (func(), error) + }) + if !ok { + err = errors.New("this tool runner's bus makes no subscription for new messages") + break + } + var stop func() + stop, err = newer.SubscribeNew(said.Pattern, func(envelope json.RawMessage) error { + _, err := c.ask(module, "mesh/event", map[string]any{"envelope": envelope}, EventTimeout) + return err + }) + if err == nil { + c.mu.Lock() + if c.why != nil { + c.mu.Unlock() + stop() + } else { + c.watches = append(c.watches, stop) + c.mu.Unlock() + } + } + break + } mu.Lock() subscriber = c mu.Unlock() @@ -577,6 +642,10 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an for _, stop := range watches { stop() } + // A session of the bundle's put or get ends with its channel (design 47 G §518). + if d, ok := mesh.(interface{ DropSessions() }); ok { + d.DropSessions() + } close(c.dead) mu.Lock() // Only a child that was serving is brought back; one that died in its own handshake was diff --git a/node-tools/internal/launch/seat_test.go b/node-tools/internal/launch/seat_test.go index 1d23ab5..25edae4 100644 --- a/node-tools/internal/launch/seat_test.go +++ b/node-tools/internal/launch/seat_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -68,6 +69,65 @@ func TestABundleTakesASeatsWorkThroughItsOwnChannel(t *testing.T) { if err != nil || string(got) != `{"taken":true}` { t.Fatalf("the work was not handed to the bundle: %s %v", got, err) } + // Design 47 G §576, §518: a bundle reads the runner's capability words with mesh/capabilities, then runs its put session + // through the runner's put-chunk, and the sessions of its channel are dropped when the channel closes. + t.Run("ABundleReadsTheCapabilityWordsRunsAPutSessionAndItsChannelsClosingDropsThem", func(t *testing.T) { + dir := t.TempDir() + entry := filepath.Join(dir, "bundle") + if err := os.WriteFile(entry, []byte(puttingBundle), 0o755); err != nil { + t.Fatal(err) + } + b := &sessionBus{dropped: make(chan struct{})} + l, err := Start("putter", entry, os.Environ(), b, func(string, ...any) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(l.Stop) + select { + case <-b.dropped: + case <-time.After(5 * time.Second): + t.Fatal("the bundle's sessions were not dropped when its channel closed") + } + b.mu.Lock() + defer b.mu.Unlock() + if len(b.chunks) != 1 || !strings.HasPrefix(b.chunks[0], "mesh/objects.put-chunk ") || !strings.Contains(b.chunks[0], `"seq":1`) { + t.Fatalf("the runner took %v", b.chunks) + } + }) + // Design 47 G §559: `mesh/publish` with `ack` is answered with its sequence once the bus acknowledged it, or `{error}`; + // `mesh/subscribe` with `deliver: new` makes a subscription for new messages that ends with the bundle's channel. + t.Run("APublishWithAckIsAnsweredWithItsSequenceAndASubscriptionForNewMessagesEndsWithTheChannel", func(t *testing.T) { + dir := t.TempDir() + entry, out := filepath.Join(dir, "bundle"), filepath.Join(dir, "out") + if err := os.WriteFile(entry, []byte(findingBundle), 0o755); err != nil { + t.Fatal(err) + } + b := &findingBus{stopped: make(chan struct{})} + l, err := Start("writer", entry, append(os.Environ(), "FINDING_OUT="+out), b, func(string, ...any) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(l.Stop) + select { + case <-b.stopped: + case <-time.After(5 * time.Second): + t.Fatal("the subscription for new messages was not ended with the bundle's channel") + } + got, _ := os.ReadFile(out) + for _, want := range []string{`"sequence":42`, `"error":"the bus refused it"`} { + if !strings.Contains(string(got), want) { + t.Errorf("the bundle was answered %s, want it to hold %s", got, want) + } + } + b.mu.Lock() + defer b.mu.Unlock() + if b.pattern != "mesh-conditions.findings-asked" { + t.Errorf("the subscription was made for %q", b.pattern) + } + if len(b.published) != 0 { + t.Errorf("a publish with ack went the unacknowledged way: %v", b.published) + } + }) } // A bundle that pulls one ask from its worker when it is listed. diff --git a/node-tools/internal/launch/sessions_test.go b/node-tools/internal/launch/sessions_test.go new file mode 100644 index 0000000..e2d2c93 --- /dev/null +++ b/node-tools/internal/launch/sessions_test.go @@ -0,0 +1,78 @@ +package launch + +import ( + "encoding/json" + "errors" + "strings" + "sync" +) + +// puttingBundle asks the capability words, and once they are answered with put-sessions sends the one chunk of a put +// session, as mesh-sdk's bundle does; it then ends, which closes its channel. +const puttingBundle = `#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; + *'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id" + printf '{"jsonrpc":"2.0","id":"w1","method":"mesh/capabilities","params":{}}\n' ;; + *'"id":"w1"'**put-sessions*) printf '{"jsonrpc":"2.0","id":"p1","method":"mesh/objects.put-chunk","params":{"objects":"o","name":"n","call":"c","seq":1,"last":true,"data":"aGk="}}\n' ;; + *'"id":"p1"'*) exit 0 ;; + esac +done +` + +type sessionBus struct { + subscribing + mu sync.Mutex + chunks []string + dropped chan struct{} +} + +func (b *sessionBus) CapabilityWords() []string { return []string{"put-sessions"} } +func (b *sessionBus) BusState(method string, params json.RawMessage) (json.RawMessage, error) { + b.mu.Lock() + b.chunks = append(b.chunks, method+" "+string(params)) + b.mu.Unlock() + return json.RawMessage(`{"name":"n"}`), nil +} +func (b *sessionBus) DropSessions() { close(b.dropped) } + +// findingBundle asks, as mesh-sdk's finding writer does, a publish with ack that the bus takes, one it refuses, and a +// subscription for new messages; each answer it gets is written to $FINDING_OUT, and it ends after the subscription's. +const findingBundle = `#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; + *'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id" + printf '{"jsonrpc":"2.0","id":"a1","method":"mesh/publish","params":{"key":"taken","ack":true,"body":{}}}\n' ;; + *'"id":"a1"'*) printf '%s\n' "$line" >>"$FINDING_OUT" + printf '{"jsonrpc":"2.0","id":"a2","method":"mesh/publish","params":{"key":"refused","ack":true,"body":{}}}\n' ;; + *'"id":"a2"'*) printf '%s\n' "$line" >>"$FINDING_OUT" + printf '{"jsonrpc":"2.0","id":"s1","method":"mesh/subscribe","params":{"pattern":"mesh-conditions.findings-asked","deliver":"new"}}\n' ;; + *'"id":"s1"'*) printf '%s\n' "$line" >>"$FINDING_OUT"; exit 0 ;; + esac +done +` + +type findingBus struct { + subscribing + mu sync.Mutex + pattern string + stopped chan struct{} +} + +func (b *findingBus) PublishAcked(params json.RawMessage) (uint64, error) { + if strings.Contains(string(params), `"refused"`) { + return 0, errors.New("the bus refused it") + } + return 42, nil +} + +func (b *findingBus) SubscribeNew(pattern string, _ func(json.RawMessage) error) (func(), error) { + b.mu.Lock() + b.pattern = pattern + b.mu.Unlock() + return func() { close(b.stopped) }, nil +} diff --git a/node-tools/internal/ledger/ledger.go b/node-tools/internal/ledger/ledger.go index 3088a6f..65b833d 100644 --- a/node-tools/internal/ledger/ledger.go +++ b/node-tools/internal/ledger/ledger.go @@ -58,6 +58,10 @@ type History interface { // Read is the operations of the instances keys names (a key, with `*` for one name and `>` for the rest) from a // sequence on, oldest first, at most limit of them (every one when limit is 0). Read(keys string, from uint64, limit int) ([]Kept, error) + // Purge removes every operation of the instance key names, and nothing of another (an instance's removal). + Purge(key string) error + // Fill is the history's fill against its cap, the ledger's cap (buscalls.LedgerFill; design 47 G §587). + Fill() (buscalls.Fill, error) } // State is a ledger's state bucket: each instance's record under its key. @@ -69,6 +73,8 @@ type State interface { Write(key string, value []byte, revision uint64) (uint64, error) // Keys is every key the bucket holds. Keys() ([]string, error) + // Delete removes the key; a key it does not hold is no fault. + Delete(key string) error } // Snapshots are a ledger's snapshots: the latest record of each instance, on a subject of its own, never expiring. @@ -77,6 +83,8 @@ type Snapshots interface { Get(key string) ([]byte, uint64, error) // Put writes the key's snapshot, expecting its last to be at expect (0: none); ErrRaced when it is not. Put(key string, value []byte, expect uint64) error + // Purge removes the key's snapshot; one it does not hold is no fault. + Purge(key string) error } // Ledger is one ledger of one module: its types and where it is kept. @@ -495,3 +503,74 @@ func (l *Ledger) Apply(r buscalls.ApplyRequest, author buscalls.Author) (buscall } return buscalls.Applied{Taken: true, Sequence: seq, StateBehind: behind}, nil } + +// Fill is the fill of the ledger's history against the ledger's cap (buscalls.LedgerFill; design 47 G §587): its messages, +// its bytes and the cap in bytes. The history holds every operation, and the cap is its alone: neither the fullest of the +// stores nor their sum is answered. A history that cannot say its fill is an error naming the ledger, never a fill of +// nothing. +func (l *Ledger) Fill(buscalls.LedgerRequest) (buscalls.Fill, error) { + f, err := l.History.Fill() + if err != nil { + return buscalls.Fill{}, fmt.Errorf("%s's ledger %s cannot say the fill of its history: %w", l.Module, l.Name, err) + } + return f, nil +} + +// Remove removes one instance with everything it kept (buscalls.LedgerRemove; design 47 G §587, §155): its operations, its +// snapshot, its key in the state bucket and its keys in every declared index, and answers how many operations and how many +// bytes of the history that was. An instance the ledger does not hold answers 0 and 0, and a removal that left something +// (said, and answered as an error) is made again by calling it again, which removes the rest. +func (l *Ledger) Remove(r buscalls.LedgerRemoveRequest) (buscalls.LedgerRemoved, error) { + key, err := KeyOf(r.Instance) + if err != nil { + return buscalls.LedgerRemoved{}, err + } + out := buscalls.LedgerRemoved{Instance: r.Instance} + before, err := l.History.Fill() + if err != nil { + return out, fmt.Errorf("%s's ledger %s cannot say the fill of its history, so a removal is not made: %w", l.Module, l.Name, err) + } + ops, err := l.History.Read(key, 1, 0) + if err != nil { + return out, err + } + for _, step := range []struct { + what string + remove func(string) error + }{{"operations", l.History.Purge}, {"snapshot", l.Snapshots.Purge}, {"state", l.State.Delete}, {"index entries", l.dropFromIndexes}} { + if err := step.remove(key); err != nil { + l.say("[mesh-tools] %s's ledger %s removes %s and its %s could not be removed: %v; removing it again removes what "+ + "is left", l.Module, l.Name, r.Instance, step.what, err) + return out, fmt.Errorf("%s's ledger %s removed part of %s and its %s are left: %w", l.Module, l.Name, r.Instance, step.what, err) + } + } + after, err := l.History.Fill() + if err != nil { + return out, fmt.Errorf("%s's ledger %s removed %s and cannot say the fill of its history after: %w", l.Module, l.Name, r.Instance, err) + } + out.Operations = uint64(len(ops)) + if before.Bytes > after.Bytes { + out.Bytes = before.Bytes - after.Bytes + } + return out, nil +} + +// dropFromIndexes removes the entries of the instance key names from every index bucket the ledger is bound to. +func (l *Ledger) dropFromIndexes(key string) error { + instance := InstanceOf(key) + for name, ix := range l.Indexes { + held, err := ix.Entries("", 0) + if err != nil { + return fmt.Errorf("the index %s: %w", name, err) + } + for _, e := range held { + var v buscalls.IndexEntry + if json.Unmarshal(e.Value, &v) == nil && v.Instance == instance { + if err := ix.Delete(e.Key); err != nil { + return fmt.Errorf("the index %s: %w", name, err) + } + } + } + } + return nil +} diff --git a/node-tools/internal/ledger/ledger_test.go b/node-tools/internal/ledger/ledger_test.go index 01b3017..04b7c4b 100644 --- a/node-tools/internal/ledger/ledger_test.go +++ b/node-tools/internal/ledger/ledger_test.go @@ -99,4 +99,50 @@ func TestApplyAppendsDerivesAndSnapshots(t *testing.T) { c.run(t, l, f) }) } + // design 47 G §587: a ledger's fill is its history's against the ledger's cap, a store that cannot say it is an error naming + // the ledger; a removal takes an instance's operations, snapshot, state and index entries and answers what that was, an + // unknown instance answers none, and nothing of another instance goes. + t.Run("TheLedgerSaysItsFillAndRemovesAnInstanceWhole", func(t *testing.T) { + l, f := newLedger() + f.history.MaxBytes = 1 << 20 + for _, n := range []string{"a", "b"} { + applied(t, l, "decision/0290", "note", `{"says":"`+n+`"}`, owner) + } + applied(t, l, "decision/0291", "note", `{"says":"other"}`, owner) + entry, _ := json.Marshal(buscalls.IndexEntry{Instance: "decision/0290", Sequence: 2}) + other, _ := json.Marshal(buscalls.IndexEntry{Instance: "decision/0291", Sequence: 1}) + _ = f.index.Put("t.decision.0290", entry) + _ = f.index.Put("t.decision.0291", other) + + fill, err := l.Fill(buscalls.LedgerRequest{Ledger: "records"}) + if err != nil || fill.Count != 3 || fill.Bytes == 0 || fill.MaxBytes != 1<<20 { + t.Fatalf("the fill is %+v (%v), want 3 messages against 1 MiB", fill, err) + } + got, err := l.Remove(buscalls.LedgerRemoveRequest{Ledger: "records", Instance: "decision/0290"}) + if err != nil || got.Instance != "decision/0290" || got.Operations != 2 || got.Bytes == 0 { + t.Fatalf("the removal answered %+v (%v), want 2 operations and their bytes", got, err) + } + if kept, _ := f.history.Read("decision.0290", 1, 0); len(kept) != 0 { + t.Errorf("operations are left: %d", len(kept)) + } + if raw, _, _ := f.state.Get("decision.0290"); raw != nil { + t.Errorf("the state is left: %s", raw) + } + if raw, _, _ := f.snapshots.Get("decision.0290"); raw != nil { + t.Errorf("the snapshot is left: %s", raw) + } + if held, _ := f.index.Entries("", 0); len(held) != 1 || held[0].Key != "t.decision.0291" { + t.Errorf("the index holds %v, want the other instance's entry alone", held) + } + if kept, _ := f.history.Read("decision.0291", 1, 0); len(kept) != 1 { + t.Errorf("another instance lost operations: %d left", len(kept)) + } + if again, err := l.Remove(buscalls.LedgerRemoveRequest{Ledger: "records", Instance: "decision/0290"}); err != nil || again.Operations != 0 || again.Bytes != 0 { + t.Errorf("a removal said twice answered %+v (%v), want none", again, err) + } + f.history.FillFails = true + if _, err := l.Fill(buscalls.LedgerRequest{}); err == nil || !strings.Contains(err.Error(), "ledger records cannot say the fill") { + t.Errorf("a history that cannot say its fill answered %v", err) + } + }) } diff --git a/node-tools/internal/ledger/memory.go b/node-tools/internal/ledger/memory.go index 0b14670..1d3faf5 100644 --- a/node-tools/internal/ledger/memory.go +++ b/node-tools/internal/ledger/memory.go @@ -1,9 +1,12 @@ package ledger import ( + "errors" "sort" "strings" "sync" + + "git.novox.be/novox/mesh-sdk/go/buscalls" ) // The memory stores keep what the bus keeps, as the bus answers it, for a test of a ledger without a bus: a stream whose @@ -12,9 +15,13 @@ import ( // MemoryHistory is a history in memory. type MemoryHistory struct { - mu sync.Mutex - seq uint64 - kept []struct { + // MaxBytes is the cap its fill is said against. + MaxBytes uint64 + // FillFails makes Fill an error, as a store that cannot say its fill. + FillFails bool + mu sync.Mutex + seq uint64 + kept []struct { key string Kept } @@ -73,6 +80,32 @@ func (h *MemoryHistory) Read(keys string, from uint64, limit int) ([]Kept, error return out, nil } +func (h *MemoryHistory) Purge(key string) error { + h.mu.Lock() + defer h.mu.Unlock() + kept := h.kept[:0] + for _, k := range h.kept { + if k.key != key { + kept = append(kept, k) + } + } + h.kept = kept + return nil +} + +func (h *MemoryHistory) Fill() (buscalls.Fill, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.FillFails { + return buscalls.Fill{}, errors.New("the history does not say its fill") + } + f := buscalls.Fill{Count: uint64(len(h.kept)), MaxBytes: h.MaxBytes} + for _, k := range h.kept { + f.Bytes += uint64(len(k.Data)) + } + return f, nil +} + // Expire drops every operation of the history, as a ledger's keeping days end them. func (h *MemoryHistory) Expire() { h.mu.Lock() @@ -129,6 +162,13 @@ func (s *MemoryState) Keys() ([]string, error) { return out, nil } +func (s *MemoryState) Delete(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.kv, key) + return nil +} + // MemorySnapshots are snapshots in memory. type MemorySnapshots struct { mu sync.Mutex @@ -157,6 +197,13 @@ func (s *MemorySnapshots) Put(key string, value []byte, expect uint64) error { return nil } +func (s *MemorySnapshots) Purge(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.kv, key) + return nil +} + // MemoryIndex is an index's bucket in memory. type MemoryIndex struct { mu sync.Mutex diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index 2f021e1..3fc053e 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -23,8 +23,7 @@ import ( assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" seatassigner "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - meshcatalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - meshcatalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + meshcatalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/membership" "github.com/nats-io/nats-server/v2/server" @@ -153,25 +152,27 @@ func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership { return out } -// The indexer's two states the console reads, and their buckets (the test bed's own names: the indexer's client names +// The states the console reads, and their buckets (the test bed's own names: the indexer's client names // no holder). const ( - indexer = "mesh-delivery-indexer" - assignerHolder = "mesh-delivery-assigner" + catalogHolder = "mesh-catalog" + assignerHolder = "mesh-module-assigner" // the seat's name stands for its holder in the console's state names (firstview.AssignmentsRead) // AssignmentsBucket and SeatHoldersBucket are the assigner's states assignments and seat-holders. AssignmentsBucket = assignerHolder + "_assignments" SeatHoldersBucket = assignerHolder + "_seat-holders" - ModulesBucket = indexer + "_modules" - ManifestsBucket = indexer + "_manifests" + // ModulesBucket holds the catalog half's entries by module, the test bed's own stand-in for the runner's first views of it. + ModulesBucket = catalogHolder + "_modules" ) +// ModulesRead names the test bed's state of the catalog half's entries. +const ModulesRead = catalogHolder + ".modules" + // ConsoleReads are the states a console's runtime reads to say what the mesh holds (novox/hq ADR 0571 clauses 4, 5): -// assignments, the indexer's modules and manifests, and the seat holders. +// assignments, the catalog half's entries, and the seat holders. func ConsoleReads() []bus.StateIssued { return []bus.StateIssued{ {Name: assignerHolder + ".assignments", Bucket: AssignmentsBucket}, - {Name: indexer + ".modules", Bucket: ModulesBucket}, - {Name: indexer + ".manifests", Bucket: ManifestsBucket}, + {Name: ModulesRead, Bucket: ModulesBucket}, {Name: assignerHolder + ".seat-holders", Bucket: SeatHoldersBucket}, } } @@ -188,20 +189,11 @@ func (m *Mesh) Placed(t *testing.T, tools map[string][]string, on map[string][]s } return b } - modules, manifests, assigned := kv(ModulesBucket), kv(ManifestsBucket), kv(AssignmentsBucket) + modules, assigned := kv(ModulesBucket), kv(AssignmentsBucket) kv(SeatHoldersBucket) for module, declared := range tools { - manifest, _ := json.Marshal(map[string]any{"module": module, "tools": declared}) - digest := meshcatalogclient.ManifestDigest(manifest) - raw, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: module, Manifest: string(manifest), V: 1}) - if err != nil { - t.Fatal(err) - } - if _, err := manifests.Put(digest, raw); err != nil { - t.Fatal(err) - } - raw, err = meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{None: "not built here", Manifest: digest, Repository: "novox/" + module, - Commit: strings.Repeat("a", 40), V: 1}) + manifest := map[string]any{"module": module, "tools": declared} + raw, err := json.Marshal(meshcatalog.CatalogEntry{Module: module, Manifest: manifest, Commit: strings.Repeat("a", 40)}) if err != nil { t.Fatal(err) } diff --git a/node-tools/internal/meshtest/state.go b/node-tools/internal/meshtest/state.go new file mode 100644 index 0000000..4ec6417 --- /dev/null +++ b/node-tools/internal/meshtest/state.go @@ -0,0 +1,113 @@ +package meshtest + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// 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. A key the bus refuses (a name outside letters, digits and -/_=, in +// dot-separated names) is refused here too, so no test passes a key the live bus refuses (lead answer A-209). +type State struct { + mu sync.Mutex + Vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 + Fail error +} + +// checkKey refuses a key the bus would. +func checkKey(k string) error { + for _, part := range strings.Split(k, ".") { + if keys.CheckName("key", part) != nil { + return fmt.Errorf("%q is not a key the bus can hold: letters, digits and -/_=, in dot-separated names", k) + } + } + return nil +} + +// 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 err := checkKey(k); err != nil { + return nil, err + } + 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 err := checkKey(k); err != nil { + return 0, err + } + 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 err := checkKey(k); err != nil { + return 0, err + } + 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 err := checkKey(k); err != nil { + return 0, err + } + 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 +} diff --git a/node-tools/internal/provide/provide_test.go b/node-tools/internal/provide/provide_test.go index eb35808..0d41e7a 100644 --- a/node-tools/internal/provide/provide_test.go +++ b/node-tools/internal/provide/provide_test.go @@ -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", diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index 2896130..6999ec6 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -2,11 +2,15 @@ package runtime import ( "encoding/json" + "os" + "slices" "sort" "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/buscalls" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/micro" @@ -121,6 +125,7 @@ func TestTheRuntimeAnnouncesWhatItServesInTheServicesProtocol(t *testing.T) { // issue 1966 TLS-3 (ENG-2): the runner states the capabilities of the calls it answers in its PING, on its own instance, which // is what its machine's node-engine asks and takes into the node's record; no bundle is needed for it. func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { + t.Setenv(FingerprintWord, "sha256:runner") mesh := mt.New(t) mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) nodeTools := connect(t, "node-tools", "anchor") @@ -139,7 +144,63 @@ 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,seat-settle-delay,stream-publish" { + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls,stream-publish" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } + // Its own fingerprint beside them (design 46 approval mark §6). + if ping.Metadata[announce.Fingerprint] != "sha256:runner" { + t.Errorf("the runner states its fingerprint as %q", ping.Metadata[announce.Fingerprint]) + } + // Design 47 G §462, §488, §622: each call is stated under the capability word of its own family, the words mesh-sdk's buscalls + // names: the read of a sent object under sent-objects-calls (no longer under bus-state-calls), the ledger's fill and removal + // under ledger-upkeep-calls, the others where they were. + t.Run("EachCallIsStatedUnderTheWordOfItsFamily", func(t *testing.T) { + for method, word := range map[string]string{ + buscalls.ObjectsSentGet: buscalls.SentObjectsCalls, + buscalls.LedgerFill: buscalls.LedgerUpkeepCalls, + buscalls.LedgerRemove: buscalls.LedgerUpkeepCalls, + buscalls.LedgerApply: buscalls.LedgerCalls, + buscalls.ObjectsPut: buscalls.BusStateCalls, + buscalls.StreamPublish: buscalls.StreamPublishWord, + } { + var in []string + for c, methods := range callFamilies { + if slices.Contains(methods, method) { + in = append(in, c) + } + } + if len(in) != 1 || in[0] != word { + t.Errorf("%s is stated under %v, want %s alone", method, in, word) + } + } + }) + // Design 47 G §545: the words the tool runner states are exactly its manifest's artifact `states`, so the delivery plan's edge + // of §605 runs to it for each word a module's artifact `calls`. + t.Run("TheWordsTheRunnerStatesAreItsManifestsStates", func(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Build struct { + Artifacts []struct { + Name string `json:"name"` + States []string `json:"states"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + var declared []string + for _, a := range m.Build.Artifacts { + if a.Name == "runtime" { + declared = a.States + } + } + slices.Sort(declared) + if got := CallCapabilities(); !slices.Equal(got, declared) { + t.Errorf("the runner states %v, its manifest's states are %v", got, declared) + } + }) } diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index fd43409..567c3de 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -1,12 +1,16 @@ package runtime import ( + "context" "encoding/json" "fmt" + "io" "sort" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/buscalls" + + "github.com/novox/mesh-tools/node-tools/internal/bus" ) // The capabilities the tool runner states of the calls it answers (novox/hq ADR 0595 §9, ADR 0597; design 25 § 12.4): each is @@ -14,10 +18,20 @@ import ( // (announce.Capabilities) for its machine's node-engine to take into the node's record. A module whose manifest asks for one is // held off a machine whose runner does not state it. const ( - CapBusStateCalls = "bus-state-calls" - CapLedgerCalls = "ledger-calls" - // CapStreamPublish is the call that says an event on the module's own bound stream (A-44). - CapStreamPublish = "stream-publish" + CapBusStateCalls = buscalls.BusStateCalls + CapLedgerCalls = buscalls.LedgerCalls + // CapLedgerUpkeepCalls is the ledger's fill and removal (§462, §488) and CapSentObjectsCalls the read of a sent object (§488): + // each is stated once the runner answers its family, the words mesh-sdk's buscalls names (§622). + CapLedgerUpkeepCalls = buscalls.LedgerUpkeepCalls + CapSentObjectsCalls = buscalls.SentObjectsCalls + // 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" + // CapStreamPublish is the call that says an event on the module's own bound stream (design 42 zfs-owns-zfs, A-44), the + // word mesh-sdk's buscalls names (§622). + CapStreamPublish = buscalls.StreamPublishWord ) // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. @@ -27,12 +41,16 @@ var callFamilies = map[string][]string{ CapStreamPublish: {buscalls.StreamPublish}, CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery, buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag}, + CapLedgerUpkeepCalls: {buscalls.LedgerFill, buscalls.LedgerRemove}, + CapSentObjectsCalls: {buscalls.ObjectsSentGet}, + CapPutSessions: {buscalls.ObjectsPutChunk, buscalls.ObjectsGetChunk}, } // 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). +// message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5); so does `finding-calls`, +// the acknowledged `mesh/publish` and the `mesh/subscribe` for new messages the launch answers (design 47 G §559). func CallCapabilities() []string { - out := []string{stdio.CapSeatSettleDelay} + out := []string{stdio.CapSeatSettleDelay, stdio.CapFindingCalls, CapApprovalMark} for c := range callFamilies { out = append(out, c) } @@ -56,11 +74,10 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes case buscalls.ObjectsGet: var r buscalls.ObjectsNameRequest if err = decode(method, params, &r); err == nil { - var got *buscalls.ObjectData - if got, err = conn.ObjectsGet(b.module, r); err == nil && got == nil { + answer, err = b.all.objectsGet(b.module, r) + if err == nil && answer == nil { return json.RawMessage("null"), nil } - answer = got } case buscalls.ObjectsDelete: var r buscalls.ObjectsNameRequest @@ -68,6 +85,15 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes err = conn.ObjectsDelete(b.module, r) answer = buscalls.Done{} } + case buscalls.ObjectsSentGet: + var r buscalls.ObjectsSentGetRequest + if err = decode(method, params, &r); err == nil { + answer, err = b.all.sentGet(b.module, r) + } + case buscalls.ObjectsPutChunk: + answer, err = b.all.putChunk(b.module, params) + case buscalls.ObjectsGetChunk: + answer, err = b.all.getChunk(b.module, params) case buscalls.ObjectsList: var r buscalls.ObjectsRequest if err = decode(method, params, &r); err == nil { @@ -104,7 +130,7 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes answer, err = conn.WorkQueueDead(b.module, r) } case buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerQuery, buscalls.LedgerRebuild, - buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag: + buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag, buscalls.LedgerFill, buscalls.LedgerRemove: answer, err = b.ledgerCall(method, params) default: return nil, fmt.Errorf("the runtime answers no %s", method) @@ -143,3 +169,50 @@ func decode(method string, params json.RawMessage, into any) error { } return nil } + +// objectsGet answers a get: the object whole when it is at most one chunk, else the session that reads it chunk by chunk +// (design 47 G §490, §518); nil when there is none. +func (c *consumers) objectsGet(module string, r buscalls.ObjectsNameRequest) (any, error) { + st := c.objects() + if st == nil { + return nil, fmt.Errorf("the runner has no bus to read %s of %s from", r.Name, module) + } + ctx, cancel := context.WithTimeout(context.Background(), bus.StateTimeout) + defer cancel() + info, rd, found, err := st.ObjectsOpen(ctx, module, r) + if err != nil || !found { + return nil, err + } + if info.Size > buscalls.ChunkBytes { + return c.openGet(module, info, rd, sessionDigest(info.Digest), false) + } + defer rd.Close() + data, err := io.ReadAll(rd) + if err != nil { + return nil, err + } + return &buscalls.ObjectData{Info: info, Data: data}, nil +} + +// sentGet answers a get of a sent object by its reference: whole when the reference's size is at most one chunk, else a +// session (design 47 G §620). +func (c *consumers) sentGet(module string, r buscalls.ObjectsSentGetRequest) (any, error) { + if r.Size <= buscalls.ChunkBytes { + got, err := c.conn.SentGet(r) + if err != nil { + return nil, err + } + return got, nil + } + st := c.objects() + if st == nil { + return nil, fmt.Errorf("the runner has no bus to read %s from", r.Object) + } + ctx, cancel := context.WithTimeout(context.Background(), bus.StateTimeout) + defer cancel() + info, rd, err := st.SentOpen(ctx, r) + if err != nil { + return nil, err + } + return c.openGet(module, info, rd, sessionDigest(r.Digest), true) +} diff --git a/node-tools/internal/runtime/caller_test.go b/node-tools/internal/runtime/caller_test.go index 7958b39..d44a0d2 100644 --- a/node-tools/internal/runtime/caller_test.go +++ b/node-tools/internal/runtime/caller_test.go @@ -2,9 +2,13 @@ package runtime import ( "encoding/json" + "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/approval" + "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 +28,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 +66,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 +102,96 @@ 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", "workstation.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) + } + } + // Design 47 G A-249: a bundle's seat call is received by the runner in chunks only for a verb whose definition says `chunks`, + // of any machine: a verb a node holds is asked of the node that ran the ask (A-254). + t.Run("OnlyAChunkedSeatVerbIsAskedInChunks", func(t *testing.T) { + c := &consumers{definitions: func() map[string]seatdef.Definition { + return map[string]seatdef.Definition{"mesh-catalog": {Verbs: []seatdef.Verb{{Name: "input", Chunks: true}, {Name: "entry"}}}} + }} + for key, want := range map[string]bool{ + "seat:mesh-catalog.input": true, + "seat:mesh-catalog.entry": false, + "seat:mesh-catalog.input@home-server": true, + "seat:mesh-catalog.entry@home-server": false, + "seat:mesh-unread.input": false, + "mesh-delivery-indexer.indexer": false, + } { + if got := c.chunked(key); got != want { + t.Errorf("chunked(%s) = %v, want %v", key, got, want) + } + } + if (&consumers{}).chunked("seat:mesh-catalog.input") { + t.Error("a runner that holds no seat definitions asks no chunks") + } + }) + // **The tool runner answers a held seat's marked verb by caller and serves the seat's `approvals`** (design 47 G §677; lead + // answers A-265, A-271), with zsh's node-login-shell `execute` as the case: the marked verb reaches the gate with the caller + // its subject names, and `approvals` is answered from the runner's own record of the calls its gate holds, with the + // arguments for the call's own caller only, though no bundle names the verb. + t.Run("TheRunnerServesApprovalsForTheSeatsItsBundlesHold", func(t *testing.T) { + mesh := mt.New(t) + mesh.Issue(t, mt.MembershipOf("zsh", "anchor", true, map[string][]string{"node-login-shell": {"execute", seatdef.ApprovalsVerb}})) + nodeTools := connect(t, "node-tools", "anchor") + gate := &gateHolding{} + defined := WithSeatDefinitions(func() map[string]seatdef.Definition { + return map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{ + {Name: "execute", NeedsApproval: true}, {Name: seatdef.ApprovalsVerb}}}} + }) + stop, err := Run(nodeTools, []Served{{"zsh", []string{mt.Fixture("login-shell-execute.py")}}}, nil, func(string, ...any) {}, defined, WithGate(gate)) + if err != nil { + t.Fatal(err) + } + defer stop() + asker, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "workstation", User: "workstation.node-tools", Password: "unused"}) + if err != nil { + t.Fatal(err) + } + asker.Logf = func(string, ...any) {} + defer asker.Close() + + mt.Until(t, func() error { + _, err := asker.Ask("seat:node-login-shell.execute@anchor", map[string]any{"command": "ls"}, "") + return err + }) + if len(gate.calls) != 1 || gate.calls[0].Seat != "node-login-shell" || gate.calls[0].Name != "execute" || gate.calls[0].Caller != "workstation.node-tools" { + t.Fatalf("the marked verb reached the gate as %+v, want the seat's execute by workstation.node-tools", gate.calls) + } + var got approval.Approvals + mt.Until(t, func() error { + answered, err := asker.Ask("seat:node-login-shell.approvals@anchor", map[string]any{}, "") + if err == nil { + err = json.Unmarshal(answered.Result, &got) + } + return err + }) + if len(got.Calls) != 1 || got.Calls[0].Caller != "workstation.node-tools" || got.Calls[0].Verb != "node-login-shell.execute" || + string(got.Calls[0].Arguments) != `{"command":"ls"}` { + t.Fatalf("approvals answered %+v, want the one call the gate holds, with its caller's arguments", got) + } + }) } diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go new file mode 100644 index 0000000..a29d84d --- /dev/null +++ b/node-tools/internal/runtime/gate.go @@ -0,0 +1,118 @@ +package runtime + +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "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) + // Approvals is the seat verb `approvals` for a caller: the calls the gate holds, its acts and claims (A-265, A-271). + Approvals(caller string) (approval.Approvals, 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) +} + +// approvalsOf is how the runner answers a held seat's verb `approvals`: from its gate's record, nil without a gate. +func (r *runner) approvalsOf() func(caller string) (any, error) { + if r.events.gate == nil { + return nil + } + return func(caller string) (any, error) { return r.events.gate.Approvals(caller) } +} + +// 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.", 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) +} + +// chunked says whether key names a seat's verb whose definition says `chunks` (design 47 G A-249): its answer is received in +// chunks by the runner, whose call hands the bundle the whole. +func (c *consumers) chunked(key string) bool { + if c.definitions == nil || !strings.HasPrefix(key, "seat:") { + return false + } + name, _, _ := strings.Cut(key, "@") // a verb asked of the machine that ran the ask is chunked as the verb is (A-254) + seat, verb, ok := strings.Cut(strings.TrimPrefix(name, "seat:"), ".") + if !ok { + return false + } + for _, v := range c.definitions()[seat].Verbs { + if v.Name == verb { + return v.Chunks + } + } + return false +} diff --git a/node-tools/internal/runtime/gate_approvals_test.go b/node-tools/internal/runtime/gate_approvals_test.go new file mode 100644 index 0000000..7f85aac --- /dev/null +++ b/node-tools/internal/runtime/gate_approvals_test.go @@ -0,0 +1,26 @@ +package runtime + +import ( + "git.novox.be/novox/mesh-sdk/go/approval" +) + +// gateHolding is the runner's gate as far as the runtime reaches it: it records the calls it is run for and answers +// `approvals` from them. +type gateHolding struct{ calls []approval.Call } + +func (g *gateHolding) Unserved(string, string, []string) []string { return nil } +func (g *gateHolding) Run(c approval.Call, run func() (any, error)) (any, error) { + g.calls = append(g.calls, c) + return run() +} +func (g *gateHolding) Approvals(caller string) (approval.Approvals, error) { + out := approval.Approvals{Calls: []approval.ListedCall{}, Acts: []approval.ListedAct{}, Claims: []string{}} + for i, c := range g.calls { + l := approval.ListedCall{Call: string(rune('a' + i)), Caller: c.Caller, Verb: c.Seat + "." + c.Name, State: "waiting"} + if caller == c.Caller { + l.Arguments = c.Args + } + out.Calls = append(out.Calls, l) + } + return out, nil +} diff --git a/node-tools/internal/runtime/ledger_integration_test.go b/node-tools/internal/runtime/ledger_integration_test.go index 802fb03..5c62586 100644 --- a/node-tools/internal/runtime/ledger_integration_test.go +++ b/node-tools/internal/runtime/ledger_integration_test.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "strings" + "sync" "testing" "git.novox.be/novox/mesh-sdk/go/buscalls" @@ -240,6 +241,35 @@ func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) { t.Fatalf("queried %+v, %v", q, err) } }}, + {"fill and removal are answered through the calls and remove an instance from the bus whole", "decision.gone", func(t *testing.T) { + must(t, open(t), "decision/gone", "title", `{"title":"Gone"}`) + must(t, open(t), "decision/gone", "note", `{"says":"two"}`) + must(t, open(t), "decision/stays", "note", `{"says":"kept"}`) + raw, err := m.BusState(buscalls.LedgerFill, json.RawMessage(`{"ledger":"records"}`)) + var fill buscalls.Fill + if _ = json.Unmarshal(raw, &fill); err != nil || fill.Count < 3 || fill.Bytes == 0 { + t.Fatalf("the fill answered %s (%v)", raw, err) + } + raw, err = m.BusState(buscalls.LedgerRemove, json.RawMessage(`{"ledger":"records","instance":"decision/gone"}`)) + var removed buscalls.LedgerRemoved + if _ = json.Unmarshal(raw, &removed); err != nil || removed.Operations != 2 || removed.Bytes == 0 { + t.Fatalf("the removal answered %s (%v)", raw, err) + } + l := open(t) + if last, _ := l.History.Last("decision.gone"); last != 0 { + t.Fatalf("operations are left, at %d", last) + } + if st, _, _ := l.State.Get("decision.gone"); st != nil { + t.Fatalf("the state is left: %s", st) + } + if last, _ := l.History.Last("decision.stays"); last == 0 { + t.Fatal("another instance lost its operations") + } + if raw, err = m.BusState(buscalls.LedgerRemove, json.RawMessage(`{"ledger":"records","instance":"decision/gone"}`)); err != nil || + !strings.Contains(string(raw), `"operations":0`) { + t.Fatalf("a removal said twice answered %s (%v)", raw, err) + } + }}, {"a forged author is refused", "decision.forged", func(t *testing.T) { _, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"decision/forged","operation":"title","body":{"title":"T","author":"op@shanks"},"call":"forged"}`)) if err == nil || !strings.Contains(err.Error(), "the call forged, which is not a tool call in flight") { @@ -258,4 +288,70 @@ func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) { } }) } + // Design 47 G §559, A-153: against the real bus a publish with ack answers the sequence the bus gave it, and a subscription + // for new messages hears only what is published after it was made, on the subject mesh-sdk's one rule names for the event; + // a wildcard is refused. + t.Run("AnAckedPublishAnswersItsSequenceAndANewSubscriptionHearsOnlyWhatComesAfter", func(t *testing.T) { + mt.New(t) + nc, err := nats.Connect(mt.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, _ := jetstream.New(nc) + ctx := context.Background() + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEATEVENTS", Subjects: []string{"mesh.seat.mesh-conditions.event.>"}}); err != nil { + t.Fatal(err) + } + conn := connect(t, "node-tools", "anchor") + publish := func(body string) { + t.Helper() + if _, err := js.Publish(ctx, "mesh.seat.mesh-conditions.event.findings-asked", []byte(body)); err != nil { + t.Fatal(err) + } + } + publish(`{"before":true}`) + + var mu sync.Mutex + var heard []string + stop, err := conn.SubscribeNew("mesh-conditions.findings-asked", func(raw json.RawMessage) error { + var env bus.Envelope + _ = json.Unmarshal(raw, &env) + mu.Lock() + heard = append(heard, env.Key+" "+string(env.Body)) + mu.Unlock() + return nil + }) + if err != nil { + t.Fatal(err) + } + defer stop() + publish(`{"after":true}`) + publish(`{"after":2}`) + mt.Until(t, func() error { + mu.Lock() + defer mu.Unlock() + if len(heard) < 2 { + return context.DeadlineExceeded + } + return nil + }) + mu.Lock() + if len(heard) != 2 || heard[0] != `mesh-conditions.findings-asked {"after":true}` { + t.Errorf("heard %v, want the messages after the subscription alone, in order", heard) + } + mu.Unlock() + + for name, want := range map[string]string{"mesh-conditions.*": "[A-Za-z0-9_-]"} { + if _, err := conn.SubscribeNew(name, func(json.RawMessage) error { return nil }); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("a subscription for %s answered %v, want one naming %q", name, err, want) + } + } + + first, err := conn.PublishAsAcked("node-tools", bus.Envelope{Key: "finding-written", Body: json.RawMessage(`{}`)}) + second, err2 := conn.PublishAsAcked("node-tools", bus.Envelope{Key: "finding-written", Body: json.RawMessage(`{}`)}) + if err != nil || err2 != nil || first == 0 || second != first+1 { + t.Errorf("acknowledged publishes answered %d (%v) and %d (%v), want consecutive sequences", first, err, second, err2) + } + }) } diff --git a/node-tools/internal/runtime/ledgers.go b/node-tools/internal/runtime/ledgers.go index 114cd54..960dab5 100644 --- a/node-tools/internal/runtime/ledgers.go +++ b/node-tools/internal/runtime/ledgers.go @@ -171,6 +171,17 @@ func (b *moduleBus) ledgerCall(method string, params json.RawMessage) (any, erro var r buscalls.LedgerRequest _ = json.Unmarshal(params, &r) return l.Lag(r) + case buscalls.LedgerFill: + var r buscalls.LedgerRequest + _ = json.Unmarshal(params, &r) + return l.Fill(r) + case buscalls.LedgerRemove: + // Never on an apply: a removal is no operation of the history and has no author (design 47 G §587). + var r buscalls.LedgerRemoveRequest + if err := decode(method, params, &r); err != nil { + return nil, err + } + return l.Remove(r) } return nil, fmt.Errorf("the runtime answers no %s", method) } diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 23bf5fb..ff586ef 100644 --- a/node-tools/internal/runtime/ledgers_test.go +++ b/node-tools/internal/runtime/ledgers_test.go @@ -204,7 +204,7 @@ 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,seat-settle-delay,stream-publish" { + if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls,stream-publish" { t.Fatalf("the runner states %s", got) } for capability, methods := range callFamilies { diff --git a/node-tools/internal/runtime/own_account_test.go b/node-tools/internal/runtime/own_account_test.go index eaf433d..ff678e6 100644 --- a/node-tools/internal/runtime/own_account_test.go +++ b/node-tools/internal/runtime/own_account_test.go @@ -36,6 +36,25 @@ func TestNoBusWordReachesABundle(t *testing.T) { t.Errorf("%s did not reach the bundle", want) } } + // design 47 G §618, §519: the runtime's own fingerprint reaches no bundle; the fingerprint a module's composed words hold does. + t.Run("TheRuntimesOwnFingerprintReachesNoBundle", func(t *testing.T) { + base := []string{"MESH_FINGERPRINT=runner-build", "PATH=/usr/bin"} + for name, c := range map[string]struct { + given map[string]string + want string + }{ + "composed with none sees none": {nil, ""}, + "composed with one sees its own": {map[string]string{"MESH_FINGERPRINT": "module-build"}, "MESH_FINGERPRINT=module-build"}, + } { + env := strings.Join(BundleEnv(base, c.given, "telegram", "anchor"), "\n") + if strings.Contains(env, "runner-build") { + t.Errorf("%s: the runtime's own fingerprint reached the bundle: %s", name, env) + } + if c.want == "" && strings.Contains(env, "MESH_FINGERPRINT") || !strings.Contains(env, c.want) { + t.Errorf("%s: the environment is %s", name, env) + } + } + }) } // A tool call reaches only a tool's subject: whatever key a caller names, it is never a seat's event, accept diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 84580ef..b29e08e 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -16,6 +16,9 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/approval" + "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/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" @@ -59,6 +62,10 @@ type ListedTool struct { // NodeRuntimeModule is the module that is a machine's runtime, serving every module on it. const NodeRuntimeModule = "node-tools" +// fingerprintEnv is the word the node-engine starts each process with, the fingerprint of the want it applies (mesh-sdk's +// process.FingerprintEnv; this package imports no SDK): the runtime's own is no bundle's. +const fingerprintEnv = "MESH_FINGERPRINT" + // runtimeOnly are the words of the runtime's own environment no bundle is given. var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true, "MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true, LedgersFile: true} @@ -230,6 +237,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 +274,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 +310,8 @@ 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.events.chunked, r.approvalsOf(), + r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -308,7 +321,10 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log announced, err := announce.Serve(conn, announce.Service{ Name: conn.Module(), ID: instanceOf(conn), Description: "the mesh's tool runtime on " + node + ": every assigned module's tools and the seats they hold", - Metadata: map[string]string{"node": node, announce.Capabilities: strings.Join(CallCapabilities(), ",")}, + // Its own fingerprint beside the capabilities, so a reading is tied to the runner that made it (design 46 approval + // mark §6, lead answer A-203). + Metadata: map[string]string{"node": node, announce.Capabilities: strings.Join(CallCapabilities(), ","), + announce.Fingerprint: os.Getenv(FingerprintWord)}, }, func() []announce.Endpoint { return endpointsOf(conn, r.own(), r.registrations(), r.modules) }) if err != nil { logf("[mesh-tools] cannot announce what it serves: %v", err) @@ -409,6 +425,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 +475,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) @@ -637,6 +663,9 @@ func endpointsOf(conn *bus.Conn, own []registration, registrations []registratio } for _, v := range m.Seats { t, ok := impl[v.Seat][v.Verb] + if v.Verb == seatdef.ApprovalsVerb && !ok { + t, ok = launch.Tool{Name: v.Verb, Description: "The calls this seat's holder holds for the operator's approval, its acts and its claims."}, true + } if !ok || have[v.Subject] { continue } @@ -691,7 +720,9 @@ 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, chunked func(key string) bool, approvals func(caller string) (any, error), + fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -725,13 +756,43 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, continue } have[v.Subject] = true + // The seat's verb `approvals` is the gate's own, served wherever a marked verb is: the runner answers it from its + // record of the calls its gate holds, by caller, and no bundle names it (lead answers A-265, A-266, A-271). + if v.Verb == seatdef.ApprovalsVerb && approvals != nil { + seat := v.Seat + stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, _ json.RawMessage) (any, error) { + if err := admitted(seat, seatdef.ApprovalsVerb, caller); err != nil { + return nil, err + } + return approvals(caller) + }) + if err != nil { + logf("[mesh-tools] cannot serve %s's approvals on %s: %v", seat, v.Subject, err) + continue + } + stops = append(stops, stop) + logf("[mesh-tools] serving %s's approvals on %s, the gate's own verb", seat, v.Subject) + continue + } t, ok := impl[v.Seat][v.Verb] if !ok { 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 + } + answer, err := 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 || !chunked("seat:"+v.Seat+"."+v.Verb) { + return answer, err + } + // A verb marked chunks: the bundle answers the whole file, the runner sends it in chunks (design 47 G A-257). + return conn.AnswerChunked(v.Seat, v.Verb, body, answer) }) if err != nil { logf("[mesh-tools] cannot serve %s's %s on %s: %v", v.Seat, v.Verb, v.Subject, err) @@ -770,6 +831,12 @@ type consumers struct { ledgers ledgerSource // calls are the tool calls in flight, by the id each bundle was handed (ledgers.go, novox/hq ADR 0597 §7). 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 { @@ -845,6 +912,7 @@ func (c *consumers) dropModule(module string) { } } c.provisioned.stopModule(module) + c.dropModuleSessions(module) } // forModule is the bus one launched bundle of a module reaches the mesh through. @@ -869,6 +937,21 @@ func (b *moduleBus) Publish(params json.RawMessage) error { return b.all.conn.PublishAs(b.module, env) } +// PublishAcked is Publish that answers the sequence the bus acknowledged the event at (`mesh/publish` with `ack`, design 47 G §559). +func (b *moduleBus) PublishAcked(params json.RawMessage) (uint64, error) { + var env bus.Envelope + if err := json.Unmarshal(params, &env); err != nil { + return 0, fmt.Errorf("not an event envelope: %w", err) + } + return b.all.conn.PublishAsAcked(b.module, env) +} + +// SubscribeNew hands the bundle one event's messages published from now on, on an ordered consumer of its own (`mesh/subscribe` +// with `deliver: new`, design 47 G §559); the consumer ends with the bundle's channel. +func (b *moduleBus) SubscribeNew(pattern string, deliver func(json.RawMessage) error) (func(), error) { + return b.all.conn.SubscribeNew(pattern, deliver) +} + // Ask calls a tool as the module: `{key, body}`, answered with the tool's result (ADR 0198). func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) { var asked struct { @@ -882,7 +965,11 @@ func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) { if len(asked.Body) == 0 { body = map[string]any{} } - answered, err := b.all.conn.AskAs(b.module, asked.Key, body) + ask := b.all.conn.AskAs + if b.all.chunked(asked.Key) { + ask = b.all.conn.AskAsChunked // the runner receives the chunks of the answer; the bundle receives none (design 47 G A-249) + } + answered, err := ask(b.module, asked.Key, body) if err != nil { return nil, err } @@ -1366,11 +1453,13 @@ func (b *moduleBus) bind(key string, stop func()) { // BundleEnv is what one module's bundle is started with: the runtime's environment without its own words, // the module's composed words over it, and the module's and machine's names. **No bus word reaches a // bundle** (security review of 2026-10-08): the credential and the bus's address are the runtime's, and a -// bundle reaches the bus only through the runtime. +// bundle reaches the bus only through the runtime. **The runtime's own fingerprint reaches none either**: a bundle reads +// only the `MESH_FINGERPRINT` its module's composed words hold, the build the node-engine applied for that module, and with +// none it has none (design 47 G §519, §618). func BundleEnv(base []string, given map[string]string, module, node string) []string { words := map[string]string{} for _, kv := range base { - if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] { + if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] && k != fingerprintEnv { words[k] = v } } diff --git a/node-tools/internal/runtime/runtime_test.go b/node-tools/internal/runtime/runtime_test.go index eb56bfa..b8e8906 100644 --- a/node-tools/internal/runtime/runtime_test.go +++ b/node-tools/internal/runtime/runtime_test.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "os" + "slices" "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/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,50 @@ 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() } +func (unservingGate) Approvals(string) (approval.Approvals, error) { return approval.Approvals{}, nil } + +// 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") diff --git a/node-tools/internal/runtime/sessions.go b/node-tools/internal/runtime/sessions.go new file mode 100644 index 0000000..817ad6e --- /dev/null +++ b/node-tools/internal/runtime/sessions.go @@ -0,0 +1,307 @@ +package runtime + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + + "github.com/novox/mesh-tools/node-tools/internal/launch" +) + +// A bundle's put and get sessions (design 47 G §518, A-21, A-25, §620): a value over buscalls.ChunkBytes crosses the +// bundle's channel as calls the runner holds the session of, nothing of it on the bus. A put writes each chunk into the +// module's object store as it arrives (through a pipe into the store's own chunking, so no more than one chunk is held) and +// seals the object on the last chunk; a get reads the object out as the bundle asks its chunks, in order. + +// SessionBound is how long a session has, counted once for the whole session from its first chunk (§518 "The bound"): the +// call's bound, launch.CallTimeout. +var SessionBound = launch.CallTimeout + +// objectSessions is what a session needs of the bus: *bus.Conn. +type objectSessions interface { + ObjectsPutStream(ctx context.Context, module string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error) + ObjectsOpen(ctx context.Context, module string, r buscalls.ObjectsNameRequest) (buscalls.ObjectInfo, io.ReadCloser, bool, error) + SentOpen(ctx context.Context, r buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error) +} + +// sessionTable holds the sessions of every module, each under its module's name and the bundle's call id, so a bundle +// reaches its own sessions alone. +type sessionTable struct { + mu sync.Mutex + puts map[string]*putSession + gets map[string]*getSession + // store replaces the bus where a test has none. + store objectSessions +} + +type putSession struct { + mu sync.Mutex + next uint32 + pw *io.PipeWriter + done chan struct{} // closed once res is set + res putResult + cancel context.CancelFunc + timer *time.Timer + store string +} + +type putResult struct { + info buscalls.ObjectInfo + err error +} + +type getSession struct { + mu sync.Mutex + next uint32 + rd *bufio.Reader + closer io.Closer + timer *time.Timer +} + +var errSessionBound = errors.New("the session was not completed within the call's bound") + +func sessionKey(module, call string) string { return module + " " + call } + +// putChunk takes one chunk of a put session: it answers {seq}, or {error}, and the put's own answer for the last chunk. +func (c *consumers) putChunk(module string, params json.RawMessage) (any, error) { + var r buscalls.ObjectsPutChunkRequest + if err := decode(buscalls.ObjectsPutChunk, params, &r); err != nil { + return nil, err + } + if r.Call == "" { + return nil, errors.New("a chunk names the put's call") + } + t := &c.sessions + key := sessionKey(module, r.Call) + t.mu.Lock() + s := t.puts[key] + if r.Seq == 1 && s == nil { + var err error + if s, err = c.beginPut(module, key, r); err != nil { + t.mu.Unlock() + return buscalls.ObjectsChunkTaken{Error: err.Error()}, nil + } + } + t.mu.Unlock() + if s == nil { + return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("no put %s of %s is open at chunk %d: it was dropped at its bound, or never begun", r.Call, r.Name, r.Seq)}, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if r.Seq != s.next { + c.dropPut(key, s, errors.New("a chunk out of order")) + return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s: chunk %d came where chunk %d was next", r.Name, r.Seq, s.next)}, nil + } + if len(r.Data) > buscalls.ChunkBytes { + c.dropPut(key, s, errors.New("a chunk over the limit")) + return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s: chunk %d is %d bytes, over the %d a chunk carries", r.Name, r.Seq, len(r.Data), buscalls.ChunkBytes)}, nil + } + if _, err := s.pw.Write(r.Data); err != nil { + // The store refused the chunk (its cap) or the bound passed: its own reason is the answer. + <-s.done + res := s.res + c.forgetPut(key, s) + reason := res.err + if reason == nil || errors.Is(reason, io.ErrClosedPipe) { + reason = err + } + return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s into %s, at chunk %d: %v", r.Name, s.store, r.Seq, reason)}, nil + } + if !r.Last { + s.next++ + return buscalls.ObjectsChunkTaken{Seq: r.Seq}, nil + } + _ = s.pw.Close() + <-s.done + res := s.res + c.forgetPut(key, s) + if res.err != nil { + return buscalls.ObjectsChunkTaken{Error: fmt.Sprintf("putting %s into %s: %v", r.Name, s.store, res.err)}, nil + } + return res.info, nil +} + +// beginPut opens the put of the first chunk, with t.mu held. +func (c *consumers) beginPut(module, key string, r buscalls.ObjectsPutChunkRequest) (*putSession, error) { + if r.Name == "" { + return nil, errors.New("an object is put under a name") + } + st := c.objects() + if st == nil { + return nil, errors.New("the runner has no bus to put on") + } + pr, pw := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + s := &putSession{next: 1, pw: pw, done: make(chan struct{}), cancel: cancel, store: r.Objects} + s.timer = time.AfterFunc(SessionBound, func() { + c.sessions.mu.Lock() + current := c.sessions.puts[key] == s + c.sessions.mu.Unlock() + if current { + _ = pw.CloseWithError(errSessionBound) + cancel() + } + }) + if c.sessions.puts == nil { + c.sessions.puts = map[string]*putSession{} + } + c.sessions.puts[key] = s + go func() { + info, err := st.ObjectsPutStream(ctx, module, buscalls.ObjectsNameRequest{Objects: r.Objects, Name: r.Name}, r.Description, pr) + _ = pr.CloseWithError(err) + s.res = putResult{info, err} + close(s.done) + }() + return s, nil +} + +// dropPut ends a put that will not be sealed: the store purges what it wrote. +func (c *consumers) dropPut(key string, s *putSession, why error) { + _ = s.pw.CloseWithError(why) + <-s.done + c.forgetPut(key, s) +} + +func (c *consumers) forgetPut(key string, s *putSession) { + s.timer.Stop() + s.cancel() + c.sessions.mu.Lock() + delete(c.sessions.puts, key) + c.sessions.mu.Unlock() +} + +// objects is the bus a session reads and writes objects through. +func (c *consumers) objects() objectSessions { + if c.sessions.store != nil { + return c.sessions.store + } + if c.conn == nil { + return nil + } + return c.conn +} + +// openGet registers the get session of an opened object and answers the chunked answer that begins it. +func (c *consumers) openGet(module string, info buscalls.ObjectInfo, rd io.ReadCloser, digest string, sent bool) (any, error) { + call := newCall() + g := &getSession{next: 1, rd: bufio.NewReaderSize(rd, buscalls.ChunkBytes), closer: rd} + key := sessionKey(module, call) + g.timer = time.AfterFunc(SessionBound, func() { c.dropGet(key, g) }) + c.sessions.mu.Lock() + if c.sessions.gets == nil { + c.sessions.gets = map[string]*getSession{} + } + c.sessions.gets[key] = g + c.sessions.mu.Unlock() + var chunked json.RawMessage = json.RawMessage("true") + if !sent { + chunked = json.RawMessage(fmt.Sprint((info.Size + buscalls.ChunkBytes - 1) / buscalls.ChunkBytes)) + } + return buscalls.ObjectsChunked{Chunked: chunked, Size: info.Size, Digest: digest, Call: call}, nil +} + +func (c *consumers) dropGet(key string, g *getSession) { + g.timer.Stop() + c.sessions.mu.Lock() + delete(c.sessions.gets, key) + c.sessions.mu.Unlock() + _ = g.closer.Close() +} + +// getChunk answers the next chunk of a get session: {seq, last, data}, or {error}. +func (c *consumers) getChunk(module string, params json.RawMessage) (any, error) { + var r buscalls.ObjectsGetChunkRequest + if err := decode(buscalls.ObjectsGetChunk, params, &r); err != nil { + return nil, err + } + key := sessionKey(module, r.Call) + c.sessions.mu.Lock() + g := c.sessions.gets[key] + c.sessions.mu.Unlock() + if g == nil { + return buscalls.ObjectsChunk{Error: fmt.Sprintf("no get %s is open at chunk %d: it was dropped at its bound, or never begun", r.Call, r.Seq)}, nil + } + g.mu.Lock() + defer g.mu.Unlock() + if r.Seq != g.next { + c.dropGet(key, g) + return buscalls.ObjectsChunk{Error: fmt.Sprintf("chunk %d was asked where chunk %d is next", r.Seq, g.next)}, nil + } + buf := make([]byte, buscalls.ChunkBytes) + n, err := io.ReadFull(g.rd, buf) + last := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) + if err != nil && !last { + c.dropGet(key, g) + return buscalls.ObjectsChunk{Error: fmt.Sprintf("reading chunk %d: %v", r.Seq, err)}, nil + } + if !last { + if _, perr := g.rd.Peek(1); errors.Is(perr, io.EOF) { + last = true + } + } + g.next++ + if last { + c.dropGet(key, g) + } + return buscalls.ObjectsChunk{Seq: r.Seq, Last: last, Data: buf[:n]}, nil +} + +// dropModuleSessions ends every session of a module whose bundle is gone. +func (c *consumers) dropModuleSessions(module string) { + c.sessions.mu.Lock() + var puts []*putSession + var putKeys []string + var gets []*getSession + var getKeys []string + for k, s := range c.sessions.puts { + if strings.HasPrefix(k, module+" ") { + puts, putKeys = append(puts, s), append(putKeys, k) + } + } + for k, g := range c.sessions.gets { + if strings.HasPrefix(k, module+" ") { + gets, getKeys = append(gets, g), append(getKeys, k) + } + } + c.sessions.mu.Unlock() + for i, s := range puts { + c.dropPut(putKeys[i], s, errors.New("the bundle's channel closed")) + } + for i, g := range gets { + c.dropGet(getKeys[i], g) + } +} + +// sessionDigest is the `sha256:` form of an object's digest as the bus store keeps it (`SHA-256=` and base64), or as a +// reference carries it (hex). +func sessionDigest(d string) string { + if raw, ok := strings.CutPrefix(d, "SHA-256="); ok { + if b, err := base64.URLEncoding.DecodeString(raw); err == nil { + return "sha256:" + hex.EncodeToString(b) + } + } + return "sha256:" + strings.TrimPrefix(d, "sha256:") +} + +func newCall() string { + var b [12]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// CapabilityWords are the capability words this runner states, answered to a bundle's `mesh/capabilities` (§576). +func (b *moduleBus) CapabilityWords() []string { return CallCapabilities() } + +// DropSessions ends the sessions of the module whose bundle's channel closed (§518). +func (b *moduleBus) DropSessions() { b.all.dropModuleSessions(b.module) } diff --git a/node-tools/internal/runtime/sessions_test.go b/node-tools/internal/runtime/sessions_test.go new file mode 100644 index 0000000..4d67f94 --- /dev/null +++ b/node-tools/internal/runtime/sessions_test.go @@ -0,0 +1,193 @@ +package runtime + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// fakeObjects is the bus of a session: what was put whole, what a read gives, and what the store deleted. +type fakeObjects struct { + put map[string][]byte + description string + deleted []string + read []byte + ctxEnded chan struct{} +} + +func (f *fakeObjects) ObjectsPutStream(ctx context.Context, _ string, r buscalls.ObjectsNameRequest, description string, in io.Reader) (buscalls.ObjectInfo, error) { + data, err := io.ReadAll(in) + if err != nil { + f.deleted = append(f.deleted, r.Name) + if f.ctxEnded != nil { + <-ctx.Done() + close(f.ctxEnded) + } + return buscalls.ObjectInfo{}, err + } + f.put[r.Name], f.description = data, description + return buscalls.ObjectInfo{Name: r.Name, Size: uint64(len(data)), Description: description}, nil +} + +func (f *fakeObjects) ObjectsOpen(context.Context, string, buscalls.ObjectsNameRequest) (buscalls.ObjectInfo, io.ReadCloser, bool, error) { + return buscalls.ObjectInfo{Name: "o", Size: uint64(len(f.read))}, io.NopCloser(bytes.NewReader(f.read)), true, nil +} + +func (f *fakeObjects) SentOpen(context.Context, buscalls.ObjectsSentGetRequest) (buscalls.ObjectInfo, io.ReadCloser, error) { + return buscalls.ObjectInfo{Size: uint64(len(f.read))}, io.NopCloser(bytes.NewReader(f.read)), nil +} + +func aSession(t *testing.T, f *fakeObjects) *moduleBus { + t.Helper() + c := &consumers{} + c.sessions.store = f + return &moduleBus{all: c, module: "m"} +} + +func asked(t *testing.T, b *moduleBus, method string, req any, into any) { + t.Helper() + params, _ := json.Marshal(req) + raw, err := b.BusState(method, params) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + if err := json.Unmarshal(raw, into); err != nil { + t.Fatal(err) + } +} + +// §490, §518, §620: a value over one chunk is got as a session (the chunked answer, then each chunk by its call and sequence, +// the whole checked against the digest); a sent object over one chunk the same, with `chunked` true. +func TestAGetOverOneChunkRunsAsASession(t *testing.T) { + whole := bytes.Repeat([]byte("y"), 2*buscalls.ChunkBytes) + f := &fakeObjects{read: whole} + b := aSession(t, f) + sum := sha256.Sum256(whole) + want := "sha256:" + hex.EncodeToString(sum[:]) + for name, run := range map[string]func() buscalls.ObjectsChunked{ + "state": func() buscalls.ObjectsChunked { + var c buscalls.ObjectsChunked + asked(t, b, buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: "files", Name: "o"}, &c) + return c + }, + "sent": func() buscalls.ObjectsChunked { + var c buscalls.ObjectsChunked + asked(t, b, buscalls.ObjectsSentGet, buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: int64(len(whole)), Digest: hex.EncodeToString(sum[:])}, &c) + if string(c.Chunked) != "true" { + t.Errorf("a sent object's chunked is %s", c.Chunked) + } + return c + }, + } { + t.Run(name, func(t *testing.T) { + c := run() + if c.Call == "" || c.Size != uint64(len(whole)) { + t.Fatalf("the get answered %+v", c) + } + if name == "state" { + if string(c.Chunked) != "2" { + t.Errorf("chunked is %s, want 2", c.Chunked) + } + } + var got []byte + for seq := uint32(1); ; seq++ { + var ch buscalls.ObjectsChunk + asked(t, b, buscalls.ObjectsGetChunk, buscalls.ObjectsGetChunkRequest{Call: c.Call, Seq: seq}, &ch) + if ch.Error != "" || ch.Seq != seq { + t.Fatalf("chunk %d: %+v", seq, ch) + } + got = append(got, ch.Data...) + if ch.Last { + if seq != 2 { + t.Fatalf("an object of two chunks ended at chunk %d", seq) + } + break + } + } + if !bytes.Equal(got, whole) || name == "state" && c.Digest == "" { + t.Fatalf("read %d bytes, digest %s", len(got), c.Digest) + } + if name == "sent" && c.Digest != want { + t.Errorf("digest %s, want %s", c.Digest, want) + } + var again buscalls.ObjectsChunk + asked(t, b, buscalls.ObjectsGetChunk, buscalls.ObjectsGetChunkRequest{Call: c.Call, Seq: 3}, &again) + if again.Error == "" { + t.Error("a finished session answered another chunk") + } + }) + } + // A value of one chunk or less is the value itself, as before. + f.read = []byte("small") + var small buscalls.ObjectData + asked(t, b, buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: "files", Name: "o"}, &small) + if string(small.Data) != "small" { + t.Fatalf("a small value was answered %+v", small) + } + // Design 47 G §518, A-21, A-25: a put session writes each chunk as it is taken, carries the description of its first chunk, + // and answers the put's own info for the last; a chunk out of order fails the put naming both sequences and leaves none. + t.Run("APutSessionSealsOnTheLastChunkAndDropsOnADisorder", func(t *testing.T) { + f := &fakeObjects{put: map[string][]byte{}} + b := aSession(t, f) + whole := bytes.Repeat([]byte("x"), 2*buscalls.ChunkBytes+5) + var taken buscalls.ObjectsChunkTaken + for seq, at := uint32(1), 0; ; seq++ { + end := min(at+buscalls.ChunkBytes, len(whole)) + req := buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "a", Call: "c1", Seq: seq, Last: end == len(whole), Data: whole[at:end]} + if seq == 1 { + req.Description = "what it is" + } + if req.Last { + var info buscalls.ObjectInfo + asked(t, b, buscalls.ObjectsPutChunk, req, &info) + if info.Size != uint64(len(whole)) || info.Description != "what it is" { + t.Fatalf("the put answered %+v", info) + } + break + } + asked(t, b, buscalls.ObjectsPutChunk, req, &taken) + if taken.Seq != seq || taken.Error != "" { + t.Fatalf("chunk %d was answered %+v", seq, taken) + } + at = end + } + if !bytes.Equal(f.put["a"], whole) { + t.Fatalf("the store holds %d bytes, want %d", len(f.put["a"]), len(whole)) + } + + asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 1, Data: []byte("one")}, &taken) + asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 3, Data: []byte("three")}, &taken) + if !strings.Contains(taken.Error, "chunk 3") || !strings.Contains(taken.Error, "chunk 2") { + t.Fatalf("a disorder was answered %+v", taken) + } + if _, ok := f.put["b"]; ok || len(f.deleted) != 1 || f.deleted[0] != "b" { + t.Fatalf("a dropped put left %v, deleted %v", f.put, f.deleted) + } + asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "b", Call: "c2", Seq: 2, Data: []byte("two")}, &taken) + if taken.Error == "" { + t.Fatal("a chunk of a dropped put was taken") + } + }) + // §518 "The bound": a put not sealed within the bound is dropped, its object deleted. + t.Run("APutNotSealedWithinTheBoundIsDropped", func(t *testing.T) { + was := SessionBound + SessionBound = time.Millisecond + t.Cleanup(func() { SessionBound = was }) + f := &fakeObjects{put: map[string][]byte{}, ctxEnded: make(chan struct{})} + b := aSession(t, f) + var taken buscalls.ObjectsChunkTaken + asked(t, b, buscalls.ObjectsPutChunk, buscalls.ObjectsPutChunkRequest{Objects: "files", Name: "a", Call: "c", Seq: 1, Data: []byte("x")}, &taken) + <-f.ctxEnded + if len(f.deleted) != 1 { + t.Fatalf("the unsealed object was left: %v", f.deleted) + } + }) +} diff --git a/node-tools/module.json b/node-tools/module.json index f490158..ebdfa46 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -1,17 +1,13 @@ { "module": "node-tools", - "version": "2", + "version": "0.1", "slug": "node-tools", "tool-runner": true, "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": [ { @@ -62,21 +58,47 @@ "language": "go", "system": "arch", "from": "cmd/node-tools", - "binary": "node-tools" + "binary": "node-tools", + "states": [ + "approval-mark/1", + "bus-state-calls", + "finding-calls", + "ledger-calls", + "ledger-upkeep-calls", + "put-sessions", + "seat-settle-delay", + "sent-objects-calls", + "stream-publish" + ] } ] }, "state": [ "bound", - "findings" + "findings", + { + "name": "approvals", + "per-machine": true, + "ttl-seconds": 172800 + } ], "consumes": [ + "mesh-catalog.catalog-revision", + "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", - "mesh-delivery-assigner.views.assignments", - "mesh-delivery-assigner.views.seat-holders", - "mesh-delivery-indexer.views.manifests", - "mesh-delivery-indexer.views.modules" + "mesh-conditions.findings-asked", + "seat:mesh-module-assigner.views.assignments", + "seat:mesh-module-assigner.views.seat-holders", + "mesh-delivery.source-moved" + ], + "streams": [ + { + "name": "node-tools-findings", + "events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"], + "max-age-days": 7, + "max-mib": 16 + } ], "reads": [ "mesh-provisioner.provisions", diff --git a/node-tools/test/fixtures/login-shell-execute.py b/node-tools/test/fixtures/login-shell-execute.py new file mode 100755 index 0000000..3c4517e --- /dev/null +++ b/node-tools/test/fixtures/login-shell-execute.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# The login shell's bundle as the seat node-login-shell has it: MCP over stdio, no SDK, one seat verb, execute, which says +# the command it was given and the caller the call's `_meta` named. +import json, sys + +def say(m): + m["jsonrpc"] = "2.0"; sys.stdout.write(json.dumps(m) + "\n"); sys.stdout.flush() + +TOOLS = [ + {"name": "node-login-shell.execute", "description": "run a command", "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}}}}, +] +for line in sys.stdin: + req = json.loads(line); rid = req.get("id"); m = req.get("method"); p = req.get("params") or {} + if m == "initialize": + say({"id": rid, "result": {"protocolVersion": "2025-03-26", "capabilities": {"tools": {}}, "serverInfo": {"name": "zsh", "version": "1"}}}) + elif m == "tools/list": + say({"id": rid, "result": {"tools": TOOLS}}) + elif m == "tools/call": + meta = p.get("_meta") or {} + said = {"ran": (p.get("arguments") or {}).get("command", ""), "caller": meta.get("mesh/caller", "")} + say({"id": rid, "result": {"content": [{"type": "text", "text": json.dumps(said)}]}}) + elif rid is not None: + say({"id": rid, "result": {}})