From a5b8ae1a7262d55798f0013b97d0fa1d3e6989ee Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 12:04:32 +0100 Subject: [PATCH 01/44] T-1: the tool runner's manifest says 0.1, the finding stream and the keeper's findings-asked, and follows catalog-revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §368, §387, §196 (consumes catalog-revision; views.modules goes with T-2's first view) Held: waits (§426) goes with the every-event part, which removes the code reading it. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- node-tools/internal/console/manifest_waits_test.go | 13 ++++++++++++- node-tools/module.json | 12 +++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index 3511ec8..bce0e7f 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -21,10 +21,18 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { Module string `json:"module"` Waits []string `json:"waits"` 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) } + if len(m.Streams) != 1 || m.Streams[0].Name != "node-tools-findings" || + !reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { + t.Errorf("the finding stream is %+v, want node-tools-findings with the three finding events (design 47 G §387)", m.Streams) + } want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status", "mesh-self-check.checked", "operator-channel.decided.claude-code"} if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) { @@ -37,7 +45,10 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { } // 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", + // Design 47 G §196, §387: the catalog half by `catalog-revision` (never the indexer's `views.modules`), and the keeper's + // `findings-asked`; the finding stream below. `views.modules` goes with the first view that follows it (T-2). + wantConsumes := []string{"mesh-catalog.catalog-revision", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", + "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.seat-holders", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"} if !reflect.DeepEqual(m.Consumes, wantConsumes) { t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes) diff --git a/node-tools/module.json b/node-tools/module.json index f490158..a202049 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -1,6 +1,6 @@ { "module": "node-tools", - "version": "2", + "version": "0.1", "slug": "node-tools", "tool-runner": true, "invokes": [ @@ -71,13 +71,23 @@ "findings" ], "consumes": [ + "mesh-catalog.catalog-revision", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", + "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.seat-holders", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules" ], + "streams": [ + { + "name": "node-tools-findings", + "events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"], + "max-age-days": 7, + "max-mib": 16 + } + ], "reads": [ "mesh-provisioner.provisions", "mesh-provisioner.plans" -- 2.54.0 From 78e5de538b7253287a1b60a3ad1c83b3c3ad1253 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 12:06:40 +0100 Subject: [PATCH 02/44] T-6: the tool runner reads a sent object by its reference, under the bus state capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh/objects.sent-get reads a sender's sent store only; refused: a state's objects, a reference that is none, an object over a bundle's channel. Builds: design 47 G §347, §59, §462 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- node-tools/internal/bus/sent.go | 55 ++++++++++++++++++++ node-tools/internal/bus/sent_test.go | 30 +++++++++++ node-tools/internal/runtime/announce_test.go | 14 +++++ node-tools/internal/runtime/busstate.go | 10 +++- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 node-tools/internal/bus/sent.go create mode 100644 node-tools/internal/bus/sent_test.go diff --git a/node-tools/internal/bus/sent.go b/node-tools/internal/bus/sent.go new file mode 100644 index 0000000..7cd2406 --- /dev/null +++ b/node-tools/internal/bus/sent.go @@ -0,0 +1,55 @@ +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, from the manifests' callers and consumers; a refusal says the store. +func (c *Conn) SentGet(r buscalls.ObjectsSentGetRequest) (*buscalls.ObjectData, 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) + } + if r.Size > stateobjects.BundleObjectMost { + return nil, fmt.Errorf("the sent object %s is %d bytes and a bundle's runtime channel carries at most %d: it is not read "+ + "through a bundle", r.Object, r.Size, stateobjects.BundleObjectMost) + } + js, err := c.jetStream() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) + defer cancel() + 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) + } + 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 +} diff --git a/node-tools/internal/bus/sent_test.go b/node-tools/internal/bus/sent_test.go new file mode 100644 index 0000000..26ce44d --- /dev/null +++ b/node-tools/internal/bus/sent_test.go @@ -0,0 +1,30 @@ +package bus + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/stateobjects" +) + +// 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, a reference that is none and an object too large for a bundle's channel are refused before the bus is asked. +func TestSentGetRefusesWhatIsNoSentObjectsReference(t *testing.T) { + digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + big := int64(stateobjects.BundleObjectMost) + 1 + 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"}, + {"over the channel", buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: big, Digest: digest}, "not read through a bundle"}, + } { + _, 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) + } + } +} diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index 6965fea..441c81e 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -2,6 +2,7 @@ package runtime import ( "encoding/json" + "git.novox.be/novox/mesh-sdk/go/buscalls" "sort" "strings" "testing" @@ -143,3 +144,16 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } + +// Design 47 G §462: the call that reads a sent object is listed under the bus state capability, and the ledger calls keep theirs. +func TestTheSentObjectCallBelongsToTheBusStateCapability(t *testing.T) { + for c, method := range map[string]string{CapBusStateCalls: buscalls.ObjectsSentGet} { + found := false + for _, m := range callFamilies[c] { + found = found || m == method + } + if !found { + t.Errorf("%s does not list %s", c, method) + } + } +} diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index 2c67519..3d55731 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -21,7 +21,7 @@ const ( // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. var callFamilies = map[string][]string{ CapBusStateCalls: {buscalls.ObjectsPut, buscalls.ObjectsGet, buscalls.ObjectsList, buscalls.ObjectsDelete, buscalls.ObjectsInfo, - buscalls.ObjectsWatch, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead}, + buscalls.ObjectsWatch, buscalls.ObjectsSentGet, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead}, CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery, buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag}, } @@ -65,6 +65,14 @@ 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 { + var got *buscalls.ObjectData + if got, err = conn.SentGet(r); err == nil { + answer = got + } + } case buscalls.ObjectsList: var r buscalls.ObjectsRequest if err = decode(method, params, &r); err == nil { -- 2.54.0 From 60c432838bc87d4dcdf8897c64bf0a43b1bce553 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 14:41:05 +0100 Subject: [PATCH 03/44] T-8: the console reads an answer sent as a reference for the person asking (5bcf68d, taken) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §69 (the console reads whole), §62, §407 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- node-tools/internal/console/address.go | 6 ++- node-tools/internal/console/sent.go | 31 +++++++++++++ node-tools/internal/console/sent_test.go | 56 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 node-tools/internal/console/sent.go create mode 100644 node-tools/internal/console/sent_test.go diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 15bac45..cf7ed7e 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -1170,7 +1170,11 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any } return failure(why) } - content := []map[string]any{{"type": "text", "text": pretty(got.Result)}} + 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}) + } if got.Node != "" { content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node}) } 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/sent_test.go b/node-tools/internal/console/sent_test.go new file mode 100644 index 0000000..bd1d0c9 --- /dev/null +++ b/node-tools/internal/console/sent_test.go @@ -0,0 +1,56 @@ +package console + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "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" +) + +// 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. +func TestAnAnswerSentAsAReferenceIsReadForThePersonAsking(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", 3<<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, "3145") || !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) + } +} -- 2.54.0 From 1176df0cd2efa40696b58446a5c1e73bb82563db Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 14:41:05 +0100 Subject: [PATCH 04/44] T-8: the 8 MiB cap holds on a bundle's runtime call only; the console reads a larger sent object whole (af0ba22, taken) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §69, §62, §407 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- node-tools/internal/bus/sent.go | 6 +----- node-tools/internal/bus/sent_test.go | 5 +---- node-tools/internal/console/sent_test.go | 4 ++-- node-tools/internal/runtime/busstate.go | 6 +++++- node-tools/internal/runtime/sent_cap_test.go | 21 ++++++++++++++++++++ 5 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 node-tools/internal/runtime/sent_cap_test.go diff --git a/node-tools/internal/bus/sent.go b/node-tools/internal/bus/sent.go index 7cd2406..271252e 100644 --- a/node-tools/internal/bus/sent.go +++ b/node-tools/internal/bus/sent.go @@ -15,7 +15,7 @@ import ( // 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, from the manifests' callers and consumers; a refusal says the store. +// 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) { 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 { @@ -25,10 +25,6 @@ func (c *Conn) SentGet(r buscalls.ObjectsSentGetRequest) (*buscalls.ObjectData, 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) } - if r.Size > stateobjects.BundleObjectMost { - return nil, fmt.Errorf("the sent object %s is %d bytes and a bundle's runtime channel carries at most %d: it is not read "+ - "through a bundle", r.Object, r.Size, stateobjects.BundleObjectMost) - } js, err := c.jetStream() if err != nil { return nil, err diff --git a/node-tools/internal/bus/sent_test.go b/node-tools/internal/bus/sent_test.go index 26ce44d..ca7d1b4 100644 --- a/node-tools/internal/bus/sent_test.go +++ b/node-tools/internal/bus/sent_test.go @@ -5,14 +5,12 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/buscalls" - "git.novox.be/novox/mesh-sdk/go/stateobjects" ) // 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, a reference that is none and an object too large for a bundle's channel are refused before the bus is asked. +// state's objects, and a reference that is none are refused before the bus is asked. func TestSentGetRefusesWhatIsNoSentObjectsReference(t *testing.T) { digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - big := int64(stateobjects.BundleObjectMost) + 1 for _, c := range []struct { name string req buscalls.ObjectsSentGetRequest @@ -20,7 +18,6 @@ func TestSentGetRefusesWhatIsNoSentObjectsReference(t *testing.T) { }{ {"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"}, - {"over the channel", buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", Size: big, Digest: digest}, "not read through a bundle"}, } { _, err := (&Conn{}).SentGet(c.req) if err == nil || !strings.Contains(err.Error(), c.want) { diff --git a/node-tools/internal/console/sent_test.go b/node-tools/internal/console/sent_test.go index bd1d0c9..80cd477 100644 --- a/node-tools/internal/console/sent_test.go +++ b/node-tools/internal/console/sent_test.go @@ -33,7 +33,7 @@ func TestAnAnswerSentAsAReferenceIsReadForThePersonAsking(t *testing.T) { t.Fatal(err) } defer conn.Close() - body := []byte(`{"lines":"` + strings.Repeat("x", 3<<20) + `"}`) + 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} @@ -47,7 +47,7 @@ func TestAnAnswerSentAsAReferenceIsReadForThePersonAsking(t *testing.T) { missing.Store = "gone_sent-objects" wrapped := stateobjects.Wrap(missing) got, unread := resolveSent(conn, wrapped) - if string(got) != string(wrapped) || !strings.Contains(unread, "3145") || !strings.Contains(unread, "gone_sent-objects") { + 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}` { diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index 3d55731..1c9dacc 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -7,6 +7,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/stateobjects" ) // The capabilities the tool runner states of the calls it answers (novox/hq ADR 0595 §9, ADR 0597; design 25 § 12.4): each is @@ -69,7 +70,10 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes var r buscalls.ObjectsSentGetRequest if err = decode(method, params, &r); err == nil { var got *buscalls.ObjectData - if got, err = conn.SentGet(r); err == nil { + if r.Size > stateobjects.BundleObjectMost { + // Only the bundle's channel is limited (design 47 "Any size"): the console reads a larger one whole. + err = fmt.Errorf("the sent object %s is %d bytes and a bundle's runtime channel carries at most %d: it is not read through a bundle", r.Object, r.Size, stateobjects.BundleObjectMost) + } else if got, err = conn.SentGet(r); err == nil { answer = got } } diff --git a/node-tools/internal/runtime/sent_cap_test.go b/node-tools/internal/runtime/sent_cap_test.go new file mode 100644 index 0000000..1498a39 --- /dev/null +++ b/node-tools/internal/runtime/sent_cap_test.go @@ -0,0 +1,21 @@ +package runtime + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/stateobjects" +) + +// Design 47 G §69, §62, §407: only a bundle's runtime channel holds the cap, so the runtime call refuses a sent object over +// it before the bus is asked, where the console (which reads through the bus) reads it whole. +func TestARuntimeCallRefusesASentObjectOverTheBundleChannel(t *testing.T) { + params, _ := json.Marshal(buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", + Size: int64(stateobjects.BundleObjectMost) + 1, Digest: strings.Repeat("a", 64)}) + _, err := (&moduleBus{all: &consumers{}}).BusState(buscalls.ObjectsSentGet, params) + if err == nil || !strings.Contains(err.Error(), "not read through a bundle") { + t.Fatalf("a sent object over the channel was answered %v", err) + } +} -- 2.54.0 From 4bfae38a7332ce93a3f75a96c8da61eaa0e39412 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:23:44 +0100 Subject: [PATCH 05/44] G findings: the runner's first views write their unanswered finding through one held writer over its own connection bus.Conn gains its one JetStream accessor; firstview keeps the runner's own consumer and its four followers and writes and clears each follower's first-view-unanswered finding with the contract's Finding. Builds: design 47 G A-27, A-28, A-16, A-29 --- node-tools/internal/bus/bindings.go | 13 +- node-tools/internal/bus/ledger.go | 4 +- node-tools/internal/bus/sent.go | 2 +- node-tools/internal/firstview/firstview.go | 125 +++++++++++++----- .../firstview/firstview_integration_test.go | 27 +++- .../internal/firstview/firstview_test.go | 31 +++-- 6 files changed, 143 insertions(+), 59 deletions(-) diff --git a/node-tools/internal/bus/bindings.go b/node-tools/internal/bus/bindings.go index 8937a61..3a441bf 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 } @@ -301,7 +302,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 } @@ -361,7 +362,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/ledger.go b/node-tools/internal/bus/ledger.go index c9080eb..fb38439 100644 --- a/node-tools/internal/bus/ledger.go +++ b/node-tools/internal/bus/ledger.go @@ -82,7 +82,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 } @@ -149,7 +149,7 @@ type busState struct { } 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 } diff --git a/node-tools/internal/bus/sent.go b/node-tools/internal/bus/sent.go index 271252e..4eecf27 100644 --- a/node-tools/internal/bus/sent.go +++ b/node-tools/internal/bus/sent.go @@ -25,7 +25,7 @@ func (c *Conn) SentGet(r buscalls.ObjectsSentGetRequest) (*buscalls.ObjectData, 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() + js, err := c.JetStream() if err != nil { return nil, err } diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 4b93e15..0ba4efb 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -4,7 +4,8 @@ // **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 ( @@ -12,12 +13,14 @@ import ( "encoding/json" "fmt" "sort" + "strings" "sync" "time" - conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -74,36 +77,85 @@ 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) + // 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 } +// 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 +} + // 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) + 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 findings state. -func DepsOf(conn *bus.Conn) Deps { +// 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(), 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: []string{views.UnansweredKind}}) + 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) @@ -120,12 +172,20 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View v.wg.Add(1) go func() { defer v.wg.Done(); _ = f.Start(ctx) }() } + 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 + } + } 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. @@ -145,17 +205,18 @@ 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. +// 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(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) + f := unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()) + 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) @@ -211,19 +272,15 @@ 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. diff --git a/node-tools/internal/firstview/firstview_integration_test.go b/node-tools/internal/firstview/firstview_integration_test.go index baa369d..08db388 100644 --- a/node-tools/internal/firstview/firstview_integration_test.go +++ b/node-tools/internal/firstview/firstview_integration_test.go @@ -11,6 +11,7 @@ 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" @@ -84,10 +85,10 @@ 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) @@ -156,3 +157,23 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen 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..e3f5375 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/views" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -33,6 +34,21 @@ func newOwners() *owners { rev: map[string]uint64{"assignments": 4, "seat-holders": 2, "manifests": 7, "modules": 3}, asked: map[string]int{}, finds: map[string]bool{}} } +// 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) { o.mu.Lock() defer o.mu.Unlock() @@ -87,19 +103,8 @@ 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() -- 2.54.0 From 99c36d8ddfac22903b2c793ccb5b56a51d015cd3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:39:04 +0100 Subject: [PATCH 06/44] G sessions: the runner serves a bundle's put and get sessions and states put-sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value over buscalls.ChunkBytes is put by mesh/objects.put-chunk (written into the module's store as each chunk is taken, sealed on the last, dropped at its bound or a disorder) and got by mesh/objects.get-chunk after a chunked answer, for a state's object and a sent object alike; the 8 MiB BundleObjectMost cap is gone with the stateobjects constant. Builds: design 47 G §518, §345, §457, §490, §620, §622, A-21, A-25 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/bindings_test.go | 18 ++ node-tools/internal/bus/sent.go | 39 ++- node-tools/internal/bus/sessions.go | 76 +++++ node-tools/internal/launch/launch.go | 2 +- node-tools/internal/runtime/announce_test.go | 2 +- node-tools/internal/runtime/busstate.go | 72 ++++- node-tools/internal/runtime/ledgers_test.go | 2 +- node-tools/internal/runtime/runtime.go | 3 + node-tools/internal/runtime/sent_cap_test.go | 21 -- node-tools/internal/runtime/sessions.go | 301 +++++++++++++++++++ node-tools/internal/runtime/sessions_test.go | 195 ++++++++++++ 11 files changed, 681 insertions(+), 50 deletions(-) create mode 100644 node-tools/internal/bus/sessions.go delete mode 100644 node-tools/internal/runtime/sent_cap_test.go create mode 100644 node-tools/internal/runtime/sessions.go create mode 100644 node-tools/internal/runtime/sessions_test.go diff --git a/node-tools/internal/bus/bindings_test.go b/node-tools/internal/bus/bindings_test.go index 81c25a0..d6409a4 100644 --- a/node-tools/internal/bus/bindings_test.go +++ b/node-tools/internal/bus/bindings_test.go @@ -3,6 +3,7 @@ package bus_test import ( "context" "errors" + "io" "strings" "testing" @@ -70,6 +71,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/sent.go b/node-tools/internal/bus/sent.go index 4eecf27..e8242fa 100644 --- a/node-tools/internal/bus/sent.go +++ b/node-tools/internal/bus/sent.go @@ -17,24 +17,11 @@ import ( // 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) { - 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 - } ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) defer cancel() - obs, err := js.ObjectStore(ctx, r.Store) + obs, err := c.sentStore(ctx, r) 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 nil, err } info, err := obs.GetInfo(ctx, r.Object) if errors.Is(err, jetstream.ErrObjectNotFound) { @@ -49,3 +36,25 @@ func (c *Conn) SentGet(r buscalls.ObjectsSentGetRequest) (*buscalls.ObjectData, } 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/launch/launch.go b/node-tools/internal/launch/launch.go index d293a19..633c848 100644 --- a/node-tools/internal/launch/launch.go +++ b/node-tools/internal/launch/launch.go @@ -123,7 +123,7 @@ var busStateCalls = map[string]bool{ buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: 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. diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index 441c81e..cc67d5b 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -140,7 +140,7 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Fatalf("ping of the instance: %v", err) } var ping micro.Ping - if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,seat-settle-delay" { + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index 1c9dacc..5ac8bbd 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -1,13 +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" - "git.novox.be/novox/mesh-sdk/go/stateobjects" + + "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 @@ -17,6 +20,8 @@ import ( const ( CapBusStateCalls = "bus-state-calls" CapLedgerCalls = "ledger-calls" + // CapPutSessions is the put and get sessions' word (design 47 G §518, §622), mesh-sdk's. + CapPutSessions = stdio.CapPutSessions ) // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. @@ -25,6 +30,7 @@ var callFamilies = map[string][]string{ buscalls.ObjectsWatch, buscalls.ObjectsSentGet, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead}, CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery, buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag}, + CapPutSessions: {buscalls.ObjectsPutChunk, buscalls.ObjectsGetChunk}, } // CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat @@ -54,11 +60,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 @@ -69,14 +74,12 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes case buscalls.ObjectsSentGet: var r buscalls.ObjectsSentGetRequest if err = decode(method, params, &r); err == nil { - var got *buscalls.ObjectData - if r.Size > stateobjects.BundleObjectMost { - // Only the bundle's channel is limited (design 47 "Any size"): the console reads a larger one whole. - err = fmt.Errorf("the sent object %s is %d bytes and a bundle's runtime channel carries at most %d: it is not read through a bundle", r.Object, r.Size, stateobjects.BundleObjectMost) - } else if got, err = conn.SentGet(r); err == nil { - answer = got - } + 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 { @@ -147,3 +150,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/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 805984a..96bc96e 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" { + if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Fatalf("the runner states %s", got) } for capability, methods := range callFamilies { diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 84580ef..f3bc178 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -770,6 +770,8 @@ 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 } type moduleEvents struct { @@ -845,6 +847,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. diff --git a/node-tools/internal/runtime/sent_cap_test.go b/node-tools/internal/runtime/sent_cap_test.go deleted file mode 100644 index 1498a39..0000000 --- a/node-tools/internal/runtime/sent_cap_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package runtime - -import ( - "encoding/json" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/buscalls" - "git.novox.be/novox/mesh-sdk/go/stateobjects" -) - -// Design 47 G §69, §62, §407: only a bundle's runtime channel holds the cap, so the runtime call refuses a sent object over -// it before the bus is asked, where the console (which reads through the bus) reads it whole. -func TestARuntimeCallRefusesASentObjectOverTheBundleChannel(t *testing.T) { - params, _ := json.Marshal(buscalls.ObjectsSentGetRequest{Store: "m_sent-objects", Object: "o", - Size: int64(stateobjects.BundleObjectMost) + 1, Digest: strings.Repeat("a", 64)}) - _, err := (&moduleBus{all: &consumers{}}).BusState(buscalls.ObjectsSentGet, params) - if err == nil || !strings.Contains(err.Error(), "not read through a bundle") { - t.Fatalf("a sent object over the channel was answered %v", err) - } -} diff --git a/node-tools/internal/runtime/sessions.go b/node-tools/internal/runtime/sessions.go new file mode 100644 index 0000000..7227f64 --- /dev/null +++ b/node-tools/internal/runtime/sessions.go @@ -0,0 +1,301 @@ +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[:]) +} diff --git a/node-tools/internal/runtime/sessions_test.go b/node-tools/internal/runtime/sessions_test.go new file mode 100644 index 0000000..5924c97 --- /dev/null +++ b/node-tools/internal/runtime/sessions_test.go @@ -0,0 +1,195 @@ +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) + } +} + +// 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. +func TestAPutSessionSealsOnTheLastChunkAndDropsOnADisorder(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. +func TestAPutNotSealedWithinTheBoundIsDropped(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) + } +} + +// §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) + } +} -- 2.54.0 From 4a614cae26ba8222a7e80d1812c0701cd78c8876 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:41:24 +0100 Subject: [PATCH 07/44] G sessions: the runner answers mesh/capabilities and drops a bundle's sessions when its channel closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §576, §518, §622, A-49 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/launch/launch.go | 15 +++++ node-tools/internal/launch/sessions_test.go | 68 +++++++++++++++++++++ node-tools/internal/runtime/sessions.go | 6 ++ 3 files changed, 89 insertions(+) create mode 100644 node-tools/internal/launch/sessions_test.go diff --git a/node-tools/internal/launch/launch.go b/node-tools/internal/launch/launch.go index 633c848..ef3826f 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" @@ -392,6 +393,16 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an 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": mu.Lock() subscriber = c @@ -577,6 +588,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/sessions_test.go b/node-tools/internal/launch/sessions_test.go new file mode 100644 index 0000000..e4a1992 --- /dev/null +++ b/node-tools/internal/launch/sessions_test.go @@ -0,0 +1,68 @@ +package launch + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// 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) } + +// 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. +func TestABundleReadsTheCapabilityWordsRunsAPutSessionAndItsChannelsClosingDropsThem(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) + } +} diff --git a/node-tools/internal/runtime/sessions.go b/node-tools/internal/runtime/sessions.go index 7227f64..817ad6e 100644 --- a/node-tools/internal/runtime/sessions.go +++ b/node-tools/internal/runtime/sessions.go @@ -299,3 +299,9 @@ func newCall() string { _, _ = 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) } -- 2.54.0 From 3501164f4b40c9350545283a95bc1f6697534092 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:40:23 +0100 Subject: [PATCH 08/44] W16.6: the tool runner reads which modules exist and their manifests from the catalog half through meshcatalogclient's branch follower on its own consumer (milestone 47/G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firstview adopts the shared branch follower over a wiring of its own consumer (views held with the assigner's, findings through its held writer, events routed to each adopted view); the console's Source gives Modules from the catalog entries, the manifest an entry carries whole; the indexer's modules and manifests first views and their consumes are removed, the registrations and the gate's source-moved are consumed. Builds: design 47 G §196, §594, §614; lead answers A-5, A-27, A-103 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/address.go | 76 ++---- node-tools/internal/console/firstviews.go | 4 +- .../internal/console/manifest_waits_test.go | 14 +- node-tools/internal/console/source_test.go | 21 ++ node-tools/internal/firstview/firstview.go | 230 ++++++++++++++++-- .../firstview/firstview_integration_test.go | 14 +- .../internal/firstview/firstview_test.go | 69 +++++- node-tools/internal/meshtest/meshtest.go | 32 +-- node-tools/module.json | 4 +- 9 files changed, 333 insertions(+), 131 deletions(-) diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index cf7ed7e..b8860a7 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -19,7 +19,7 @@ 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/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/catalogue" "context" @@ -95,8 +95,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{ @@ -359,14 +358,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 +373,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 +400,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 +424,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 } diff --git a/node-tools/internal/console/firstviews.go b/node-tools/internal/console/firstviews.go index 0bb995c..0a7b05d 100644 --- a/node-tools/internal/console/firstviews.go +++ b/node-tools/internal/console/firstviews.go @@ -32,8 +32,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/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index bce0e7f..f80ec5f 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -43,13 +43,13 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { t.Errorf("a family named by a wildcard: %s", w) } } - // 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. - // Design 47 G §196, §387: the catalog half by `catalog-revision` (never the indexer's `views.modules`), and the keeper's - // `findings-asked`; the finding stream below. `views.modules` goes with the first view that follows it (T-2). - wantConsumes := []string{"mesh-catalog.catalog-revision", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", - "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.seat-holders", - "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"} + // 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", "mesh-delivery-assigner.views.assignments", + "mesh-delivery-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/source_test.go b/node-tools/internal/console/source_test.go index a3fed70..027117f 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/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/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 0ba4efb..77b7427 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -1,5 +1,7 @@ -// 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; @@ -12,12 +14,15 @@ import ( "context" "encoding/json" "fmt" + "slices" "sort" "strings" "sync" "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" @@ -26,40 +31,37 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// The owners and their first-view tools (the tools' addresses are `.`). -const ( - Indexer = "mesh-delivery-indexer" - Assigner = "mesh-delivery-assigner" -) +// The owner and its first-view tools (the tools' addresses are `.`). +const Assigner = "mesh-delivery-assigner" -// The names the console reads the four states by (`.`), as the manifest's `consumes` names their views +// The names the console reads the assigner's two 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. type Spec struct{ Owner, Tool, State string } -// Specs are the four. +// Specs are the two owner states asked of an owner'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"}, } -// Name is the name a state is read by, `.`: AssignmentsRead, SeatHoldersRead and the indexer's. +// Name is the name a state is read by, `.`: AssignmentsRead and SeatHoldersRead. func (s Spec) Name() string { return s.Owner + "." + 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 ctx context.Context cancel context.CancelFunc wg sync.WaitGroup @@ -94,7 +96,7 @@ type FindingWriter interface { Clear(ctx context.Context, scope, id, kind, why string) error } -// Start asks the four first views, each until answered, and follows the owners' views families on the runner's own +// 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) @@ -159,14 +161,19 @@ func (s findingsState) Keys() ([]string, error) { return s.conn.StateKeys(s.modu // 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} + 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} 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) }} + Raise: func(st views.Status) { + v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true) + }, + Clear: func(st views.Status) { + v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + }} v.followers[s.Name()] = f v.byState[s.State] = f v.wg.Add(1) @@ -180,6 +187,15 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View 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...) } + 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) @@ -190,6 +206,17 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View // hear applies one views event; a gap asks the first view once more. func (v *Views) hear(env bus.Envelope) error { + 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 @@ -206,8 +233,7 @@ func (v *Views) hear(env bus.Envelope) error { } // 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(st views.Status, raised bool) { - f := unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()) +func (v *Views) finding(f condseat.Finding, tool string, raised bool) { if v.deps.Findings == nil { v.logf("[mesh-tools] %s", f.Summary) return @@ -219,14 +245,16 @@ func (v *Views) finding(st views.Status, raised bool) { 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() f := v.followers[name] + v.mu.Unlock() if f == nil { return nil, fmt.Errorf("the tool runner holds no first view of %s", name) } @@ -254,17 +282,28 @@ 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 } +// 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) } @@ -285,3 +324,142 @@ func unansweredFinding(module, node string, st views.Status, at time.Time) conds // 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 +} + +// 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") + } + followed := v.branches.Followed() + if len(followed) == 0 { + return nil, fmt.Errorf("the catalog half names no watched branch yet (the registrations of %s are asked until they answer)", catalogseat.Seat) + } + out := map[string]catalogseat.CatalogEntry{} + var waiting []string + for _, branch := range followed { + c, ok := v.branches.Catalog(branch) + if !ok { + continue + } + half, held := c.View() + if !held { + waiting = append(waiting, branch) + continue + } + for m, e := range half.Entries { + if _, taken := out[m]; !taken { + out[m] = e + } + } + } + if len(waiting) > 0 { + return nil, fmt.Errorf("the catalog half has no first view of %s yet", strings.Join(waiting, ", ")) + } + return out, nil +} diff --git a/node-tools/internal/firstview/firstview_integration_test.go b/node-tools/internal/firstview/firstview_integration_test.go index 08db388..c9fadcd 100644 --- a/node-tools/internal/firstview/firstview_integration_test.go +++ b/node-tools/internal/firstview/firstview_integration_test.go @@ -41,12 +41,12 @@ 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")} + streamOf := map[string]string{Assigner: 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] @@ -60,7 +60,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen 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} { + for _, owner := range []string{Assigner} { stream := streamOf[owner] mesh.ModuleStream(t, owner, stream) mesh.ConsumerOn(t, stream, "anchor", "node-tools", filters[stream], 2*time.Second) @@ -99,7 +99,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != len(Specs) { + if len(findings) != len(Specs)+catalogViews { return errFindings(len(findings)) } return nil @@ -130,7 +130,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != 0 { + if len(findings) != catalogViews { // the seats' verbs are not served in this test: the catalog half stays unanswered return errFindings(len(findings)) } return nil @@ -154,6 +154,10 @@ 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" } diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index e3f5375..997203b 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -12,6 +12,9 @@ import ( "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-sdk/go/views" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -26,12 +29,15 @@ 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 } 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"}} } // Write and Clear are the runner's finding writer in a test: the findings written and not cleared, by their key. @@ -57,6 +63,18 @@ 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 { + a.Entries = append(a.Entries, catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}}) + } + return json.Marshal(a) + } vals := map[string]json.RawMessage{} for k, v := range o.state[tool] { vals[k] = json.RawMessage(v) @@ -118,15 +136,29 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes 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("mesh-delivery-assigner.seat-holders"); 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("mesh-delivery-assigner.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) == 1 }) + 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"} + 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) == 2 }) + // 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") @@ -155,15 +187,15 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes if assignments.Owner != "mesh-delivery-assigner" || 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) } } @@ -186,7 +218,7 @@ 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()) @@ -199,4 +231,15 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { if len(want) > 0 { t.Errorf("no first view reads %v", want) } + // 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) + } + } } diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index 2f021e1..aafeef3 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -23,7 +23,6 @@ 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" "git.novox.be/novox/mesh-sdk/go/membership" @@ -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" + catalogHolder = "mesh-catalog" assignerHolder = "mesh-delivery-assigner" // 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/module.json b/node-tools/module.json index a202049..f206a4d 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -72,13 +72,13 @@ ], "consumes": [ "mesh-catalog.catalog-revision", + "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.seat-holders", - "mesh-delivery-indexer.views.manifests", - "mesh-delivery-indexer.views.modules" + "mesh-delivery.source-moved" ], "streams": [ { -- 2.54.0 From 5bf7a4b656b8552078b4d3029af4e052a32902b8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:12:29 +0100 Subject: [PATCH 09/44] Fix 9.4d: the tool runner's catalog half uses the follower's Ready and lists the entries by fingerprint in its first views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-103, §614 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/firstviews.go | 10 +++++++- .../internal/console/firstviews_test.go | 5 +++- node-tools/internal/firstview/firstview.go | 25 +++++++++---------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/node-tools/internal/console/firstviews.go b/node-tools/internal/console/firstviews.go index 0a7b05d..4a36fe1 100644 --- a/node-tools/internal/console/firstviews.go +++ b/node-tools/internal/console/firstviews.go @@ -16,6 +16,10 @@ 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 } + // 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 +28,11 @@ 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 el, ok := fv.(entryLister); ok { + out["entries"] = el.Entries() + } + return answerText(out) } why, _ := args["why"].(string) if why = strings.TrimSpace(why); why == "" { diff --git a/node-tools/internal/console/firstviews_test.go b/node-tools/internal/console/firstviews_test.go index 1af9b3b..abd7af9 100644 --- a/node-tools/internal/console/firstviews_test.go +++ b/node-tools/internal/console/firstviews_test.go @@ -20,6 +20,9 @@ func (v *viewer) Status() []views.Status { return []views.Status{{Owner: "mesh-delivery-assigner", Tool: "assignments", State: "assignments", Revision: 4, Asks: 1}} } +// 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,7 +38,7 @@ 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) } got := s.discover(context.Background(), "mesh_first_view_again", map[string]any{"why": "doubted"}) diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 77b7427..9e60efd 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -289,6 +289,14 @@ func (v *Views) Status() []views.Status { return out } +// 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() @@ -436,30 +444,21 @@ 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") } - followed := v.branches.Followed() - if len(followed) == 0 { - return nil, fmt.Errorf("the catalog half names no watched branch yet (the registrations of %s are asked until they answer)", catalogseat.Seat) + if err := v.branches.Ready(); err != nil { + return nil, err } out := map[string]catalogseat.CatalogEntry{} - var waiting []string - for _, branch := range followed { + for _, branch := range v.branches.Followed() { c, ok := v.branches.Catalog(branch) if !ok { continue } - half, held := c.View() - if !held { - waiting = append(waiting, branch) - 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 } } } - if len(waiting) > 0 { - return nil, fmt.Errorf("the catalog half has no first view of %s yet", strings.Join(waiting, ", ")) - } return out, nil } -- 2.54.0 From e88e0c825c9886e989d948728c02dcc0fd5d767a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:56:33 +0100 Subject: [PATCH 10/44] R-5: the tool runner follows the assigner's views of the module that claims the seat, named by the seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The holder is read from the catalog half's entries serving a verb of mesh-module-assigner, on the branch follower's change callback; the manifest's consumes names the two views by the seat (seat:mesh-module-assigner.views.*). Builds: design 47 G §616, §428, §7 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/console/manifest_waits_test.go | 4 +- node-tools/internal/firstview/firstview.go | 131 ++++++++++++++---- .../firstview/firstview_integration_test.go | 45 ++++-- .../internal/firstview/firstview_test.go | 89 +++++++++--- node-tools/internal/meshtest/meshtest.go | 2 +- node-tools/module.json | 4 +- 6 files changed, 208 insertions(+), 67 deletions(-) diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index f80ec5f..c985454 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -48,8 +48,8 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { // `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", "mesh-delivery-assigner.views.assignments", - "mesh-delivery-assigner.views.seat-holders", "mesh-delivery.source-moved"} + "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/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..706c5d7 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -20,6 +20,7 @@ import ( "sync" "time" + "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/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -31,27 +32,27 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// The owner and its first-view tools (the tools' addresses are `.`). -const Assigner = "mesh-delivery-assigner" +// 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 -// The names the console reads the assigner's two 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" + AssignmentsRead = AssignerSeat + ".assignments" + SeatHoldersRead = AssignerSeat + ".seat-holders" ) -// Spec is one first view the runner holds. -type Spec struct{ Owner, Tool, State string } +// 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 } -// Specs are the two owner states asked of an owner's tool; the catalog half's views are adopted by the branch follower. +// 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"}, + {"assignments", "assignments"}, + {"seat-holders", "seat-holders"}, } -// Name is the name a state is read by, `.`: AssignmentsRead and SeatHoldersRead. -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 first views: the assigner's two, and the catalog half's per watched branch. type Views struct { @@ -62,6 +63,7 @@ type Views struct { 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 @@ -163,22 +165,6 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View ctx, cancel := context.WithCancel(parent) 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} - 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(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true) - }, - Clear: func(st views.Status) { - v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) - }} - v.followers[s.Name()] = f - v.byState[s.State] = f - v.wg.Add(1) - go func() { defer v.wg.Done(); _ = f.Start(ctx) }() - } stopFindings := func() {} if d.StartFindings != nil { if stop, err := d.StartFindings(ctx); err != nil { @@ -194,6 +180,9 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View 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) @@ -221,7 +210,9 @@ func (v *Views) hear(env bus.Envelope) error { 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 } @@ -253,6 +244,13 @@ func (v *Views) finding(f condseat.Finding, tool string, raised bool) { // 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 { @@ -462,3 +460,78 @@ func (v *Views) Modules() (map[string]catalogseat.CatalogEntry, error) { } 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 c9fadcd..e3afd0f 100644 --- a/node-tools/internal/firstview/firstview_integration_test.go +++ b/node-tools/internal/firstview/firstview_integration_test.go @@ -17,6 +17,10 @@ import ( "git.novox.be/novox/mesh-sdk/go/views" "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/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" ) @@ -41,7 +45,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - streamOf := map[string]string{Assigner: catalogue.StreamBusName("assigner-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 { @@ -53,16 +59,16 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen 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} { - 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"}) } @@ -93,13 +99,13 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen 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)+catalogViews { + if len(findings) != catalogViews { return errFindings(len(findings)) } return nil @@ -111,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) @@ -130,7 +151,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != catalogViews { // the seats' verbs are not served in this test: the catalog half stays unanswered + if len(findings) != 0 { // every view answered clears its finding return errFindings(len(findings)) } return nil @@ -138,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() diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index 997203b..9229cb3 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -32,12 +32,14 @@ type owners struct { // 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}`}}, rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, asked: map[string]int{}, finds: map[string]bool{}, - catalog: 3, modules: []string{"web"}} + 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. @@ -71,7 +73,11 @@ func (o *owners) ask(address string, _ any) (json.RawMessage, error) { case "seat:mesh-catalog.revision": a := catalogseat.RevisionAnswer{Revision: &catalogseat.CatalogRevision{Revision: o.catalog, Kind: meshcatalogclient.KindTrunk, Branch: "main"}} for _, m := range o.modules { - a.Entries = append(a.Entries, catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}}) + 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) } @@ -94,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() @@ -127,37 +129,37 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes 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-assigner.seat-holders"); 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-assigner.seat-holders", "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) == 1 }) + 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"} + 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) == 2 }) + eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 3 }) // 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] } @@ -166,7 +168,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 { @@ -184,7 +186,7 @@ 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("seat-holders") @@ -224,13 +226,19 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { 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) { @@ -243,3 +251,42 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { } } } + +// 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. +func TestTheAssignersViewsFollowTheModuleThatClaimsTheSeat(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/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index aafeef3..685b59e 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -156,7 +156,7 @@ func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership { // no holder). const ( catalogHolder = "mesh-catalog" - assignerHolder = "mesh-delivery-assigner" + 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" diff --git a/node-tools/module.json b/node-tools/module.json index f206a4d..1c474b8 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -76,8 +76,8 @@ "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", - "mesh-delivery-assigner.views.assignments", - "mesh-delivery-assigner.views.seat-holders", + "seat:mesh-module-assigner.views.assignments", + "seat:mesh-module-assigner.views.seat-holders", "mesh-delivery.source-moved" ], "streams": [ -- 2.54.0 From e873cb3b86145763337aed3c89bbcee9355b5671 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:57:12 +0100 Subject: [PATCH 11/44] R-7: mesh_first_views names the tool runner's own build, or says own build unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §205, §424 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/firstviews.go | 8 ++++++++ node-tools/internal/console/firstviews_test.go | 12 ++++++++++++ node-tools/internal/firstview/firstview.go | 10 +++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/node-tools/internal/console/firstviews.go b/node-tools/internal/console/firstviews.go index 4a36fe1..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" ) @@ -20,6 +21,10 @@ type FirstViewer interface { // 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) @@ -29,6 +34,9 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st } if name == verbFirstViews { 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() } diff --git a/node-tools/internal/console/firstviews_test.go b/node-tools/internal/console/firstviews_test.go index abd7af9..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,16 @@ 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"} } @@ -41,6 +46,13 @@ func TestTheConsoleShowsAndAsksAgainTheRunnersFirstViews(t *testing.T) { 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/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 706c5d7..21f4a2f 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -27,6 +27,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "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" @@ -86,6 +87,9 @@ type Deps struct { 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). @@ -108,7 +112,7 @@ func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (* // 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() - d := Deps{Module: me, Node: conn.Node(), + 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 @@ -287,6 +291,10 @@ func (v *Views) Status() []views.Status { 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 { -- 2.54.0 From 64015b59529ef61cdcf4597186d85aff4679cdaa Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:57:42 +0100 Subject: [PATCH 12/44] R-8: the tool runner's own fingerprint reaches no bundle; a bundle reads the one its module is composed with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §519, §618 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/runtime/own_account_test.go | 20 +++++++++++++++++++ node-tools/internal/runtime/runtime.go | 10 ++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/node-tools/internal/runtime/own_account_test.go b/node-tools/internal/runtime/own_account_test.go index eaf433d..a44d681 100644 --- a/node-tools/internal/runtime/own_account_test.go +++ b/node-tools/internal/runtime/own_account_test.go @@ -38,6 +38,26 @@ func TestNoBusWordReachesABundle(t *testing.T) { } } +// design 47 G §618, §519: the runtime's own fingerprint reaches no bundle; the fingerprint a module's composed words hold does. +func TestTheRuntimesOwnFingerprintReachesNoBundle(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 // or proof — so no tool call says a choice, a link or a code, or submits an ask, in anybody's name. func TestAToolCallNeverReachesASeatsEventAcceptOrProof(t *testing.T) { diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index f3bc178..bc982f5 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -59,6 +59,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} @@ -1369,11 +1373,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 } } -- 2.54.0 From 3f89c96d205773877a8ddf0765bd001703d09493 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:00:03 +0100 Subject: [PATCH 13/44] E-1: the tool runner's manifest names no waits and keeps its approvals (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit module.json drops waits and its five families, uses operator-channel for the asks of marked waits, and keeps the per-machine state approvals (48 hours) in its old state section; the manifest test holds each change. Builds: design 34 every-event §11, §19 (the manifest test); design 46 7-approval-mark §7, §13 (manifest entry) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/console/manifest_waits_test.go | 58 +++++++++++++------ node-tools/internal/provide/provide_test.go | 13 ++++- node-tools/module.json | 15 ++--- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index f80ec5f..da628ff 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -8,19 +8,28 @@ import ( "testing" ) -// novox/hq ADR 0367 §5: the tool runner's manifest names the families a session waits on today, one by one, -// and the asker's `decided` for the claude-code module alone. Adding one is a reviewed change to this file -// (§6), so this list changes with it. The delivery's transitions are waited on under the gate's name alone (ADR 0450 §8, issues 1350 -// and 1406): today's module is forgotten. -func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { +// The tool runner's manifest names no events to wait on (novox/hq ADR 0670 §2): a session waits on any event by its +// name. It holds each change of design 34 every-event §11 (E-1): no `waits`, `operator-channel` in `uses` for the asks of +// marked waits, the per-machine state `approvals` in the old `state` section with its 48 hours and none in `requires` +// (design 46 7-approval-mark §13), and the finding stream (design 47 G §387). +func TestTheManifestNamesNoWaitsAndKeepsItsApprovals(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { t.Fatal(err) } + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + t.Fatal(err) + } + if _, ok := keys["waits"]; ok { + t.Error("the manifest still carries waits (novox/hq ADR 0670 §2)") + } var m struct { - Module string `json:"module"` - Waits []string `json:"waits"` - Consumes []string `json:"consumes"` + Module string `json:"module"` + Uses []string `json:"uses"` + State []json.RawMessage `json:"state"` + Requires []json.RawMessage `json:"requires"` + Consumes []string `json:"consumes"` Streams []struct { Name string `json:"name"` Events []string `json:"events"` @@ -29,20 +38,33 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + if m.Module != "node-tools" || !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) { + t.Errorf("%s uses %v, want operator-channel (design 46 7-approval-mark §7)", m.Module, m.Uses) + } + type state struct { + Name string `json:"name"` + PerMachine bool `json:"per-machine"` + TTLSeconds int `json:"ttl-seconds"` + } + var approvals []state + for _, r := range m.State { + var s state + if json.Unmarshal(r, &s) == nil && s.Name == "approvals" { + approvals = append(approvals, s) + } + } + if want := []state{{Name: "approvals", PerMachine: true, TTLSeconds: 172800}}; !reflect.DeepEqual(approvals, want) { + t.Errorf("the old state section's approvals is %+v, want %+v (design 46 7-approval-mark §13)", approvals, want) + } + for _, r := range m.Requires { + if strings.Contains(string(r), `"approvals"`) { + t.Errorf("approvals is asked in requires too: %s", r) + } + } if len(m.Streams) != 1 || m.Streams[0].Name != "node-tools-findings" || !reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { t.Errorf("the finding stream is %+v, want node-tools-findings with the three finding events (design 47 G §387)", m.Streams) } - want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status", - "mesh-self-check.checked", "operator-channel.decided.claude-code"} - if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) { - t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want) - } - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - t.Errorf("a family named by a wildcard: %s", w) - } - } // A wait is no consumer (ADR 0367 §2): what it consumes is the assigner's two views families (ADR 0615 §5, issue 1966 // TLS-5), the events of the catalog half's branch follower (design 47 G §196, §614: `catalog-revision`, // `registration-changed`, and the gate's `source-moved`; never the indexer's `views.modules` or `views.manifests`), the two 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/module.json b/node-tools/module.json index f206a4d..28fae51 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -6,12 +6,8 @@ "invokes": [ "*" ], - "waits": [ - "mesh-knowledge.reviewed", - "mesh-delivery-gate.transition", - "gitea.forge.status", - "mesh-self-check.checked", - "operator-channel.decided.claude-code" + "uses": [ + "operator-channel" ], "provides": [ { @@ -68,7 +64,12 @@ }, "state": [ "bound", - "findings" + "findings", + { + "name": "approvals", + "per-machine": true, + "ttl-seconds": 172800 + } ], "consumes": [ "mesh-catalog.catalog-revision", -- 2.54.0 From 5e373af70d2871301691f7ebd655a4f6eda09c9b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:02:04 +0100 Subject: [PATCH 14/44] R-1 rest, R-12, R-13: the capability words are mesh-sdk's, the ledger's fill and removal are answered, the artifact states the words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sent-get is under sent-objects-calls; mesh/ledger.fill answers the history's fill against its cap and mesh/ledger.remove removes an instance's operations, snapshot, state and index entries, both under ledger-upkeep-calls; the runtime artifact's `states` is exactly the words the runner states. Builds: design 47 G §462, §488, §545, §587, §622, §155 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/ledger.go | 76 ++++++++++++++++++ node-tools/internal/ledger/ledger.go | 79 +++++++++++++++++++ node-tools/internal/ledger/ledger_test.go | 47 +++++++++++ node-tools/internal/ledger/memory.go | 53 ++++++++++++- node-tools/internal/runtime/announce_test.go | 60 +++++++++++--- node-tools/internal/runtime/busstate.go | 16 ++-- .../runtime/ledger_integration_test.go | 29 +++++++ node-tools/internal/runtime/ledgers.go | 11 +++ node-tools/internal/runtime/ledgers_test.go | 2 +- node-tools/module.json | 10 ++- 10 files changed, 364 insertions(+), 19 deletions(-) diff --git a/node-tools/internal/bus/ledger.go b/node-tools/internal/bus/ledger.go index fb38439..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" @@ -143,6 +144,76 @@ 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 @@ -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/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..cbc315d 100644 --- a/node-tools/internal/ledger/ledger_test.go +++ b/node-tools/internal/ledger/ledger_test.go @@ -100,3 +100,50 @@ func TestApplyAppendsDerivesAndSnapshots(t *testing.T) { }) } } + +// 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. +func TestTheLedgerSaysItsFillAndRemovesAnInstanceWhole(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/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index cc67d5b..50fea4a 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -3,6 +3,8 @@ package runtime import ( "encoding/json" "git.novox.be/novox/mesh-sdk/go/buscalls" + "os" + "slices" "sort" "strings" "testing" @@ -140,20 +142,60 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Fatalf("ping of the instance: %v", err) } var ping micro.Ping - if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } -// Design 47 G §462: the call that reads a sent object is listed under the bus state capability, and the ledger calls keep theirs. -func TestTheSentObjectCallBelongsToTheBusStateCapability(t *testing.T) { - for c, method := range map[string]string{CapBusStateCalls: buscalls.ObjectsSentGet} { - found := false - for _, m := range callFamilies[c] { - found = found || m == method +// 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. +func TestEachCallIsStatedUnderTheWordOfItsFamily(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, + } { + var in []string + for c, methods := range callFamilies { + if slices.Contains(methods, method) { + in = append(in, c) + } } - if !found { - t.Errorf("%s does not list %s", c, method) + 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`. +func TestTheWordsTheRunnerStatesAreItsManifestsStates(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 5ac8bbd..d85a250 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -18,8 +18,12 @@ 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" + 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 ) @@ -27,10 +31,12 @@ const ( // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. var callFamilies = map[string][]string{ CapBusStateCalls: {buscalls.ObjectsPut, buscalls.ObjectsGet, buscalls.ObjectsList, buscalls.ObjectsDelete, buscalls.ObjectsInfo, - buscalls.ObjectsWatch, buscalls.ObjectsSentGet, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead}, + buscalls.ObjectsWatch, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead}, CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery, buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag}, - CapPutSessions: {buscalls.ObjectsPutChunk, buscalls.ObjectsGetChunk}, + 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 @@ -111,7 +117,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) diff --git a/node-tools/internal/runtime/ledger_integration_test.go b/node-tools/internal/runtime/ledger_integration_test.go index 802fb03..589afce 100644 --- a/node-tools/internal/runtime/ledger_integration_test.go +++ b/node-tools/internal/runtime/ledger_integration_test.go @@ -240,6 +240,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") { 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 96bc96e..2fdbf66 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,put-sessions,seat-settle-delay" { + if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" { t.Fatalf("the runner states %s", got) } for capability, methods := range callFamilies { diff --git a/node-tools/module.json b/node-tools/module.json index 1c474b8..076c37f 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -62,7 +62,15 @@ "language": "go", "system": "arch", "from": "cmd/node-tools", - "binary": "node-tools" + "binary": "node-tools", + "states": [ + "bus-state-calls", + "ledger-calls", + "ledger-upkeep-calls", + "put-sessions", + "seat-settle-delay", + "sent-objects-calls" + ] } ] }, -- 2.54.0 From cb47de85176cca12e56c0ec0ecdd3131341c3b9b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:03:13 +0100 Subject: [PATCH 15/44] E-2: a heard event keeps its own time, and a refused wait is never a hot loop (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HearFirst reads the event's x-time as Published, notes a subscription the bus refuses while the others go on waiting, and answers a wait refused whole only after its timeout, in words naming the grants part's grant of every event subject; mesh_wait lists the refused subjects in its answer. Builds: design 34 every-event §12, §16, §18, §19 (bus/wait_test.go) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/wait.go | 74 ++++++++++++++++--------- node-tools/internal/bus/wait_test.go | 82 ++++++++++++++++++++-------- node-tools/internal/console/wait.go | 21 ++++--- 3 files changed, 119 insertions(+), 58 deletions(-) diff --git a/node-tools/internal/bus/wait.go b/node-tools/internal/bus/wait.go index 4dda03b..aa2e961 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" @@ -23,31 +24,42 @@ type WaitIssued struct { 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 +70,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 +88,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/wait.go b/node-tools/internal/console/wait.go index 9ee1a41..1194b5b 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -86,18 +86,25 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin family[subject] = name } - heard, ok, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) + heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) if err != nil { return failure("mesh_wait could not wait: " + err.Error()) } + var answer map[string]any 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"}) + 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"} + } else { + answer = 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(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)", - }) + // A subscription the bus refused while others stood is said (design 34 every-event §16). + if len(refused) > 0 { + answer["refused"] = refused + } + return answerText(answer) } // waitNames is the families a call names: a list of names, or one name. -- 2.54.0 From e63d707eac214209f434fba476c922108d98c898 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:04:18 +0100 Subject: [PATCH 16/44] R-11: mesh/publish with ack answers its sequence or {error}, mesh/subscribe with deliver new makes an ordered consumer; finding-calls is stated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscription's key resolves through mesh-sdk's buscompose.EventSubjects (lead answer A-153; the sdk commit EE-S1 is cherry-picked into this change's sdk placement), a wildcard is refused. Builds: design 47 G §559, §488, §576, §622 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/bus.go | 13 ++- node-tools/internal/bus/subscribenew.go | 87 +++++++++++++++++++ node-tools/internal/launch/launch.go | 56 +++++++++++- node-tools/internal/launch/sessions_test.go | 75 ++++++++++++++++ node-tools/internal/runtime/announce_test.go | 2 +- node-tools/internal/runtime/busstate.go | 5 +- node-tools/internal/runtime/ledgers_test.go | 2 +- node-tools/internal/runtime/runtime.go | 15 ++++ .../runtime/subscribenew_integration_test.go | 84 ++++++++++++++++++ node-tools/module.json | 1 + 10 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 node-tools/internal/bus/subscribenew.go create mode 100644 node-tools/internal/runtime/subscribenew_integration_test.go diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 920189e..6b5419e 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -852,6 +852,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 +879,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/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/launch/launch.go b/node-tools/internal/launch/launch.go index ef3826f..0dbe175 100644 --- a/node-tools/internal/launch/launch.go +++ b/node-tools/internal/launch/launch.go @@ -387,7 +387,29 @@ 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 { @@ -404,6 +426,38 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an 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() diff --git a/node-tools/internal/launch/sessions_test.go b/node-tools/internal/launch/sessions_test.go index e4a1992..f46a5a2 100644 --- a/node-tools/internal/launch/sessions_test.go +++ b/node-tools/internal/launch/sessions_test.go @@ -2,6 +2,7 @@ package launch import ( "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -66,3 +67,77 @@ func TestABundleReadsTheCapabilityWordsRunsAPutSessionAndItsChannelsClosingDrops t.Fatalf("the runner took %v", b.chunks) } } + +// 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 +} + +// 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. +func TestAPublishWithAckIsAnsweredWithItsSequenceAndASubscriptionForNewMessagesEndsWithTheChannel(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) + } +} diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index 50fea4a..86356b7 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -142,7 +142,7 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Fatalf("ping of the instance: %v", err) } var ping micro.Ping - if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" { + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index d85a250..ccf7ed7 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -40,9 +40,10 @@ var callFamilies = map[string][]string{ } // CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat -// message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5). +// 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} for c := range callFamilies { out = append(out, c) } diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 2fdbf66..324d054 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,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" { + if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" { t.Fatalf("the runner states %s", got) } for capability, methods := range callFamilies { diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index bc982f5..c95d14e 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -876,6 +876,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 { diff --git a/node-tools/internal/runtime/subscribenew_integration_test.go b/node-tools/internal/runtime/subscribenew_integration_test.go new file mode 100644 index 0000000..9dc1d14 --- /dev/null +++ b/node-tools/internal/runtime/subscribenew_integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package runtime + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-tools/node-tools/internal/bus" + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" +) + +// 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. +func TestAnAckedPublishAnswersItsSequenceAndANewSubscriptionHearsOnlyWhatComesAfter(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/module.json b/node-tools/module.json index 076c37f..fbc4950 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -65,6 +65,7 @@ "binary": "node-tools", "states": [ "bus-state-calls", + "finding-calls", "ledger-calls", "ledger-upkeep-calls", "put-sessions", -- 2.54.0 From 827628164ad67b90b0d244a31bc5fffec345a958 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:05:13 +0100 Subject: [PATCH 17/44] A-1 marks: the tool runner derives each served module's marks from the catalog half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate (node-tools/internal/approval) derives a module's marked tools and verbs, and the subjects of its marked events and restricted streams, from its served entry (the build its composed environment's fingerprint names) and the newest, unioned while they differ, and the seat definitions' verb marks, which no holder unmarks. A bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised through the runner's held writer and cleared when a bundle serves it. The first views now start whether or not the console listens, since the gate reads the catalog half too. Builds: design 46 7-approval-mark §5, §4 (at load), §3; design 34 every-event §33; lead answer A-154 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/main.go | 15 +- node-tools/cmd/node-tools/seatlist.go | 11 ++ node-tools/internal/approval/gate.go | 135 ++++++++++++++++ node-tools/internal/approval/marks.go | 176 +++++++++++++++++++++ node-tools/internal/approval/marks_test.go | 136 ++++++++++++++++ node-tools/internal/firstview/approval.go | 83 ++++++++++ node-tools/internal/firstview/firstview.go | 3 +- node-tools/internal/runtime/gate.go | 36 +++++ node-tools/internal/runtime/runtime.go | 14 ++ 9 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 node-tools/internal/approval/gate.go create mode 100644 node-tools/internal/approval/marks.go create mode 100644 node-tools/internal/approval/marks_test.go create mode 100644 node-tools/internal/firstview/approval.go create mode 100644 node-tools/internal/runtime/gate.go diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 7da745a..bd861b1 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -26,6 +26,7 @@ import ( "time" "github.com/novox/mesh-tools/node-tools/internal/alive" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/console" "github.com/novox/mesh-tools/node-tools/internal/firstview" @@ -51,7 +52,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 +70,14 @@ func main() { defer ledgers.Stop() opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer)) } + // The indexer's and the assigner's states the console indexes the mesh by, and the gate reads its marks from, come to it as + // first views, asked of the owners' own tools and followed on their views families, never read from their state (novox/hq + // ADR 0615 §1; design 46 7-approval-mark §5). + source, stopViews := firstview.Start(context.Background(), conn, log.Printf) + defer stopViews() + gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), + Findings: source.Findings(), Logf: log.Printf}) + opts = append(opts, runtime.WithGate(gate)) stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) if err != nil { log.Fatalf("node-tools: %v", err) @@ -90,10 +99,6 @@ 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 = "?" 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/internal/approval/gate.go b/node-tools/internal/approval/gate.go new file mode 100644 index 0000000..02f7d07 --- /dev/null +++ b/node-tools/internal/approval/gate.go @@ -0,0 +1,135 @@ +package approval + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The runner's findings of the gate (design 46 7-approval-mark §4, §16, §19), written through its held writer. +const ( + KindMarkUnserved = "mark-unserved" + KindCallUnanswered = "call-unanswered" + KindApprovalsUnreadable = "approvals-unreadable" +) + +// Kinds are the finding kinds the gate writes through the runner's writer. +var Kinds = []string{KindMarkUnserved, KindCallUnanswered, KindApprovalsUnreadable} + +// FindingWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding). +type FindingWriter interface { + Write(ctx context.Context, finding condseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// Deps is what the gate reaches the mesh through: the runner's connection in its one place, fakes in a test. +type Deps struct { + // Node is the machine the runner serves; Runner its own module's name, the subject of its findings. + Node, Runner string + // Catalog is the catalog half the marks are read from. + Catalog Catalog + // Findings is the runner's held writer; nil says a finding in the log only. + Findings FindingWriter + Logf func(string, ...any) + Now func() time.Time +} + +// Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). +type Gate struct { + d Deps + mu sync.Mutex + // registered is each module's tools as its bundle registered them at its last load. + registered map[string]map[string]bool + // unserved is each module whose marks name a tool its bundle does not register, with those tools. + unserved map[string][]string +} + +// New is a gate over d. +func New(d Deps) *Gate { + if d.Logf == nil { + d.Logf = func(string, ...any) {} + } + if d.Now == nil { + d.Now = time.Now + } + return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} +} + +// Unserved is, of the module's marked tools, those its bundle does not register (§4): such a bundle is not loaded, its +// finding `mark-unserved` raised; a bundle that serves them, or marks that no longer name them, clear it. Before the marks +// are read nothing is known, and every marked call is refused until they are (§19). +func (g *Gate) Unserved(module, fingerprint string, registered []string) []string { + set := map[string]bool{} + for _, t := range registered { + set[t] = true + } + g.mu.Lock() + g.registered[module] = set + g.mu.Unlock() + if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { + return nil + } + return g.checkServed(Derive(g.d.Catalog, module, fingerprint)) +} + +// checkServed raises or clears the module's `mark-unserved` as its marks and its bundle's registrations stand now, and is +// the tools it marks and does not register. +func (g *Gate) checkServed(m Marks) []string { + g.mu.Lock() + set := g.registered[m.Module] + var missing []string + for t := range m.Tools { + if !set[t] { + missing = append(missing, t) + } + } + sort.Strings(missing) + was := g.unserved[m.Module] + if len(missing) > 0 { + g.unserved[m.Module] = missing + } else { + delete(g.unserved, m.Module) + } + g.mu.Unlock() + switch { + case len(missing) > 0 && strings.Join(was, ",") != strings.Join(missing, ","): + g.finding(m.Module, KindMarkUnserved, true, fmt.Sprintf("%s's marks (%s, catalog revision %s) name %s, which its bundle on %s does not "+ + "register: no tool of %s is served here", m.Module, sourceOf(m, missing[0]), m.Revision, strings.Join(missing, ", "), g.d.Node, m.Module), + fmt.Sprintf("%s is not served on %s", m.Module, g.d.Node)) + case len(missing) == 0 && len(was) > 0: + g.finding(m.Module, KindMarkUnserved, false, "a bundle that serves every marked tool, or marks without the tool, is loaded", "") + } + return missing +} + +func sourceOf(m Marks, tool string) string { return m.Tools[tool] } + +// finding writes or clears one of the gate's findings: scope machine, its id `.` escaped as a key's name is, +// about the runner's module on this machine (lead answer A-154). +func (g *Gate) finding(what, kind string, raised bool, summary, headline string) { + id := keys.EscapeName(g.d.Node + "." + what) + if g.d.Findings == nil { + g.d.Logf("[mesh-tools] %s %s: %s", kind, id, summary) + return + } + var err error + if raised { + err = g.d.Findings.Write(context.Background(), condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id, + Severity: condseat.FindingSeverityWarning, Source: "approval", Observer: g.d.Runner, + Subject: condseat.Subject{Scope: "module", ID: g.d.Runner, Machine: g.d.Node}, Summary: summary, Headline: headline, + At: g.d.Now().UTC()}) + } else { + err = g.d.Findings.Clear(context.Background(), scopeMachine, id, kind, summary) + } + if err != nil { + g.d.Logf("[mesh-tools] the finding %s of %s is not kept: %v", kind, id, err) + } +} + +const scopeMachine = "machine" diff --git a/node-tools/internal/approval/marks.go b/node-tools/internal/approval/marks.go new file mode 100644 index 0000000..a61d59a --- /dev/null +++ b/node-tools/internal/approval/marks.go @@ -0,0 +1,176 @@ +// Package approval is the tool runner's gate on a call that needs the operator's approval (design 46 7-approval-mark): the +// marks it reads from the catalog half, the act it digests, the ask it raises on the seat operator-channel under the +// marked module's name, the judging of the same call again on the router's record, the claim, the recording in the +// hand-act log, and the refusals when a part of the check is missing (§5, §8 to §20). No module's name is written here. +package approval + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Catalog is what the marks are read from: the catalog half the runner follows (design 46 7-approval-mark §5, design 47 G +// §196, §443) and the seat definitions it carries (§2). +type Catalog interface { + // Ready is nil once the catalog half's first view is read, else says what it waits for. + Ready() error + // Revision is the catalog revision the marks are read at, as the runner names it. + Revision() string + // Served is the entry of the build a bundle serves, by the fingerprint its composed environment carries; false when + // none is read (not yet, or not found), with why. + Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) + // Newest is the module's entry at the newest revision; false when the catalog half lists none. + Newest(module string) (catalogseat.CatalogEntry, bool) + // Definitions are the seat definitions the catalog half carries, by seat. + Definitions() map[string]seatdef.Definition +} + +// Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and +// restricted streams, each with its source (§18: its module's entry in the catalog half, or its seat's definition). +type Marks struct { + Module string + // Tools are its marked tools by name, Verbs its marked seat verbs as `.`, Subjects the bus subjects of its + // marked events and restricted streams (design 34 every-event §33); each to its source. + Tools, Verbs, Subjects map[string]string + // Revision is the catalog revision read; Served the served entry's fingerprint, empty when it could not be read. + Revision, Served string + // Unread says why the served entry could not be read: every marked call is then refused (§5, §19). + Unread string +} + +// Marked says whether a tool or a seat verb of the module needs the operator's approval, and its source. +func (m Marks) Marked(seat, name string) (bool, string) { + if seat == "" { + src, ok := m.Tools[name] + return ok, src + } + src, ok := m.Verbs[seat+"."+name] + return ok, src +} + +// Any says the module marks anything at all. +func (m Marks) Any() bool { return len(m.Tools)+len(m.Verbs)+len(m.Subjects) > 0 } + +// marked is what an entry's manifest says of its marks: `needs-approval`, its events and its streams. +type marked struct { + NeedsApproval []string `json:"needs-approval"` + Emits []string `json:"emits"` + // Streams are read for their events and whether each carries a `grant`, and no other word, so a word added to a + // stream's declaration later never hides its marks. + Streams []struct { + Events []string `json:"events"` + Grant json.RawMessage `json:"grant"` + } `json:"streams"` +} + +// Derive is a module's marks from its served entry and its newest one, unioned while they differ so a mark is never lost +// before or after the module moves (§5, ADR 0286 §3), and the seat definitions' verb marks for every verb an entry serves, +// which no holder unmarks (§2, §3). fingerprint is the build its bundle serves; empty, or an entry not read, leaves the +// marks of the newest entry and the seats, with Unread saying why. +func Derive(c Catalog, module, fingerprint string) Marks { + m := Marks{Module: module, Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}, + Revision: c.Revision()} + defs := c.Definitions() + var entries []catalogseat.CatalogEntry + if fingerprint == "" { + m.Unread = "its bundle's environment carries no fingerprint, so the build it serves cannot be read" + } else if e, ok, why := c.Served(fingerprint); ok { + entries, m.Served = append(entries, e), fingerprint + } else { + m.Unread = fmt.Sprintf("the catalog half's entry of %s cannot be read: %s", fingerprint, why) + } + if e, ok := c.Newest(module); ok && (len(entries) == 0 || e.Fp != entries[0].Fp) { + entries = append(entries, e) + } + for _, e := range entries { + source := "entry " + e.Module + " " + orNone(e.Fp) + var said marked + if raw, err := json.Marshal(e.Manifest); err == nil { + _ = json.Unmarshal(raw, &said) + } + serves := map[string]bool{} + for _, sv := range e.Serves { + serves[sv] = true + seat, verb, _ := strings.Cut(sv, ".") + for _, v := range defs[seat].Verbs { + if v.Name == verb && v.NeedsApproval { + m.Verbs[sv] = "seat " + seat + } + } + } + events := map[string]bool{} + for _, ev := range said.Emits { + events[ev] = true + } + for _, s := range said.Streams { + for _, ev := range s.Events { + events[ev] = true + } + } + for _, name := range said.NeedsApproval { + switch { + case serves[name]: + if _, bySeat := m.Verbs[name]; !bySeat { + m.Verbs[name] = source + } + case events[name]: + for _, s := range eventSubjects(e.Module, name) { + m.Subjects[s] = source + } + default: + m.Tools[name] = source + } + } + for _, s := range said.Streams { + if len(s.Grant) == 0 || string(s.Grant) == "null" { + continue + } + wire := make([]string, 0, len(s.Events)) + for _, ev := range s.Events { + wire = append(wire, buscompose.WirePattern(ev)) + } + for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { + m.Subjects[sub] = source + if !strings.HasSuffix(sub, ">") { + m.Subjects[sub+".>"] = source + } + } + } + } + return m +} + +// eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its +// own stream, each also with every subject below it. +func eventSubjects(module, event string) []string { + ev := buscompose.WirePattern(event) + out := []string{"mesh.mod." + module + ".event." + ev, buscompose.ModuleStreamSubject(module, ev)} + if !strings.HasSuffix(ev, ">") { + out = append(out, out[0]+".>", out[1]+".>") + } + sort.Strings(out) + return out +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} + +// keysOf is a set's names, sorted; nil when empty. +func keysOf(set map[string]string) []string { + var out []string + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go new file mode 100644 index 0000000..85053f6 --- /dev/null +++ b/node-tools/internal/approval/marks_test.go @@ -0,0 +1,136 @@ +package approval + +import ( + "context" + "reflect" + "testing" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" + + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// fakeCatalog is a catalog half as a fixture: entries by fingerprint, the newest by module, the seat definitions. +type fakeCatalog struct { + ready error + revision string + byFp map[string]catalogseat.CatalogEntry + newest map[string]catalogseat.CatalogEntry + defs map[string]seatdef.Definition +} + +func (c *fakeCatalog) Ready() error { return c.ready } +func (c *fakeCatalog) Revision() string { return c.revision } +func (c *fakeCatalog) Served(fp string) (catalogseat.CatalogEntry, bool, string) { + e, ok := c.byFp[fp] + return e, ok, "not-found" +} +func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) { + e, ok := c.newest[m] + return e, ok +} +func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } + +func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry { + return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest} +} + +// Design 46 7-approval-mark §5, §3, §4 and design 34 every-event §33: the marks a module's calls are gated by, derived from +// fixture catalog halves and served entries. +func TestTheMarks(t *testing.T) { + login := map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", + Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}, {Name: "sessions"}}}} + v1 := entry("src2:1", []string{"node-login-shell.execute", "node-login-shell.sessions"}, + map[string]any{"needs-approval": []any{"wipe"}}) + v2 := entry("src2:2", []string{"node-login-shell.execute", "node-login-shell.sessions"}, + map[string]any{"needs-approval": []any{"erase", "node-login-shell.sessions"}}) + mail := catalogseat.CatalogEntry{Module: "mail", Fp: "src2:m", Manifest: map[string]any{ + "needs-approval": []any{"mail.received"}, "emits": []any{"mail.received"}, + "streams": []any{map[string]any{"name": "MAIL", "events": []any{"mail.**"}, "grant": map[string]any{"config": "readers", "events": "mail.{key}.**"}}}}} + for _, tc := range []struct { + name string + cat *fakeCatalog + module string + fp string + tools []string + verbs map[string]string + subjects []string + unread bool + }{ + {name: "a seat's mark stands for every holder, and the holder's own adds up", cat: &fakeCatalog{revision: "7", + byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell"}}, + {name: "a holder's needs-approval cannot unmark its seat's verb, and marks another", cat: &fakeCatalog{revision: "8", + byFp: map[string]catalogseat.CatalogEntry{"src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:2", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}}, + {name: "across a version change the union stands: a dropped mark is asked while the old bundle runs", cat: &fakeCatalog{revision: "9", + byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1, "src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"erase", "wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}}, + {name: "a served entry that cannot be read leaves the newest marks, every marked call refused", cat: &fakeCatalog{revision: "9", + newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}, unread: true}, + {name: "a bundle with no fingerprint has no served entry", cat: &fakeCatalog{revision: "9", defs: login}, + module: "shell", unread: true}, + {name: "a marked event and a stream with a grant give their subjects and each .>", cat: &fakeCatalog{revision: "3", + byFp: map[string]catalogseat.CatalogEntry{"src2:m": mail}, newest: map[string]catalogseat.CatalogEntry{"mail": mail}}, + module: "mail", fp: "src2:m", subjects: []string{"mesh.mod.mail.event.mail.received", "mesh.mod.mail.event.mail.received.>", + "mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Derive(tc.cat, tc.module, tc.fp) + if got := keysOf(m.Tools); !reflect.DeepEqual(got, tc.tools) { + t.Errorf("tools %v, want %v", got, tc.tools) + } + if tc.verbs == nil { + tc.verbs = map[string]string{} + } + if !reflect.DeepEqual(m.Verbs, tc.verbs) { + t.Errorf("verbs %v, want %v", m.Verbs, tc.verbs) + } + if got := keysOf(m.Subjects); !reflect.DeepEqual(got, tc.subjects) { + t.Errorf("subjects %v, want %v", got, tc.subjects) + } + if (m.Unread != "") != tc.unread || m.Revision != tc.cat.revision { + t.Errorf("unread %q, revision %q", m.Unread, m.Revision) + } + }) + } + // §4: a bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised; a bundle that + // serves it clears it, and a catalog without the mark too. + t.Run("mark-unserved raised and cleared", func(t *testing.T) { + cat := &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login} + w := &findings{} + g := New(Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: w}) + if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != KindMarkUnserved { + t.Fatalf("unserved %v, findings %v", got, w.raised) + } + if got := g.Unserved("shell", "src2:1", []string{"wipe"}); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" { + t.Fatalf("a bundle serving the mark: unserved %v, findings %v", got, w.raised) + } + _ = g.Unserved("shell", "src2:1", nil) + cat.byFp["src2:1"], cat.newest["shell"] = entry("src2:1", nil, nil), entry("src2:1", nil, nil) + if got := g.Unserved("shell", "src2:1", nil); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" { + t.Fatalf("a catalog without the mark: unserved %v, findings %v", got, w.raised) + } + }) +} + +// findings is the runner's held writer as a fixture: each finding raised now, by id. +type findings struct{ raised map[string]string } + +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 +} diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go new file mode 100644 index 0000000..bf317d6 --- /dev/null +++ b/node-tools/internal/firstview/approval.go @@ -0,0 +1,83 @@ +package firstview + +import ( + "context" + "strconv" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "github.com/novox/mesh-tools/node-tools/internal/approval" +) + +// Findings is the runner's held finding writer, the one the gate writes its findings through too (design 46 7-approval-mark +// §4, §16, §19); nil when it was not made. +func (v *Views) Findings() approval.FindingWriter { + if v.deps.Findings == nil { + return nil + } + return v.deps.Findings +} + +// ApprovalCatalog is the catalog half as the gate reads its marks (design 46 7-approval-mark §5): the entries the branch +// follower holds, an entry by fingerprint asked of `mesh-catalog.entry` when no branch lists it, and the seat definitions +// the seat list holds (defs). +func (v *Views) ApprovalCatalog(defs func() map[string]seatdef.Definition) approval.Catalog { + return approvalCatalog{v: v, defs: defs} +} + +type approvalCatalog struct { + v *Views + defs func() map[string]seatdef.Definition +} + +func (c approvalCatalog) Ready() error { + if c.v.branches == nil { + return errNotFollowed + } + return c.v.branches.Ready() +} + +func (c approvalCatalog) Revision() string { + if c.v.branches == nil { + return "" + } + for _, branch := range c.v.branches.Followed() { + if cat, ok := c.v.branches.Catalog(branch); ok { + if half, ok := cat.View(); ok { + return strconv.FormatInt(half.Revision.Revision, 10) + } + } + } + return "" +} + +func (c approvalCatalog) Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) { + if c.v.branches == nil { + return catalogseat.CatalogEntry{}, false, errNotFollowed.Error() + } + e, state := c.v.branches.Resolve(context.Background(), fingerprint) + return e, state == meshcatalogclient.EntryAnswered, string(state) +} + +func (c approvalCatalog) Newest(module string) (catalogseat.CatalogEntry, bool) { + if c.v.branches == nil { + return catalogseat.CatalogEntry{}, false + } + e, ok, err := c.v.branches.Entry(module) + return e, ok && err == nil +} + +func (c approvalCatalog) Definitions() map[string]seatdef.Definition { + if c.defs == nil { + return nil + } + return c.defs() +} + +type notFollowed struct{} + +func (notFollowed) Error() string { return "the catalog half is not followed by this runner" } + +var errNotFollowed error = notFollowed{} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..c187bcb 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -28,6 +28,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" ) @@ -120,7 +121,7 @@ func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps { } w, err := views.NewHeldWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ Asked: condseat.SubjectFindingsAsked, Module: me, Node: conn.Node(), State: findingsState{conn: conn, module: me}, JS: js, Logf: logf, - Kinds: []string{views.UnansweredKind}}) + Kinds: append([]string{views.UnansweredKind}, approval.Kinds...)}) if err != nil { logf("[mesh-tools] the runner's findings are not written (%v): the first views' findings are in the log only", err) return d diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go new file mode 100644 index 0000000..ed7cd5f --- /dev/null +++ b/node-tools/internal/runtime/gate.go @@ -0,0 +1,36 @@ +package runtime + +import ( + "github.com/novox/mesh-tools/node-tools/internal/approval" + "github.com/novox/mesh-tools/node-tools/internal/launch" +) + +// FingerprintWord is the word of a module's composed environment that names the build its bundle serves (design 47 G §424). +const FingerprintWord = "MESH_FINGERPRINT" + +// Gate is the approval gate the runner serves through (design 46 7-approval-mark §4, §20; node-tools/internal/approval). +type Gate interface { + // Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding. + Unserved(module, fingerprint string, registered []string) []string +} + +// 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) + +// 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) +} diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index f3bc178..7dae3af 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -230,6 +230,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 +267,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. @@ -409,6 +414,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} @@ -772,6 +784,8 @@ type consumers struct { calls callsInFlight // sessions are the bundles' put and get sessions (sessions.go). sessions sessionTable + // gate is the approval gate every call is served through (gate.go); nil serves every call as before. + gate Gate } type moduleEvents struct { -- 2.54.0 From 662ab2a3b1f54c11a8c8311d75af86ee3ca64216 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:06:36 +0100 Subject: [PATCH 18/44] E-3: mesh_wait takes any event name (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitAnswer no longer reads the membership: each name becomes its subjects through buscompose.EventSubjects, optionally narrowed by from (seat, module or stream), at most 16 names, a subject once; the heard event is named by buscompose.EventName with its namespace and its own time, and a decided event says the operator's answer is read only with claude_code_answer. WaitIssued and Membership.Waits go; the refusals are one table. Builds: design 34 every-event §13, §15, §17, §19, §26 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/bus.go | 3 - node-tools/internal/bus/wait.go | 7 - node-tools/internal/console/wait.go | 87 +++++++----- node-tools/internal/console/wait_test.go | 172 +++++++++++++++-------- 4 files changed, 163 insertions(+), 106 deletions(-) diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 920189e..f430db6 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"` diff --git a/node-tools/internal/bus/wait.go b/node-tools/internal/bus/wait.go index aa2e961..c42a667 100644 --- a/node-tools/internal/bus/wait.go +++ b/node-tools/internal/bus/wait.go @@ -17,13 +17,6 @@ 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, 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 { diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index 1194b5b..c35af73 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,11 @@ import ( "encoding/json" "fmt" "math" - "sort" + "slices" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" ) // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last @@ -25,20 +27,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,34 +67,25 @@ 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 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) + for _, subject := range subs { + if !slices.Contains(subjects, subject) { + subjects = append(subjects, subject) + } } - family[subject] = name } heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) @@ -95,10 +97,17 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin 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"} } else { - answer = 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)", + 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} } // A subscription the bus refused while others stood is said (design 34 every-event §16). if len(refused) > 0 { @@ -107,7 +116,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin return answerText(answer) } -// waitNames is the families a call names: a list of names, or one name. +// 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) { @@ -117,7 +126,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) } @@ -126,7 +135,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..a19522e 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -3,6 +3,7 @@ package console import ( "context" "encoding/json" + "fmt" "net/http" "os" "reflect" @@ -11,24 +12,22 @@ import ( "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" ) -// 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 +35,17 @@ func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) { return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh } +// 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 +60,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 +70,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 +115,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): @@ -148,38 +180,64 @@ 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) } } -} -// 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) + // 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) } } -- 2.54.0 From 9a8636a8edc1e112824ada387e165c1c7b2fe84f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:09:20 +0100 Subject: [PATCH 19/44] A-2 the first call: a marked call is asked on operator-channel under its module's name and not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate digests a marked call's act (address, node, module and tool or seat and verb, build, each argument as itself or its canonical JSON), refuses at once and asks nothing for a secret's shape or more than 16 KiB of arguments, and otherwise asks through operator-channel's client under the marked module's name, its ask kept in the runner's state approvals under the machine's keys with the act and the whole arguments, the act's key naming it. The ask shows each argument whole only where it may leave the mesh, and the first call answers Not run, with the router's record or without it within 5 seconds. Builds: design 46 7-approval-mark §8, §9, §10, §11, §12, §13 (keys) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/go.mod | 2 + node-tools/internal/approval/act.go | 291 ++++++++++++++++++ node-tools/internal/approval/approval_test.go | 276 +++++++++++++++++ node-tools/internal/approval/ask.go | 230 ++++++++++++++ node-tools/internal/approval/gate.go | 12 + 5 files changed, 811 insertions(+) create mode 100644 node-tools/internal/approval/act.go create mode 100644 node-tools/internal/approval/approval_test.go create mode 100644 node-tools/internal/approval/ask.go diff --git a/node-tools/go.mod b/node-tools/go.mod index 54e4d8b..e9db0f0 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -7,6 +7,8 @@ require ( git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/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 diff --git a/node-tools/internal/approval/act.go b/node-tools/internal/approval/act.go new file mode 100644 index 0000000..7f9445d --- /dev/null +++ b/node-tools/internal/approval/act.go @@ -0,0 +1,291 @@ +package approval + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves. +type Call struct { + // Address is the address as called: `.` or `.`. + Address string + // Module is the module whose bundle serves it; Seat the seat of a verb, empty for a tool; Name the tool or the verb. + Module, Seat, Name string + // Fingerprint is the build the bundle serves, from its composed environment (§5). + Fingerprint string + // Caller is the caller the bus proved. + Caller string + // Args are the call's arguments, a JSON object. + Args json.RawMessage + // Description is the tool's or verb's own description, whose first line the ask explains it by. + Description string +} + +// MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9). +const MostArguments = 16 * 1024 + +// arguments are a call's arguments by name; a body that is not an object is refused. +func arguments(raw json.RawMessage) (map[string]json.RawMessage, error) { + args := map[string]json.RawMessage{} + if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return args, nil + } + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("its arguments are not a JSON object: %v", err) + } + return args, nil +} + +// refusedUnasked says why a marked call is refused at once, asking nothing (§9): its arguments hold a secret's shape, or are +// more than MostArguments. +func refusedUnasked(raw json.RawMessage) string { + if len(raw) > MostArguments { + return fmt.Sprintf("its arguments are %d bytes, more than the %d a call that needs the operator's approval may carry", len(raw), MostArguments) + } + if r, ok := outward.CheckSecret(string(raw)); !ok { + return fmt.Sprintf("its arguments hold %s, which no ask may carry", r.What) + } + return "" +} + +// ActOf is the act a marked call binds (§8): `address`, `node`, `module` and `tool` or `seat` and `verb`, `build`, and +// `arg.` for each argument given, a string as itself and any other value as its JSON in RFC 8785's canonical form. +func ActOf(c Call, node string) (operatorchannelclient.Act, error) { + act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint} + if c.Seat == "" { + act["module"], act["tool"] = c.Module, c.Name + } else { + act["seat"], act["verb"] = c.Seat, c.Name + } + args, err := arguments(c.Args) + if err != nil { + return nil, err + } + for name, raw := range args { + var s string + if json.Unmarshal(raw, &s) == nil { + act["arg."+name] = s + continue + } + canon, err := Canonical(raw) + if err != nil { + return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err) + } + act["arg."+name] = canon + } + return act, nil +} + +// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16 +// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names. +func Canonical(raw json.RawMessage) (string, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return "", err + } + var b strings.Builder + if err := canonicalValue(&b, v); err != nil { + return "", err + } + return b.String(), nil +} + +func canonicalValue(b *strings.Builder, v any) error { + switch x := v.(type) { + case nil: + b.WriteString("null") + case bool: + b.WriteString(strconv.FormatBool(x)) + case json.Number: + f, err := strconv.ParseFloat(string(x), 64) + if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { + return fmt.Errorf("the number %s is not a double", x) + } + b.WriteString(es6Number(f)) + case string: + canonicalString(b, x) + case []any: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteByte(',') + } + if err := canonicalValue(b, e); err != nil { + return err + } + } + b.WriteByte(']') + case map[string]any: + names := make([]string, 0, len(x)) + for k := range x { + names = append(names, k) + } + sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) }) + b.WriteByte('{') + for i, k := range names { + if i > 0 { + b.WriteByte(',') + } + canonicalString(b, k) + b.WriteByte(':') + if err := canonicalValue(b, x[k]); err != nil { + return err + } + } + b.WriteByte('}') + default: + return fmt.Errorf("a value of type %T", v) + } + return nil +} + +// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3). +func es6Number(f float64) string { + if f == 0 { + return "0" + } + abs := math.Abs(f) + if abs >= 1e-6 && abs < 1e21 { + return strconv.FormatFloat(f, 'f', -1, 64) + } + s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07 + mant, exp, _ := strings.Cut(s, "e") + sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0") + return mant + "e" + sign + digits +} + +func canonicalString(b *strings.Builder, s string) { + b.WriteByte('"') + for _, r := range s { + switch { + case r == '"': + b.WriteString(`\"`) + case r == '\\': + b.WriteString(`\\`) + case r == '\b': + b.WriteString(`\b`) + case r == '\f': + b.WriteString(`\f`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\t': + b.WriteString(`\t`) + case r < 0x20: + fmt.Fprintf(b, `\u%04x`, r) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') +} + +func utf16Less(a, b string) bool { + ua, ub := utf16Of(a), utf16Of(b) + for i := 0; i < len(ua) && i < len(ub); i++ { + if ua[i] != ub[i] { + return ua[i] < ub[i] + } + } + return len(ua) < len(ub) +} + +func utf16Of(s string) []uint16 { + out := make([]uint16, 0, utf8.RuneCountInString(s)) + for _, r := range s { + if r >= 0x10000 { + r -= 0x10000 + out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF))) + } else { + out = append(out, uint16(r)) + } + } + return out +} + +// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10). +func Fingerprint(digest string) string { + hex := strings.TrimPrefix(digest, "sha256:") + if len(hex) > 24 { + hex = hex[:24] + } + var groups []string + for len(hex) > 4 { + groups, hex = append(groups, hex[:4]), hex[4:] + } + return strings.Join(append(groups, hex), " ") +} + +// The bounds of the arguments shown in an ask (§11). +const ( + ShownBytes, ShownLines = 2000, 40 + PartBytes, PartLines = 1200, 20 +) + +// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole: +// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at +// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL. +func shown(raw json.RawMessage) (string, bool) { + args, _ := arguments(raw) + names := make([]string, 0, len(args)) + for n := range args { + names = append(names, n) + } + sort.Strings(names) + whole := true + var lines []string + for _, n := range names { + var value string + if json.Unmarshal(args[n], &value) != nil { + value = string(args[n]) + } + if r, ok := outward.Check(value); !ok { + value, whole = shapeOf(r), false + } + lines = append(lines, n+": "+value) + } + text := strings.Join(lines, "\n") + if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines { + if len(all) > PartLines { + all = all[:PartLines] + } + text = strings.Join(all, "\n") + if len(text) > PartBytes { + text = cutBytes(text, PartBytes) + } + text += "\nNOT SHOWN IN FULL" + whole = false + } + return text, whole +} + +// shapeOf is what an argument outward.Check refuses is shown as. +func shapeOf(r outward.Refusal) string { + switch r.Class { + case "path": + return "‹path›" + case "address": + return "‹address›" + } + return "‹withheld›" +} + +// cutBytes is s at most n bytes, cut at a character's end. +func cutBytes(s string, n int) string { + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go new file mode 100644 index 0000000..42b25ff --- /dev/null +++ b/node-tools/internal/approval/approval_test.go @@ -0,0 +1,276 @@ +package approval + +import ( + "encoding/json" + "sort" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// mem is the runner's state approvals as a fixture; fail makes every read and write fail, as a bus refusing them. +type mem struct { + mu sync.Mutex + vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 + fail error +} + +func newMem() *mem { return &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} } + +func (m *mem) Get(k string) (*stdio.StateEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return nil, m.fail + } + v, ok := m.vals[k] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil +} +func (m *mem) Create(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + if _, ok := m.vals[k]; ok { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + if rev == 0 || m.revs[k] != rev { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) Put(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + return m.put(k, v) +} +func (m *mem) 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 *mem) 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 +} + +// world is a gate on a fixture mesh: the catalog half, the router's records as the router writes them (mesh-catalog's +// messenger, asks.go), the asks published, the clock. +type world struct { + g *Gate + state *mem + cat *fakeCatalog + records map[string]seat.AsksByAskerIDValue // by `.` + takes bool // the router takes an ask as it is published + asked []seat.Ask + now time.Time + runs int + findings *findings +} + +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{}, + now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), + cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell}, + newest: map[string]catalogseat.CatalogEntry{"shell": shell}, + defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", + Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} + w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, + Now: func() time.Time { return w.now }, + After: func(d time.Duration) <-chan time.Time { + w.now = w.now.Add(d) + c := make(chan time.Time, 1) + c <- w.now + return c + }, + Publish: func(module, subject string, body any, id string) error { + if subject != seat.AcceptSubjectAsk(module) { + return nil + } + a := body.(seat.Ask) + w.asked = append(w.asked, a) + if w.takes { + w.records[module+"."+a.ID] = seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", + Headline: a.Headline, Expires: a.Expires, Opened: operatorchannelclient.FormatTime(w.now), OfferedOn: []string{"telegram"}} + } + return nil + }, + Record: func(module, id string) (*stdio.StateEntry, error) { + rec, ok := w.records[module+"."+id] + if !ok { + return nil, nil + } + raw, _ := json.Marshal(rec) + return &stdio.StateEntry{Key: module + "." + id, Value: raw}, nil + }}) + _ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"}) + return w +} + +// call is a fixture call of the marked tool wipe, or of another tool of the module. +func call(name, args string) Call { + return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args), + Description: "Wipes a scratch directory.\nMore words."} +} + +// theActAndItsAsk is §8's and §11's rows: the act's fields, and how an ask shows a call's arguments. +func theActAndItsAsk(t *testing.T) { + base := call("wipe", `{"dir":"scratch","n":1}`) + digest := func(c Call) string { + act, err := ActOf(c, "n1") + if err != nil { + t.Fatal(err) + } + d, _ := operatorchannelclient.ActDigest(act) + return d + } + act, _ := ActOf(base, "n1") + want := operatorchannelclient.Act{"address": "shell.wipe", "node": "n1", "module": "shell", "tool": "wipe", "build": "src2:1", + "arg.dir": "scratch", "arg.n": "1"} + if len(act) != len(want) { + t.Fatalf("act %v", act) + } + for k, v := range want { + if act[k] != v { + t.Errorf("act[%s] = %q, want %q", k, act[k], v) + } + } + seen := map[string]string{digest(base): "the call"} + for name, c := range map[string]Call{ + "an omitted argument": call("wipe", `{"dir":"scratch"}`), + "another number": call("wipe", `{"dir":"scratch","n":2}`), + "an object": call("wipe", `{"dir":{"name":"scratch"},"n":1}`), + "a changed build": func() Call { c := base; c.Fingerprint = "src2:2"; return c }(), + "another node's seat": {Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1"}, + } { + d := digest(c) + if other, dup := seen[d]; dup { + t.Errorf("%s gives the digest of %s", name, other) + } + seen[d] = name + } + if got, _ := Canonical(json.RawMessage(`{"b":[1.0,2.5e-7,1e21],"a":"é<"}`)); got != `{"a":"é<","b":[1,2.5e-7,1e+21]}` { + t.Errorf("canonical form %s", got) + } + // §11: a path, an address and a long argument are shown in their shape and cut. + text, whole := shown(json.RawMessage(`{"dir":"/home/someone/scratch","host":"10.0.0.7","ok":"yes"}`)) + if whole || text != "dir: ‹path›\nhost: ‹address›\nok: yes" { + t.Errorf("shown %q whole %v", text, whole) + } + long, _ := json.Marshal(map[string]string{"text": strings.Repeat("word ", 500)}) + if text, whole := shown(long); whole || !strings.HasSuffix(text, "NOT SHOWN IN FULL") || len(text) > PartBytes+len("\nNOT SHOWN IN FULL") { + t.Errorf("a long argument shown whole %v, %d bytes", whole, len(text)) + } + // §10: the headline, cut with … to 60 characters, ending Part shown. + for _, tc := range []struct{ name, node, want string }{ + {"wipe", "n1", "Run wipe on n1?"}, + {strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"}, + {strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"}, + } { + if got := headlineOf(tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { + t.Errorf("headline %q, want %q", got, tc.want) + } + } + if got := headlineOf("wipe", "n1", false); got != "Run wipe on n1? Part shown" { + t.Errorf("headline %q", got) + } +} + +// Design 46 7-approval-mark §22: the refusals and answers of a marked call, on the fixture mesh, the router's record and +// the warrant written as the router writes them. +func TestTheRefusalsAndAnswers(t *testing.T) { + t.Run("§8, §10, §11: the act's fields give each its digest, and the ask shows the arguments", theActAndItsAsk) + for _, tc := range []struct { + name string + run func(t *testing.T, w *world) + }{ + {"§9: a secret in the arguments is refused asking nothing", func(t *testing.T, w *world) { + _, err := w.g.firstCall(call("wipe", `{"token":"ghp_0123456789abcdefghijABCDEFGHIJ012345"}`)) + if err == nil || !strings.Contains(err.Error(), "which no ask may carry") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§9: more than 16 KiB of arguments is refused asking nothing", func(t *testing.T, w *world) { + big, _ := json.Marshal(map[string]string{"text": strings.Repeat("a b ", 5000)}) + _, err := w.g.firstCall(call("wipe", string(big))) + if err == nil || !strings.Contains(err.Error(), "more than the 16384") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§10, §12: the published ask's fields, and Not run with the router's record", func(t *testing.T, w *world) { + _, err := w.g.firstCall(call("wipe", `{"dir":"scratch"}`)) + if err == nil || !strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") || + !strings.Contains(err.Error(), "make the same call again within 30 minutes of the approval; the ask expires at ") { + t.Fatalf("answer %v", err) + } + if len(w.asked) != 1 { + t.Fatalf("asked %d", len(w.asked)) + } + a := w.asked[0] + act, _ := ActOf(call("wipe", `{"dir":"scratch"}`), "n1") + d, _ := operatorchannelclient.ActDigest(act) + if a.About != d || a.Who != "operator" || a.OnExpiry != OnExpiry || a.Headline != "Run wipe on n1?" || + a.Expires != operatorchannelclient.FormatTime(time.Date(2026, 11, 12, 11, 0, 0, 0, time.UTC)) || len(a.Options) != 2 || + a.Options[0].ID != "approve" || a.Options[1].ID != "decline" || a.Options[0].Binds != d || a.Options[1].Binds != d || + a.Options[1].Level != "approve" { + t.Errorf("ask %+v", a) + } + if !strings.HasPrefix(a.Explanation, "Wipes a scratch directory.\nOn n1, called through the mesh's tools, which cannot tell who called.\ndir: scratch\nFingerprint ") { + t.Errorf("explanation %q", a.Explanation) + } + var k operatorchannelclient.Kept + _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.vals["n1.act."+d]) != `"`+a.ID+`"` { + t.Errorf("kept %+v, act key %s", k, w.state.vals["n1.act."+d]) + } + }}, + {"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) { + w.takes = false + start := w.now + _, err := w.g.firstCall(call("wipe", `{}`)) + if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > RecordWithin { + t.Fatalf("answer %v after %s", err, w.now.Sub(start)) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) + } +} diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go new file mode 100644 index 0000000..a183f3f --- /dev/null +++ b/node-tools/internal/approval/ask.go @@ -0,0 +1,230 @@ +package approval + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// StateName is the runner's own state its asks and claims are kept in, per machine (§13). +const StateName = "approvals" + +// Store is the runner's state `approvals` (§13): a key's value, a create that refuses a key that exists and an update by +// compare-and-set, each failing with stdio.ErrChanged when another write came first; a put, and its keys. +type Store interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) + Put(key string, value any) (uint64, error) + Keys() ([]string, error) +} + +// The bounds of an ask (§10, §12, §13, §14, §17). +const ( + AskLasts = 60 * time.Minute + RecordWithin = 5 * time.Second + RunWithin = 30 * time.Minute + DeclinedFor = 10 * time.Minute + MostKeptValue = 256 * 1024 + OnExpiry = "nothing runs; call again to ask anew" +) + +// The options every ask offers (§10), both at level approve, both binding the act's digest. +const ( + OptionApprove = "approve" + OptionDecline = "decline" +) + +// client is operator-channel's client asking under the marked module's name, its asks kept in the runner's state under +// this machine's keys, its publish and the router's record on the runner's own account (§7, §10). +func (g *Gate) client(module string) *operatorchannelclient.Client { + return &operatorchannelclient.Client{Module: module, State: g.d.State, KeyPrefix: g.d.Node + ".", Now: g.d.Now, + Publish: func(subject string, body any, id string) error { return g.d.Publish(module, subject, body, id) }, + Record: func(id string) (*stdio.StateEntry, error) { return g.d.Record(module, id) }} +} + +// actKey is the key naming the newest ask of an act (§13). +func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + digest } + +// askKey is the key the client keeps an ask under (§13). +func (g *Gate) askKey(id string) string { return g.d.Node + ".ask." + id } + +// newID is an ask's id: 128 random bits. +func newID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// askFor is the ask a marked call raises (§10, §11). +func (g *Gate) askFor(c Call, digest, id string) seat.Ask { + args, whole := shown(c.Args) + name := c.Name + if c.Seat != "" { + name = c.Seat + "." + c.Name + } + headline := headlineOf(name, g.d.Node, whole) + first, _, _ := strings.Cut(strings.TrimSpace(c.Description), "\n") + if first == "" { + first = name + } + lines := []string{first, "On " + g.d.Node + ", called through the mesh's tools, which cannot tell who called."} + if args != "" { + lines = append(lines, args) + } + if !whole { + lines = append(lines, "The whole is read with mesh_approvals on "+g.d.Node+", whose fingerprint must be the one on the phone.") + } + lines = append(lines, "Fingerprint "+Fingerprint(digest)) + return seat.Ask{ID: id, About: digest, Who: operatorchannelclient.Operator, Headline: headline, + Explanation: strings.Join(lines, "\n"), Expires: operatorchannelclient.FormatTime(g.d.Now().Add(AskLasts)), OnExpiry: OnExpiry, + Options: []seat.Option{ + {ID: OptionApprove, Label: "Approve", Does: "the same call, made again within 30 minutes, runs once", + Level: operatorchannelclient.Approve, Binds: digest}, + {ID: OptionDecline, Label: "Decline", Does: "nothing runs; the same call is not asked again for 10 minutes", + Level: operatorchannelclient.Approve, Binds: digest}}} +} + +// headlineOf is "Run on ?", "Run ?" when that is longer than the router allows, and the names cut with +// "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). +func headlineOf(name, node string, whole bool) string { + suffix := "" + if !whole { + suffix = " Part shown" + } + max := operatorchannelclient.HeadlineLength + fits := func(s string) bool { return len([]rune(s)) <= max } + if h := "Run " + name + " on " + node + "?" + suffix; fits(h) { + return h + } + if h := "Run " + name + "?" + suffix; fits(h) { + return h + } + room := max - len([]rune("Run …?"+suffix)) + r := []rune(name) + if room < 1 { + room = 1 + } + if len(r) > room { + r = r[:room] + } + return "Run " + string(r) + "…?" + suffix +} + +// firstCall is a marked call whose act has no ask open: refused at once when its arguments may not be asked (§9), else +// asked (§10, §12). +func (g *Gate) firstCall(c Call) (any, error) { + if why := refusedUnasked(c.Args); why != "" { + return nil, g.refused(c, "", why) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + digest, err := operatorchannelclient.ActDigest(act) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + return nil, g.raise(c, act, digest, "") +} + +// raise raises the ask of a marked call whose act has none open (§10): kept in the runner's state with the act and the whole +// arguments, published under the module's name, its act's key naming it; then the 5 seconds for the router's record and +// the first answer (§12), always an error, never a result. +func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince string) error { + id := newID() + a := g.askFor(c, digest, id) + cl := g.client(c.Module) + if size := keptSize(a, act, c.Args); size > MostKeptValue { + return g.refused(c, digest, fmt.Sprintf("its ask would keep %d bytes, more than the %d a value of the state %s holds", size, MostKeptValue, StateName)) + } + if err := cl.Ask(a); err != nil { + return g.refused(c, digest, "the ask could not be raised: "+err.Error()) + } + if err := g.keepCall(id, act, c.Args); err != nil { + return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) + } + if _, err := g.d.State.Put(g.actKey(digest), id); err != nil { + return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) + } + return g.firstAnswer(c, id, digest, markedSince) +} + +// keptSize is the bytes the kept ask would take. +func keptSize(a seat.Ask, act operatorchannelclient.Act, args json.RawMessage) int { + raw, _ := json.Marshal(operatorchannelclient.Kept{Ask: a, Act: act, Arguments: args}) + return len(raw) +} + +// keepCall writes the act and the whole arguments beside the ask the client kept (§13). +func (g *Gate) keepCall(id string, act operatorchannelclient.Act, args json.RawMessage) error { + e, err := g.d.State.Get(g.askKey(id)) + if err != nil || e == nil { + return fmt.Errorf("the kept ask %s cannot be read: %v", id, err) + } + var k operatorchannelclient.Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return err + } + k.Act, k.Arguments = act, args + _, err = g.d.State.Update(g.askKey(id), k, e.Revision) + return err +} + +// firstAnswer waits up to RecordWithin for the router's record of the ask and says the call was not run (§12). +func (g *Gate) firstAnswer(c Call, id, digest, markedSince string) error { + rec, _ := g.awaitRecord(c.Module, id) + since := "" + if markedSince != "" { + since = "; marked since revision " + markedSince + } + if rec == nil { + return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make the same call again later%s", + id, Fingerprint(digest), since) + } + return fmt.Errorf("Not run: this needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make the same call "+ + "again within 30 minutes of the approval; the ask expires at %s%s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened, + id, Fingerprint(digest), rec.Expires, since) +} + +// record is the router's record of an ask under the module's name, nil when it holds none. +func (g *Gate) record(module, id string) (*seat.AsksByAskerIDValue, error) { + e, err := g.d.Record(module, id) + if err != nil || e == nil { + return nil, err + } + var rec seat.AsksByAskerIDValue + if err := json.Unmarshal(e.Value, &rec); err != nil { + return nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) + } + return &rec, nil +} + +// awaitRecord reads the router's record of a new ask until it is there, within RecordWithin: the seat answers a key's +// value and says nothing when the router takes an ask, so it is read again after a growing pause (§12). +func (g *Gate) awaitRecord(module, id string) (*seat.AsksByAskerIDValue, error) { + until := g.d.Now().Add(RecordWithin) + pause := 50 * time.Millisecond + for { + rec, err := g.record(module, id) + if rec != nil || g.d.Now().Add(pause).After(until) { + return rec, err + } + <-g.d.After(pause) + pause *= 2 + } +} + +// refused is a marked call refused, run nothing, written in the runner's log with the ask's fingerprint and never its +// arguments (§19). +func (g *Gate) refused(c Call, digest, why string) error { + g.d.Logf("[mesh-tools] %s on %s is refused (fingerprint %s): %s", c.Address, g.d.Node, orNone(Fingerprint(digest)), why) + return fmt.Errorf("Not run: %s needs the operator's approval and %s", c.Address, why) +} diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 02f7d07..0aa5e32 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -9,6 +9,7 @@ import ( "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" ) @@ -38,6 +39,14 @@ type Deps struct { Findings FindingWriter Logf func(string, ...any) Now func() time.Time + // After is the clock's pause; nil is time.After. + After func(time.Duration) <-chan time.Time + // State is the runner's state approvals; nil when its membership binds none, so every marked call is refused (§13). + State Store + // Publish says an accept on a seat under the module's name, on the runner's own account (§7). + Publish func(module, subject string, body any, id string) error + // Record reads the router's record of an ask, `.` in the seat's state asks, on the runner's own account (§14). + Record func(module, id string) (*stdio.StateEntry, error) } // Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). @@ -58,6 +67,9 @@ func New(d Deps) *Gate { if d.Now == nil { d.Now = time.Now } + if d.After == nil { + d.After = time.After + } return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} } -- 2.54.0 From 24f257e92928bfc5b560301253ec590e68bebe56 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:09:56 +0100 Subject: [PATCH 20/44] MV1: move contracts and sdk under modules/, importers follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery repository's contracts and SDK live under modules/ as mesh-delivery-contracts and mesh-delivery-sdk, their Go module paths renamed to match the folders (the TypeScript names already did), and every importer in mesh-delivery, mesh-catalog, mesh-host, mesh-tools and mesh-sdk follows in the same group: a design as it should be, one path per module, checked by the group's check building every importer. Builds: design 47 G §427, §88 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/go.mod | 4 ++-- node-tools/internal/console/address.go | 2 +- node-tools/internal/console/source_test.go | 2 +- node-tools/internal/firstview/firstview.go | 4 ++-- node-tools/internal/firstview/firstview_test.go | 6 +++--- node-tools/internal/meshtest/meshtest.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/node-tools/go.mod b/node-tools/go.mod index 54e4d8b..9a18092 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -8,8 +8,8 @@ require ( 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/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 ) diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index b8860a7..d1e9c9e 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -19,7 +19,7 @@ package console import ( assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/catalogue" "context" diff --git a/node-tools/internal/console/source_test.go b/node-tools/internal/console/source_test.go index 027117f..cb9aeb6 100644 --- a/node-tools/internal/console/source_test.go +++ b/node-tools/internal/console/source_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "sync" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "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" diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..2eab14b 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -21,8 +21,8 @@ import ( "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-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/keys" diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index 997203b..22c5045 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -12,9 +12,9 @@ import ( "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "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" diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index aafeef3..de71ef1 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -23,7 +23,7 @@ import ( assigner "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" seatassigner "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - 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" -- 2.54.0 From 0b0f879e86f1225e7d0a8428989fdc5f31a90308 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:11:52 +0100 Subject: [PATCH 21/44] E-4: a misspelt event name is refused, not waited on for ever (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before it subscribes, mesh_wait checks each name's emitter against the catalog half the tool runner holds (its seats' definitions and its modules' entries), refusing one neither knows and naming the half and its revisions; a known emitter's undeclared event is waited on and its timed-out answer says so; while the half is not yet read the answer says the names were unchecked. The first views answer the halves by branch for it. Builds: design 34 every-event §14, §19 (rows of the table of refusals) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/wait.go | 110 +++++++++++++++++- node-tools/internal/console/wait_test.go | 35 +++++- node-tools/internal/firstview/firstview.go | 19 +++ .../internal/firstview/firstview_test.go | 4 + 4 files changed, 166 insertions(+), 2 deletions(-) diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index c35af73..a93a16d 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -17,7 +17,9 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last @@ -75,12 +77,29 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin 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))) } - var subjects []string + known, unchecked := s.emitters() + var subjects, undeclared []string for _, name := range names { subs, err := buscompose.EventSubjects(name, from) if err != nil { return failure("mesh_wait may not wait on it: " + err.Error()) } + // 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) @@ -96,6 +115,10 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin 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, ", "), known.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)} @@ -109,6 +132,9 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin } 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 @@ -116,6 +142,88 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin return answerText(answer) } +// 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 diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index a19522e..21bd105 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -35,6 +37,14 @@ 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() @@ -157,7 +167,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 { @@ -211,6 +222,28 @@ func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { } } + // E §14: against the catalog half, a misspelt emitter is refused naming the half and its revision, a known emitter's + // undeclared event is waited on and its timeout says so, and a declared one says nothing of it. + half := NewSurface(s.conn, halfSource{busSource{s.conn}, map[string]meshcatalogclient.Half{"main": { + Revision: catalogseat.CatalogRevision{Revision: 7, Definitions: []catalogseat.CatalogRevisionDefinitionsItem{{Seat: "mesh-catalog"}}}, + Entries: map[string]catalogseat.CatalogEntry{"mesh-delivery-view": {Module: "mesh-delivery-view", + Manifest: map[string]any{"emits": []any{"delivered"}}}}, + }}}, "desk.node-tools") + for _, c := range []struct { + name, says string + refused bool + }{ + {"mesh-delivry-view.delivered", "mesh-delivry-view is neither a seat nor a module of the catalog half (mesh-catalog.revision, revision 7 of main)", true}, + {"mesh-delivery-view.planned", "no declaration names mesh-delivery-view.planned", false}, + {"mesh-catalog.catalog-revision", "", false}, + } { + text, isErr := callWait(half, map[string]any{"subjects": []any{c.name}, "timeout": 1}) + if isErr != c.refused || c.says != "" && !strings.Contains(text, c.says) || + c.says == "" && (strings.Contains(text, "undeclared") || strings.Contains(text, "unchecked")) { + t.Errorf("%s against the catalog half: %s", c.name, text) + } + } + // The bus refusing every subscription of the call: answered after the timeout, in §16's words. b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true, Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{ diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..ff5b5eb 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -437,6 +437,25 @@ func (w wiring) Drop(owner, state string) error { return nil } +// Halves is the catalog half of each followed branch, by branch: its revision, with the seat definitions it was judged +// with, and its entries; mesh_wait checks an event name's emitter against them (design 34 every-event §14). An error says +// the half has no first view yet, never that no module exists. +func (v *Views) Halves() (map[string]meshcatalogclient.Half, error) { + if v.branches == nil { + return nil, fmt.Errorf("the tool runner does not follow the catalog half") + } + if err := v.branches.Ready(); err != nil { + return nil, err + } + out := map[string]meshcatalogclient.Half{} + for _, branch := range v.branches.Followed() { + if c, ok := v.branches.Catalog(branch); ok { + out[branch], _ = c.View() // a branch at none-yet is ready and empty (§614) + } + } + return out, nil +} + // Modules is every module the catalog half holds, by name, with its entry: the entries of the watched branches' newest trunk // revisions, the branch followed first naming a module more than one hold (design 47 G §196, §594). An error says the half has // no first view yet, never that no module exists. diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index 997203b..18c7698 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -158,6 +158,10 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes t.Fatal(err) } eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 }) + // And the halves by branch, with their revision, which mesh_wait names checking an emitter (design 34 every-event §14). + if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 2 { + t.Errorf("the halves: %+v, %v", h, err) + } // Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again. asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] } -- 2.54.0 From e0cfcabe60e52775bf4097a541fd24eaf2e91ac3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:13:18 +0100 Subject: [PATCH 22/44] A-3 judge and claim: the same call again runs once on the router's record of its Approve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate sits on both handlers the runner serves, a module's tools (its ledger repairs among them) and the seat verbs its modules hold, before the bundle. The same call again is judged on the router's own record of its act's newest ask: held to the warrant (operatorchannelclient.For), the option and the act it bound (Performs), within 30 minutes of the choice, and claimed by compare-and-set (Client.Take) before it runs, so one approval runs one call. A later call is told it still waits, was declined (not asked again for 10 minutes), or asks anew after expired, approved too long ago or already run. A runner's own tool marked is refused unasked; with no state approvals bound a marked call is refused and an unmarked one runs. The gate's reach (state, publish, the router's record) is the runner's own connection, in one place; it waits for the catalog half's first view on the events the runner hears. Builds: design 46 7-approval-mark §13, §14, §15, §17, §20 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 101 ++++++++++ node-tools/cmd/node-tools/main.go | 5 +- node-tools/internal/approval/act.go | 2 + node-tools/internal/approval/approval_test.go | 162 ++++++++++++++++ node-tools/internal/approval/gate.go | 4 + node-tools/internal/approval/run.go | 183 ++++++++++++++++++ node-tools/internal/firstview/approval.go | 74 +++++++ node-tools/internal/firstview/firstview.go | 6 +- node-tools/internal/runtime/gate.go | 14 ++ node-tools/internal/runtime/runtime.go | 16 +- 10 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 node-tools/cmd/node-tools/approvals.go create mode 100644 node-tools/internal/approval/run.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go new file mode 100644 index 0000000..3b3b4ed --- /dev/null +++ b/node-tools/cmd/node-tools/approvals.go @@ -0,0 +1,101 @@ +package main + +import ( + "encoding/json" + "errors" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "github.com/novox/mesh-tools/node-tools/internal/approval" + "github.com/novox/mesh-tools/node-tools/internal/bus" +) + +// The gate's reach through the runner's own connection, in this one place (design 46 7-approval-mark §7, §13, §14): its +// state approvals, its publish of an ask under the marked module's name, and its read of the router's record. + +// approvalsState is the runner's state approvals over its connection: a lost compare-and-set is stdio.ErrChanged, as +// operator-channel's client reads it. +type approvalsState struct { + conn *bus.Conn + module string +} + +func (s approvalsState) Get(key string) (*stdio.StateEntry, error) { + e, err := s.conn.StateGet(s.module, approval.StateName, key) + if err != nil || e == nil { + return nil, err + } + return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil +} + +func (s approvalsState) Create(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return changedAs(s.conn.StateCreate(s.module, approval.StateName, key, raw)) +} + +func (s approvalsState) Update(key string, value any, revision uint64) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return changedAs(s.conn.StateUpdate(s.module, approval.StateName, key, raw, revision)) +} + +func (s approvalsState) Put(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return s.conn.StatePut(s.module, approval.StateName, key, raw) +} + +func (s approvalsState) Keys() ([]string, error) { + return s.conn.StateKeys(s.module, approval.StateName) +} + +func changedAs(rev uint64, err error) (uint64, error) { + if errors.Is(err, bus.ErrStateChanged) { + return 0, stdio.ErrChanged + } + return rev, err +} + +// Bound says the runner's membership binds its state approvals now (§13): a membership issued later binds it with no restart. +func (s approvalsState) Bound() bool { + m := s.conn.Membership(s.module) + if m == nil { + return false + } + for _, st := range m.State { + if st.Name == approval.StateName && st.Writes { + return true + } + } + return false +} + +// publishAsk says an accept on a seat under the module's name, on the runner's own account (§7). +func publishAsk(conn *bus.Conn) func(module, subject string, body any, id string) error { + return func(module, subject string, body any, id string) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + _, err = conn.SeatPublish(module, subject, raw, id) + return err + } +} + +// routerRecord reads the router's record `.` 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 + } +} diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index bd861b1..78e15cd 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -75,8 +75,9 @@ func main() { // ADR 0615 §1; design 46 7-approval-mark §5). source, stopViews := firstview.Start(context.Background(), conn, log.Printf) defer stopViews() - gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), - Findings: source.Findings(), Logf: log.Printf}) + gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, + Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, + Publish: publishAsk(conn), Record: routerRecord(conn)}) opts = append(opts, runtime.WithGate(gate)) stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) if err != nil { diff --git a/node-tools/internal/approval/act.go b/node-tools/internal/approval/act.go index 7f9445d..5ad9df8 100644 --- a/node-tools/internal/approval/act.go +++ b/node-tools/internal/approval/act.go @@ -28,6 +28,8 @@ type Call struct { Args json.RawMessage // Description is the tool's or verb's own description, whose first line the ask explains it by. Description string + // Own says it is one of the runner's own tools served under the module's name (its ledger repairs), not its bundle's. + Own bool } // MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9). diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 42b25ff..95ed780 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -145,6 +145,24 @@ func newWorld() *world { return w } +// decide writes the router's record of the newest ask as the router writes a person's choice: its state and warrant. +func (w *world) decide(option string, at time.Time) seat.Ask { + a := w.asked[len(w.asked)-1] + key := "shell." + a.ID + rec := w.records[key] + rec.State = "chosen" + rec.Warrant = &seat.Warrant{Ask: a.ID, Asker: "shell", Outcome: operatorchannelclient.OutcomeChosen, Option: option, + Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(at), AskDigest: operatorchannelclient.Digest(a), + By: &seat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + w.records[key] = rec + return a +} + +// do makes a call through the gate, counting the runs. +func (w *world) do(c Call) (any, error) { + return w.g.Run(c, func() (any, error) { w.runs++; return "done", nil }) +} + // call is a fixture call of the marked tool wipe, or of another tool of the module. func call(name, args string) Call { return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args), @@ -270,6 +288,150 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("answer %v after %s", err, w.now.Sub(start)) } }}, + {"§10, §17: a second identical call publishes nothing and is told it still waits", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + _, err := w.do(call("wipe", `{"dir":"scratch"}`)) + if len(w.asked) != 1 || err == nil || !strings.HasPrefix(err.Error(), "Not run: still waiting for the operator's answer") || w.runs != 0 { + t.Fatalf("asked %d, %v, runs %d", len(w.asked), err, w.runs) + } + }}, + {"§17: an ask the router holds back as the fourth open one still waits", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.asked[0] + rec := w.records["shell."+a.ID] + rec.State, rec.HeldSince = "held-back", operatorchannelclient.FormatTime(w.now) + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "still waiting") || len(w.asked) != 1 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§14, §15: approved, the same call runs once; made again it is already run and asks anew", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + w.decide(OptionApprove, w.now.Add(time.Minute)) + w.now = w.now.Add(2 * time.Minute) + if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got != "done" || w.runs != 1 { + t.Fatalf("%v %v, runs %d", got, err, w.runs) + } + _, err := w.do(call("wipe", `{"dir":"scratch"}`)) + if err == nil || !strings.Contains(err.Error(), "was already run; asked again") || w.runs != 1 || len(w.asked) != 2 { + t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked)) + } + }}, + {"§15: two calls at the same moment run it once", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + w.decide(OptionApprove, w.now) + var wg sync.WaitGroup + var mu sync.Mutex + runs := 0 + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = w.g.Run(call("wipe", `{}`), func() (any, error) { mu.Lock(); runs++; mu.Unlock(); return "done", nil }) + }() + } + wg.Wait() + if runs != 1 { + t.Fatalf("ran %d times", runs) + } + }}, + {"§14: approved for other arguments runs nothing", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + a := w.decide(OptionApprove, w.now) + rec := w.records["shell."+a.ID] + other, _ := ActOf(call("wipe", `{"dir":"home"}`), "n1") + d, _ := operatorchannelclient.ActDigest(other) + a.Options[0].Binds = d // the ask the person was shown bound another act + rec.Warrant.AskDigest = operatorchannelclient.Digest(a) + w.records["shell."+a.ID] = rec + var k operatorchannelclient.Kept + _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + k.Ask = a + raw, _ := json.Marshal(k) + w.state.vals["n1.ask."+a.ID] = raw + if _, err := w.do(call("wipe", `{"dir":"scratch"}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "nothing is done") { + t.Fatalf("%v, runs %d", err, w.runs) + } + }}, + {"§14: approved 31 minutes ago asks anew", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + w.decide(OptionApprove, w.now) + w.now = w.now.Add(31 * time.Minute) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "approved too long ago") || w.runs != 0 || len(w.asked) != 2 { + t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked)) + } + }}, + {"§14, §17: declined, the same call is not asked again for 10 minutes", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + w.decide(OptionDecline, w.now) + w.now = w.now.Add(9 * time.Minute) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "declined") || len(w.asked) != 1 || w.runs != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + w.now = w.now.Add(2 * time.Minute) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "asked again") || len(w.asked) != 2 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§14, §17: expired asks anew", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.asked[0] + rec := w.records["shell."+a.ID] + rec.State = "expired" + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "ended expired; asked again") || len(w.asked) != 2 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§14: another asker's warrant runs nothing", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.decide(OptionApprove, w.now) + rec := w.records["shell."+a.ID] + rec.Warrant.Asker = "mail" + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "its warrant does not hold") { + t.Fatalf("%v, runs %d", err, w.runs) + } + }}, + {"§17: the router's refusal is said with its words", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.asked[0] + rec := w.records["shell."+a.ID] + rec.State, rec.Answer = "refused", "the asker has too many open asks" + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the asker has too many open asks") { + t.Fatalf("%v", err) + } + }}, + {"§20: a runner's own tool marked is refused unasked, never run", func(t *testing.T, w *world) { + c := call("wipe", `{}`) + c.Own = true + if _, err := w.do(c); err == nil || w.runs != 0 || len(w.asked) != 0 { + t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked)) + } + }}, + {"§13: with no binding, an unmarked call runs and a marked one is refused", func(t *testing.T, w *world) { + w.g.d.State = nil + if got, err := w.do(call("tidy", `{}`)); err != nil || got != "done" { + t.Fatalf("unmarked: %v %v", got, err) + } + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "binds no state approvals") || w.runs != 1 { + t.Fatalf("marked: %v, runs %d", err, w.runs) + } + }}, + {"§13: an ask over 256 KiB is refused naming the cause", func(t *testing.T, w *world) { + c := call("wipe", `{}`) + c.Description = strings.Repeat("w", 300*1024) + if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "more than the 262144 a value of the state approvals holds") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§3, §2: a seat verb its seat marks is asked for its holder", func(t *testing.T, w *world) { + c := Call{Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1", Args: json.RawMessage(`{"command":"ls"}`)} + if _, err := w.do(c); err == nil || !strings.HasPrefix(err.Error(), "Not run") || len(w.asked) != 1 || w.asked[0].Headline != "Run node-login-shell.execute on n1?" { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 0aa5e32..a8c8498 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -35,6 +35,8 @@ type Deps struct { Node, Runner string // Catalog is the catalog half the marks are read from. Catalog Catalog + // Wait waits, within the bound, for the catalog half's first view, and is nil once it is read or why not. + Wait func(within time.Duration) error // Findings is the runner's held writer; nil says a finding in the log only. Findings FindingWriter Logf func(string, ...any) @@ -57,6 +59,8 @@ type Gate struct { registered map[string]map[string]bool // unserved is each module whose marks name a tool its bundle does not register, with those tools. unserved map[string][]string + // unmarkedAt is each caller's address it last called unmarked, so a mark added since is named (§12). + unmarkedAt map[string]bool } // New is a gate over d. diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go new file mode 100644 index 0000000..b1d3bef --- /dev/null +++ b/node-tools/internal/approval/run.go @@ -0,0 +1,183 @@ +package approval + +import ( + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// Run is a call through the gate (§20): an unmarked call runs; a marked one runs only on the operator's approval of that +// one call, judged on the router's record and claimed once (§14, §15), and is otherwise answered as an error saying why +// (§12, §17, §19). run is called at most once. +func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { + m, err := g.marksOf(c) + if err != nil { + return nil, err + } + if missing := g.unservedOf(c.Module); len(missing) > 0 { + return nil, fmt.Errorf("Not run: %s's marks name %s, which its bundle on %s does not register, so none of its tools is "+ + "served here (design 46 7-approval-mark §4)", c.Module, strings.Join(missing, ", "), g.d.Node) + } + marked, _ := m.Marked(c.Seat, c.Name) + since := g.markedSince(c, marked, m.Revision) + if !marked { + return run() + } + if c.Own { + return nil, g.refused(c, "", "it is one of the tool runner's own tools, which no ask covers") + } + if m.Unread != "" { + return nil, g.refused(c, "", m.Unread) + } + if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() { + return nil, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in") + } + if why := refusedUnasked(c.Args); why != "" { + return nil, g.refused(c, "", why) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + digest, err := operatorchannelclient.ActDigest(act) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + named, err := g.d.State.Get(g.actKey(digest)) + if err != nil { + return nil, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()) + } + if named == nil { + return nil, g.raise(c, act, digest, since) + } + var id string + if err := json.Unmarshal(named.Value, &id); err != nil || id == "" { + return nil, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)) + } + return g.judge(c, act, digest, id, since, run) +} + +// Binding is a Store that says whether the runner's membership binds the state now (§13). +type Binding interface{ Bound() bool } + +// marksOf is the module's marks as the catalog half has them now; before its first view, the call waits for it within +// RecordWithin and is then judged, refused naming mesh_first_views when it is still unread (§19). +func (g *Gate) marksOf(c Call) (Marks, error) { + if g.d.Catalog == nil { + return Marks{}, g.refused(c, "", "the tool runner reads no catalog half, so its marks are not read") + } + if err := g.d.Catalog.Ready(); err != nil { + if g.d.Wait != nil { + err = g.d.Wait(RecordWithin) + } + if err != nil { + return Marks{}, fmt.Errorf("Not run: the marks of %s are not read yet (%v); mesh_first_views says what the tool runner "+ + "waits for", c.Module, err) + } + } + m := Derive(g.d.Catalog, c.Module, c.Fingerprint) + g.checkServed(m) + return m, nil +} + +func (g *Gate) unservedOf(module string) []string { + g.mu.Lock() + defer g.mu.Unlock() + return g.unserved[module] +} + +// markedSince is the revision a call was marked at when the same caller last called it unmarked (§12), empty otherwise. +func (g *Gate) markedSince(c Call, marked bool, revision string) string { + key := c.Caller + " " + c.Address + g.mu.Lock() + defer g.mu.Unlock() + if g.unmarkedAt == nil { + g.unmarkedAt = map[string]bool{} + } + was := g.unmarkedAt[key] + if marked { + delete(g.unmarkedAt, key) + if was { + return revision + } + return "" + } + g.unmarkedAt[key] = true + return "" +} + +// judge is the same call made again, judged on the router's own record of its act's newest ask (§14 to §17). +func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since string, run func() (any, error)) (any, error) { + rec, err := g.record(c.Module, id) + if err != nil { + return nil, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()) + } + if rec == nil { + return nil, g.firstAnswer(c, id, digest, since) // §17: not yet taken by the router + } + again := func(why string) (any, error) { + err := g.raise(c, act, digest, since) + return nil, fmt.Errorf("The ask %s %s; asked again. %w", id, why, err) + } + switch rec.State { + case "open", "held-back": + return nil, fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s", + id, Fingerprint(digest), rec.Expires) + case "refused": + return nil, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)) + case "chosen": + default: // expired, cancelled, replaced: each asks again (§15, §17) + return again("ended " + rec.State) + } + w := rec.Warrant + if w == nil { + return nil, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant") + } + at, _ := operatorchannelclient.Time(w.At) + if w.Option == OptionDecline { + if until := at.Add(DeclinedFor); g.d.Now().Before(until) { + return nil, fmt.Errorf("Not run: the operator declined ask %s; the same call is not asked again until %s", + id, operatorchannelclient.FormatTime(until)) + } + return again("was declined more than 10 minutes ago") + } + if g.d.Now().After(at.Add(RunWithin)) { + return again("was approved too long ago (more than 30 minutes)") + } + var answer any + var ran error + h, err := g.client(c.Module).Take(*w, func(o seat.Option, _ seat.Warrant) error { + if err := operatorchannelclient.Performs(o, act); err != nil { + ran = err + return err + } + answer, ran = run() + return nil + }) + switch { + case h == operatorchannelclient.Done: + return again("was already run") + case h == operatorchannelclient.Refused: + return nil, g.refused(c, digest, "its warrant does not hold: "+errText(err)) + case h == "" && err != nil: + return nil, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()) + case err != nil: // the act about to run is not the one the option bound: nothing runs, the warrant stays used + return nil, g.refused(c, digest, err.Error()) + } + return g.ran(c, id, rec, answer, ran) +} + +// ran is a call run on the operator's approval: the tool's own answer. +func (g *Gate) ran(_ Call, _ string, _ *seat.AsksByAskerIDValue, answer any, err error) (any, error) { + return answer, err +} + +func errText(err error) string { + if err == nil { + return "no reason given" + } + return err.Error() +} diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index bf317d6..587612e 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -3,6 +3,7 @@ package firstview import ( "context" "strconv" + "time" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -81,3 +82,76 @@ 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 +} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index c187bcb..1251cac 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -67,6 +67,8 @@ type Views struct { cancel context.CancelFunc wg sync.WaitGroup deps Deps + // heard is closed and made again at every event heard, so a wait for the picture wakes on events, never on a timer. + heard chan struct{} } // FindingsState is the runner's own state the findings are written in. @@ -205,8 +207,10 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View return v, func() { stop(); cancel(); v.wg.Wait(); stopFindings() } } -// hear applies one views event; a gap asks the first view once more. +// hear applies one views event; a gap asks the first view once more. Every event heard wakes what waits on the picture +// (heardOne). func (v *Views) hear(env bus.Envelope) error { + defer v.heardOne() v.mu.Lock() hs := append([]*adoption(nil), v.hearers[env.Key]...) v.mu.Unlock() diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index ed7cd5f..649bec5 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -12,6 +12,20 @@ const FingerprintWord = "MESH_FINGERPRINT" type Gate interface { // Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding. Unserved(module, fingerprint string, registered []string) []string + // Run is a call through the gate: run is called only when the call may run now, once. + Run(call approval.Call, run func() (any, error)) (any, error) +} + +// gatedRun is how a handler calls a tool: through the gate where there is one. +type gatedRun func(call approval.Call, t launch.Tool, caller string) (any, error) + +// gated calls the tool through the gate, or straight where the runner has none. +func (c *consumers) gated(call approval.Call, t launch.Tool, caller string) (any, error) { + run := func() (any, error) { return t.Run(caller, call.Args) } + if c.gate == nil { + return run() + } + return c.gate.Run(call, run) } // WithGate serves every call through the approval gate. diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 7dae3af..5bbea33 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -17,6 +17,7 @@ import ( "time" "github.com/novox/mesh-tools/node-tools/internal/announce" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" ) @@ -303,7 +304,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -464,8 +465,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) @@ -703,7 +707,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again // whenever a membership changes, and when again is called because a bundle was loaded again. Whether // this machine holds the seat is the bus's to decide. It answers its stop and again. -func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf func(string, ...any)) (func(), func()) { +func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, fingerprintOf func(string) string, + logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -742,8 +747,11 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb) continue } + // Through the approval gate, before the bundle (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)) + return gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb, + Fingerprint: fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller) }) if err != nil { logf("[mesh-tools] cannot serve %s's %s on %s: %v", v.Seat, v.Verb, v.Subject, err) -- 2.54.0 From 0fdec2bd606c3b41a41e2ddf19f2c7add29a8e37 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:15:43 +0100 Subject: [PATCH 23/44] A-6 the capability: the tool runner states approval-mark/1 in its announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its gate sits on every handler it serves, so it says it honours the mark, and the grants part, reading its node's readings, composes no deny of its modules' marked subjects. Builds: design 46 7-approval-mark §6 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/runtime/announce_test.go | 2 +- node-tools/internal/runtime/busstate.go | 5 ++++- node-tools/internal/runtime/ledgers_test.go | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index cc67d5b..53de9c5 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -140,7 +140,7 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Fatalf("ping of the instance: %v", err) } var ping micro.Ping - if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index 5ac8bbd..c74f9af 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -22,6 +22,9 @@ const ( CapLedgerCalls = "ledger-calls" // CapPutSessions is the put and get sessions' word (design 47 G §518, §622), mesh-sdk's. CapPutSessions = stdio.CapPutSessions + // CapApprovalMark says the runner honours the approval mark: its gate sits on every handler it serves (design 46 + // 7-approval-mark §6, §20), so the grants part composes no deny of its modules' marked subjects. + CapApprovalMark = "approval-mark/1" ) // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. @@ -36,7 +39,7 @@ var callFamilies = map[string][]string{ // CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat // message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5). func CallCapabilities() []string { - out := []string{stdio.CapSeatSettleDelay} + out := []string{stdio.CapSeatSettleDelay, CapApprovalMark} for c := range callFamilies { out = append(out, c) } diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 96bc96e..3d50b3a 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,put-sessions,seat-settle-delay" { + if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Fatalf("the runner states %s", got) } for capability, methods := range callFamilies { -- 2.54.0 From 3640447711e1dd6b905b9a9fd5c1b3c17fc9884a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:18:42 +0100 Subject: [PATCH 24/44] A-4 record: a run on the operator's approval answers its approval and is recorded in the hand-act log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call run on its warrant answers {answer, approval}, the tool's own answer and the line naming the approval, as a process holder's gated-ended says it. The runner then calls mesh-hand-acts.warranted, each try a kept call open while the ask lacks recorded, and on its answer writes recorded; past the kept call's bound it is asked again when the hand-act seat's holder is heard again and at each start, for every acted, unrecorded ask within its 48 hours. Two bound keys per run in the runner's key-ttl state bound raise call-unanswered at five minutes and say an unrecorded run an hour before its key's end; the answer clears the finding. Builds: design 46 7-approval-mark §16, §13 (the log line an hour before 48 hours); lead answers A-157, A-158 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 48 ++++ node-tools/cmd/node-tools/main.go | 12 +- node-tools/go.mod | 1 + node-tools/internal/approval/approval_test.go | 69 +++++- node-tools/internal/approval/gate.go | 11 + node-tools/internal/approval/record.go | 220 ++++++++++++++++++ node-tools/internal/approval/run.go | 5 - node-tools/internal/firstview/approval.go | 25 ++ node-tools/internal/firstview/firstview.go | 3 + 9 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 node-tools/internal/approval/record.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index 3b3b4ed..d048b7c 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -3,6 +3,11 @@ package main import ( "encoding/json" "errors" + "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" @@ -99,3 +104,46 @@ func routerRecord(conn *bus.Conn) func(module, id string) (*stdio.StateEntry, er 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 + } +} diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 78e15cd..168250b 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -25,7 +25,9 @@ import ( "syscall" "time" + handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" "github.com/novox/mesh-tools/node-tools/internal/alive" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/console" @@ -77,8 +79,16 @@ func main() { defer stopViews() gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, - Publish: publishAsk(conn), Record: routerRecord(conn)}) + Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}}) opts = append(opts, runtime.WithGate(gate)) + // 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) diff --git a/node-tools/go.mod b/node-tools/go.mod index e9db0f0..0eda7d2 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -7,6 +7,7 @@ require ( git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 95ed780..2baddfb 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -2,6 +2,7 @@ package approval import ( "encoding/json" + "errors" "sort" "strings" "sync" @@ -12,6 +13,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" ) @@ -103,17 +105,49 @@ type world struct { now time.Time runs int findings *findings + bounds *bounds + // warranted is what mesh-hand-acts.warranted answers, and recorded each ask it answered for. + warranted error + recorded []string +} + +// bounds is the runner's key-ttl state bound as a fixture: expire hands a key's expiry to the watch. +type bounds struct { + keys map[string]time.Duration + expired func(string) +} + +func (b *bounds) CreateTTL(key string, _ any, ttl time.Duration) error { b.keys[key] = ttl; return nil } +func (b *bounds) Delete(key string) error { delete(b.keys, key); return nil } +func (b *bounds) Watch(_ string, expired func(string)) (func(), error) { + b.expired = expired + return func() {}, nil +} +func (b *bounds) expire(key string) { + delete(b.keys, key) + b.expired(key) } func newWorld() *world { shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}}) w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{}, - now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), + bounds: &bounds{keys: map[string]time.Duration{}}, + now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell}, newest: map[string]catalogseat.CatalogEntry{"shell": shell}, defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} - w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, + w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, Bounds: w.bounds, + Go: func(f func()) { f() }, + 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) @@ -309,7 +343,8 @@ func TestTheRefusalsAndAnswers(t *testing.T) { _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) w.decide(OptionApprove, w.now.Add(time.Minute)) w.now = w.now.Add(2 * time.Minute) - if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got != "done" || w.runs != 1 { + if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got.(Answered).Answer != "done" || w.runs != 1 || + !strings.HasPrefix(got.(Answered).Approval, "run on the operator's approval via telegram at 2026-11-12T10:01:00Z, ask ") { t.Fatalf("%v %v, runs %d", got, err, w.runs) } _, err := w.do(call("wipe", `{"dir":"scratch"}`)) @@ -432,6 +467,34 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("%v, asked %d", err, len(w.asked)) } }}, + {"§16: the run is recorded once; unanswered within five minutes it is a finding, cleared when warranted answers", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.decide(OptionApprove, w.now) + stop, _ := w.g.WatchBounds() + defer stop() + w.warranted = errors.New("no responders") + if _, err := w.do(call("wipe", `{}`)); err != nil || w.runs != 1 || len(w.recorded) != 0 { + t.Fatalf("%v, runs %d, recorded %v", err, w.runs, w.recorded) + } + if w.bounds.keys["n1.unanswered."+a.ID] != UnansweredAfter || w.bounds.keys["n1.unrecorded."+a.ID] != KeptFor-SaidBefore { + t.Fatalf("bounds %v", w.bounds.keys) + } + w.bounds.expire("n1.unanswered." + a.ID) + if w.findings.raised[keys.EscapeName("n1."+a.ID)] != KindCallUnanswered { + t.Fatalf("findings %v", w.findings.raised) + } + w.warranted = nil + w.g.Recover() // a start, or the hand-act seat's holder heard again + w.g.Recover() + 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) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index a8c8498..5381ec9 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -49,6 +49,12 @@ type Deps struct { Publish func(module, subject string, body any, id string) error // Record reads the router's record of an ask, `.` in the seat's state asks, on the runner's own account (§14). Record func(module, id string) (*stdio.StateEntry, error) + // Warranted asks `mesh-hand-acts.warranted` {asker, ask}, each try a kept call open while open says so (§16, A-157). + Warranted func(asker, ask string, open func() (bool, error)) error + // Bounds is the runner's key-ttl state bound (A-157, A-158); nil keeps no bound. + Bounds Bounds + // Go runs what goes on beside the call (a run's recording); nil is a goroutine. + Go func(func()) } // Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). @@ -61,6 +67,8 @@ type Gate struct { unserved map[string][]string // unmarkedAt is each caller's address it last called unmarked, so a mark added since is named (§12). unmarkedAt map[string]bool + // inFlight is each ask whose recording runs now. + inFlight map[string]bool } // New is a gate over d. @@ -74,6 +82,9 @@ func New(d Deps) *Gate { if d.After == nil { d.After = time.After } + if d.Go == nil { + d.Go = func(f func()) { go f() } + } return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} } diff --git a/node-tools/internal/approval/record.go b/node-tools/internal/approval/record.go new file mode 100644 index 0000000..a01a038 --- /dev/null +++ b/node-tools/internal/approval/record.go @@ -0,0 +1,220 @@ +package approval + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The bounds of a run's recording (§13, §16; lead answers A-157, A-158). +const ( + UnansweredAfter = 5 * time.Minute + KeptFor = 48 * time.Hour + SaidBefore = time.Hour +) + +// Bounds is the runner's key-ttl state `bound` (lead answers A-157, A-158): a key created with its own time to live, which +// the bus removes when it passes, every watch hearing the removal as an expiry, so the gate's waits need no timer. +type Bounds interface { + CreateTTL(key string, value any, ttl time.Duration) error + Delete(key string) error + // Watch hands expired every key under the pattern whose time to live passed, until the stop is called. + Watch(pattern string, expired func(key string)) (stop func(), err error) +} + +// Answered is a call run on the operator's approval: the tool's own answer and the line naming the approval (§16), in the +// form a process holder's `gated-ended` says it (design 47 G §677; lead answer A-158). +type Answered struct { + Answer any `json:"answer"` + Approval string `json:"approval"` +} + +// The bound keys of a run (lead answer A-158; §13). +func (g *Gate) unansweredKey(id string) string { return g.d.Node + ".unanswered." + id } +func (g *Gate) unrecordedKey(id string) string { return g.d.Node + ".unrecorded." + id } + +// ran is a call run on the operator's approval: answered with the tool's answer and the approval's line, and its warrant +// recorded in the hand-act log (§16). +func (g *Gate) ran(c Call, id string, rec *seat.AsksByAskerIDValue, answer any, err error) (any, error) { + channel := "" + at := "" + if w := rec.Warrant; w != nil { + channel, at = w.Channel, w.At + } + if channel == "" { + channel = strings.Join(rec.OfferedOn, ", ") + } + line := fmt.Sprintf("run on the operator's approval via %s at %s, ask %s", orNone(channel), at, id) + g.d.Logf("[mesh-tools] %s on %s %s", c.Address, g.d.Node, line) + g.bindRecording(c.Module, id) + g.d.Go(func() { g.recordRun(c.Module, id) }) + if err != nil { + return nil, fmt.Errorf("%w (%s)", err, line) + } + return Answered{Answer: answer, Approval: line}, nil +} + +// bindRecording keeps the run's two bound keys: one that raises `call-unanswered` when `warranted` has not answered within +// five minutes, one that says an unrecorded run an hour before its key's 48 hours end (§13, §16). +func (g *Gate) bindRecording(module, id string) { + if g.d.Bounds == nil { + return + } + for key, ttl := range map[string]time.Duration{g.unansweredKey(id): UnansweredAfter, g.unrecordedKey(id): KeptFor - SaidBefore} { + if err := g.d.Bounds.CreateTTL(key, map[string]string{"asker": module, "ask": id}, ttl); err != nil { + g.d.Logf("[mesh-tools] the bound %s of the run of ask %s is not kept: %v", key, id, err) + } + } +} + +// recordRun calls `mesh-hand-acts.warranted` for one acted ask, each try a kept call open while the ask lacks `recorded` +// (§16, A-157), and on its answer writes `recorded` and clears what waited on it. One recording per ask at a time. +func (g *Gate) recordRun(module, id string) { + if !g.recording(id, true) { + return + } + defer g.recording(id, false) + if g.d.Warranted == nil { + g.d.Logf("[mesh-tools] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id) + return + } + if err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }); err != nil { + g.d.Logf("[mesh-tools] mesh-hand-acts.warranted has not answered for ask %s (%v): asked again when the hand-act seat's "+ + "holder is heard again and at the next start", id, err) + return + } + if err := g.markRecorded(id); err != nil { + g.d.Logf("[mesh-tools] ask %s is recorded in the hand-act log and its mark recorded is not kept: %v", id, err) + return + } + if g.d.Bounds != nil { + _ = g.d.Bounds.Delete(g.unansweredKey(id)) + _ = g.d.Bounds.Delete(g.unrecordedKey(id)) + } + g.finding(id, KindCallUnanswered, false, "mesh-hand-acts.warranted answered for ask "+id, "") +} + +// recording claims or releases an ask's recording: false when one runs already. +func (g *Gate) recording(id string, start bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.inFlight == nil { + g.inFlight = map[string]bool{} + } + if start { + if g.inFlight[id] { + return false + } + g.inFlight[id] = true + return true + } + delete(g.inFlight, id) + return true +} + +// kept is the ask the client keeps under its id, with its revision; nil when there is none. +func (g *Gate) kept(id string) (*operatorchannelclient.Kept, uint64, error) { + e, err := g.d.State.Get(g.askKey(id)) + if err != nil || e == nil { + return nil, 0, err + } + var k operatorchannelclient.Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return nil, 0, err + } + return &k, e.Revision, nil +} + +// unrecorded says the ask is acted on and its run not yet recorded: the key that keeps `warranted` open (§16). +func (g *Gate) unrecorded(id string) (bool, error) { + k, _, err := g.kept(id) + if err != nil || k == nil { + return false, err + } + return k.Acted && !k.Recorded, nil +} + +// markRecorded writes `recorded` on the kept ask by compare-and-set, again when another write came first. +func (g *Gate) markRecorded(id string) error { + for { + k, rev, err := g.kept(id) + if err != nil || k == nil { + return fmt.Errorf("the kept ask cannot be read: %v", err) + } + if k.Recorded { + return nil + } + k.Recorded = true + if _, err := g.d.State.Update(g.askKey(id), k, rev); err == nil { + return nil + } else if !isChanged(err) { + return err + } + } +} + +// Recover calls `warranted` again for every acted, unrecorded ask this machine's keys hold within their 48 hours (§16): at +// each start, and when the hand-act seat's holder is heard again (A-157). +func (g *Gate) Recover() { + if g.d.State == nil { + return + } + keys, err := g.d.State.Keys() + if err != nil { + g.d.Logf("[mesh-tools] the acted, unrecorded asks cannot be read (%v): read again at the next start or holder heard", err) + return + } + prefix := g.d.Node + ".ask." + for _, key := range keys { + if !strings.HasPrefix(key, prefix) { + continue + } + id := strings.TrimPrefix(key, prefix) + k, _, err := g.kept(id) + if err != nil || k == nil || !k.Acted || k.Recorded || g.d.Now().Sub(k.Asked) > KeptFor { + continue + } + module := moduleOf(k) + g.d.Go(func() { g.recordRun(module, id) }) + } +} + +// moduleOf is the module an ask was asked under: the warrant's asker. +func moduleOf(k *operatorchannelclient.Kept) string { + if k.Answer != nil { + return k.Answer.Asker + } + return "" +} + +// WatchBounds hears the run's bound keys expire (A-157): `call-unanswered` raised when `warranted` has not answered within +// five minutes; an unrecorded run said in the log an hour before its key's 48 hours end (§13). +func (g *Gate) WatchBounds() (func(), error) { + if g.d.Bounds == nil { + return func() {}, nil + } + return g.d.Bounds.Watch(g.d.Node+".**", func(key string) { + rest := strings.TrimPrefix(key, g.d.Node+".") + kind, id, _ := strings.Cut(rest, ".") + if open, err := g.unrecorded(id); err != nil || !open { + return + } + switch kind { + case "unanswered": + g.finding(id, KindCallUnanswered, true, fmt.Sprintf("mesh-hand-acts.warranted has not answered for ask %s, run on the "+ + "operator's approval on %s, within %s: the run is not yet in the hand-act log", id, g.d.Node, UnansweredAfter), + "a run's hand-act is unrecorded on "+g.d.Node) + case "unrecorded": + g.d.Logf("[mesh-tools] ERROR the run of ask %s on %s is still unrecorded an hour before its key's 48 hours end", id, g.d.Node) + } + }) +} + +// isChanged says a compare-and-set lost to another write. +func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go index b1d3bef..c828e6f 100644 --- a/node-tools/internal/approval/run.go +++ b/node-tools/internal/approval/run.go @@ -170,11 +170,6 @@ func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since st return g.ran(c, id, rec, answer, ran) } -// ran is a call run on the operator's approval: the tool's own answer. -func (g *Gate) ran(_ Call, _ string, _ *seat.AsksByAskerIDValue, answer any, err error) (any, error) { - return answer, err -} - func errText(err error) string { if err == nil { return "no reason given" diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index 587612e..45e1b6c 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -3,6 +3,7 @@ package firstview import ( "context" "strconv" + "strings" "time" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" @@ -155,3 +156,27 @@ func waitAny(next <-chan struct{}, bound <-chan time.Time, ready []<-chan struct } 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 1251cac..8cc6eaa 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -69,6 +69,8 @@ type Views struct { 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. @@ -230,6 +232,7 @@ func (v *Views) hear(env bus.Envelope) error { if f == nil || f.Owner != ev.Owner { return nil } + defer v.heardKey(ev.State, ev.Key) if gap, why := f.Apply(ev); gap { v.logf("[mesh-tools] a gap in %s's %s (%s): its first view %s is asked once more", f.Owner, f.State, why, f.Tool) go func() { _ = f.Again(v.ctx, "a gap: "+why) }() -- 2.54.0 From 65db969f72b471ffe916b4821122d11def48a4c0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:20:46 +0100 Subject: [PATCH 25/44] A-5 failing closed: a marked call runs nothing when a part of the check is missing, and a stuck gate is said MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marked call is refused, asking nothing, when no module holds operator-channel (read from the assigner's view of the seats' holders the runner follows, an unread view refusing too); refused when the served entry cannot be read, the ask cannot be published, the router refuses it or its record cannot be read; and while the state approvals cannot be read or written no marked call runs on the machine, the failed read made again at the next marked call and the next membership change, a bound key raising approvals-unreadable after five minutes and the read again clearing it. Before the catalog half's first view a call waits for it within 5 seconds, on the events the runner hears, and is refused naming mesh_first_views when it is still unread. Each refusal is in the log with the ask's fingerprint, never the arguments. Builds: design 46 7-approval-mark §19, §5 (an unread served entry); lead answers A-157, A-154 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/main.go | 5 +- node-tools/internal/approval/approval_test.go | 74 ++++++++++- node-tools/internal/approval/ask.go | 2 + node-tools/internal/approval/closed.go | 119 ++++++++++++++++++ node-tools/internal/approval/gate.go | 4 + node-tools/internal/approval/record.go | 4 + node-tools/internal/approval/run.go | 6 + 7 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 node-tools/internal/approval/closed.go diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 168250b..5796757 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -79,7 +79,10 @@ func main() { defer stopViews() gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, - Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}}) + Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}, + Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) })}) + // 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)) // 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 { diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 2baddfb..9c42034 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -106,6 +106,9 @@ type world struct { 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 @@ -131,14 +134,16 @@ func (b *bounds) expire(key string) { 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{}}, - now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), + bounds: &bounds{keys: map[string]time.Duration{}}, + holders: map[string][]byte{"operator-channel": []byte(`{"seat":"operator-channel","scope":"mesh","holders":[{"module":"messenger"}]}`)}, + now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell}, newest: map[string]catalogseat.CatalogEntry{"shell": shell}, defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, Bounds: w.bounds, - Go: func(f func()) { f() }, + 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 @@ -495,6 +500,69 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("kept %+v", k) } }}, + {"§19: with no holder of operator-channel in the view, a marked call is refused asking nothing", func(t *testing.T, w *world) { + w.holders["operator-channel"] = []byte(`{"seat":"operator-channel","scope":"mesh","holders":[]}`) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "no module holds operator-channel") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + w.holdersErr = errors.New("the first view has not been answered yet") + if _, err := w.do(call("wipe", `{}`)); err == nil || len(w.asked) != 0 { + t.Fatalf("an unread view: %v, asked %d", err, len(w.asked)) + } + }}, + {"§19: a publish that fails and a record that cannot be read refuse, running nothing", func(t *testing.T, w *world) { + pub := w.g.d.Publish + w.g.d.Publish = func(string, string, any, string) error { return errors.New("the bus refused it") } + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the ask could not be raised") { + t.Fatalf("%v", err) + } + w.g.d.Publish = pub + w.state.vals = map[string]json.RawMessage{} + _, _ = w.do(call("wipe", `{}`)) + w.g.d.Record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") } + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the router's record of its ask") || w.runs != 0 { + t.Fatalf("%v, runs %d", err, w.runs) + } + }}, + {"§5, §19: a served entry that cannot be read refuses every marked call on that runtime", func(t *testing.T, w *world) { + c := call("wipe", `{}`) + c.Fingerprint = "src2:unknown" + if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "cannot be read") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§19: the marks unread, a call waits within 5 seconds and is refused naming mesh_first_views", func(t *testing.T, w *world) { + w.cat.ready = errors.New("the catalog half has no first view of main yet") + waited := time.Duration(0) + w.g.d.Wait = func(d time.Duration) error { waited = d; return w.cat.ready } + if _, err := w.do(call("tidy", `{}`)); err == nil || !strings.Contains(err.Error(), "mesh_first_views") || waited != RecordWithin || w.runs != 0 { + t.Fatalf("%v, waited %s", err, waited) + } + w.g.d.Wait = func(time.Duration) error { w.cat.ready = nil; return nil } + if _, err := w.do(call("tidy", `{}`)); err != nil || w.runs != 1 { + t.Fatalf("read within the wait: %v, runs %d", err, w.runs) + } + }}, + {"§19: the state failing refuses, raises approvals-unreadable after five minutes, and clears when read again", func(t *testing.T, w *world) { + stop, _ := w.g.WatchBounds() + defer stop() + w.state.fail = errors.New("the bus is unreachable") + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "cannot be read") { + t.Fatalf("%v", err) + } + if w.bounds.keys["n1.unreadable.approvals"] != UnreadableAfter || len(w.findings.raised) != 0 { + t.Fatalf("bounds %v, findings %v", w.bounds.keys, w.findings.raised) + } + w.bounds.expire("n1.unreadable.approvals") + if w.findings.raised[keys.EscapeName("n1.approvals")] != KindApprovalsUnreadable { + t.Fatalf("findings %v", w.findings.raised) + } + w.state.fail = nil + w.g.StateAgain() // a membership change heard, no further call + if len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 { + t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go index a183f3f..43e1bc9 100644 --- a/node-tools/internal/approval/ask.go +++ b/node-tools/internal/approval/ask.go @@ -149,9 +149,11 @@ func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince return g.refused(c, digest, "the ask could not be raised: "+err.Error()) } if err := g.keepCall(id, act, c.Args); err != nil { + g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) } if _, err := g.d.State.Put(g.actKey(digest), id); err != nil { + g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) } return g.firstAnswer(c, id, digest, markedSince) diff --git a/node-tools/internal/approval/closed.go b/node-tools/internal/approval/closed.go new file mode 100644 index 0000000..96219a4 --- /dev/null +++ b/node-tools/internal/approval/closed.go @@ -0,0 +1,119 @@ +package approval + +import ( + "encoding/json" + "fmt" + "time" + + assignerseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" +) + +// AskedOn is the seat an ask is raised on, whose holder must be there for a marked call to be asked (§19). +const AskedOn = "operator-channel" + +// UnreadableAfter is how long the state approvals fails before the runner's finding says so (§19). +const UnreadableAfter = 5 * time.Minute + +// unreadableKey is the bound key whose expiry raises `approvals-unreadable` (lead answer A-157). +func (g *Gate) unreadableKey() string { return g.d.Node + ".unreadable." + StateName } + +// held says why no marked call can be asked now, or "" (§19): no module holds operator-channel, as the assigner's view +// of the seat's holders says, or that view is not read. +func (g *Gate) held() string { + if g.d.Holder == nil { + return "the tool runner reads no view of the seats' holders, so it cannot tell that a module holds " + AskedOn + } + ok, err := g.d.Holder(AskedOn) + switch { + case err != nil: + return fmt.Sprintf("whether a module holds %s cannot be read: %v", AskedOn, err) + case !ok: + return "no module holds " + AskedOn + ", so nobody can be asked" + } + return "" +} + +// stateFailed notes that the state approvals could not be read or written: no marked call runs on this machine until it +// can, the failed read or write is made again at the next marked call and at the next membership change (A-157), and +// five minutes on the runner's finding says it (§19). +func (g *Gate) stateFailed(err error) { + g.mu.Lock() + first := g.failingSince.IsZero() + if first { + g.failingSince = g.d.Now() + } + g.mu.Unlock() + if first { + g.d.Logf("[mesh-tools] the state %s on %s cannot be read or written (%v): no marked call runs here until it can", StateName, g.d.Node, err) + if g.d.Bounds != nil { + if e := g.d.Bounds.CreateTTL(g.unreadableKey(), map[string]string{"since": g.d.Now().UTC().Format(time.RFC3339)}, UnreadableAfter); e != nil { + g.d.Logf("[mesh-tools] the bound of the state %s's failure is not kept: %v", StateName, e) + } + } + } +} + +// stateRead notes that the state approvals was read or written: a failure noted before ends, its finding cleared. +func (g *Gate) stateRead() { + g.mu.Lock() + was := !g.failingSince.IsZero() + g.failingSince = time.Time{} + g.mu.Unlock() + if !was { + return + } + if g.d.Bounds != nil { + _ = g.d.Bounds.Delete(g.unreadableKey()) + } + g.finding(StateName, KindApprovalsUnreadable, false, "the state "+StateName+" is read again", "") +} + +// failing says the state approvals is failing now. +func (g *Gate) failing() bool { + g.mu.Lock() + defer g.mu.Unlock() + return !g.failingSince.IsZero() +} + +// StateAgain makes the failed read of the state approvals again: at the next membership change the runner hears (A-157). +func (g *Gate) StateAgain() { + if g.d.State == nil || !g.failing() { + return + } + if _, err := g.d.State.Keys(); err != nil { + return + } + g.stateRead() +} + +// unreadableExpired is the bound key of a failing state expiring: the finding raised while the state still fails. +func (g *Gate) unreadableExpired() { + g.mu.Lock() + since := g.failingSince + g.mu.Unlock() + if since.IsZero() { + return + } + g.finding(StateName, KindApprovalsUnreadable, true, fmt.Sprintf("the state %s on %s cannot be read or written since %s: no marked call "+ + "runs here until it can", StateName, g.d.Node, since.UTC().Format(time.RFC3339)), "approvals unreadable on "+g.d.Node) +} + +// HoldersIn is Holder over the assigner's view of the seats' holders as the runner holds it (§19): view answers the view's +// values by key, an error while its first view is not answered; a seat is held when its key names a holder. +func HoldersIn(view func() (map[string][]byte, error)) func(seat string) (bool, error) { + return func(seat string) (bool, error) { + values, err := view() + if err != nil { + return false, err + } + raw, ok := values[assignerseat.SeatHoldersBySeatKey(seat)] + if !ok { + return false, nil + } + var h assignerseat.SeatHolders + if err := json.Unmarshal(raw, &h); err != nil { + return false, fmt.Errorf("the holders of %s cannot be read: %w", seat, err) + } + return len(h.Holders) > 0, nil + } +} diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 5381ec9..419197c 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -53,6 +53,8 @@ type Deps struct { Warranted func(asker, ask string, open func() (bool, error)) error // Bounds is the runner's key-ttl state bound (A-157, A-158); nil keeps no bound. Bounds Bounds + // Holder says whether a module holds a seat, from the assigner's view of the seats' holders the runner follows (§19). + Holder func(seat string) (bool, error) // Go runs what goes on beside the call (a run's recording); nil is a goroutine. Go func(func()) } @@ -69,6 +71,8 @@ type Gate struct { unmarkedAt map[string]bool // inFlight is each ask whose recording runs now. inFlight map[string]bool + // failingSince is when the state approvals began to fail; zero while it is read. + failingSince time.Time } // New is a gate over d. diff --git a/node-tools/internal/approval/record.go b/node-tools/internal/approval/record.go index a01a038..20af7ab 100644 --- a/node-tools/internal/approval/record.go +++ b/node-tools/internal/approval/record.go @@ -200,6 +200,10 @@ func (g *Gate) WatchBounds() (func(), error) { return func() {}, nil } return g.d.Bounds.Watch(g.d.Node+".**", func(key string) { + if key == g.unreadableKey() { + g.unreadableExpired() + return + } rest := strings.TrimPrefix(key, g.d.Node+".") kind, id, _ := strings.Cut(rest, ".") if open, err := g.unrecorded(id); err != nil || !open { diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go index c828e6f..b0f525c 100644 --- a/node-tools/internal/approval/run.go +++ b/node-tools/internal/approval/run.go @@ -38,6 +38,9 @@ func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { if why := refusedUnasked(c.Args); why != "" { return nil, g.refused(c, "", why) } + if why := g.held(); why != "" { + return nil, g.refused(c, "", why) + } act, err := ActOf(c, g.d.Node) if err != nil { return nil, g.refused(c, "", err.Error()) @@ -48,8 +51,10 @@ func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { } named, err := g.d.State.Get(g.actKey(digest)) if err != nil { + g.stateFailed(err) return nil, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()) } + g.stateRead() if named == nil { return nil, g.raise(c, act, digest, since) } @@ -163,6 +168,7 @@ func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since st case h == operatorchannelclient.Refused: return nil, g.refused(c, digest, "its warrant does not hold: "+errText(err)) case h == "" && err != nil: + g.stateFailed(err) return nil, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()) case err != nil: // the act about to run is not the one the option bound: nothing runs, the warrant stays used return nil, g.refused(c, digest, err.Error()) -- 2.54.0 From 99f8c8c050ec1aa37935080e9230864378fe5b8b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:21:14 +0100 Subject: [PATCH 26/44] Finding 6: every module requires the mesh modules it builds with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK, the assigner, the builder's buildrun, the indexer, the pipeline, the watchman, mesh-enrolment and mesh-grants (and through the SDK the checker's checkermsg and mesh-tools' node-tools) imported packages of modules their go.mod did not require at the zero version; the placement's go.work over the whole repository hid it, and a build of the module alone with -mod=readonly refused it. Each now requires them (beside a relative replace where the module keeps its siblings so), and the indexer's and watchman's duplicated lines go. Builds: design 47 G §427 (the group's check builds every importer); delivery review round 1, finding 6 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/node-tools/go.mod b/node-tools/go.mod index 9a18092..719ad46 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -27,3 +27,5 @@ 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 -- 2.54.0 From 6851ee165714c917ea564ae8f2380a0e7c49b7c3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:24:41 +0100 Subject: [PATCH 27/44] A-7 tools: mesh_approvals reads and steers what was asked; mesh_describe says a mark and its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh MCP server gains mesh_approvals: with no argument the calls asked on the machine in the last day, newest first, with state and fingerprint, the marks the gate applies with their sources and the catalog revision, and the state's fill against its cap (every machine's keys together); an ask read whole with the act's digest; record asking the hand-act again, and cancel taking an open ask back, each recorded in the hand-act log first and refused when it cannot be. mesh_describe answers needs-approval with its source, and a marked call that reached no one names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §18, §6 (the mesh MCP server's withheld answer), §22 (eight tools) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 15 ++ node-tools/cmd/node-tools/main.go | 4 +- node-tools/internal/approval/approval_test.go | 29 +++ node-tools/internal/approval/gate.go | 6 + node-tools/internal/approval/tools.go | 168 ++++++++++++++++++ node-tools/internal/console/address.go | 16 +- node-tools/internal/console/approvals.go | 99 +++++++++++ node-tools/internal/console/mcp.go | 2 + node-tools/internal/console/wait_test.go | 39 +++- 9 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 node-tools/internal/approval/tools.go create mode 100644 node-tools/internal/console/approvals.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index d048b7c..48dfe63 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "errors" + "fmt" "log" "time" @@ -147,3 +148,17 @@ func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error) return 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/main.go b/node-tools/cmd/node-tools/main.go index 5796757..8a81f04 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -118,7 +118,9 @@ func main() { 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/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 9c42034..230cd1e 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -563,6 +563,35 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys) } }}, + {"§18: the asks of the last day, one read whole, the marks applied with their sources, a record again and a cancel", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + list, err := w.g.List() + if err != nil || len(list) != 1 || list[0].State != "asked" || list[0].Address != "shell.wipe" || list[0].Fingerprint == "" { + t.Fatalf("%+v %v", list, err) + } + whole, err := w.g.Read(list[0].Ask) + if err != nil || string(whole.Arguments) != `{"dir":"scratch"}` || !strings.HasPrefix(whole.Digest, "sha256:") { + t.Fatalf("%+v %v", whole, err) + } + applied := w.g.Applied() + if len(applied) != 1 || applied[0].Tools["wipe"] != "entry shell src2:1" || applied[0].Verbs["node-login-shell.execute"] != "seat node-login-shell" || applied[0].Revision != "7" { + t.Fatalf("applied %+v", applied) + } + if ok, src := w.g.Describe("shell", "", "wipe"); !ok || src != "entry shell src2:1" { + t.Errorf("describe %v %q", ok, src) + } + if ok, src := w.g.Describe("", "node-login-shell", "execute"); !ok || src != "seat node-login-shell" { + t.Errorf("describe a seat verb %v %q", ok, src) + } + if err := w.g.Again(list[0].Ask); err == nil { + t.Error("an ask not run was asked its hand-act again") + } + cancelled := "" + w.g.d.Publish = func(module, subject string, body any, id string) error { cancelled = subject + " " + id; return nil } + if err := w.g.Cancel(list[0].Ask); err != nil || cancelled != "mesh.seat.operator-channel.accept.cancel.shell cancel."+list[0].Ask { + t.Fatalf("cancel %v %q", err, cancelled) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 419197c..787014a 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -73,6 +73,8 @@ type Gate struct { inFlight map[string]bool // failingSince is when the state approvals began to fail; zero while it is read. failingSince time.Time + // served is each module this runner serves, with the build its bundle serves, as its last load said (§18). + served map[string]string } // New is a gate over d. @@ -102,6 +104,10 @@ func (g *Gate) Unserved(module, fingerprint string, registered []string) []strin } g.mu.Lock() g.registered[module] = set + if g.served == nil { + g.served = map[string]string{} + } + g.served[module] = fingerprint g.mu.Unlock() if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { return nil diff --git a/node-tools/internal/approval/tools.go b/node-tools/internal/approval/tools.go new file mode 100644 index 0000000..13b4cbf --- /dev/null +++ b/node-tools/internal/approval/tools.go @@ -0,0 +1,168 @@ +package approval + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// The mesh MCP server's mesh_approvals reads and steers what the gate asked on this machine (§18). + +// Listed is one ask of the last day as mesh_approvals lists it: its state and its act's fingerprint. +type Listed struct { + Ask string `json:"ask"` + Address string `json:"address"` + Module string `json:"module"` + Asked time.Time `json:"asked"` + State string `json:"state"` + Fingerprint string `json:"fingerprint"` +} + +// Whole is one ask read whole: its arguments and the act's digest. +type Whole struct { + Listed + Digest string `json:"digest"` + Act map[string]string `json:"act"` + Arguments json.RawMessage `json:"arguments"` + Recorded bool `json:"recorded"` +} + +// stateOf is a kept ask's state as the runner knows it. +func stateOf(acted, recorded bool, ended string) string { + switch { + case acted && recorded: + return "run, recorded" + case acted: + return "run, not yet recorded" + case ended != "": + return ended + } + return "asked" +} + +// List is the calls asked on this machine in the last day, newest first (§18). +func (g *Gate) List() ([]Listed, error) { + if g.d.State == nil { + return nil, fmt.Errorf("the tool runner's membership binds no state %s", StateName) + } + keys, err := g.d.State.Keys() + if err != nil { + return nil, err + } + prefix := g.d.Node + ".ask." + out := []Listed{} + for _, key := range keys { + if !strings.HasPrefix(key, prefix) { + continue + } + w, err := g.Read(strings.TrimPrefix(key, prefix)) + if err != nil || g.d.Now().Sub(w.Asked) > 24*time.Hour { + continue + } + out = append(out, w.Listed) + } + sort.Slice(out, func(i, j int) bool { return out[i].Asked.After(out[j].Asked) }) + return out, nil +} + +// Read is one ask whole: its arguments and the act's digest (§18). +func (g *Gate) Read(id string) (Whole, error) { + if g.d.State == nil { + return Whole{}, fmt.Errorf("the tool runner's membership binds no state %s", StateName) + } + k, _, err := g.kept(id) + if err != nil { + return Whole{}, err + } + if k == nil { + return Whole{}, fmt.Errorf("no ask %s is kept on %s", id, g.d.Node) + } + module := k.Act["module"] + if module == "" { + module = moduleOf(k) + } + return Whole{Listed: Listed{Ask: id, Address: k.Act["address"], Module: module, Asked: k.Asked, + State: stateOf(k.Acted, k.Recorded, k.Ended), Fingerprint: Fingerprint(k.Ask.About)}, + Digest: k.Ask.About, Act: k.Act, Arguments: k.Arguments, Recorded: k.Recorded}, nil +} + +// Applied are the marks the gate applies on this machine, each module's marked tools and verbs with their source and the +// catalog revision read (§18). +func (g *Gate) Applied() []Marks { + g.mu.Lock() + modules := make([]string, 0, len(g.served)) + for m := range g.served { + modules = append(modules, m) + } + g.mu.Unlock() + sort.Strings(modules) + var out []Marks + if g.d.Catalog == nil { + return out + } + for _, m := range modules { + if marks := Derive(g.d.Catalog, m, g.servedFingerprint(m)); marks.Any() { + out = append(out, marks) + } + } + return out +} + +func (g *Gate) servedFingerprint(module string) string { + g.mu.Lock() + defer g.mu.Unlock() + return g.served[module] +} + +// Describe says whether a tool or a seat verb of a module is marked, and its source, for mesh_describe (§18): the served +// build's marks where this machine serves the module, else the newest entry's and its seat's. +func (g *Gate) Describe(module, seat, name string) (bool, string) { + if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { + return false, "" + } + if seat != "" && module == "" { + for s, d := range g.d.Catalog.Definitions() { + if s != seat { + continue + } + for _, v := range d.Verbs { + if v.Name == name && v.NeedsApproval { + return true, "seat " + seat + } + } + } + return false, "" + } + return Derive(g.d.Catalog, module, g.servedFingerprint(module)).Marked(seat, name) +} + +// Again asks `warranted` again for one acted, unrecorded ask, by hand, in the kept-call form of §16 (§18); the caller +// records the hand act first. +func (g *Gate) Again(id string) error { + w, err := g.Read(id) + if err != nil { + return err + } + if !strings.HasPrefix(w.State, "run, not yet") { + return fmt.Errorf("the ask %s is %s: only a run not yet recorded is asked again", id, w.State) + } + g.recordRun(w.Module, id) + if again, _ := g.Read(id); !again.Recorded { + return fmt.Errorf("mesh-hand-acts.warranted has not answered for ask %s yet", id) + } + return nil +} + +// Cancel takes an open ask back through operator-channel's client (§18); the caller records the hand act first. +func (g *Gate) Cancel(id string) error { + w, err := g.Read(id) + if err != nil { + return err + } + if w.State != "asked" { + return fmt.Errorf("the ask %s is %s, not open", id, w.State) + } + return g.client(w.Module).Cancel(id) +} diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index b8860a7..84de6fd 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -24,6 +24,7 @@ import ( "context" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -103,12 +104,13 @@ func discovery() []map[string]any { }), "description": "Ask the tool runner's four first views again, now, and say what each answers. A hand act, recorded in " + "the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."}, waitTool(), + approvalsTool(), } } func isDiscovery(name string) bool { switch name { - case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait: + case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait, verbApprovals: return true } return false @@ -949,6 +951,9 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any if name == verbWait { return s.waitAnswer(ctx, args) } + if name == verbApprovals { + return s.approvalsAnswer(args) + } x, err := s.index() if err != nil { return failure("the mesh's discovery failed: " + err.Error()) @@ -1109,6 +1114,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: @@ -1129,6 +1138,11 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any } if err != nil { 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() + ")" } diff --git a/node-tools/internal/console/approvals.go b/node-tools/internal/console/approvals.go new file mode 100644 index 0000000..f189e8b --- /dev/null +++ b/node-tools/internal/console/approvals.go @@ -0,0 +1,99 @@ +package console + +import ( + "log" + "strings" + + "github.com/novox/mesh-tools/node-tools/internal/approval" +) + +// verbApprovals is the mesh MCP server's tool that reads and steers what the gate asked on this machine (design 46 +// 7-approval-mark §18). +const verbApprovals = "mesh_approvals" + +// Approvals is the gate of this machine's tool runner, as mesh_approvals and mesh_describe read it. +type Approvals interface { + List() ([]approval.Listed, error) + Read(id string) (approval.Whole, error) + Applied() []approval.Marks + Describe(module, seat, name string) (bool, string) + Again(id string) error + Cancel(id string) error + // Fill is the state approvals' bytes against its cap, every machine's keys together. + Fill() (bytes uint64, max int64, err error) +} + +func approvalsTool() map[string]any { + str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } + return map[string]any{"name": verbApprovals, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{ + "ask": str("an ask's id: its whole arguments and the act's digest"), + "record": str("an ask's id: ask mesh-hand-acts.warranted again for its run, recorded in the hand-act log first"), + "cancel": str("an ask's id: take that open ask back, recorded in the hand-act log first"), + "why": str("why, for record and cancel, kept in the hand-act log"), + }}, "description": "The calls that needed the operator's approval on this machine: with no argument, those asked in " + + "the last day, newest first, with their state and fingerprint, the marks the tool runner applies (each module's " + + "marked tools and verbs with their source and the catalog revision read) and its state's fill; `ask` reads one " + + "whole, `record` asks its hand-act again, `cancel` takes an open ask back."} +} + +// approvalsAnswer is mesh_approvals. +func (s *Surface) approvalsAnswer(args map[string]any) map[string]any { + if s.Approvals == nil { + return failure("this tool runner has no approval gate") + } + str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) } + why := str("why") + if why == "" { + why = "asked by hand with no reason given" + } + act := func(id, what, cause string, do func(string) error) map[string]any { + // A hand act, recorded through the seat mesh-hand-acts before it is done (§18); one that cannot be recorded is refused. + log.Printf("[mesh-tools] hand act: %s %s ask %s: %s", s.who, what, id, why) + if err := s.recordAct(map[string]any{"what": what + " ask " + id, "why": why, "cause": cause, "verb": verbApprovals}); err != nil { + return failure("the hand act was not recorded through mesh-hand-acts, so nothing was done: " + err.Error()) + } + if err := do(id); err != nil { + return failure(err.Error()) + } + return answerText(map[string]any{"done": what + " ask " + id, "recorded": "mesh-hand-acts.hand-act"}) + } + switch { + case str("record") != "": + return act(str("record"), "asked the hand-act of", "approval-record", s.Approvals.Again) + case str("cancel") != "": + return act(str("cancel"), "cancelled", "approval-cancel", s.Approvals.Cancel) + case str("ask") != "": + w, err := s.Approvals.Read(str("ask")) + if err != nil { + return failure(err.Error()) + } + return answerText(w) + } + asks, err := s.Approvals.List() + out := map[string]any{"asks": asks, "marks": s.Approvals.Applied()} + if err != nil { + out["asks"], out["asks_why"] = []approval.Listed{}, "the state approvals cannot be read: "+err.Error() + } + if b, max, err := s.Approvals.Fill(); err != nil { + out["fill_why"] = err.Error() + } else { + out["fill"] = map[string]any{"bytes": b, "max_bytes": max, "of": "every machine's keys together"} + } + return answerText(out) +} + +// marked is what mesh_describe and a call that reached no one say of a marked tool or verb (§6, §18). +func (s *Surface) marked(module, seat, name string) (bool, string) { + if s.Approvals == nil { + return false, "" + } + return s.Approvals.Describe(module, seat, name) +} + +// markedTool is marked for a resolved address. +func (s *Surface) markedTool(t target) (bool, string) { + if t.Seat { + return s.marked("", t.Tool.Module, t.Tool.Name) + } + return s.marked(t.Tool.Module, "", t.Tool.Name) +} 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/wait_test.go b/node-tools/internal/console/wait_test.go index 862517f..3cfc486 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -3,6 +3,7 @@ package console import ( "context" "encoding/json" + "errors" "net/http" "os" "reflect" @@ -11,6 +12,7 @@ import ( "testing" "time" + "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" ) @@ -194,7 +196,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 +209,43 @@ 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 } + +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(_, _, _ string) (bool, string) { return false, "" } +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) { -- 2.54.0 From ea8d9c5f5674f948ec4865ffb7cff009fbec8b3f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:26:24 +0100 Subject: [PATCH 28/44] Test budget: the approval mark's two tables replace two tests folded into their neighbours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool runner ends milestone G with no more tests than it started with: the no-responders test takes the 503 case, and the stated-capabilities test takes the sent-object row. Builds: design 46 7-approval-mark §22; design 47 G §626 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/noresponders_test.go | 8 +++----- node-tools/internal/runtime/announce_test.go | 14 -------------- node-tools/internal/runtime/ledgers_test.go | 5 +++++ 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go index fd008c8..51f0c0a 100644 --- a/node-tools/internal/console/noresponders_test.go +++ b/node-tools/internal/console/noresponders_test.go @@ -11,15 +11,13 @@ 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 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) diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index 53de9c5..dd58ba0 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -2,7 +2,6 @@ package runtime import ( "encoding/json" - "git.novox.be/novox/mesh-sdk/go/buscalls" "sort" "strings" "testing" @@ -144,16 +143,3 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } - -// Design 47 G §462: the call that reads a sent object is listed under the bus state capability, and the ledger calls keep theirs. -func TestTheSentObjectCallBelongsToTheBusStateCapability(t *testing.T) { - for c, method := range map[string]string{CapBusStateCalls: buscalls.ObjectsSentGet} { - found := false - for _, m := range callFamilies[c] { - found = found || m == method - } - if !found { - t.Errorf("%s does not list %s", c, method) - } - } -} diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 3d50b3a..798c85b 100644 --- a/node-tools/internal/runtime/ledgers_test.go +++ b/node-tools/internal/runtime/ledgers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "slices" "sort" "strings" "testing" @@ -207,6 +208,10 @@ func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) { if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Fatalf("the runner states %s", got) } + // Design 47 G §462: the call that reads a sent object is listed under the bus state capability. + if !slices.Contains(callFamilies[CapBusStateCalls], buscalls.ObjectsSentGet) { + t.Errorf("%s does not list %s", CapBusStateCalls, buscalls.ObjectsSentGet) + } for capability, methods := range callFamilies { for _, method := range methods { t.Run(capability+" "+method, func(t *testing.T) { -- 2.54.0 From 809288da6972fbb1f63428635202f570a3bfe4ff Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:41:53 +0100 Subject: [PATCH 29/44] E-5 marked waits: a wait on a marked event runs only on the operator's warrant for that one wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh_wait goes through the runner's approval gate (node-tools/internal/approval, Gate.Waited): a wait whose subjects overlap a marked subject of any module the catalog half lists (the newest entry of every followed branch, with the served entry of each module this runner serves) is an act of the runner's own module and build (MESH_FINGERPRINT at its start), asked once on operator-channel as "Wait on on ?", and held, subscribing nothing but its ask's decided, until its timeout, its caller's going or the choice. On a warrant it subscribes, its timeout running from then, and answers {answer, approval}. Before the marks are read nothing is asked; a wait from a seat is never marked. The state fixture moves to meshtest so the console's table can hold a gate. Builds: design 34 every-event §34, §35, §36, §37; design 47 G lead answer A-168 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 10 + node-tools/cmd/node-tools/main.go | 4 +- node-tools/internal/approval/approval_test.go | 102 +----- node-tools/internal/approval/ask.go | 33 +- node-tools/internal/approval/gate.go | 7 + node-tools/internal/approval/marks.go | 99 +++--- node-tools/internal/approval/marks_test.go | 7 + node-tools/internal/approval/wait.go | 296 ++++++++++++++++++ node-tools/internal/console/wait.go | 41 ++- node-tools/internal/console/wait_test.go | 231 +++++++++++++- node-tools/internal/firstview/approval.go | 14 + node-tools/internal/meshtest/state.go | 87 +++++ 12 files changed, 783 insertions(+), 148 deletions(-) create mode 100644 node-tools/internal/approval/wait.go create mode 100644 node-tools/internal/meshtest/state.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index 48dfe63..99153d6 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -149,6 +150,15 @@ func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error) } } +// 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 diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 8a81f04..302afde 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -80,7 +80,9 @@ func main() { gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}, - Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) })}) + Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) }), + // The runner's own build, the one a marked wait's act binds (design 34 every-event §35). + Build: os.Getenv(runtime.FingerprintWord), Hear: hearOne(conn)}) // A failed read or write of the state approvals is made again at the next membership change the runner hears (A-157). conn.OnMembership(func(bus.Membership) { go gate.StateAgain() }) opts = append(opts, runtime.WithGate(gate)) diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 230cd1e..f1cf18f 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -3,7 +3,6 @@ package approval import ( "encoding/json" "errors" - "sort" "strings" "sync" "testing" @@ -15,83 +14,14 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" + + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) -// mem is the runner's state approvals as a fixture; fail makes every read and write fail, as a bus refusing them. -type mem struct { - mu sync.Mutex - vals map[string]json.RawMessage - revs map[string]uint64 - n uint64 - fail error -} +// mem is the runner's state approvals as a fixture (meshtest.State). +type mem = mt.State -func newMem() *mem { return &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} } - -func (m *mem) Get(k string) (*stdio.StateEntry, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return nil, m.fail - } - v, ok := m.vals[k] - if !ok { - return nil, nil - } - return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil -} -func (m *mem) Create(k string, v any) (uint64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return 0, m.fail - } - if _, ok := m.vals[k]; ok { - return 0, stdio.ErrChanged - } - return m.put(k, v) -} -func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return 0, m.fail - } - if rev == 0 || m.revs[k] != rev { - return 0, stdio.ErrChanged - } - return m.put(k, v) -} -func (m *mem) Put(k string, v any) (uint64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return 0, m.fail - } - return m.put(k, v) -} -func (m *mem) 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 *mem) 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 -} +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. @@ -262,11 +192,11 @@ func theActAndItsAsk(t *testing.T) { {strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"}, {strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"}, } { - if got := headlineOf(tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { + if got := headlineOf("Run", tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { t.Errorf("headline %q, want %q", got, tc.want) } } - if got := headlineOf("wipe", "n1", false); got != "Run wipe on n1? Part shown" { + if got := headlineOf("Run", "wipe", "n1", false); got != "Run wipe on n1? Part shown" { t.Errorf("headline %q", got) } } @@ -314,9 +244,9 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Errorf("explanation %q", a.Explanation) } var k operatorchannelclient.Kept - _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) - if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.vals["n1.act."+d]) != `"`+a.ID+`"` { - t.Errorf("kept %+v, act key %s", k, w.state.vals["n1.act."+d]) + _ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k) + if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.Vals["n1.act."+d]) != `"`+a.ID+`"` { + t.Errorf("kept %+v, act key %s", k, w.state.Vals["n1.act."+d]) } }}, {"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) { @@ -385,10 +315,10 @@ func TestTheRefusalsAndAnswers(t *testing.T) { 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) + _ = 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 + 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) } @@ -495,7 +425,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { 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) + _ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k) if !k.Recorded || !k.Acted { t.Fatalf("kept %+v", k) } @@ -517,7 +447,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("%v", err) } w.g.d.Publish = pub - w.state.vals = map[string]json.RawMessage{} + w.state.Vals = map[string]json.RawMessage{} _, _ = w.do(call("wipe", `{}`)) w.g.d.Record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") } if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the router's record of its ask") || w.runs != 0 { @@ -546,7 +476,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { {"§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") + 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) } @@ -557,7 +487,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { if w.findings.raised[keys.EscapeName("n1.approvals")] != KindApprovalsUnreadable { t.Fatalf("findings %v", w.findings.raised) } - w.state.fail = nil + 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) diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go index 43e1bc9..60a27da 100644 --- a/node-tools/internal/approval/ask.go +++ b/node-tools/internal/approval/ask.go @@ -70,7 +70,7 @@ func (g *Gate) askFor(c Call, digest, id string) seat.Ask { if c.Seat != "" { name = c.Seat + "." + c.Name } - headline := headlineOf(name, g.d.Node, whole) + headline := headlineOf("Run", name, g.d.Node, whole) first, _, _ := strings.Cut(strings.TrimSpace(c.Description), "\n") if first == "" { first = name @@ -92,22 +92,23 @@ func (g *Gate) askFor(c Call, digest, id string) seat.Ask { Level: operatorchannelclient.Approve, Binds: digest}}} } -// headlineOf is "Run on ?", "Run ?" when that is longer than the router allows, and the names cut with -// "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). -func headlineOf(name, node string, whole bool) string { +// headlineOf is " on ?", " ?" when that is longer than the router allows, and the names cut +// with "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). verb is "Run" +// for a call and "Wait on" for a wait (design 34 every-event §35). +func headlineOf(verb, name, node string, whole bool) string { suffix := "" if !whole { suffix = " Part shown" } max := operatorchannelclient.HeadlineLength fits := func(s string) bool { return len([]rune(s)) <= max } - if h := "Run " + name + " on " + node + "?" + suffix; fits(h) { + if h := verb + " " + name + " on " + node + "?" + suffix; fits(h) { return h } - if h := "Run " + name + "?" + suffix; fits(h) { + if h := verb + " " + name + "?" + suffix; fits(h) { return h } - room := max - len([]rune("Run …?"+suffix)) + room := max - len([]rune(verb+" …?"+suffix)) r := []rune(name) if room < 1 { room = 1 @@ -115,7 +116,7 @@ func headlineOf(name, node string, whole bool) string { if len(r) > room { r = r[:room] } - return "Run " + string(r) + "…?" + suffix + return verb + " " + string(r) + "…?" + suffix } // firstCall is a marked call whose act has no ask open: refused at once when its arguments may not be asked (§9), else @@ -140,7 +141,15 @@ func (g *Gate) firstCall(c Call) (any, error) { // the first answer (§12), always an error, never a result. func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince string) error { id := newID() - a := g.askFor(c, digest, id) + if err := g.keepAsk(c, act, digest, g.askFor(c, digest, id)); err != nil { + return err + } + return g.firstAnswer(c, id, digest, markedSince) +} + +// keepAsk raises an ask and keeps it in the runner's state with the act and the whole arguments, its act's key naming it +// (§10, §13); refused, as an error, when it cannot be. +func (g *Gate) keepAsk(c Call, act operatorchannelclient.Act, digest string, a seat.Ask) error { cl := g.client(c.Module) if size := keptSize(a, act, c.Args); size > MostKeptValue { return g.refused(c, digest, fmt.Sprintf("its ask would keep %d bytes, more than the %d a value of the state %s holds", size, MostKeptValue, StateName)) @@ -148,15 +157,15 @@ func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince if err := cl.Ask(a); err != nil { return g.refused(c, digest, "the ask could not be raised: "+err.Error()) } - if err := g.keepCall(id, act, c.Args); err != nil { + if err := g.keepCall(a.ID, act, c.Args); err != nil { g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) } - if _, err := g.d.State.Put(g.actKey(digest), id); err != nil { + if _, err := g.d.State.Put(g.actKey(digest), a.ID); err != nil { g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) } - return g.firstAnswer(c, id, digest, markedSince) + return nil } // keptSize is the bytes the kept ask would take. diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 787014a..315db22 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -2,6 +2,7 @@ package approval import ( "context" + "encoding/json" "fmt" "sort" "strings" @@ -55,6 +56,12 @@ type Deps struct { Bounds Bounds // Holder says whether a module holds a seat, from the assigner's view of the seats' holders the runner follows (§19). Holder func(seat string) (bool, error) + // Build is the tool runner's own fingerprint, read from its MESH_FINGERPRINT at its start: the build a marked wait's + // act binds; empty while unknown, when every marked wait is refused (design 34 every-event §35). + Build string + // Hear waits within the bound for the first event on the subjects whose body match takes, or the caller's going: a + // marked wait's ask's `decided` (every-event §36); false when none came. + Hear func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) // Go runs what goes on beside the call (a run's recording); nil is a goroutine. Go func(func()) } diff --git a/node-tools/internal/approval/marks.go b/node-tools/internal/approval/marks.go index a61d59a..824ef6e 100644 --- a/node-tools/internal/approval/marks.go +++ b/node-tools/internal/approval/marks.go @@ -29,6 +29,9 @@ type Catalog interface { Newest(module string) (catalogseat.CatalogEntry, bool) // Definitions are the seat definitions the catalog half carries, by seat. Definitions() map[string]seatdef.Definition + // Entries are the newest entry of every module in every followed branch's half, a marked wait's marks (lead answer + // A-168). + Entries() []catalogseat.CatalogEntry } // Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and @@ -89,61 +92,67 @@ func Derive(c Catalog, module, fingerprint string) Marks { entries = append(entries, e) } for _, e := range entries { - source := "entry " + e.Module + " " + orNone(e.Fp) - var said marked - if raw, err := json.Marshal(e.Manifest); err == nil { - _ = json.Unmarshal(raw, &said) - } - serves := map[string]bool{} - for _, sv := range e.Serves { - serves[sv] = true - seat, verb, _ := strings.Cut(sv, ".") - for _, v := range defs[seat].Verbs { - if v.Name == verb && v.NeedsApproval { - m.Verbs[sv] = "seat " + seat - } + m.add(e, defs) + } + return m +} + +// add takes one entry's marks into m: its `needs-approval` (a tool, a verb it serves or an event it declares), the seat +// definitions' marks of every verb it serves, and every stream it declares with a `grant`, each with its source. +func (m *Marks) add(e catalogseat.CatalogEntry, defs map[string]seatdef.Definition) { + source := "entry " + e.Module + " " + orNone(e.Fp) + var said marked + if raw, err := json.Marshal(e.Manifest); err == nil { + _ = json.Unmarshal(raw, &said) + } + serves := map[string]bool{} + for _, sv := range e.Serves { + serves[sv] = true + seat, verb, _ := strings.Cut(sv, ".") + for _, v := range defs[seat].Verbs { + if v.Name == verb && v.NeedsApproval { + m.Verbs[sv] = "seat " + seat } } - events := map[string]bool{} - for _, ev := range said.Emits { + } + events := map[string]bool{} + for _, ev := range said.Emits { + events[ev] = true + } + for _, s := range said.Streams { + for _, ev := range s.Events { events[ev] = true } - for _, s := range said.Streams { - for _, ev := range s.Events { - events[ev] = true + } + for _, name := range said.NeedsApproval { + switch { + case serves[name]: + if _, bySeat := m.Verbs[name]; !bySeat { + m.Verbs[name] = source + } + case events[name]: + for _, s := range eventSubjects(e.Module, name) { + m.Subjects[s] = source } + default: + m.Tools[name] = source + } + } + for _, s := range said.Streams { + if len(s.Grant) == 0 || string(s.Grant) == "null" { + continue } - for _, name := range said.NeedsApproval { - switch { - case serves[name]: - if _, bySeat := m.Verbs[name]; !bySeat { - m.Verbs[name] = source - } - case events[name]: - for _, s := range eventSubjects(e.Module, name) { - m.Subjects[s] = source - } - default: - m.Tools[name] = source - } + wire := make([]string, 0, len(s.Events)) + for _, ev := range s.Events { + wire = append(wire, buscompose.WirePattern(ev)) } - for _, s := range said.Streams { - if len(s.Grant) == 0 || string(s.Grant) == "null" { - continue - } - wire := make([]string, 0, len(s.Events)) - for _, ev := range s.Events { - wire = append(wire, buscompose.WirePattern(ev)) - } - for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { - m.Subjects[sub] = source - if !strings.HasSuffix(sub, ">") { - m.Subjects[sub+".>"] = source - } + for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { + m.Subjects[sub] = source + if !strings.HasSuffix(sub, ">") { + m.Subjects[sub+".>"] = source } } } - return m } // eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go index 85053f6..39573cf 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -32,6 +32,13 @@ func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) { return e, ok } func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } +func (c *fakeCatalog) Entries() []catalogseat.CatalogEntry { + var out []catalogseat.CatalogEntry + for _, e := range c.newest { + out = append(out, e) + } + return out +} func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry { return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest} diff --git a/node-tools/internal/approval/wait.go b/node-tools/internal/approval/wait.go new file mode 100644 index 0000000..7fa963c --- /dev/null +++ b/node-tools/internal/approval/wait.go @@ -0,0 +1,296 @@ +package approval + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// Wait is one mesh_wait call as the gate judges it (design 34 every-event §34 to §36): the event names given, the subjects +// they became (after `from`), the filter and the timeout. +type Wait struct { + Names []string + From string + Subjects []string + Filter map[string]any + Timeout time.Duration +} + +// Waited is a wait through the gate (every-event §34 to §36). An unmarked wait is heard at once by hear, given how long it +// may wait. A marked one is heard only on the operator's warrant for that one wait, its timeout running from then; with +// none it subscribes to nothing, its ask raised once under the runner's own module's name, and is answered as an error at +// its timeout, when its caller goes, or as soon as the router's record says the ask was chosen, never in a hot loop. c is +// the call as the console makes it: its address, its tool's name, its caller and its arguments as given. +func (g *Gate) Waited(ctx context.Context, c Call, w Wait, hear func(within time.Duration) (any, error)) (any, error) { + deadline := g.d.Now().Add(w.Timeout) + // A wait from a seat is never marked, and a seat's event never is (§32, §34). + if w.From == "seat" || !outsideSeats(w.Subjects) { + return hear(w.Timeout) + } + if g.d.Catalog == nil { + return nil, g.holdRefused(ctx, deadline, "this wait is refused: the tool runner reads no catalog half, so the marks are not read") + } + if err := g.d.Catalog.Ready(); err != nil { + // Before the marks are read nothing is asked: the wait waits for the first view within its own timeout and is + // then judged, and is refused at its timeout when it does not come (§34). + if g.d.Wait != nil { + err = g.waitCatalog(ctx, w.Timeout) + } + if err != nil { + return nil, g.holdRefused(ctx, deadline, fmt.Sprintf("this wait is refused at its timeout: the marks are not yet read (%v); "+ + "mesh_first_views says what the tool runner waits for", err)) + } + } + if !overlapsAny(w.Subjects, g.waitMarks()) { + return hear(deadline.Sub(g.d.Now())) + } + c.Module, c.Seat, c.Fingerprint = g.d.Runner, "", g.d.Build + if c.Fingerprint == "" { + return nil, g.holdRefused(ctx, deadline, "this wait needs the operator's approval, and the tool runner's own fingerprint is "+ + "unknown (its MESH_FINGERPRINT was not set at its start), so no warrant can bind its build") + } + if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in").Error()) + } + if why := refusedUnasked(c.Args); why != "" { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error()) + } + if why := g.held(); why != "" { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error()) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error()) + } + digest, err := operatorchannelclient.ActDigest(act) + if err != nil { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error()) + } + named, err := g.d.State.Get(g.actKey(digest)) + if err != nil { + g.stateFailed(err) + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()).Error()) + } + g.stateRead() + anew := func() (any, error) { + id := newID() + if err := g.keepAsk(c, act, digest, g.askForWait(c, w, digest, id)); err != nil { + return nil, g.holdRefused(ctx, deadline, err.Error()) + } + return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, true) + } + if named == nil { + return anew() + } + var id string + if err := json.Unmarshal(named.Value, &id); err != nil || id == "" { + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)).Error()) + } + rec, err := g.record(c.Module, id) + switch { + case err != nil: + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()).Error()) + case rec == nil, rec.State == "open", rec.State == "held-back": + return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, false) + case rec.State == "refused": + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)).Error()) + case rec.State != "chosen": + return anew() // expired, cancelled, replaced: asked again (§15, §17) + } + wt := rec.Warrant + if wt == nil { + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant").Error()) + } + at, _ := operatorchannelclient.Time(wt.At) + if wt.Option == OptionDecline { + if g.d.Now().Before(at.Add(DeclinedFor)) { + return nil, g.holdRefused(ctx, deadline, declinedWords(id, at)) + } + return anew() + } + if g.d.Now().After(at.Add(RunWithin)) { + return anew() + } + var answer any + var ran error + h, err := g.client(c.Module).Take(*wt, func(o seat.Option, _ seat.Warrant) error { + if err := operatorchannelclient.Performs(o, act); err != nil { + ran = err + return err + } + // On the warrant it subscribes, and the wait's timeout runs from then (§35). + answer, ran = hear(w.Timeout) + return nil + }) + switch { + case h == operatorchannelclient.Done: + return anew() // already run: one warrant is one wait (§35) + case h == operatorchannelclient.Refused: + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its warrant does not hold: "+errText(err)).Error()) + case h == "" && err != nil: + g.stateFailed(err) + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()).Error()) + case err != nil: + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, err.Error()).Error()) + } + return g.ran(c, id, rec, answer, ran) +} + +// outsideSeats says a subject is not a seat's: only such a subject can be marked (§32, §34). +func outsideSeats(subjects []string) bool { + for _, s := range subjects { + if !strings.HasPrefix(s, "mesh.seat.") { + return true + } + } + return false +} + +// waitMarks are the marked subjects of every module the catalog half lists (§33): the newest entry of every followed +// branch, and the served entry of each module this runner serves (lead answer A-168), so a mark is never lost. +func (g *Gate) waitMarks() map[string]string { + m := Marks{Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}} + defs := g.d.Catalog.Definitions() + for _, e := range g.d.Catalog.Entries() { + m.add(e, defs) + } + g.mu.Lock() + served := make(map[string]string, len(g.served)) + for module, fp := range g.served { + served[module] = fp + } + g.mu.Unlock() + for _, fp := range served { + if e, ok, _ := g.d.Catalog.Served(fp); ok { + m.add(e, defs) + } + } + return m.Subjects +} + +// overlapsAny says a subject a wait subscribes overlaps a marked subject (§34). +func overlapsAny(subjects []string, marked map[string]string) bool { + for _, s := range subjects { + for mk := range marked { + if buscompose.Overlaps(s, mk) { + return true + } + } + } + return false +} + +// waitCatalog waits for the catalog half's first view within the bound, or until the caller goes. +func (g *Gate) waitCatalog(ctx context.Context, within time.Duration) error { + done := make(chan error, 1) + go func() { done <- g.d.Wait(within) }() + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// pause waits until the deadline or the caller's going. +func (g *Gate) pause(ctx context.Context, deadline time.Time) { + if left := deadline.Sub(g.d.Now()); left > 0 { + select { + case <-g.d.After(left): + case <-ctx.Done(): + } + } +} + +// holdRefused is a refused marked wait, said at its timeout or when its caller goes, never at once, so a watcher's loop +// is never a hot one (§36). +func (g *Gate) holdRefused(ctx context.Context, deadline time.Time, words string) error { + g.pause(ctx, deadline) + if !strings.HasPrefix(words, "Not run") { + words = "Not run: " + words + } + return fmt.Errorf("%s", words) +} + +// holdAsk holds a marked wait whose ask is open, subscribing to nothing but its ask's `decided` (§36): it answers, as an +// error, at its timeout, when its caller goes, or once the router's record says the ask was chosen, the event only waking it. +func (g *Gate) holdAsk(ctx context.Context, deadline time.Time, module, id, digest string, first bool) error { + for { + left := deadline.Sub(g.d.Now()) + heard := false + if left > 0 && ctx.Err() == nil { + var err error + if g.d.Hear != nil { + heard, err = g.d.Hear(ctx, []string{seat.SubjectDecided(module)}, left, func(body json.RawMessage) bool { + var w seat.Warrant + return json.Unmarshal(body, &w) == nil && w.Ask == id + }) + } + if g.d.Hear == nil || err != nil { + g.pause(ctx, deadline) + } + } + rec, _ := g.record(module, id) + if heard && (rec == nil || rec.State != "chosen") { + continue // the event only wakes it: the record says nothing chosen yet + } + return g.waitWords(rec, id, digest, first) + } +} + +// waitWords are §12's and §17's words for a marked wait not run, "this wait" for "the call" (§36). +func (g *Gate) waitWords(rec *seat.AsksByAskerIDValue, id, digest string, first bool) error { + switch { + case rec == nil: + return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make this wait again later", id, Fingerprint(digest)) + case rec.State == "chosen" && rec.Warrant != nil && rec.Warrant.Option == OptionDecline: + at, _ := operatorchannelclient.Time(rec.Warrant.At) + return fmt.Errorf("%s", declinedWords(id, at)) + case rec.State == "chosen", first: + return fmt.Errorf("Not run: this wait needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make this wait "+ + "again within 30 minutes of the approval; the ask expires at %s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened, + id, Fingerprint(digest), rec.Expires) + case rec.State == "open", rec.State == "held-back": + return fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s", + id, Fingerprint(digest), rec.Expires) + } + return fmt.Errorf("Not run: the ask %s ended %s; make this wait again to ask anew", id, rec.State) +} + +func declinedWords(id string, at time.Time) string { + return fmt.Sprintf("Not run: the operator declined ask %s; this wait is not asked again until %s", id, + operatorchannelclient.FormatTime(at.Add(DeclinedFor))) +} + +// askForWait is a marked wait's ask (§35): "Wait on on ?", shortened as §10 says, and an explanation naming +// every event name one per line, the filter, and the bound as the timeout in minutes, then the act's fingerprint. +func (g *Gate) askForWait(c Call, w Wait, digest, id string) seat.Ask { + a := g.askFor(c, digest, id) + filter, whole := "none", true + if len(w.Filter) > 0 { + raw, _ := json.Marshal(w.Filter) + filter = string(raw) + if r, ok := outward.Check(filter); !ok { + filter, whole = shapeOf(r), false + } + } + a.Headline = headlineOf("Wait on", strings.Join(w.Names, ", "), g.d.Node, whole) + lines := append([]string{"Wait on " + g.d.Node + ", called through the mesh's tools, which cannot tell who called, for:"}, w.Names...) + lines = append(lines, "Filter: "+filter, fmt.Sprintf("Timeout: %s minutes", minutes(w.Timeout)), "Fingerprint "+Fingerprint(digest)) + a.Explanation = strings.Join(lines, "\n") + a.Options[0].Does = "the same wait, made again within 30 minutes, subscribes once, its timeout running from then" + a.Options[1].Does = "nothing is heard; the same wait is not asked again for 10 minutes" + return a +} + +// minutes is a timeout in minutes, as few digits as say it. +func minutes(d time.Duration) string { + return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.2f", d.Minutes()), "0"), ".") +} diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index a93a16d..541e620 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -20,6 +20,8 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + + "github.com/novox/mesh-tools/node-tools/internal/approval" ) // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last @@ -107,9 +109,42 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin } } + 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(err.Error()) + } + return answerText(answer) +} + +// 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 failure("mesh_wait could not wait: " + err.Error()) + return nil, fmt.Errorf("mesh_wait could not wait: %w", err) } var answer map[string]any if !ok { @@ -117,7 +152,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin "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, ", "), known.source) + "declares that event", strings.Join(undeclared, ", "), source) } } else { name, from, _ := buscompose.EventName(heard.Subject) @@ -139,7 +174,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin if len(refused) > 0 { answer["refused"] = refused } - return answerText(answer) + return answer, nil } // halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 54e2aaf..1eb7263 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -10,11 +10,16 @@ import ( "reflect" "sort" "strings" + "sync" "testing" "time" + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + opseat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -197,7 +202,7 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) { // 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) + s, mesh := waitingRuntime(t) var seventeen []any for i := 0; i < 17; i++ { seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i)) @@ -274,8 +279,232 @@ func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { !strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") { t.Errorf("a wait the bus refused whole: %s", text) } + // Design 34 every-event §37: a wait whose subjects overlap a marked event's, or a stream's with a `grant`, runs only on the + // operator's warrant for that one wait; with none it subscribes nothing, asks once and answers at its timeout or the choice. + markedWaits(t, s, mesh) } +// markedWaits are §37's rows, against a gate on the test bus, the router's records written there as the router writes them. +func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { + gw := newGatedWaits(t, s, mesh, "src2:runner") + g := gw.surface + took := func(args map[string]any) (string, bool, time.Duration) { + start := time.Now() + text, isErr := callWait(g, args) + return text, isErr, time.Since(start) + } + // A fixture module's marked event, no warrant: nothing of it subscribed (only its ask's decided), asked once, §12's + // words at its timeout; the same wait again asks nothing new. + before := g.conn.Subscriptions() + go func() { + listening(t, g, before, 1) + mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 1}) + }() + text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}) + if !isErr || !strings.Contains(text, "this wait needs the operator's approval. Asked on telegram") || gw.asks() != 1 || + d < time.Second || strings.Contains(text, "heard") { + t.Errorf("a marked event's wait, no warrant: %s after %s, %d asks", text, d, gw.asks()) + } + if text, isErr, _ = took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr || + !strings.Contains(text, "still waiting") || gw.asks() != 1 { + t.Errorf("the same marked wait again: %s, %d asks", text, gw.asks()) + } + // A stream with a `grant` is marked whole; a name with the node after a marked event's name is marked. + for i, name := range []string{"mail.inbox", "mail.message.desk"} { + if text, isErr, _ := took(map[string]any{"subjects": []any{name}, "timeout": 1}); !isErr || !strings.Contains(text, "Not run") || gw.asks() != 2+i { + t.Errorf("%s: %s, %d asks", name, text, gw.asks()) + } + } + // The same names from a seat ask nothing. + if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "from": "seat", "timeout": 1}); isErr || + !strings.Contains(text, `"timed-out": true`) || gw.asks() != 3 { + t.Errorf("from a seat: %s, %d asks", text, gw.asks()) + } + // The most timeout a wait takes, its ask chosen while it waits: answered at the choice; the same call made then + // subscribes and hears the event published after it, answered with its approval; a warrant for other names, another + // filter or another timeout is refused, asked anew. + long := map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 600} + done := make(chan string, 1) + go func() { text, _, d := took(long); done <- fmt.Sprintf("%s after %s", text, d) }() + mt.Until(t, func() error { + if gw.asks() < 4 { + return os.ErrNotExist + } + return nil + }) + gw.approve(t) + select { + case got := <-done: + if !strings.Contains(got, "make this wait again within 30 minutes") { + t.Errorf("a long wait chosen while it waits: %s", got) + } + case <-time.After(10 * time.Second): + t.Fatal("a long marked wait was not answered at the choice") + } + before = g.conn.Subscriptions() + go func() { + listening(t, g, before, 6) + mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 2}) + }() + if text, isErr, _ := took(long); isErr || !strings.Contains(text, `"approval": "run on the operator's approval`) || + !strings.Contains(text, `"subject": "mail.message"`) { + t.Errorf("the same wait on its warrant: %s", text) + } + for _, other := range []map[string]any{ + {"subjects": []any{"mail.message", "mail.inbox"}, "filter": map[string]any{"n": 2}, "timeout": 1}, + {"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 3}, "timeout": 1}, + {"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 2}, + } { + asked := gw.asks() + if text, isErr, _ := took(other); !isErr || !strings.Contains(text, "Not run") || gw.asks() != asked+1 { + t.Errorf("another wait than the warrant's %v: %s", other, text) + } + } + // Before the catalog half is read nothing is asked: judged when its first view comes within the timeout, refused at + // the timeout when it does not. + gw.cat.mu.Lock() + gw.cat.ready = errors.New("no first view yet") + gw.cat.mu.Unlock() + asked := gw.asks() + if text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr || + !strings.Contains(text, "the marks are not yet read") || gw.asks() != asked || d < time.Second { + t.Errorf("before the catalog half is read: %s after %s", text, d) + } + gw.cat.came = make(chan chan struct{}) + go func() { close(<-gw.cat.came) }() + if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 9}, "timeout": 1}); !isErr || + !strings.Contains(text, "this wait needs the operator's approval") || gw.asks() != asked+1 { + t.Errorf("the catalog half read within the timeout: %s", text) + } + // While the runner's own fingerprint is unknown a marked wait is refused, saying so. + unknown := newGatedWaits(t, s, mesh, "") + if text, isErr := callWait(unknown.surface, map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr || + !strings.Contains(text, "own fingerprint is unknown") || unknown.asks() != 0 { + t.Errorf("the runner's fingerprint unknown: %s", text) + } +} + +// gatedWaits is the surface with the runner's approval gate: the fixture module mail marks its event message and declares +// the stream inbox with a grant; the router takes each ask as it is published, its records on the test bus. +type gatedWaits struct { + surface *Surface + cat *waitCatalog + records nats.KeyValue + mu sync.Mutex + asked []opseat.Ask + say *bus.Conn +} + +func newGatedWaits(t *testing.T, s *Surface, mesh *mt.Mesh, build string) *gatedWaits { + gw := &gatedWaits{cat: &waitCatalog{entries: []catalogseat.CatalogEntry{{Module: "mail", Fp: "src2:mail", Manifest: map[string]any{ + "emits": []any{"message"}, "needs-approval": []any{"message"}, + "streams": []any{map[string]any{"name": "inbox", "events": []any{"inbox.*"}, "grant": map[string]any{"subscribe": []any{"x"}}}}}}}}, + records: mesh.Bucket(t, "operator-channel-asks"), say: connect(t, "messenger", "")} + gate := approval.New(approval.Deps{Node: "desk", Runner: "node-tools", Build: build, Catalog: gw.cat, Wait: gw.cat.wait, + State: mt.NewState(), Holder: func(string) (bool, error) { return true, nil }, + Hear: func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) { + _, ok, _, err := s.conn.HearFirst(ctx, subjects, within, match) + return ok, err + }, + Publish: func(module, subject string, body any, id string) error { + a, ok := body.(opseat.Ask) + if subject != opseat.AcceptSubjectAsk(module) || !ok { + return nil + } + gw.mu.Lock() + gw.asked = append(gw.asked, a) + gw.mu.Unlock() + gw.write(t, opseat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", Headline: a.Headline, + Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}}) + return nil + }, + Record: func(module, id string) (*stdio.StateEntry, error) { + e, err := gw.records.Get(module + "." + id) + if errors.Is(err, nats.ErrKeyNotFound) { + return nil, nil + } else if err != nil { + return nil, err + } + return &stdio.StateEntry{Key: e.Key(), Value: e.Value(), Revision: e.Revision()}, nil + }}) + gw.surface = NewSurface(s.conn, busSource{s.conn}, "desk.node-tools") + gw.surface.Approvals = gateApprovals{gate} + return gw +} + +func (gw *gatedWaits) write(t *testing.T, rec opseat.AsksByAskerIDValue) { + raw, _ := json.Marshal(rec) + if _, err := gw.records.Put(rec.Key, raw); err != nil { + t.Error(err) + } +} + +func (gw *gatedWaits) asks() int { + gw.mu.Lock() + defer gw.mu.Unlock() + return len(gw.asked) +} + +// approve writes the router's record of the newest ask as a person's Approve, and says its decided. +func (gw *gatedWaits) approve(t *testing.T) { + gw.mu.Lock() + a := gw.asked[len(gw.asked)-1] + gw.mu.Unlock() + gw.write(t, opseat.AsksByAskerIDValue{Asker: "node-tools", Key: "node-tools." + a.ID, State: "chosen", Expires: a.Expires, + OfferedOn: []string{"telegram"}, Warrant: &opseat.Warrant{Ask: a.ID, Asker: "node-tools", Outcome: operatorchannelclient.OutcomeChosen, + Option: approval.OptionApprove, Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(time.Now()), + AskDigest: operatorchannelclient.Digest(a), Channel: "telegram", + By: &opseat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}}) + if err := gw.say.Say(opseat.SubjectDecided("node-tools"), []byte(`{"ask":"`+a.ID+`"}`)); err != nil { + t.Fatal(err) + } +} + +// gateApprovals is the gate as the surface holds it: mesh_approvals' reads unused here, mesh_wait's gate. +type gateApprovals struct{ *approval.Gate } + +func (gateApprovals) Fill() (uint64, int64, error) { return 0, 0, nil } + +// waitCatalog is a catalog half as a fixture: its entries, read or not; with came set, its first view comes once a wait +// waits for it (waiting is told on it, then it closes). +type waitCatalog struct { + entries []catalogseat.CatalogEntry + mu sync.Mutex + ready error + came chan chan struct{} +} + +func (c *waitCatalog) Ready() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.ready +} +func (c *waitCatalog) wait(within time.Duration) error { + if c.Ready() == nil { + return nil + } + if c.came != nil { + view := make(chan struct{}) + c.came <- view + <-view + c.mu.Lock() + c.ready = nil + c.mu.Unlock() + return nil + } + <-time.After(within) // the bound of the runner's own wait for the first view + return c.Ready() +} +func (c *waitCatalog) Revision() string { return "7" } +func (c *waitCatalog) Served(string) (catalogseat.CatalogEntry, bool, string) { + return catalogseat.CatalogEntry{}, false, "not-found" +} +func (c *waitCatalog) Newest(string) (catalogseat.CatalogEntry, bool) { + return catalogseat.CatalogEntry{}, false +} +func (c *waitCatalog) Definitions() map[string]seatdef.Definition { return nil } +func (c *waitCatalog) Entries() []catalogseat.CatalogEntry { return c.entries } + // The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded. func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { var names []string diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index 45e1b6c..f230589 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -78,6 +78,20 @@ func (c approvalCatalog) Definitions() map[string]seatdef.Definition { return c.defs() } +func (c approvalCatalog) Entries() []catalogseat.CatalogEntry { + halves, err := c.v.Halves() + if err != nil { + return nil + } + var out []catalogseat.CatalogEntry + for _, half := range halves { + for _, e := range half.Entries { + out = append(out, e) + } + } + return out +} + type notFollowed struct{} func (notFollowed) Error() string { return "the catalog half is not followed by this runner" } diff --git a/node-tools/internal/meshtest/state.go b/node-tools/internal/meshtest/state.go new file mode 100644 index 0000000..87c9e4d --- /dev/null +++ b/node-tools/internal/meshtest/state.go @@ -0,0 +1,87 @@ +package meshtest + +import ( + "encoding/json" + "sort" + "sync" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// State is a module's state as a fixture, the shape a gate's Store takes: Fail makes every read and write fail, as a bus +// refusing them; Vals are its values by key. +type State struct { + mu sync.Mutex + Vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 + Fail error +} + +// NewState is an empty State. +func NewState() *State { return &State{Vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} } + +func (m *State) Get(k string) (*stdio.StateEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return nil, m.Fail + } + v, ok := m.Vals[k] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil +} +func (m *State) Create(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return 0, m.Fail + } + if _, ok := m.Vals[k]; ok { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *State) Update(k string, v any, rev uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return 0, m.Fail + } + if rev == 0 || m.revs[k] != rev { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *State) Put(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return 0, m.Fail + } + return m.put(k, v) +} +func (m *State) Keys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return nil, m.Fail + } + var out []string + for k := range m.Vals { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} +func (m *State) put(k string, v any) (uint64, error) { + raw, ok := v.(json.RawMessage) + if !ok { + raw, _ = json.Marshal(v) + } + m.n++ + m.Vals[k], m.revs[k] = raw, m.n + return m.n, nil +} -- 2.54.0 From afc327392ec8cab7f7022751babf6b4f1b483aa4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:45:53 +0100 Subject: [PATCH 30/44] A-8 pending: an acted, unrecorded ask is pending in the runner's findings-stated until warranted is made again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At each start (and when the hand-act seat's holder is heard again) the gate pends each acted, unrecorded ask's call-unanswered key in its held writer, bound five minutes, and ends the pending once its warranted is made again, so a restart neither clears nor raises the condition of a run still unrecorded. Builds: design 46 7-approval-mark §16; design 47 G §384; lead answer A-154 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/approval/approval_test.go | 13 +++++++ node-tools/internal/approval/marks_test.go | 17 +++++++++- node-tools/internal/approval/record.go | 34 ++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index f1cf18f..ef5907f 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -14,6 +14,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/views" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -419,8 +420,20 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("findings %v", w.findings.raised) } w.warranted = nil + // At a start each acted, unrecorded ask is pending in the runner's findings-stated until warranted is made again + // (design 47 G §384). + pendingKey := views.FindingKey("machine", keys.EscapeName("n1."+a.ID), KindCallUnanswered) + warranted := w.g.d.Warranted + var pendingAtCall bool + w.g.d.Warranted = func(asker, ask string, open func() (bool, error)) error { + _, pendingAtCall = w.findings.pends[pendingKey] + return warranted(asker, ask, open) + } w.g.Recover() // a start, or the hand-act seat's holder heard again w.g.Recover() + if !pendingAtCall || len(w.findings.pended) != 1 || len(w.findings.pends) != 0 || w.findings.pended[0] != pendingKey { + t.Fatalf("pending at the call %v, pended %v, pending after %v", pendingAtCall, w.findings.pended, w.findings.pends) + } if len(w.recorded) != 1 || w.recorded[0] != "shell."+a.ID || len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 { t.Fatalf("recorded %v, findings %v, bounds %v", w.recorded, w.findings.raised, w.bounds.keys) } diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go index 39573cf..000b379 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -7,6 +7,7 @@ import ( condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/seatdef" @@ -128,7 +129,21 @@ func TestTheMarks(t *testing.T) { } // findings is the runner's held writer as a fixture: each finding raised now, by id. -type findings struct{ raised map[string]string } +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 { diff --git a/node-tools/internal/approval/record.go b/node-tools/internal/approval/record.go index 20af7ab..2826bed 100644 --- a/node-tools/internal/approval/record.go +++ b/node-tools/internal/approval/record.go @@ -10,6 +10,8 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" ) // The bounds of a run's recording (§13, §16; lead answers A-157, A-158). @@ -84,7 +86,9 @@ func (g *Gate) recordRun(module, id string) { g.d.Logf("[mesh-tools] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id) return } - if err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }); err != nil { + err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }) + g.judged(id) // made again: its answer, or the bound of its key, judges it from now + if err != nil { g.d.Logf("[mesh-tools] mesh-hand-acts.warranted has not answered for ask %s (%v): asked again when the hand-act seat's "+ "holder is heard again and at the next start", id, err) return @@ -181,6 +185,7 @@ func (g *Gate) Recover() { continue } module := moduleOf(k) + g.pend(id) g.d.Go(func() { g.recordRun(module, id) }) } } @@ -220,5 +225,32 @@ func (g *Gate) WatchBounds() (func(), error) { }) } +// Pender is the runner's held writer's pending (views.HeldWriter's Pend and Judged): the keys it cannot judge yet since its +// start, named in its `findings-stated` (design 47 G §384). +type Pender interface { + Pend(views.PendingKey) + Judged(key string) +} + +// pend names an acted, unrecorded ask's `call-unanswered` in the runner's `findings-stated` until its `warranted` is made +// again (§16; design 47 G §384), its bound the five minutes the finding waits. +func (g *Gate) pend(id string) { + if p, ok := g.d.Findings.(Pender); ok { + p.Pend(views.PendingKey{Key: g.unansweredFinding(id), Since: g.d.Now().UTC(), Bound: UnansweredAfter.String()}) + } +} + +// judged ends that pending. +func (g *Gate) judged(id string) { + if p, ok := g.d.Findings.(Pender); ok { + p.Judged(g.unansweredFinding(id)) + } +} + +// unansweredFinding is the key of an ask's `call-unanswered` in the runner's state findings (lead answer A-154). +func (g *Gate) unansweredFinding(id string) string { + return views.FindingKey(scopeMachine, keys.EscapeName(g.d.Node+"."+id), KindCallUnanswered) +} + // isChanged says a compare-and-set lost to another write. func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } -- 2.54.0 From e7e20e3d13b7d41c763089fd39160865302f33fb Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:49:57 +0100 Subject: [PATCH 31/44] A-165 callers: the tool runner refuses a seat verb's caller outside its effective callers before the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where serveSeats answers a seat's verb it reads the verb from the runner's seat list (seatdef's EffectiveCallers): an unproven caller is refused, and every caller while the seat's definition is unread, names no such verb or opens it to none; a proven caller is among them, since the grants part composes the call subject only for the effective callers. A module's tool on the subject naming no caller is unchanged. Builds: design 47 G §610; lead answers A-165, A-179 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/main.go | 2 +- node-tools/internal/runtime/caller_test.go | 39 +++++++++++++++++++--- node-tools/internal/runtime/gate.go | 38 +++++++++++++++++++++ node-tools/internal/runtime/runtime.go | 16 ++++++--- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 302afde..788dd82 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -85,7 +85,7 @@ func main() { 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)) + 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) diff --git a/node-tools/internal/runtime/caller_test.go b/node-tools/internal/runtime/caller_test.go index 7958b39..46c4c95 100644 --- a/node-tools/internal/runtime/caller_test.go +++ b/node-tools/internal/runtime/caller_test.go @@ -2,9 +2,12 @@ package runtime import ( "encoding/json" + "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/nats-io/nats.go" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -24,7 +27,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T) mesh := mt.New(t) mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}})) nodeTools := connect(t, "node-tools", "anchor") - stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) + stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined) if err != nil { t.Fatal(err) } @@ -62,13 +65,20 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T) } } -// The subject that names no caller is still served, for one release (retired by hq issue 464), and says so: -// the module is named no caller, whatever the body claims. +// deskDefined is the seat list's definition of the fixture seat node-desk, its verb who taking its class's default. +var deskDefined = WithSeatDefinitions(func() map[string]seatdef.Definition { + return map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who"}}}} +}) + +// The subject that names no caller is still served for a module's tool, for one release (retired by hq issue 464), and says +// so: the module is named no caller, whatever the body claims. A seat's verb refuses it before the bundle runs (design 47 G +// §610; lead answers A-165, A-179), and refuses every caller while the seat's definition is unread, names no such verb or +// opens it to none. func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) { mesh := mt.New(t) mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}})) nodeTools := connect(t, "node-tools", "anchor") - stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) + stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined) if err != nil { t.Fatal(err) } @@ -91,8 +101,29 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) { } return err }) + if strings.HasPrefix(subject, "mesh.seat.") { + if !strings.Contains(reply.Error, "node-desk.who takes no unproven caller") || reply.Result.Call != "" { + t.Errorf("%s: an unproven caller of a seat's verb was answered %+v (error %q), want refused before the bundle", subject, reply.Result, reply.Error) + } + continue + } if reply.Error != "" || reply.Result.Caller != "" { t.Errorf("%s: the module was named caller %q (error %q), want none", subject, reply.Result.Caller, reply.Error) } } + for _, c := range []struct { + defs map[string]seatdef.Definition + says string + }{ + {nil, "is not yet read"}, + {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk"}}, "names no verb who"}, + {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Callers: &seatdef.Callers{List: []string{}}}}}}, "open to no caller"}, + {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Class: seatdef.ClassAct}}}}, ""}, + } { + defs := c.defs + err := (&consumers{definitions: func() map[string]seatdef.Definition { return defs }}).admitted("node-desk", "who", "shanks.node-tools") + if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) { + t.Errorf("a proven caller with the definitions %v: %v, want %q", c.defs, err, c.says) + } + } } diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 649bec5..7c8020e 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -1,6 +1,10 @@ package runtime import ( + "fmt" + + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/launch" ) @@ -33,6 +37,40 @@ 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 { diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 5bbea33..15bfdc2 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -16,6 +16,8 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/novox/mesh-tools/node-tools/internal/announce" "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -304,7 +306,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.fingerprintOf, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -707,8 +709,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again // whenever a membership changes, and when again is called because a bundle was loaded again. Whether // this machine holds the seat is the bus's to decide. It answers its stop and again. -func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, fingerprintOf func(string) string, - logf func(string, ...any)) (func(), func()) { +func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, + admitted func(seat, verb, caller string) error, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -747,9 +749,13 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb) continue } - // Through the approval gate, before the bundle (design 46 7-approval-mark §20). + // 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) { + if err := admitted(v.Seat, v.Verb, caller); err != nil { + return nil, err + } return gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb, Fingerprint: fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller) }) @@ -794,6 +800,8 @@ type consumers struct { 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 { -- 2.54.0 From b5c7bf13eaa625934eec3c04cf0ca7c5e86e4247 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:54:49 +0100 Subject: [PATCH 32/44] A-9 tests: a bundle not serving its marked tool is refused at the load; a withheld marked call is said so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's load test gains rows (run with the Python fixture, before the bundles' skip): a bundle whose marks name a tool it does not register serves no tool of its module, and one that registers it serves. mesh_call's failure words move into Surface.callFailed, whose rows hold that only a marked tool reaching no responder names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §4, §6, §22 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/address.go | 26 ++++++----- .../internal/console/noresponders_test.go | 19 +++++++- node-tools/internal/console/wait_test.go | 16 ++++--- node-tools/internal/runtime/runtime_test.go | 45 ++++++++++++++++++- 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 84de6fd..8739f24 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -927,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) @@ -1137,16 +1152,7 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing) } if err != nil { - why := whyItFailed(t.Address, err) - if 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 failure(why) + return failure(s.callFailed(t, err)) } result, unread := resolveSent(s.conn, got.Result) content := []map[string]any{{"type": "text", "text": pretty(result)}} diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go index 51f0c0a..1cbe5c5 100644 --- a/node-tools/internal/console/noresponders_test.go +++ b/node-tools/internal/console/noresponders_test.go @@ -11,7 +11,8 @@ 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); a real no-responders still says +// 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")) @@ -23,4 +24,20 @@ func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { 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/wait_test.go b/node-tools/internal/console/wait_test.go index 1eb7263..e66fe91 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -554,17 +554,23 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { 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 } +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(_, _, _ string) (bool, string) { return false, "" } -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 } +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. diff --git a/node-tools/internal/runtime/runtime_test.go b/node-tools/internal/runtime/runtime_test.go index eb56bfa..775b920 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" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" @@ -192,10 +194,49 @@ func TestEachBundleIsGivenItsOwnEnvironment(t *testing.T) { } } -// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). +// unservingGate is an approval gate whose marks of each module name the tools given, so a bundle not registering them is +// refused at the load (design 46 7-approval-mark §4). +type unservingGate map[string][]string + +func (g unservingGate) Unserved(module, _ string, registered []string) []string { + var missing []string + for _, tool := range g[module] { + if !slices.Contains(registered, tool) { + missing = append(missing, tool) + } + } + return missing +} +func (unservingGate) Run(_ approval.Call, run func() (any, error)) (any, error) { return run() } + +// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). A bundle whose marks name a +// tool it does not register is refused at the load, serving no tool of its module, and one that registers it serves +// (design 46 7-approval-mark §4). func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) { - mt.NeedBundles(t) mesh := mt.New(t) + { + mesh.Issue(t, mt.MembershipOf("marked", "anchor", true, nil)) + mesh.Issue(t, mt.MembershipOf("served", "anchor", true, nil)) + nodeTools := connect(t, "node-tools", "anchor") + asker := connect(t, "console", "workstation") + logs := &mt.Logs{} + stop, err := Run(nodeTools, []Served{{"marked", []string{mt.Fixture("caller-echo.py")}}, {"served", []string{mt.Fixture("caller-echo.py")}}}, + nil, logs.Logf, WithGate(unservingGate{"marked": {"wipe"}, "served": {"who"}})) + if err != nil { + t.Fatal(err) + } + if !logs.Has("marked's bundle", "failed to load: its marks name wipe, which it does not register (design 46 7-approval-mark §4)") { + t.Errorf("a bundle not serving its marked tool was not refused at the load:\n%s", logs.All()) + } + if _, err := call(t, asker, "served.who@anchor", map[string]any{}); err != nil { + t.Errorf("a bundle serving its marked tool: %v", err) + } + if _, err := asker.Ask("marked.who@anchor", map[string]any{}, ""); !errors.Is(err, bus.ErrNoResponders) { + t.Errorf("a refused bundle's tool answered: %v", err) + } + stop() + } + mt.NeedBundles(t) mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil)) nodeTools := connect(t, "node-tools", "anchor") -- 2.54.0 From 7b37e7e1d6a10e1786cb176ce017de09acf5e534 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:02:17 +0100 Subject: [PATCH 33/44] A3c item 2: the gate's whole test on the real bus; the act's key escapes its digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design 46 7-approval-mark §22's integration test: the runner's gate wired as main wires it, on a real bus with stand-ins of the operator channel's router and the hand-act seat: a marked call asked, approved, run once, recorded, listed; a restart between the run and warranted's answer recorded once; a Decline runs nothing; no state bound or no holder of operator-channel runs nothing. It failed first on the act's key: `.act.sha256:` is a key the bus refuses, so every marked call was refused on the live bus. The digest is now escaped with keys.EscapeName (lead answer A-209). The runner's two start tests are one, so the module gains no test (design 47 G §419, §626). Builds: design 46 7-approval-mark §13, §22; design 47 G §419, §626; lead answer A-209 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../node-tools/approvals_integration_test.go | 290 ++++++++++++++++++ node-tools/cmd/node-tools/main_test.go | 11 +- node-tools/internal/approval/approval_test.go | 4 +- node-tools/internal/approval/ask.go | 5 +- 4 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 node-tools/cmd/node-tools/approvals_integration_test.go 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..2e58b24 --- /dev/null +++ b/node-tools/cmd/node-tools/approvals_integration_test.go @@ -0,0 +1,290 @@ +//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/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-tools/node-tools/internal/approval" + "github.com/novox/mesh-tools/node-tools/internal/bus" + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" +) + +// 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) (catalogseat.CatalogEntry, bool, string) { + return wipeEntry, fp == wipeEntry.Fp, "not-found" +} +func (marked) Newest(m string) (catalogseat.CatalogEntry, bool) { return wipeEntry, m == "shell" } +func (marked) Definitions() map[string]seatdef.Definition { return map[string]seatdef.Definition{} } +func (marked) Entries() []catalogseat.CatalogEntry { return []catalogseat.CatalogEntry{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"}, Publish: publishAsk(b.conn), Record: routerRecord(b.conn), + Warranted: warranted(b.conn), Bounds: boundState{conn: b.conn, module: "node-tools"}, Go: record, + Holder: approval.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_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/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index ef5907f..53dfe47 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -246,8 +246,8 @@ func TestTheRefusalsAndAnswers(t *testing.T) { } var k operatorchannelclient.Kept _ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k) - if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.Vals["n1.act."+d]) != `"`+a.ID+`"` { - t.Errorf("kept %+v, act key %s", k, w.state.Vals["n1.act."+d]) + 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) { diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go index 60a27da..70ebadd 100644 --- a/node-tools/internal/approval/ask.go +++ b/node-tools/internal/approval/ask.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" ) // StateName is the runner's own state its asks and claims are kept in, per machine (§13). @@ -50,8 +51,8 @@ func (g *Gate) client(module string) *operatorchannelclient.Client { Record: func(id string) (*stdio.StateEntry, error) { return g.d.Record(module, id) }} } -// actKey is the key naming the newest ask of an act (§13). -func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + digest } +// actKey is the key naming the newest ask of an act (§13), its digest escaped as a key's name is, since the bus holds no `:` in a key (lead answer A-209). +func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + keys.EscapeName(digest) } // askKey is the key the client keeps an ask under (§13). func (g *Gate) askKey(id string) string { return g.d.Node + ".ask." + id } -- 2.54.0 From 2d95c0eb59f0fbd04e0a4e5ae27d3e2d04044786 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:02:18 +0100 Subject: [PATCH 34/44] AM-D3 path: the tool runner states its own fingerprint beside its capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capability reading must be tied to the runner that made it, so the grants part can tell an older runner's reading from the running one's. Builds: design 46 7 §6 (the announcement carries the runner's fingerprint), lead answer A-203 (key `fingerprint`) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/announce/announce.go | 5 +++++ node-tools/internal/runtime/announce_test.go | 5 +++++ node-tools/internal/runtime/runtime.go | 5 ++++- 3 files changed, 14 insertions(+), 1 deletion(-) 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/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index dd58ba0..54b4a34 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -121,6 +121,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") @@ -142,4 +143,8 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } + // 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]) + } } diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 15bfdc2..04c3f9a 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -316,7 +316,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) -- 2.54.0 From ed63e5b026dc4fb89339a5d480cc9962fea65426 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:04:14 +0100 Subject: [PATCH 35/44] A3c kind: the fixture state refuses every key the bus refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval unit table's in-memory state held `.act.sha256:`, a key the live bus refuses, so the table passed while every marked call failed on the bus. meshtest.State now refuses a key outside letters, digits and -/_= in dot-separated names, as the bus does, checked with mesh-sdk's keys.CheckName per name; a row of the refusals table holds it. Builds: design 46 7-approval-mark §13, §22; lead answer A-209 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/approval/approval_test.go | 12 ++++++++ node-tools/internal/meshtest/state.go | 28 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 53dfe47..77cbc66 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -390,6 +390,18 @@ func TestTheRefusalsAndAnswers(t *testing.T) { 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) diff --git a/node-tools/internal/meshtest/state.go b/node-tools/internal/meshtest/state.go index 87c9e4d..4ec6417 100644 --- a/node-tools/internal/meshtest/state.go +++ b/node-tools/internal/meshtest/state.go @@ -2,14 +2,18 @@ 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. +// 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 @@ -18,12 +22,25 @@ type State struct { 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 } @@ -36,6 +53,9 @@ func (m *State) Get(k string) (*stdio.StateEntry, error) { 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 } @@ -47,6 +67,9 @@ func (m *State) Create(k string, v any) (uint64, error) { 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 } @@ -58,6 +81,9 @@ func (m *State) Update(k string, v any, rev uint64) (uint64, error) { 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 } -- 2.54.0 From 5e3639c32fcc9f49488bb3384a6de97e58d6a5f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:25:56 +0100 Subject: [PATCH 36/44] A3e item 1: the tool runner serves through mesh-sdk's gate, translating the contracts for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §677, §700, lead answers A-219, A-224, A-228 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 2 +- .../node-tools/approvals_integration_test.go | 21 +- node-tools/cmd/node-tools/main.go | 14 +- node-tools/internal/approval/act.go | 293 ----------------- node-tools/internal/approval/approval_test.go | 202 +++++------- node-tools/internal/approval/ask.go | 242 -------------- node-tools/internal/approval/closed.go | 119 ------- node-tools/internal/approval/contracts.go | 100 ++++++ node-tools/internal/approval/gate.go | 179 ----------- node-tools/internal/approval/marks.go | 185 ----------- node-tools/internal/approval/marks_test.go | 31 +- node-tools/internal/approval/record.go | 256 --------------- node-tools/internal/approval/run.go | 184 ----------- node-tools/internal/approval/tools.go | 168 ---------- node-tools/internal/approval/wait.go | 296 ------------------ node-tools/internal/console/approvals.go | 2 +- node-tools/internal/console/wait.go | 2 +- node-tools/internal/console/wait_test.go | 42 ++- node-tools/internal/firstview/approval.go | 24 +- node-tools/internal/firstview/firstview.go | 2 +- node-tools/internal/runtime/gate.go | 2 +- node-tools/internal/runtime/runtime.go | 2 +- 22 files changed, 265 insertions(+), 2103 deletions(-) delete mode 100644 node-tools/internal/approval/act.go delete mode 100644 node-tools/internal/approval/ask.go delete mode 100644 node-tools/internal/approval/closed.go create mode 100644 node-tools/internal/approval/contracts.go delete mode 100644 node-tools/internal/approval/gate.go delete mode 100644 node-tools/internal/approval/marks.go delete mode 100644 node-tools/internal/approval/record.go delete mode 100644 node-tools/internal/approval/run.go delete mode 100644 node-tools/internal/approval/tools.go delete mode 100644 node-tools/internal/approval/wait.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index 99153d6..3f57f65 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -12,8 +12,8 @@ import ( "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/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" ) diff --git a/node-tools/cmd/node-tools/approvals_integration_test.go b/node-tools/cmd/node-tools/approvals_integration_test.go index 2e58b24..2eb2ed1 100644 --- a/node-tools/cmd/node-tools/approvals_integration_test.go +++ b/node-tools/cmd/node-tools/approvals_integration_test.go @@ -13,10 +13,11 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "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/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -28,12 +29,14 @@ var wipeEntry = catalogseat.CatalogEntry{Module: "shell", Fp: "src2:1", Manifest func (marked) Ready() error { return nil } func (marked) Revision() string { return "7" } -func (marked) Served(fp string) (catalogseat.CatalogEntry, bool, string) { - return wipeEntry, fp == wipeEntry.Fp, "not-found" +func (marked) Served(fp string) (approval.Entry, bool, string) { + return runnerapproval.Entry(wipeEntry), fp == wipeEntry.Fp, "not-found" } -func (marked) Newest(m string) (catalogseat.CatalogEntry, bool) { return wipeEntry, m == "shell" } -func (marked) Definitions() map[string]seatdef.Definition { return map[string]seatdef.Definition{} } -func (marked) Entries() []catalogseat.CatalogEntry { return []catalogseat.CatalogEntry{wipeEntry} } +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. @@ -143,9 +146,11 @@ func (b *bed) gate(record func(func())) *approval.Gate { 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"}, Publish: publishAsk(b.conn), Record: routerRecord(b.conn), + 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: approval.HoldersIn(func() (map[string][]byte, error) { + Holder: runnerapproval.HoldersIn(func() (map[string][]byte, error) { b.mu.Lock() defer b.mu.Unlock() return b.holders, nil diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 788dd82..07b66fe 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -26,9 +26,11 @@ import ( "time" handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" - "github.com/novox/mesh-tools/node-tools/internal/alive" + "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/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" @@ -77,10 +79,12 @@ func main() { // 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: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, - Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}, - Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) }), + 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). diff --git a/node-tools/internal/approval/act.go b/node-tools/internal/approval/act.go deleted file mode 100644 index 5ad9df8..0000000 --- a/node-tools/internal/approval/act.go +++ /dev/null @@ -1,293 +0,0 @@ -package approval - -import ( - "bytes" - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - "strings" - "unicode/utf8" - - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - "git.novox.be/novox/mesh-sdk/go/outward" -) - -// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves. -type Call struct { - // Address is the address as called: `.` or `.`. - Address string - // Module is the module whose bundle serves it; Seat the seat of a verb, empty for a tool; Name the tool or the verb. - Module, Seat, Name string - // Fingerprint is the build the bundle serves, from its composed environment (§5). - Fingerprint string - // Caller is the caller the bus proved. - Caller string - // Args are the call's arguments, a JSON object. - Args json.RawMessage - // Description is the tool's or verb's own description, whose first line the ask explains it by. - Description string - // Own says it is one of the runner's own tools served under the module's name (its ledger repairs), not its bundle's. - Own bool -} - -// MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9). -const MostArguments = 16 * 1024 - -// arguments are a call's arguments by name; a body that is not an object is refused. -func arguments(raw json.RawMessage) (map[string]json.RawMessage, error) { - args := map[string]json.RawMessage{} - if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { - return args, nil - } - if err := json.Unmarshal(raw, &args); err != nil { - return nil, fmt.Errorf("its arguments are not a JSON object: %v", err) - } - return args, nil -} - -// refusedUnasked says why a marked call is refused at once, asking nothing (§9): its arguments hold a secret's shape, or are -// more than MostArguments. -func refusedUnasked(raw json.RawMessage) string { - if len(raw) > MostArguments { - return fmt.Sprintf("its arguments are %d bytes, more than the %d a call that needs the operator's approval may carry", len(raw), MostArguments) - } - if r, ok := outward.CheckSecret(string(raw)); !ok { - return fmt.Sprintf("its arguments hold %s, which no ask may carry", r.What) - } - return "" -} - -// ActOf is the act a marked call binds (§8): `address`, `node`, `module` and `tool` or `seat` and `verb`, `build`, and -// `arg.` for each argument given, a string as itself and any other value as its JSON in RFC 8785's canonical form. -func ActOf(c Call, node string) (operatorchannelclient.Act, error) { - act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint} - if c.Seat == "" { - act["module"], act["tool"] = c.Module, c.Name - } else { - act["seat"], act["verb"] = c.Seat, c.Name - } - args, err := arguments(c.Args) - if err != nil { - return nil, err - } - for name, raw := range args { - var s string - if json.Unmarshal(raw, &s) == nil { - act["arg."+name] = s - continue - } - canon, err := Canonical(raw) - if err != nil { - return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err) - } - act["arg."+name] = canon - } - return act, nil -} - -// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16 -// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names. -func Canonical(raw json.RawMessage) (string, error) { - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - var v any - if err := dec.Decode(&v); err != nil { - return "", err - } - var b strings.Builder - if err := canonicalValue(&b, v); err != nil { - return "", err - } - return b.String(), nil -} - -func canonicalValue(b *strings.Builder, v any) error { - switch x := v.(type) { - case nil: - b.WriteString("null") - case bool: - b.WriteString(strconv.FormatBool(x)) - case json.Number: - f, err := strconv.ParseFloat(string(x), 64) - if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { - return fmt.Errorf("the number %s is not a double", x) - } - b.WriteString(es6Number(f)) - case string: - canonicalString(b, x) - case []any: - b.WriteByte('[') - for i, e := range x { - if i > 0 { - b.WriteByte(',') - } - if err := canonicalValue(b, e); err != nil { - return err - } - } - b.WriteByte(']') - case map[string]any: - names := make([]string, 0, len(x)) - for k := range x { - names = append(names, k) - } - sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) }) - b.WriteByte('{') - for i, k := range names { - if i > 0 { - b.WriteByte(',') - } - canonicalString(b, k) - b.WriteByte(':') - if err := canonicalValue(b, x[k]); err != nil { - return err - } - } - b.WriteByte('}') - default: - return fmt.Errorf("a value of type %T", v) - } - return nil -} - -// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3). -func es6Number(f float64) string { - if f == 0 { - return "0" - } - abs := math.Abs(f) - if abs >= 1e-6 && abs < 1e21 { - return strconv.FormatFloat(f, 'f', -1, 64) - } - s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07 - mant, exp, _ := strings.Cut(s, "e") - sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0") - return mant + "e" + sign + digits -} - -func canonicalString(b *strings.Builder, s string) { - b.WriteByte('"') - for _, r := range s { - switch { - case r == '"': - b.WriteString(`\"`) - case r == '\\': - b.WriteString(`\\`) - case r == '\b': - b.WriteString(`\b`) - case r == '\f': - b.WriteString(`\f`) - case r == '\n': - b.WriteString(`\n`) - case r == '\r': - b.WriteString(`\r`) - case r == '\t': - b.WriteString(`\t`) - case r < 0x20: - fmt.Fprintf(b, `\u%04x`, r) - default: - b.WriteRune(r) - } - } - b.WriteByte('"') -} - -func utf16Less(a, b string) bool { - ua, ub := utf16Of(a), utf16Of(b) - for i := 0; i < len(ua) && i < len(ub); i++ { - if ua[i] != ub[i] { - return ua[i] < ub[i] - } - } - return len(ua) < len(ub) -} - -func utf16Of(s string) []uint16 { - out := make([]uint16, 0, utf8.RuneCountInString(s)) - for _, r := range s { - if r >= 0x10000 { - r -= 0x10000 - out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF))) - } else { - out = append(out, uint16(r)) - } - } - return out -} - -// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10). -func Fingerprint(digest string) string { - hex := strings.TrimPrefix(digest, "sha256:") - if len(hex) > 24 { - hex = hex[:24] - } - var groups []string - for len(hex) > 4 { - groups, hex = append(groups, hex[:4]), hex[4:] - } - return strings.Join(append(groups, hex), " ") -} - -// The bounds of the arguments shown in an ask (§11). -const ( - ShownBytes, ShownLines = 2000, 40 - PartBytes, PartLines = 1200, 20 -) - -// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole: -// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at -// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL. -func shown(raw json.RawMessage) (string, bool) { - args, _ := arguments(raw) - names := make([]string, 0, len(args)) - for n := range args { - names = append(names, n) - } - sort.Strings(names) - whole := true - var lines []string - for _, n := range names { - var value string - if json.Unmarshal(args[n], &value) != nil { - value = string(args[n]) - } - if r, ok := outward.Check(value); !ok { - value, whole = shapeOf(r), false - } - lines = append(lines, n+": "+value) - } - text := strings.Join(lines, "\n") - if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines { - if len(all) > PartLines { - all = all[:PartLines] - } - text = strings.Join(all, "\n") - if len(text) > PartBytes { - text = cutBytes(text, PartBytes) - } - text += "\nNOT SHOWN IN FULL" - whole = false - } - return text, whole -} - -// shapeOf is what an argument outward.Check refuses is shown as. -func shapeOf(r outward.Refusal) string { - switch r.Class { - case "path": - return "‹path›" - case "address": - return "‹address›" - } - return "‹withheld›" -} - -// cutBytes is s at most n bytes, cut at a character's end. -func cutBytes(s string, n int) string { - for n > 0 && !utf8.RuneStart(s[n]) { - n-- - } - return s[:n] -} diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 77cbc66..8bc98ff 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -12,6 +12,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" + 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" @@ -27,7 +28,7 @@ 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 + g *gate.Gate state *mem cat *fakeCatalog records map[string]seat.AsksByAskerIDValue // by `.` @@ -43,6 +44,16 @@ type world struct { // 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. @@ -72,7 +83,11 @@ func newWorld() *world { newest: map[string]catalogseat.CatalogEntry{"shell": shell}, defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} - w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, Bounds: w.bounds, + 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 { @@ -91,27 +106,28 @@ func newWorld() *world { c <- w.now return c }, - Publish: func(module, subject string, body any, id string) error { - if subject != seat.AcceptSubjectAsk(module) { - return nil - } - a := body.(seat.Ask) - w.asked = append(w.asked, a) - if w.takes { - w.records[module+"."+a.ID] = seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", - Headline: a.Headline, Expires: a.Expires, Opened: operatorchannelclient.FormatTime(w.now), OfferedOn: []string{"telegram"}} - } + } + w.publish = func(module, subject string, body any, id string) error { + if subject != seat.AcceptSubjectAsk(module) { return nil - }, - Record: func(module, id string) (*stdio.StateEntry, error) { - rec, ok := w.records[module+"."+id] - if !ok { - return nil, nil - } - raw, _ := json.Marshal(rec) - return &stdio.StateEntry{Key: module + "." + id, Value: raw}, nil - }}) - _ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"}) + } + 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 } @@ -129,102 +145,38 @@ func (w *world) decide(option string, at time.Time) seat.Ask { } // do makes a call through the gate, counting the runs. -func (w *world) do(c Call) (any, error) { +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) Call { - return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args), +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."} } -// theActAndItsAsk is §8's and §11's rows: the act's fields, and how an ask shows a call's arguments. -func theActAndItsAsk(t *testing.T) { - base := call("wipe", `{"dir":"scratch","n":1}`) - digest := func(c Call) string { - act, err := ActOf(c, "n1") - if err != nil { - t.Fatal(err) - } - d, _ := operatorchannelclient.ActDigest(act) - return d - } - act, _ := ActOf(base, "n1") - want := operatorchannelclient.Act{"address": "shell.wipe", "node": "n1", "module": "shell", "tool": "wipe", "build": "src2:1", - "arg.dir": "scratch", "arg.n": "1"} - if len(act) != len(want) { - t.Fatalf("act %v", act) - } - for k, v := range want { - if act[k] != v { - t.Errorf("act[%s] = %q, want %q", k, act[k], v) - } - } - seen := map[string]string{digest(base): "the call"} - for name, c := range map[string]Call{ - "an omitted argument": call("wipe", `{"dir":"scratch"}`), - "another number": call("wipe", `{"dir":"scratch","n":2}`), - "an object": call("wipe", `{"dir":{"name":"scratch"},"n":1}`), - "a changed build": func() Call { c := base; c.Fingerprint = "src2:2"; return c }(), - "another node's seat": {Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1"}, - } { - d := digest(c) - if other, dup := seen[d]; dup { - t.Errorf("%s gives the digest of %s", name, other) - } - seen[d] = name - } - if got, _ := Canonical(json.RawMessage(`{"b":[1.0,2.5e-7,1e21],"a":"é<"}`)); got != `{"a":"é<","b":[1,2.5e-7,1e+21]}` { - t.Errorf("canonical form %s", got) - } - // §11: a path, an address and a long argument are shown in their shape and cut. - text, whole := shown(json.RawMessage(`{"dir":"/home/someone/scratch","host":"10.0.0.7","ok":"yes"}`)) - if whole || text != "dir: ‹path›\nhost: ‹address›\nok: yes" { - t.Errorf("shown %q whole %v", text, whole) - } - long, _ := json.Marshal(map[string]string{"text": strings.Repeat("word ", 500)}) - if text, whole := shown(long); whole || !strings.HasSuffix(text, "NOT SHOWN IN FULL") || len(text) > PartBytes+len("\nNOT SHOWN IN FULL") { - t.Errorf("a long argument shown whole %v, %d bytes", whole, len(text)) - } - // §10: the headline, cut with … to 60 characters, ending Part shown. - for _, tc := range []struct{ name, node, want string }{ - {"wipe", "n1", "Run wipe on n1?"}, - {strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"}, - {strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"}, - } { - if got := headlineOf("Run", tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { - t.Errorf("headline %q, want %q", got, tc.want) - } - } - if got := headlineOf("Run", "wipe", "n1", false); got != "Run wipe on n1? Part shown" { - t.Errorf("headline %q", got) - } -} - // Design 46 7-approval-mark §22: the refusals and answers of a marked call, on the fixture mesh, the router's record and // the warrant written as the router writes them. func TestTheRefusalsAndAnswers(t *testing.T) { - t.Run("§8, §10, §11: the act's fields give each its digest, and the ask shows the arguments", theActAndItsAsk) for _, tc := range []struct { name string run func(t *testing.T, w *world) }{ {"§9: a secret in the arguments is refused asking nothing", func(t *testing.T, w *world) { - _, err := w.g.firstCall(call("wipe", `{"token":"ghp_0123456789abcdefghijABCDEFGHIJ012345"}`)) + _, 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.g.firstCall(call("wipe", string(big))) + _, 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.g.firstCall(call("wipe", `{"dir":"scratch"}`)) + _, 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) @@ -233,9 +185,9 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("asked %d", len(w.asked)) } a := w.asked[0] - act, _ := ActOf(call("wipe", `{"dir":"scratch"}`), "n1") + act, _ := gate.ActOf(call("wipe", `{"dir":"scratch"}`), "n1") d, _ := operatorchannelclient.ActDigest(act) - if a.About != d || a.Who != "operator" || a.OnExpiry != OnExpiry || a.Headline != "Run wipe on n1?" || + 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" { @@ -253,8 +205,8 @@ func TestTheRefusalsAndAnswers(t *testing.T) { {"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) { w.takes = false start := w.now - _, err := w.g.firstCall(call("wipe", `{}`)) - if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > RecordWithin { + _, 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)) } }}, @@ -277,10 +229,10 @@ func TestTheRefusalsAndAnswers(t *testing.T) { }}, {"§14, §15: approved, the same call runs once; made again it is already run and asks anew", func(t *testing.T, w *world) { _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) - w.decide(OptionApprove, w.now.Add(time.Minute)) + w.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.(Answered).Answer != "done" || w.runs != 1 || - !strings.HasPrefix(got.(Answered).Approval, "run on the operator's approval via telegram at 2026-11-12T10:01:00Z, ask ") { + 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"}`)) @@ -290,7 +242,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { }}, {"§15: two calls at the same moment run it once", func(t *testing.T, w *world) { _, _ = w.do(call("wipe", `{}`)) - w.decide(OptionApprove, w.now) + w.decide(gate.OptionApprove, w.now) var wg sync.WaitGroup var mu sync.Mutex runs := 0 @@ -308,9 +260,9 @@ func TestTheRefusalsAndAnswers(t *testing.T) { }}, {"§14: approved for other arguments runs nothing", func(t *testing.T, w *world) { _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) - a := w.decide(OptionApprove, w.now) + a := w.decide(gate.OptionApprove, w.now) rec := w.records["shell."+a.ID] - other, _ := ActOf(call("wipe", `{"dir":"home"}`), "n1") + 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) @@ -326,7 +278,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { }}, {"§14: approved 31 minutes ago asks anew", func(t *testing.T, w *world) { _, _ = w.do(call("wipe", `{}`)) - w.decide(OptionApprove, w.now) + 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)) @@ -334,7 +286,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { }}, {"§14, §17: declined, the same call is not asked again for 10 minutes", func(t *testing.T, w *world) { _, _ = w.do(call("wipe", `{}`)) - w.decide(OptionDecline, w.now) + w.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)) @@ -356,7 +308,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { }}, {"§14: another asker's warrant runs nothing", func(t *testing.T, w *world) { _, _ = w.do(call("wipe", `{}`)) - a := w.decide(OptionApprove, w.now) + a := w.decide(gate.OptionApprove, w.now) rec := w.records["shell."+a.ID] rec.Warrant.Asker = "mail" w.records["shell."+a.ID] = rec @@ -382,7 +334,8 @@ func TestTheRefusalsAndAnswers(t *testing.T) { } }}, {"§13: with no binding, an unmarked call runs and a marked one is refused", func(t *testing.T, w *world) { - w.g.d.State = nil + w.deps.State = nil + w.rebuild() if got, err := w.do(call("tidy", `{}`)); err != nil || got != "done" { t.Fatalf("unmarked: %v %v", got, err) } @@ -410,37 +363,38 @@ func TestTheRefusalsAndAnswers(t *testing.T) { } }}, {"§3, §2: a seat verb its seat marks is asked for its holder", func(t *testing.T, w *world) { - c := Call{Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1", Args: json.RawMessage(`{"command":"ls"}`)} + 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(OptionApprove, w.now) + 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] != UnansweredAfter || w.bounds.keys["n1.unrecorded."+a.ID] != KeptFor-SaidBefore { + 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)] != KindCallUnanswered { + 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), KindCallUnanswered) - warranted := w.g.d.Warranted + pendingKey := views.FindingKey("machine", keys.EscapeName("n1."+a.ID), gate.KindCallUnanswered) + warranted := w.deps.Warranted var pendingAtCall bool - w.g.d.Warranted = func(asker, ask string, open func() (bool, error)) error { + 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 { @@ -466,15 +420,15 @@ func TestTheRefusalsAndAnswers(t *testing.T) { } }}, {"§19: a publish that fails and a record that cannot be read refuse, running nothing", func(t *testing.T, w *world) { - pub := w.g.d.Publish - w.g.d.Publish = func(string, string, any, string) error { return errors.New("the bus refused it") } + 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.g.d.Publish = pub + w.publish = pub w.state.Vals = map[string]json.RawMessage{} _, _ = w.do(call("wipe", `{}`)) - w.g.d.Record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") } + 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) } @@ -489,11 +443,13 @@ func TestTheRefusalsAndAnswers(t *testing.T) { {"§19: the marks unread, a call waits within 5 seconds and is refused naming mesh_first_views", func(t *testing.T, w *world) { w.cat.ready = errors.New("the catalog half has no first view of main yet") waited := time.Duration(0) - w.g.d.Wait = func(d time.Duration) error { waited = d; return w.cat.ready } - if _, err := w.do(call("tidy", `{}`)); err == nil || !strings.Contains(err.Error(), "mesh_first_views") || waited != RecordWithin || w.runs != 0 { + 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.g.d.Wait = func(time.Duration) error { w.cat.ready = nil; return nil } + 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) } @@ -505,11 +461,11 @@ func TestTheRefusalsAndAnswers(t *testing.T) { if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "cannot be read") { t.Fatalf("%v", err) } - if w.bounds.keys["n1.unreadable.approvals"] != UnreadableAfter || len(w.findings.raised) != 0 { + 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")] != KindApprovalsUnreadable { + if w.findings.raised[keys.EscapeName("n1.approvals")] != gate.KindApprovalsUnreadable { t.Fatalf("findings %v", w.findings.raised) } w.state.Fail = nil @@ -542,7 +498,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Error("an ask not run was asked its hand-act again") } cancelled := "" - w.g.d.Publish = func(module, subject string, body any, id string) error { cancelled = subject + " " + id; return nil } + 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) } diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go deleted file mode 100644 index 70ebadd..0000000 --- a/node-tools/internal/approval/ask.go +++ /dev/null @@ -1,242 +0,0 @@ -package approval - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "strings" - "time" - - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/keys" -) - -// StateName is the runner's own state its asks and claims are kept in, per machine (§13). -const StateName = "approvals" - -// Store is the runner's state `approvals` (§13): a key's value, a create that refuses a key that exists and an update by -// compare-and-set, each failing with stdio.ErrChanged when another write came first; a put, and its keys. -type Store interface { - Get(key string) (*stdio.StateEntry, error) - Create(key string, value any) (uint64, error) - Update(key string, value any, revision uint64) (uint64, error) - Put(key string, value any) (uint64, error) - Keys() ([]string, error) -} - -// The bounds of an ask (§10, §12, §13, §14, §17). -const ( - AskLasts = 60 * time.Minute - RecordWithin = 5 * time.Second - RunWithin = 30 * time.Minute - DeclinedFor = 10 * time.Minute - MostKeptValue = 256 * 1024 - OnExpiry = "nothing runs; call again to ask anew" -) - -// The options every ask offers (§10), both at level approve, both binding the act's digest. -const ( - OptionApprove = "approve" - OptionDecline = "decline" -) - -// client is operator-channel's client asking under the marked module's name, its asks kept in the runner's state under -// this machine's keys, its publish and the router's record on the runner's own account (§7, §10). -func (g *Gate) client(module string) *operatorchannelclient.Client { - return &operatorchannelclient.Client{Module: module, State: g.d.State, KeyPrefix: g.d.Node + ".", Now: g.d.Now, - Publish: func(subject string, body any, id string) error { return g.d.Publish(module, subject, body, id) }, - Record: func(id string) (*stdio.StateEntry, error) { return g.d.Record(module, id) }} -} - -// actKey is the key naming the newest ask of an act (§13), its digest escaped as a key's name is, since the bus holds no `:` in a key (lead answer A-209). -func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + keys.EscapeName(digest) } - -// askKey is the key the client keeps an ask under (§13). -func (g *Gate) askKey(id string) string { return g.d.Node + ".ask." + id } - -// newID is an ask's id: 128 random bits. -func newID() string { - var b [16]byte - _, _ = rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} - -// askFor is the ask a marked call raises (§10, §11). -func (g *Gate) askFor(c Call, digest, id string) seat.Ask { - args, whole := shown(c.Args) - name := c.Name - if c.Seat != "" { - name = c.Seat + "." + c.Name - } - headline := headlineOf("Run", name, g.d.Node, whole) - first, _, _ := strings.Cut(strings.TrimSpace(c.Description), "\n") - if first == "" { - first = name - } - lines := []string{first, "On " + g.d.Node + ", called through the mesh's tools, which cannot tell who called."} - if args != "" { - lines = append(lines, args) - } - if !whole { - lines = append(lines, "The whole is read with mesh_approvals on "+g.d.Node+", whose fingerprint must be the one on the phone.") - } - lines = append(lines, "Fingerprint "+Fingerprint(digest)) - return seat.Ask{ID: id, About: digest, Who: operatorchannelclient.Operator, Headline: headline, - Explanation: strings.Join(lines, "\n"), Expires: operatorchannelclient.FormatTime(g.d.Now().Add(AskLasts)), OnExpiry: OnExpiry, - Options: []seat.Option{ - {ID: OptionApprove, Label: "Approve", Does: "the same call, made again within 30 minutes, runs once", - Level: operatorchannelclient.Approve, Binds: digest}, - {ID: OptionDecline, Label: "Decline", Does: "nothing runs; the same call is not asked again for 10 minutes", - Level: operatorchannelclient.Approve, Binds: digest}}} -} - -// headlineOf is " on ?", " ?" when that is longer than the router allows, and the names cut -// with "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). verb is "Run" -// for a call and "Wait on" for a wait (design 34 every-event §35). -func headlineOf(verb, name, node string, whole bool) string { - suffix := "" - if !whole { - suffix = " Part shown" - } - max := operatorchannelclient.HeadlineLength - fits := func(s string) bool { return len([]rune(s)) <= max } - if h := verb + " " + name + " on " + node + "?" + suffix; fits(h) { - return h - } - if h := verb + " " + name + "?" + suffix; fits(h) { - return h - } - room := max - len([]rune(verb+" …?"+suffix)) - r := []rune(name) - if room < 1 { - room = 1 - } - if len(r) > room { - r = r[:room] - } - return verb + " " + string(r) + "…?" + suffix -} - -// firstCall is a marked call whose act has no ask open: refused at once when its arguments may not be asked (§9), else -// asked (§10, §12). -func (g *Gate) firstCall(c Call) (any, error) { - if why := refusedUnasked(c.Args); why != "" { - return nil, g.refused(c, "", why) - } - act, err := ActOf(c, g.d.Node) - if err != nil { - return nil, g.refused(c, "", err.Error()) - } - digest, err := operatorchannelclient.ActDigest(act) - if err != nil { - return nil, g.refused(c, "", err.Error()) - } - return nil, g.raise(c, act, digest, "") -} - -// raise raises the ask of a marked call whose act has none open (§10): kept in the runner's state with the act and the whole -// arguments, published under the module's name, its act's key naming it; then the 5 seconds for the router's record and -// the first answer (§12), always an error, never a result. -func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince string) error { - id := newID() - if err := g.keepAsk(c, act, digest, g.askFor(c, digest, id)); err != nil { - return err - } - return g.firstAnswer(c, id, digest, markedSince) -} - -// keepAsk raises an ask and keeps it in the runner's state with the act and the whole arguments, its act's key naming it -// (§10, §13); refused, as an error, when it cannot be. -func (g *Gate) keepAsk(c Call, act operatorchannelclient.Act, digest string, a seat.Ask) error { - cl := g.client(c.Module) - if size := keptSize(a, act, c.Args); size > MostKeptValue { - return g.refused(c, digest, fmt.Sprintf("its ask would keep %d bytes, more than the %d a value of the state %s holds", size, MostKeptValue, StateName)) - } - if err := cl.Ask(a); err != nil { - return g.refused(c, digest, "the ask could not be raised: "+err.Error()) - } - if err := g.keepCall(a.ID, act, c.Args); err != nil { - g.stateFailed(err) - return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) - } - if _, err := g.d.State.Put(g.actKey(digest), a.ID); err != nil { - g.stateFailed(err) - return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) - } - return nil -} - -// keptSize is the bytes the kept ask would take. -func keptSize(a seat.Ask, act operatorchannelclient.Act, args json.RawMessage) int { - raw, _ := json.Marshal(operatorchannelclient.Kept{Ask: a, Act: act, Arguments: args}) - return len(raw) -} - -// keepCall writes the act and the whole arguments beside the ask the client kept (§13). -func (g *Gate) keepCall(id string, act operatorchannelclient.Act, args json.RawMessage) error { - e, err := g.d.State.Get(g.askKey(id)) - if err != nil || e == nil { - return fmt.Errorf("the kept ask %s cannot be read: %v", id, err) - } - var k operatorchannelclient.Kept - if err := json.Unmarshal(e.Value, &k); err != nil { - return err - } - k.Act, k.Arguments = act, args - _, err = g.d.State.Update(g.askKey(id), k, e.Revision) - return err -} - -// firstAnswer waits up to RecordWithin for the router's record of the ask and says the call was not run (§12). -func (g *Gate) firstAnswer(c Call, id, digest, markedSince string) error { - rec, _ := g.awaitRecord(c.Module, id) - since := "" - if markedSince != "" { - since = "; marked since revision " + markedSince - } - if rec == nil { - return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make the same call again later%s", - id, Fingerprint(digest), since) - } - return fmt.Errorf("Not run: this needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make the same call "+ - "again within 30 minutes of the approval; the ask expires at %s%s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened, - id, Fingerprint(digest), rec.Expires, since) -} - -// record is the router's record of an ask under the module's name, nil when it holds none. -func (g *Gate) record(module, id string) (*seat.AsksByAskerIDValue, error) { - e, err := g.d.Record(module, id) - if err != nil || e == nil { - return nil, err - } - var rec seat.AsksByAskerIDValue - if err := json.Unmarshal(e.Value, &rec); err != nil { - return nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) - } - return &rec, nil -} - -// awaitRecord reads the router's record of a new ask until it is there, within RecordWithin: the seat answers a key's -// value and says nothing when the router takes an ask, so it is read again after a growing pause (§12). -func (g *Gate) awaitRecord(module, id string) (*seat.AsksByAskerIDValue, error) { - until := g.d.Now().Add(RecordWithin) - pause := 50 * time.Millisecond - for { - rec, err := g.record(module, id) - if rec != nil || g.d.Now().Add(pause).After(until) { - return rec, err - } - <-g.d.After(pause) - pause *= 2 - } -} - -// refused is a marked call refused, run nothing, written in the runner's log with the ask's fingerprint and never its -// arguments (§19). -func (g *Gate) refused(c Call, digest, why string) error { - g.d.Logf("[mesh-tools] %s on %s is refused (fingerprint %s): %s", c.Address, g.d.Node, orNone(Fingerprint(digest)), why) - return fmt.Errorf("Not run: %s needs the operator's approval and %s", c.Address, why) -} diff --git a/node-tools/internal/approval/closed.go b/node-tools/internal/approval/closed.go deleted file mode 100644 index 96219a4..0000000 --- a/node-tools/internal/approval/closed.go +++ /dev/null @@ -1,119 +0,0 @@ -package approval - -import ( - "encoding/json" - "fmt" - "time" - - assignerseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" -) - -// AskedOn is the seat an ask is raised on, whose holder must be there for a marked call to be asked (§19). -const AskedOn = "operator-channel" - -// UnreadableAfter is how long the state approvals fails before the runner's finding says so (§19). -const UnreadableAfter = 5 * time.Minute - -// unreadableKey is the bound key whose expiry raises `approvals-unreadable` (lead answer A-157). -func (g *Gate) unreadableKey() string { return g.d.Node + ".unreadable." + StateName } - -// held says why no marked call can be asked now, or "" (§19): no module holds operator-channel, as the assigner's view -// of the seat's holders says, or that view is not read. -func (g *Gate) held() string { - if g.d.Holder == nil { - return "the tool runner reads no view of the seats' holders, so it cannot tell that a module holds " + AskedOn - } - ok, err := g.d.Holder(AskedOn) - switch { - case err != nil: - return fmt.Sprintf("whether a module holds %s cannot be read: %v", AskedOn, err) - case !ok: - return "no module holds " + AskedOn + ", so nobody can be asked" - } - return "" -} - -// stateFailed notes that the state approvals could not be read or written: no marked call runs on this machine until it -// can, the failed read or write is made again at the next marked call and at the next membership change (A-157), and -// five minutes on the runner's finding says it (§19). -func (g *Gate) stateFailed(err error) { - g.mu.Lock() - first := g.failingSince.IsZero() - if first { - g.failingSince = g.d.Now() - } - g.mu.Unlock() - if first { - g.d.Logf("[mesh-tools] the state %s on %s cannot be read or written (%v): no marked call runs here until it can", StateName, g.d.Node, err) - if g.d.Bounds != nil { - if e := g.d.Bounds.CreateTTL(g.unreadableKey(), map[string]string{"since": g.d.Now().UTC().Format(time.RFC3339)}, UnreadableAfter); e != nil { - g.d.Logf("[mesh-tools] the bound of the state %s's failure is not kept: %v", StateName, e) - } - } - } -} - -// stateRead notes that the state approvals was read or written: a failure noted before ends, its finding cleared. -func (g *Gate) stateRead() { - g.mu.Lock() - was := !g.failingSince.IsZero() - g.failingSince = time.Time{} - g.mu.Unlock() - if !was { - return - } - if g.d.Bounds != nil { - _ = g.d.Bounds.Delete(g.unreadableKey()) - } - g.finding(StateName, KindApprovalsUnreadable, false, "the state "+StateName+" is read again", "") -} - -// failing says the state approvals is failing now. -func (g *Gate) failing() bool { - g.mu.Lock() - defer g.mu.Unlock() - return !g.failingSince.IsZero() -} - -// StateAgain makes the failed read of the state approvals again: at the next membership change the runner hears (A-157). -func (g *Gate) StateAgain() { - if g.d.State == nil || !g.failing() { - return - } - if _, err := g.d.State.Keys(); err != nil { - return - } - g.stateRead() -} - -// unreadableExpired is the bound key of a failing state expiring: the finding raised while the state still fails. -func (g *Gate) unreadableExpired() { - g.mu.Lock() - since := g.failingSince - g.mu.Unlock() - if since.IsZero() { - return - } - g.finding(StateName, KindApprovalsUnreadable, true, fmt.Sprintf("the state %s on %s cannot be read or written since %s: no marked call "+ - "runs here until it can", StateName, g.d.Node, since.UTC().Format(time.RFC3339)), "approvals unreadable on "+g.d.Node) -} - -// HoldersIn is Holder over the assigner's view of the seats' holders as the runner holds it (§19): view answers the view's -// values by key, an error while its first view is not answered; a seat is held when its key names a holder. -func HoldersIn(view func() (map[string][]byte, error)) func(seat string) (bool, error) { - return func(seat string) (bool, error) { - values, err := view() - if err != nil { - return false, err - } - raw, ok := values[assignerseat.SeatHoldersBySeatKey(seat)] - if !ok { - return false, nil - } - var h assignerseat.SeatHolders - if err := json.Unmarshal(raw, &h); err != nil { - return false, fmt.Errorf("the holders of %s cannot be read: %w", seat, err) - } - return len(h.Holders) > 0, nil - } -} diff --git a/node-tools/internal/approval/contracts.go b/node-tools/internal/approval/contracts.go new file mode 100644 index 0000000..928821f --- /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/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/gate.go b/node-tools/internal/approval/gate.go deleted file mode 100644 index 315db22..0000000 --- a/node-tools/internal/approval/gate.go +++ /dev/null @@ -1,179 +0,0 @@ -package approval - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "sync" - "time" - - condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/keys" -) - -// The runner's findings of the gate (design 46 7-approval-mark §4, §16, §19), written through its held writer. -const ( - KindMarkUnserved = "mark-unserved" - KindCallUnanswered = "call-unanswered" - KindApprovalsUnreadable = "approvals-unreadable" -) - -// Kinds are the finding kinds the gate writes through the runner's writer. -var Kinds = []string{KindMarkUnserved, KindCallUnanswered, KindApprovalsUnreadable} - -// FindingWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding). -type FindingWriter interface { - Write(ctx context.Context, finding condseat.Finding) error - Clear(ctx context.Context, scope, id, kind, why string) error -} - -// Deps is what the gate reaches the mesh through: the runner's connection in its one place, fakes in a test. -type Deps struct { - // Node is the machine the runner serves; Runner its own module's name, the subject of its findings. - Node, Runner string - // Catalog is the catalog half the marks are read from. - Catalog Catalog - // Wait waits, within the bound, for the catalog half's first view, and is nil once it is read or why not. - Wait func(within time.Duration) error - // Findings is the runner's held writer; nil says a finding in the log only. - Findings FindingWriter - Logf func(string, ...any) - Now func() time.Time - // After is the clock's pause; nil is time.After. - After func(time.Duration) <-chan time.Time - // State is the runner's state approvals; nil when its membership binds none, so every marked call is refused (§13). - State Store - // Publish says an accept on a seat under the module's name, on the runner's own account (§7). - Publish func(module, subject string, body any, id string) error - // Record reads the router's record of an ask, `.` in the seat's state asks, on the runner's own account (§14). - Record func(module, id string) (*stdio.StateEntry, error) - // Warranted asks `mesh-hand-acts.warranted` {asker, ask}, each try a kept call open while open says so (§16, A-157). - Warranted func(asker, ask string, open func() (bool, error)) error - // Bounds is the runner's key-ttl state bound (A-157, A-158); nil keeps no bound. - Bounds Bounds - // Holder says whether a module holds a seat, from the assigner's view of the seats' holders the runner follows (§19). - Holder func(seat string) (bool, error) - // Build is the tool runner's own fingerprint, read from its MESH_FINGERPRINT at its start: the build a marked wait's - // act binds; empty while unknown, when every marked wait is refused (design 34 every-event §35). - Build string - // Hear waits within the bound for the first event on the subjects whose body match takes, or the caller's going: a - // marked wait's ask's `decided` (every-event §36); false when none came. - Hear func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) - // Go runs what goes on beside the call (a run's recording); nil is a goroutine. - Go func(func()) -} - -// Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). -type Gate struct { - d Deps - mu sync.Mutex - // registered is each module's tools as its bundle registered them at its last load. - registered map[string]map[string]bool - // unserved is each module whose marks name a tool its bundle does not register, with those tools. - unserved map[string][]string - // unmarkedAt is each caller's address it last called unmarked, so a mark added since is named (§12). - unmarkedAt map[string]bool - // inFlight is each ask whose recording runs now. - inFlight map[string]bool - // failingSince is when the state approvals began to fail; zero while it is read. - failingSince time.Time - // served is each module this runner serves, with the build its bundle serves, as its last load said (§18). - served map[string]string -} - -// New is a gate over d. -func New(d Deps) *Gate { - if d.Logf == nil { - d.Logf = func(string, ...any) {} - } - if d.Now == nil { - d.Now = time.Now - } - if d.After == nil { - d.After = time.After - } - if d.Go == nil { - d.Go = func(f func()) { go f() } - } - return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} -} - -// Unserved is, of the module's marked tools, those its bundle does not register (§4): such a bundle is not loaded, its -// finding `mark-unserved` raised; a bundle that serves them, or marks that no longer name them, clear it. Before the marks -// are read nothing is known, and every marked call is refused until they are (§19). -func (g *Gate) Unserved(module, fingerprint string, registered []string) []string { - set := map[string]bool{} - for _, t := range registered { - set[t] = true - } - g.mu.Lock() - g.registered[module] = set - if g.served == nil { - g.served = map[string]string{} - } - g.served[module] = fingerprint - g.mu.Unlock() - if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { - return nil - } - return g.checkServed(Derive(g.d.Catalog, module, fingerprint)) -} - -// checkServed raises or clears the module's `mark-unserved` as its marks and its bundle's registrations stand now, and is -// the tools it marks and does not register. -func (g *Gate) checkServed(m Marks) []string { - g.mu.Lock() - set := g.registered[m.Module] - var missing []string - for t := range m.Tools { - if !set[t] { - missing = append(missing, t) - } - } - sort.Strings(missing) - was := g.unserved[m.Module] - if len(missing) > 0 { - g.unserved[m.Module] = missing - } else { - delete(g.unserved, m.Module) - } - g.mu.Unlock() - switch { - case len(missing) > 0 && strings.Join(was, ",") != strings.Join(missing, ","): - g.finding(m.Module, KindMarkUnserved, true, fmt.Sprintf("%s's marks (%s, catalog revision %s) name %s, which its bundle on %s does not "+ - "register: no tool of %s is served here", m.Module, sourceOf(m, missing[0]), m.Revision, strings.Join(missing, ", "), g.d.Node, m.Module), - fmt.Sprintf("%s is not served on %s", m.Module, g.d.Node)) - case len(missing) == 0 && len(was) > 0: - g.finding(m.Module, KindMarkUnserved, false, "a bundle that serves every marked tool, or marks without the tool, is loaded", "") - } - return missing -} - -func sourceOf(m Marks, tool string) string { return m.Tools[tool] } - -// finding writes or clears one of the gate's findings: scope machine, its id `.` escaped as a key's name is, -// about the runner's module on this machine (lead answer A-154). -func (g *Gate) finding(what, kind string, raised bool, summary, headline string) { - id := keys.EscapeName(g.d.Node + "." + what) - if g.d.Findings == nil { - g.d.Logf("[mesh-tools] %s %s: %s", kind, id, summary) - return - } - var err error - if raised { - err = g.d.Findings.Write(context.Background(), condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id, - Severity: condseat.FindingSeverityWarning, Source: "approval", Observer: g.d.Runner, - Subject: condseat.Subject{Scope: "module", ID: g.d.Runner, Machine: g.d.Node}, Summary: summary, Headline: headline, - At: g.d.Now().UTC()}) - } else { - err = g.d.Findings.Clear(context.Background(), scopeMachine, id, kind, summary) - } - if err != nil { - g.d.Logf("[mesh-tools] the finding %s of %s is not kept: %v", kind, id, err) - } -} - -const scopeMachine = "machine" diff --git a/node-tools/internal/approval/marks.go b/node-tools/internal/approval/marks.go deleted file mode 100644 index 824ef6e..0000000 --- a/node-tools/internal/approval/marks.go +++ /dev/null @@ -1,185 +0,0 @@ -// Package approval is the tool runner's gate on a call that needs the operator's approval (design 46 7-approval-mark): the -// marks it reads from the catalog half, the act it digests, the ask it raises on the seat operator-channel under the -// marked module's name, the judging of the same call again on the router's record, the claim, the recording in the -// hand-act log, and the refusals when a part of the check is missing (§5, §8 to §20). No module's name is written here. -package approval - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/seatdef" -) - -// Catalog is what the marks are read from: the catalog half the runner follows (design 46 7-approval-mark §5, design 47 G -// §196, §443) and the seat definitions it carries (§2). -type Catalog interface { - // Ready is nil once the catalog half's first view is read, else says what it waits for. - Ready() error - // Revision is the catalog revision the marks are read at, as the runner names it. - Revision() string - // Served is the entry of the build a bundle serves, by the fingerprint its composed environment carries; false when - // none is read (not yet, or not found), with why. - Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) - // Newest is the module's entry at the newest revision; false when the catalog half lists none. - Newest(module string) (catalogseat.CatalogEntry, bool) - // Definitions are the seat definitions the catalog half carries, by seat. - Definitions() map[string]seatdef.Definition - // Entries are the newest entry of every module in every followed branch's half, a marked wait's marks (lead answer - // A-168). - Entries() []catalogseat.CatalogEntry -} - -// Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and -// restricted streams, each with its source (§18: its module's entry in the catalog half, or its seat's definition). -type Marks struct { - Module string - // Tools are its marked tools by name, Verbs its marked seat verbs as `.`, Subjects the bus subjects of its - // marked events and restricted streams (design 34 every-event §33); each to its source. - Tools, Verbs, Subjects map[string]string - // Revision is the catalog revision read; Served the served entry's fingerprint, empty when it could not be read. - Revision, Served string - // Unread says why the served entry could not be read: every marked call is then refused (§5, §19). - Unread string -} - -// Marked says whether a tool or a seat verb of the module needs the operator's approval, and its source. -func (m Marks) Marked(seat, name string) (bool, string) { - if seat == "" { - src, ok := m.Tools[name] - return ok, src - } - src, ok := m.Verbs[seat+"."+name] - return ok, src -} - -// Any says the module marks anything at all. -func (m Marks) Any() bool { return len(m.Tools)+len(m.Verbs)+len(m.Subjects) > 0 } - -// marked is what an entry's manifest says of its marks: `needs-approval`, its events and its streams. -type marked struct { - NeedsApproval []string `json:"needs-approval"` - Emits []string `json:"emits"` - // Streams are read for their events and whether each carries a `grant`, and no other word, so a word added to a - // stream's declaration later never hides its marks. - Streams []struct { - Events []string `json:"events"` - Grant json.RawMessage `json:"grant"` - } `json:"streams"` -} - -// Derive is a module's marks from its served entry and its newest one, unioned while they differ so a mark is never lost -// before or after the module moves (§5, ADR 0286 §3), and the seat definitions' verb marks for every verb an entry serves, -// which no holder unmarks (§2, §3). fingerprint is the build its bundle serves; empty, or an entry not read, leaves the -// marks of the newest entry and the seats, with Unread saying why. -func Derive(c Catalog, module, fingerprint string) Marks { - m := Marks{Module: module, Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}, - Revision: c.Revision()} - defs := c.Definitions() - var entries []catalogseat.CatalogEntry - if fingerprint == "" { - m.Unread = "its bundle's environment carries no fingerprint, so the build it serves cannot be read" - } else if e, ok, why := c.Served(fingerprint); ok { - entries, m.Served = append(entries, e), fingerprint - } else { - m.Unread = fmt.Sprintf("the catalog half's entry of %s cannot be read: %s", fingerprint, why) - } - if e, ok := c.Newest(module); ok && (len(entries) == 0 || e.Fp != entries[0].Fp) { - entries = append(entries, e) - } - for _, e := range entries { - m.add(e, defs) - } - return m -} - -// add takes one entry's marks into m: its `needs-approval` (a tool, a verb it serves or an event it declares), the seat -// definitions' marks of every verb it serves, and every stream it declares with a `grant`, each with its source. -func (m *Marks) add(e catalogseat.CatalogEntry, defs map[string]seatdef.Definition) { - source := "entry " + e.Module + " " + orNone(e.Fp) - var said marked - if raw, err := json.Marshal(e.Manifest); err == nil { - _ = json.Unmarshal(raw, &said) - } - serves := map[string]bool{} - for _, sv := range e.Serves { - serves[sv] = true - seat, verb, _ := strings.Cut(sv, ".") - for _, v := range defs[seat].Verbs { - if v.Name == verb && v.NeedsApproval { - m.Verbs[sv] = "seat " + seat - } - } - } - events := map[string]bool{} - for _, ev := range said.Emits { - events[ev] = true - } - for _, s := range said.Streams { - for _, ev := range s.Events { - events[ev] = true - } - } - for _, name := range said.NeedsApproval { - switch { - case serves[name]: - if _, bySeat := m.Verbs[name]; !bySeat { - m.Verbs[name] = source - } - case events[name]: - for _, s := range eventSubjects(e.Module, name) { - m.Subjects[s] = source - } - default: - m.Tools[name] = source - } - } - for _, s := range said.Streams { - if len(s.Grant) == 0 || string(s.Grant) == "null" { - continue - } - wire := make([]string, 0, len(s.Events)) - for _, ev := range s.Events { - wire = append(wire, buscompose.WirePattern(ev)) - } - for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { - m.Subjects[sub] = source - if !strings.HasSuffix(sub, ">") { - m.Subjects[sub+".>"] = source - } - } - } -} - -// eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its -// own stream, each also with every subject below it. -func eventSubjects(module, event string) []string { - ev := buscompose.WirePattern(event) - out := []string{"mesh.mod." + module + ".event." + ev, buscompose.ModuleStreamSubject(module, ev)} - if !strings.HasSuffix(ev, ">") { - out = append(out, out[0]+".>", out[1]+".>") - } - sort.Strings(out) - return out -} - -func orNone(s string) string { - if s == "" { - return "none" - } - return s -} - -// keysOf is a set's names, sorted; nil when empty. -func keysOf(set map[string]string) []string { - var out []string - for k := range set { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go index 000b379..a18bfd7 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -3,6 +3,7 @@ package approval import ( "context" "reflect" + "sort" "testing" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" @@ -10,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/views" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + gate "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/seatdef" ) @@ -24,19 +26,19 @@ type fakeCatalog struct { func (c *fakeCatalog) Ready() error { return c.ready } func (c *fakeCatalog) Revision() string { return c.revision } -func (c *fakeCatalog) Served(fp string) (catalogseat.CatalogEntry, bool, string) { +func (c *fakeCatalog) Served(fp string) (gate.Entry, bool, string) { e, ok := c.byFp[fp] - return e, ok, "not-found" + return Entry(e), ok, "not-found" } -func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) { +func (c *fakeCatalog) Newest(m string) (gate.Entry, bool) { e, ok := c.newest[m] - return e, ok + return Entry(e), ok } func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } -func (c *fakeCatalog) Entries() []catalogseat.CatalogEntry { - var out []catalogseat.CatalogEntry +func (c *fakeCatalog) Entries() []gate.Entry { + var out []gate.Entry for _, e := range c.newest { - out = append(out, e) + out = append(out, Entry(e)) } return out } @@ -90,7 +92,7 @@ func TestTheMarks(t *testing.T) { "mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}}, } { t.Run(tc.name, func(t *testing.T) { - m := Derive(tc.cat, tc.module, tc.fp) + 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) } @@ -113,8 +115,8 @@ func TestTheMarks(t *testing.T) { t.Run("mark-unserved raised and cleared", func(t *testing.T) { cat := &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login} w := &findings{} - g := New(Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: w}) - if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != KindMarkUnserved { + 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")] != "" { @@ -156,3 +158,12 @@ 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/approval/record.go b/node-tools/internal/approval/record.go deleted file mode 100644 index 2826bed..0000000 --- a/node-tools/internal/approval/record.go +++ /dev/null @@ -1,256 +0,0 @@ -package approval - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/keys" - "git.novox.be/novox/mesh-sdk/go/views" -) - -// The bounds of a run's recording (§13, §16; lead answers A-157, A-158). -const ( - UnansweredAfter = 5 * time.Minute - KeptFor = 48 * time.Hour - SaidBefore = time.Hour -) - -// Bounds is the runner's key-ttl state `bound` (lead answers A-157, A-158): a key created with its own time to live, which -// the bus removes when it passes, every watch hearing the removal as an expiry, so the gate's waits need no timer. -type Bounds interface { - CreateTTL(key string, value any, ttl time.Duration) error - Delete(key string) error - // Watch hands expired every key under the pattern whose time to live passed, until the stop is called. - Watch(pattern string, expired func(key string)) (stop func(), err error) -} - -// Answered is a call run on the operator's approval: the tool's own answer and the line naming the approval (§16), in the -// form a process holder's `gated-ended` says it (design 47 G §677; lead answer A-158). -type Answered struct { - Answer any `json:"answer"` - Approval string `json:"approval"` -} - -// The bound keys of a run (lead answer A-158; §13). -func (g *Gate) unansweredKey(id string) string { return g.d.Node + ".unanswered." + id } -func (g *Gate) unrecordedKey(id string) string { return g.d.Node + ".unrecorded." + id } - -// ran is a call run on the operator's approval: answered with the tool's answer and the approval's line, and its warrant -// recorded in the hand-act log (§16). -func (g *Gate) ran(c Call, id string, rec *seat.AsksByAskerIDValue, answer any, err error) (any, error) { - channel := "" - at := "" - if w := rec.Warrant; w != nil { - channel, at = w.Channel, w.At - } - if channel == "" { - channel = strings.Join(rec.OfferedOn, ", ") - } - line := fmt.Sprintf("run on the operator's approval via %s at %s, ask %s", orNone(channel), at, id) - g.d.Logf("[mesh-tools] %s on %s %s", c.Address, g.d.Node, line) - g.bindRecording(c.Module, id) - g.d.Go(func() { g.recordRun(c.Module, id) }) - if err != nil { - return nil, fmt.Errorf("%w (%s)", err, line) - } - return Answered{Answer: answer, Approval: line}, nil -} - -// bindRecording keeps the run's two bound keys: one that raises `call-unanswered` when `warranted` has not answered within -// five minutes, one that says an unrecorded run an hour before its key's 48 hours end (§13, §16). -func (g *Gate) bindRecording(module, id string) { - if g.d.Bounds == nil { - return - } - for key, ttl := range map[string]time.Duration{g.unansweredKey(id): UnansweredAfter, g.unrecordedKey(id): KeptFor - SaidBefore} { - if err := g.d.Bounds.CreateTTL(key, map[string]string{"asker": module, "ask": id}, ttl); err != nil { - g.d.Logf("[mesh-tools] the bound %s of the run of ask %s is not kept: %v", key, id, err) - } - } -} - -// recordRun calls `mesh-hand-acts.warranted` for one acted ask, each try a kept call open while the ask lacks `recorded` -// (§16, A-157), and on its answer writes `recorded` and clears what waited on it. One recording per ask at a time. -func (g *Gate) recordRun(module, id string) { - if !g.recording(id, true) { - return - } - defer g.recording(id, false) - if g.d.Warranted == nil { - g.d.Logf("[mesh-tools] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id) - return - } - err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }) - g.judged(id) // made again: its answer, or the bound of its key, judges it from now - if err != nil { - g.d.Logf("[mesh-tools] mesh-hand-acts.warranted has not answered for ask %s (%v): asked again when the hand-act seat's "+ - "holder is heard again and at the next start", id, err) - return - } - if err := g.markRecorded(id); err != nil { - g.d.Logf("[mesh-tools] ask %s is recorded in the hand-act log and its mark recorded is not kept: %v", id, err) - return - } - if g.d.Bounds != nil { - _ = g.d.Bounds.Delete(g.unansweredKey(id)) - _ = g.d.Bounds.Delete(g.unrecordedKey(id)) - } - g.finding(id, KindCallUnanswered, false, "mesh-hand-acts.warranted answered for ask "+id, "") -} - -// recording claims or releases an ask's recording: false when one runs already. -func (g *Gate) recording(id string, start bool) bool { - g.mu.Lock() - defer g.mu.Unlock() - if g.inFlight == nil { - g.inFlight = map[string]bool{} - } - if start { - if g.inFlight[id] { - return false - } - g.inFlight[id] = true - return true - } - delete(g.inFlight, id) - return true -} - -// kept is the ask the client keeps under its id, with its revision; nil when there is none. -func (g *Gate) kept(id string) (*operatorchannelclient.Kept, uint64, error) { - e, err := g.d.State.Get(g.askKey(id)) - if err != nil || e == nil { - return nil, 0, err - } - var k operatorchannelclient.Kept - if err := json.Unmarshal(e.Value, &k); err != nil { - return nil, 0, err - } - return &k, e.Revision, nil -} - -// unrecorded says the ask is acted on and its run not yet recorded: the key that keeps `warranted` open (§16). -func (g *Gate) unrecorded(id string) (bool, error) { - k, _, err := g.kept(id) - if err != nil || k == nil { - return false, err - } - return k.Acted && !k.Recorded, nil -} - -// markRecorded writes `recorded` on the kept ask by compare-and-set, again when another write came first. -func (g *Gate) markRecorded(id string) error { - for { - k, rev, err := g.kept(id) - if err != nil || k == nil { - return fmt.Errorf("the kept ask cannot be read: %v", err) - } - if k.Recorded { - return nil - } - k.Recorded = true - if _, err := g.d.State.Update(g.askKey(id), k, rev); err == nil { - return nil - } else if !isChanged(err) { - return err - } - } -} - -// Recover calls `warranted` again for every acted, unrecorded ask this machine's keys hold within their 48 hours (§16): at -// each start, and when the hand-act seat's holder is heard again (A-157). -func (g *Gate) Recover() { - if g.d.State == nil { - return - } - keys, err := g.d.State.Keys() - if err != nil { - g.d.Logf("[mesh-tools] the acted, unrecorded asks cannot be read (%v): read again at the next start or holder heard", err) - return - } - prefix := g.d.Node + ".ask." - for _, key := range keys { - if !strings.HasPrefix(key, prefix) { - continue - } - id := strings.TrimPrefix(key, prefix) - k, _, err := g.kept(id) - if err != nil || k == nil || !k.Acted || k.Recorded || g.d.Now().Sub(k.Asked) > KeptFor { - continue - } - module := moduleOf(k) - g.pend(id) - g.d.Go(func() { g.recordRun(module, id) }) - } -} - -// moduleOf is the module an ask was asked under: the warrant's asker. -func moduleOf(k *operatorchannelclient.Kept) string { - if k.Answer != nil { - return k.Answer.Asker - } - return "" -} - -// WatchBounds hears the run's bound keys expire (A-157): `call-unanswered` raised when `warranted` has not answered within -// five minutes; an unrecorded run said in the log an hour before its key's 48 hours end (§13). -func (g *Gate) WatchBounds() (func(), error) { - if g.d.Bounds == nil { - return func() {}, nil - } - return g.d.Bounds.Watch(g.d.Node+".**", func(key string) { - if key == g.unreadableKey() { - g.unreadableExpired() - return - } - rest := strings.TrimPrefix(key, g.d.Node+".") - kind, id, _ := strings.Cut(rest, ".") - if open, err := g.unrecorded(id); err != nil || !open { - return - } - switch kind { - case "unanswered": - g.finding(id, KindCallUnanswered, true, fmt.Sprintf("mesh-hand-acts.warranted has not answered for ask %s, run on the "+ - "operator's approval on %s, within %s: the run is not yet in the hand-act log", id, g.d.Node, UnansweredAfter), - "a run's hand-act is unrecorded on "+g.d.Node) - case "unrecorded": - g.d.Logf("[mesh-tools] ERROR the run of ask %s on %s is still unrecorded an hour before its key's 48 hours end", id, g.d.Node) - } - }) -} - -// Pender is the runner's held writer's pending (views.HeldWriter's Pend and Judged): the keys it cannot judge yet since its -// start, named in its `findings-stated` (design 47 G §384). -type Pender interface { - Pend(views.PendingKey) - Judged(key string) -} - -// pend names an acted, unrecorded ask's `call-unanswered` in the runner's `findings-stated` until its `warranted` is made -// again (§16; design 47 G §384), its bound the five minutes the finding waits. -func (g *Gate) pend(id string) { - if p, ok := g.d.Findings.(Pender); ok { - p.Pend(views.PendingKey{Key: g.unansweredFinding(id), Since: g.d.Now().UTC(), Bound: UnansweredAfter.String()}) - } -} - -// judged ends that pending. -func (g *Gate) judged(id string) { - if p, ok := g.d.Findings.(Pender); ok { - p.Judged(g.unansweredFinding(id)) - } -} - -// unansweredFinding is the key of an ask's `call-unanswered` in the runner's state findings (lead answer A-154). -func (g *Gate) unansweredFinding(id string) string { - return views.FindingKey(scopeMachine, keys.EscapeName(g.d.Node+"."+id), KindCallUnanswered) -} - -// isChanged says a compare-and-set lost to another write. -func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go deleted file mode 100644 index b0f525c..0000000 --- a/node-tools/internal/approval/run.go +++ /dev/null @@ -1,184 +0,0 @@ -package approval - -import ( - "encoding/json" - "fmt" - "strings" - - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" -) - -// Run is a call through the gate (§20): an unmarked call runs; a marked one runs only on the operator's approval of that -// one call, judged on the router's record and claimed once (§14, §15), and is otherwise answered as an error saying why -// (§12, §17, §19). run is called at most once. -func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { - m, err := g.marksOf(c) - if err != nil { - return nil, err - } - if missing := g.unservedOf(c.Module); len(missing) > 0 { - return nil, fmt.Errorf("Not run: %s's marks name %s, which its bundle on %s does not register, so none of its tools is "+ - "served here (design 46 7-approval-mark §4)", c.Module, strings.Join(missing, ", "), g.d.Node) - } - marked, _ := m.Marked(c.Seat, c.Name) - since := g.markedSince(c, marked, m.Revision) - if !marked { - return run() - } - if c.Own { - return nil, g.refused(c, "", "it is one of the tool runner's own tools, which no ask covers") - } - if m.Unread != "" { - return nil, g.refused(c, "", m.Unread) - } - if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() { - return nil, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in") - } - if why := refusedUnasked(c.Args); why != "" { - return nil, g.refused(c, "", why) - } - if why := g.held(); why != "" { - return nil, g.refused(c, "", why) - } - act, err := ActOf(c, g.d.Node) - if err != nil { - return nil, g.refused(c, "", err.Error()) - } - digest, err := operatorchannelclient.ActDigest(act) - if err != nil { - return nil, g.refused(c, "", err.Error()) - } - named, err := g.d.State.Get(g.actKey(digest)) - if err != nil { - g.stateFailed(err) - return nil, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()) - } - g.stateRead() - if named == nil { - return nil, g.raise(c, act, digest, since) - } - var id string - if err := json.Unmarshal(named.Value, &id); err != nil || id == "" { - return nil, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)) - } - return g.judge(c, act, digest, id, since, run) -} - -// Binding is a Store that says whether the runner's membership binds the state now (§13). -type Binding interface{ Bound() bool } - -// marksOf is the module's marks as the catalog half has them now; before its first view, the call waits for it within -// RecordWithin and is then judged, refused naming mesh_first_views when it is still unread (§19). -func (g *Gate) marksOf(c Call) (Marks, error) { - if g.d.Catalog == nil { - return Marks{}, g.refused(c, "", "the tool runner reads no catalog half, so its marks are not read") - } - if err := g.d.Catalog.Ready(); err != nil { - if g.d.Wait != nil { - err = g.d.Wait(RecordWithin) - } - if err != nil { - return Marks{}, fmt.Errorf("Not run: the marks of %s are not read yet (%v); mesh_first_views says what the tool runner "+ - "waits for", c.Module, err) - } - } - m := Derive(g.d.Catalog, c.Module, c.Fingerprint) - g.checkServed(m) - return m, nil -} - -func (g *Gate) unservedOf(module string) []string { - g.mu.Lock() - defer g.mu.Unlock() - return g.unserved[module] -} - -// markedSince is the revision a call was marked at when the same caller last called it unmarked (§12), empty otherwise. -func (g *Gate) markedSince(c Call, marked bool, revision string) string { - key := c.Caller + " " + c.Address - g.mu.Lock() - defer g.mu.Unlock() - if g.unmarkedAt == nil { - g.unmarkedAt = map[string]bool{} - } - was := g.unmarkedAt[key] - if marked { - delete(g.unmarkedAt, key) - if was { - return revision - } - return "" - } - g.unmarkedAt[key] = true - return "" -} - -// judge is the same call made again, judged on the router's own record of its act's newest ask (§14 to §17). -func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since string, run func() (any, error)) (any, error) { - rec, err := g.record(c.Module, id) - if err != nil { - return nil, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()) - } - if rec == nil { - return nil, g.firstAnswer(c, id, digest, since) // §17: not yet taken by the router - } - again := func(why string) (any, error) { - err := g.raise(c, act, digest, since) - return nil, fmt.Errorf("The ask %s %s; asked again. %w", id, why, err) - } - switch rec.State { - case "open", "held-back": - return nil, fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s", - id, Fingerprint(digest), rec.Expires) - case "refused": - return nil, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)) - case "chosen": - default: // expired, cancelled, replaced: each asks again (§15, §17) - return again("ended " + rec.State) - } - w := rec.Warrant - if w == nil { - return nil, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant") - } - at, _ := operatorchannelclient.Time(w.At) - if w.Option == OptionDecline { - if until := at.Add(DeclinedFor); g.d.Now().Before(until) { - return nil, fmt.Errorf("Not run: the operator declined ask %s; the same call is not asked again until %s", - id, operatorchannelclient.FormatTime(until)) - } - return again("was declined more than 10 minutes ago") - } - if g.d.Now().After(at.Add(RunWithin)) { - return again("was approved too long ago (more than 30 minutes)") - } - var answer any - var ran error - h, err := g.client(c.Module).Take(*w, func(o seat.Option, _ seat.Warrant) error { - if err := operatorchannelclient.Performs(o, act); err != nil { - ran = err - return err - } - answer, ran = run() - return nil - }) - switch { - case h == operatorchannelclient.Done: - return again("was already run") - case h == operatorchannelclient.Refused: - return nil, g.refused(c, digest, "its warrant does not hold: "+errText(err)) - case h == "" && err != nil: - g.stateFailed(err) - return nil, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()) - case err != nil: // the act about to run is not the one the option bound: nothing runs, the warrant stays used - return nil, g.refused(c, digest, err.Error()) - } - return g.ran(c, id, rec, answer, ran) -} - -func errText(err error) string { - if err == nil { - return "no reason given" - } - return err.Error() -} diff --git a/node-tools/internal/approval/tools.go b/node-tools/internal/approval/tools.go deleted file mode 100644 index 13b4cbf..0000000 --- a/node-tools/internal/approval/tools.go +++ /dev/null @@ -1,168 +0,0 @@ -package approval - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "time" -) - -// The mesh MCP server's mesh_approvals reads and steers what the gate asked on this machine (§18). - -// Listed is one ask of the last day as mesh_approvals lists it: its state and its act's fingerprint. -type Listed struct { - Ask string `json:"ask"` - Address string `json:"address"` - Module string `json:"module"` - Asked time.Time `json:"asked"` - State string `json:"state"` - Fingerprint string `json:"fingerprint"` -} - -// Whole is one ask read whole: its arguments and the act's digest. -type Whole struct { - Listed - Digest string `json:"digest"` - Act map[string]string `json:"act"` - Arguments json.RawMessage `json:"arguments"` - Recorded bool `json:"recorded"` -} - -// stateOf is a kept ask's state as the runner knows it. -func stateOf(acted, recorded bool, ended string) string { - switch { - case acted && recorded: - return "run, recorded" - case acted: - return "run, not yet recorded" - case ended != "": - return ended - } - return "asked" -} - -// List is the calls asked on this machine in the last day, newest first (§18). -func (g *Gate) List() ([]Listed, error) { - if g.d.State == nil { - return nil, fmt.Errorf("the tool runner's membership binds no state %s", StateName) - } - keys, err := g.d.State.Keys() - if err != nil { - return nil, err - } - prefix := g.d.Node + ".ask." - out := []Listed{} - for _, key := range keys { - if !strings.HasPrefix(key, prefix) { - continue - } - w, err := g.Read(strings.TrimPrefix(key, prefix)) - if err != nil || g.d.Now().Sub(w.Asked) > 24*time.Hour { - continue - } - out = append(out, w.Listed) - } - sort.Slice(out, func(i, j int) bool { return out[i].Asked.After(out[j].Asked) }) - return out, nil -} - -// Read is one ask whole: its arguments and the act's digest (§18). -func (g *Gate) Read(id string) (Whole, error) { - if g.d.State == nil { - return Whole{}, fmt.Errorf("the tool runner's membership binds no state %s", StateName) - } - k, _, err := g.kept(id) - if err != nil { - return Whole{}, err - } - if k == nil { - return Whole{}, fmt.Errorf("no ask %s is kept on %s", id, g.d.Node) - } - module := k.Act["module"] - if module == "" { - module = moduleOf(k) - } - return Whole{Listed: Listed{Ask: id, Address: k.Act["address"], Module: module, Asked: k.Asked, - State: stateOf(k.Acted, k.Recorded, k.Ended), Fingerprint: Fingerprint(k.Ask.About)}, - Digest: k.Ask.About, Act: k.Act, Arguments: k.Arguments, Recorded: k.Recorded}, nil -} - -// Applied are the marks the gate applies on this machine, each module's marked tools and verbs with their source and the -// catalog revision read (§18). -func (g *Gate) Applied() []Marks { - g.mu.Lock() - modules := make([]string, 0, len(g.served)) - for m := range g.served { - modules = append(modules, m) - } - g.mu.Unlock() - sort.Strings(modules) - var out []Marks - if g.d.Catalog == nil { - return out - } - for _, m := range modules { - if marks := Derive(g.d.Catalog, m, g.servedFingerprint(m)); marks.Any() { - out = append(out, marks) - } - } - return out -} - -func (g *Gate) servedFingerprint(module string) string { - g.mu.Lock() - defer g.mu.Unlock() - return g.served[module] -} - -// Describe says whether a tool or a seat verb of a module is marked, and its source, for mesh_describe (§18): the served -// build's marks where this machine serves the module, else the newest entry's and its seat's. -func (g *Gate) Describe(module, seat, name string) (bool, string) { - if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { - return false, "" - } - if seat != "" && module == "" { - for s, d := range g.d.Catalog.Definitions() { - if s != seat { - continue - } - for _, v := range d.Verbs { - if v.Name == name && v.NeedsApproval { - return true, "seat " + seat - } - } - } - return false, "" - } - return Derive(g.d.Catalog, module, g.servedFingerprint(module)).Marked(seat, name) -} - -// Again asks `warranted` again for one acted, unrecorded ask, by hand, in the kept-call form of §16 (§18); the caller -// records the hand act first. -func (g *Gate) Again(id string) error { - w, err := g.Read(id) - if err != nil { - return err - } - if !strings.HasPrefix(w.State, "run, not yet") { - return fmt.Errorf("the ask %s is %s: only a run not yet recorded is asked again", id, w.State) - } - g.recordRun(w.Module, id) - if again, _ := g.Read(id); !again.Recorded { - return fmt.Errorf("mesh-hand-acts.warranted has not answered for ask %s yet", id) - } - return nil -} - -// Cancel takes an open ask back through operator-channel's client (§18); the caller records the hand act first. -func (g *Gate) Cancel(id string) error { - w, err := g.Read(id) - if err != nil { - return err - } - if w.State != "asked" { - return fmt.Errorf("the ask %s is %s, not open", id, w.State) - } - return g.client(w.Module).Cancel(id) -} diff --git a/node-tools/internal/approval/wait.go b/node-tools/internal/approval/wait.go deleted file mode 100644 index 7fa963c..0000000 --- a/node-tools/internal/approval/wait.go +++ /dev/null @@ -1,296 +0,0 @@ -package approval - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" - "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/outward" -) - -// Wait is one mesh_wait call as the gate judges it (design 34 every-event §34 to §36): the event names given, the subjects -// they became (after `from`), the filter and the timeout. -type Wait struct { - Names []string - From string - Subjects []string - Filter map[string]any - Timeout time.Duration -} - -// Waited is a wait through the gate (every-event §34 to §36). An unmarked wait is heard at once by hear, given how long it -// may wait. A marked one is heard only on the operator's warrant for that one wait, its timeout running from then; with -// none it subscribes to nothing, its ask raised once under the runner's own module's name, and is answered as an error at -// its timeout, when its caller goes, or as soon as the router's record says the ask was chosen, never in a hot loop. c is -// the call as the console makes it: its address, its tool's name, its caller and its arguments as given. -func (g *Gate) Waited(ctx context.Context, c Call, w Wait, hear func(within time.Duration) (any, error)) (any, error) { - deadline := g.d.Now().Add(w.Timeout) - // A wait from a seat is never marked, and a seat's event never is (§32, §34). - if w.From == "seat" || !outsideSeats(w.Subjects) { - return hear(w.Timeout) - } - if g.d.Catalog == nil { - return nil, g.holdRefused(ctx, deadline, "this wait is refused: the tool runner reads no catalog half, so the marks are not read") - } - if err := g.d.Catalog.Ready(); err != nil { - // Before the marks are read nothing is asked: the wait waits for the first view within its own timeout and is - // then judged, and is refused at its timeout when it does not come (§34). - if g.d.Wait != nil { - err = g.waitCatalog(ctx, w.Timeout) - } - if err != nil { - return nil, g.holdRefused(ctx, deadline, fmt.Sprintf("this wait is refused at its timeout: the marks are not yet read (%v); "+ - "mesh_first_views says what the tool runner waits for", err)) - } - } - if !overlapsAny(w.Subjects, g.waitMarks()) { - return hear(deadline.Sub(g.d.Now())) - } - c.Module, c.Seat, c.Fingerprint = g.d.Runner, "", g.d.Build - if c.Fingerprint == "" { - return nil, g.holdRefused(ctx, deadline, "this wait needs the operator's approval, and the tool runner's own fingerprint is "+ - "unknown (its MESH_FINGERPRINT was not set at its start), so no warrant can bind its build") - } - if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() { - return nil, g.holdRefused(ctx, deadline, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in").Error()) - } - if why := refusedUnasked(c.Args); why != "" { - return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error()) - } - if why := g.held(); why != "" { - return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error()) - } - act, err := ActOf(c, g.d.Node) - if err != nil { - return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error()) - } - digest, err := operatorchannelclient.ActDigest(act) - if err != nil { - return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error()) - } - named, err := g.d.State.Get(g.actKey(digest)) - if err != nil { - g.stateFailed(err) - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()).Error()) - } - g.stateRead() - anew := func() (any, error) { - id := newID() - if err := g.keepAsk(c, act, digest, g.askForWait(c, w, digest, id)); err != nil { - return nil, g.holdRefused(ctx, deadline, err.Error()) - } - return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, true) - } - if named == nil { - return anew() - } - var id string - if err := json.Unmarshal(named.Value, &id); err != nil || id == "" { - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)).Error()) - } - rec, err := g.record(c.Module, id) - switch { - case err != nil: - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()).Error()) - case rec == nil, rec.State == "open", rec.State == "held-back": - return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, false) - case rec.State == "refused": - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)).Error()) - case rec.State != "chosen": - return anew() // expired, cancelled, replaced: asked again (§15, §17) - } - wt := rec.Warrant - if wt == nil { - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant").Error()) - } - at, _ := operatorchannelclient.Time(wt.At) - if wt.Option == OptionDecline { - if g.d.Now().Before(at.Add(DeclinedFor)) { - return nil, g.holdRefused(ctx, deadline, declinedWords(id, at)) - } - return anew() - } - if g.d.Now().After(at.Add(RunWithin)) { - return anew() - } - var answer any - var ran error - h, err := g.client(c.Module).Take(*wt, func(o seat.Option, _ seat.Warrant) error { - if err := operatorchannelclient.Performs(o, act); err != nil { - ran = err - return err - } - // On the warrant it subscribes, and the wait's timeout runs from then (§35). - answer, ran = hear(w.Timeout) - return nil - }) - switch { - case h == operatorchannelclient.Done: - return anew() // already run: one warrant is one wait (§35) - case h == operatorchannelclient.Refused: - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its warrant does not hold: "+errText(err)).Error()) - case h == "" && err != nil: - g.stateFailed(err) - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()).Error()) - case err != nil: - return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, err.Error()).Error()) - } - return g.ran(c, id, rec, answer, ran) -} - -// outsideSeats says a subject is not a seat's: only such a subject can be marked (§32, §34). -func outsideSeats(subjects []string) bool { - for _, s := range subjects { - if !strings.HasPrefix(s, "mesh.seat.") { - return true - } - } - return false -} - -// waitMarks are the marked subjects of every module the catalog half lists (§33): the newest entry of every followed -// branch, and the served entry of each module this runner serves (lead answer A-168), so a mark is never lost. -func (g *Gate) waitMarks() map[string]string { - m := Marks{Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}} - defs := g.d.Catalog.Definitions() - for _, e := range g.d.Catalog.Entries() { - m.add(e, defs) - } - g.mu.Lock() - served := make(map[string]string, len(g.served)) - for module, fp := range g.served { - served[module] = fp - } - g.mu.Unlock() - for _, fp := range served { - if e, ok, _ := g.d.Catalog.Served(fp); ok { - m.add(e, defs) - } - } - return m.Subjects -} - -// overlapsAny says a subject a wait subscribes overlaps a marked subject (§34). -func overlapsAny(subjects []string, marked map[string]string) bool { - for _, s := range subjects { - for mk := range marked { - if buscompose.Overlaps(s, mk) { - return true - } - } - } - return false -} - -// waitCatalog waits for the catalog half's first view within the bound, or until the caller goes. -func (g *Gate) waitCatalog(ctx context.Context, within time.Duration) error { - done := make(chan error, 1) - go func() { done <- g.d.Wait(within) }() - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -// pause waits until the deadline or the caller's going. -func (g *Gate) pause(ctx context.Context, deadline time.Time) { - if left := deadline.Sub(g.d.Now()); left > 0 { - select { - case <-g.d.After(left): - case <-ctx.Done(): - } - } -} - -// holdRefused is a refused marked wait, said at its timeout or when its caller goes, never at once, so a watcher's loop -// is never a hot one (§36). -func (g *Gate) holdRefused(ctx context.Context, deadline time.Time, words string) error { - g.pause(ctx, deadline) - if !strings.HasPrefix(words, "Not run") { - words = "Not run: " + words - } - return fmt.Errorf("%s", words) -} - -// holdAsk holds a marked wait whose ask is open, subscribing to nothing but its ask's `decided` (§36): it answers, as an -// error, at its timeout, when its caller goes, or once the router's record says the ask was chosen, the event only waking it. -func (g *Gate) holdAsk(ctx context.Context, deadline time.Time, module, id, digest string, first bool) error { - for { - left := deadline.Sub(g.d.Now()) - heard := false - if left > 0 && ctx.Err() == nil { - var err error - if g.d.Hear != nil { - heard, err = g.d.Hear(ctx, []string{seat.SubjectDecided(module)}, left, func(body json.RawMessage) bool { - var w seat.Warrant - return json.Unmarshal(body, &w) == nil && w.Ask == id - }) - } - if g.d.Hear == nil || err != nil { - g.pause(ctx, deadline) - } - } - rec, _ := g.record(module, id) - if heard && (rec == nil || rec.State != "chosen") { - continue // the event only wakes it: the record says nothing chosen yet - } - return g.waitWords(rec, id, digest, first) - } -} - -// waitWords are §12's and §17's words for a marked wait not run, "this wait" for "the call" (§36). -func (g *Gate) waitWords(rec *seat.AsksByAskerIDValue, id, digest string, first bool) error { - switch { - case rec == nil: - return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make this wait again later", id, Fingerprint(digest)) - case rec.State == "chosen" && rec.Warrant != nil && rec.Warrant.Option == OptionDecline: - at, _ := operatorchannelclient.Time(rec.Warrant.At) - return fmt.Errorf("%s", declinedWords(id, at)) - case rec.State == "chosen", first: - return fmt.Errorf("Not run: this wait needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make this wait "+ - "again within 30 minutes of the approval; the ask expires at %s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened, - id, Fingerprint(digest), rec.Expires) - case rec.State == "open", rec.State == "held-back": - return fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s", - id, Fingerprint(digest), rec.Expires) - } - return fmt.Errorf("Not run: the ask %s ended %s; make this wait again to ask anew", id, rec.State) -} - -func declinedWords(id string, at time.Time) string { - return fmt.Sprintf("Not run: the operator declined ask %s; this wait is not asked again until %s", id, - operatorchannelclient.FormatTime(at.Add(DeclinedFor))) -} - -// askForWait is a marked wait's ask (§35): "Wait on on ?", shortened as §10 says, and an explanation naming -// every event name one per line, the filter, and the bound as the timeout in minutes, then the act's fingerprint. -func (g *Gate) askForWait(c Call, w Wait, digest, id string) seat.Ask { - a := g.askFor(c, digest, id) - filter, whole := "none", true - if len(w.Filter) > 0 { - raw, _ := json.Marshal(w.Filter) - filter = string(raw) - if r, ok := outward.Check(filter); !ok { - filter, whole = shapeOf(r), false - } - } - a.Headline = headlineOf("Wait on", strings.Join(w.Names, ", "), g.d.Node, whole) - lines := append([]string{"Wait on " + g.d.Node + ", called through the mesh's tools, which cannot tell who called, for:"}, w.Names...) - lines = append(lines, "Filter: "+filter, fmt.Sprintf("Timeout: %s minutes", minutes(w.Timeout)), "Fingerprint "+Fingerprint(digest)) - a.Explanation = strings.Join(lines, "\n") - a.Options[0].Does = "the same wait, made again within 30 minutes, subscribes once, its timeout running from then" - a.Options[1].Does = "nothing is heard; the same wait is not asked again for 10 minutes" - return a -} - -// minutes is a timeout in minutes, as few digits as say it. -func minutes(d time.Duration) string { - return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.2f", d.Minutes()), "0"), ".") -} diff --git a/node-tools/internal/console/approvals.go b/node-tools/internal/console/approvals.go index f189e8b..17bcaf3 100644 --- a/node-tools/internal/console/approvals.go +++ b/node-tools/internal/console/approvals.go @@ -4,7 +4,7 @@ import ( "log" "strings" - "github.com/novox/mesh-tools/node-tools/internal/approval" + "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 diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index 541e620..d15df4d 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -21,7 +21,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-tools/node-tools/internal/approval" + "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 diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 1eb7263..73b86ac 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -19,11 +19,12 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/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/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -400,13 +401,14 @@ func newGatedWaits(t *testing.T, s *Surface, mesh *mt.Mesh, build string) *gated "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: mt.NewState(), Holder: func(string) (bool, error) { return true, nil }, + 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 }, - Publish: func(module, subject string, body any, id string) error { + 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 @@ -418,15 +420,15 @@ func newGatedWaits(t *testing.T, s *Surface, mesh *mt.Mesh, build string) *gated Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}}) return nil }, - Record: func(module, id string) (*stdio.StateEntry, error) { - e, err := gw.records.Get(module + "." + id) - if errors.Is(err, nats.ErrKeyNotFound) { - return nil, nil - } else if err != nil { - return nil, err - } - return &stdio.StateEntry{Key: e.Key(), Value: e.Value(), Revision: e.Revision()}, nil - }}) + 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 @@ -496,14 +498,20 @@ func (c *waitCatalog) wait(within time.Duration) error { return c.Ready() } func (c *waitCatalog) Revision() string { return "7" } -func (c *waitCatalog) Served(string) (catalogseat.CatalogEntry, bool, string) { - return catalogseat.CatalogEntry{}, false, "not-found" +func (c *waitCatalog) Served(string) (approval.Entry, bool, string) { + return approval.Entry{}, false, "not-found" } -func (c *waitCatalog) Newest(string) (catalogseat.CatalogEntry, bool) { - return catalogseat.CatalogEntry{}, false +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() []catalogseat.CatalogEntry { return c.entries } +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. func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index f230589..e6f911b 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -7,15 +7,15 @@ import ( "time" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/seatdef" - "github.com/novox/mesh-tools/node-tools/internal/approval" + 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() approval.FindingWriter { +func (v *Views) Findings() runnerapproval.HeldWriter { if v.deps.Findings == nil { return nil } @@ -55,20 +55,20 @@ func (c approvalCatalog) Revision() string { return "" } -func (c approvalCatalog) Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) { +func (c approvalCatalog) Served(fingerprint string) (approval.Entry, bool, string) { if c.v.branches == nil { - return catalogseat.CatalogEntry{}, false, errNotFollowed.Error() + return approval.Entry{}, false, errNotFollowed.Error() } e, state := c.v.branches.Resolve(context.Background(), fingerprint) - return e, state == meshcatalogclient.EntryAnswered, string(state) + return runnerapproval.Entry(e), state == meshcatalogclient.EntryAnswered, string(state) } -func (c approvalCatalog) Newest(module string) (catalogseat.CatalogEntry, bool) { +func (c approvalCatalog) Newest(module string) (approval.Entry, bool) { if c.v.branches == nil { - return catalogseat.CatalogEntry{}, false + return approval.Entry{}, false } e, ok, err := c.v.branches.Entry(module) - return e, ok && err == nil + return runnerapproval.Entry(e), ok && err == nil } func (c approvalCatalog) Definitions() map[string]seatdef.Definition { @@ -78,15 +78,15 @@ func (c approvalCatalog) Definitions() map[string]seatdef.Definition { return c.defs() } -func (c approvalCatalog) Entries() []catalogseat.CatalogEntry { +func (c approvalCatalog) Entries() []approval.Entry { halves, err := c.v.Halves() if err != nil { return nil } - var out []catalogseat.CatalogEntry + var out []approval.Entry for _, half := range halves { for _, e := range half.Entries { - out = append(out, e) + out = append(out, runnerapproval.Entry(e)) } } return out diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 8f0d522..4cb48a9 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -25,10 +25,10 @@ import ( catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" - "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" ) diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 7c8020e..61af8a6 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -3,9 +3,9 @@ package runtime import ( "fmt" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/seatdef" - "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/launch" ) diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 15bfdc2..d42b3a4 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -16,10 +16,10 @@ 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/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" ) -- 2.54.0 From 1e1a1602e1cb8281e07f8ade3e05cd9db77b9053 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:23:28 +0100 Subject: [PATCH 37/44] B5g3-5: the tool runner receives a chunked seat answer and hands the bundle the whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §681, §712, A-237, A-249, A-139 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/chunked.go | 95 +++++++++++++++++++++ node-tools/internal/bus/chunked_test.go | 75 ++++++++++++++++ node-tools/internal/runtime/chunked_test.go | 29 +++++++ node-tools/internal/runtime/gate.go | 19 +++++ node-tools/internal/runtime/runtime.go | 6 +- 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 node-tools/internal/bus/chunked.go create mode 100644 node-tools/internal/bus/chunked_test.go create mode 100644 node-tools/internal/runtime/chunked_test.go diff --git a/node-tools/internal/bus/chunked.go b/node-tools/internal/bus/chunked.go new file mode 100644 index 0000000..d1da74c --- /dev/null +++ b/node-tools/internal/bus/chunked.go @@ -0,0 +1,95 @@ +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) { + seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if !strings.HasPrefix(key, "seat:") || !ok || strings.Contains(verb, "@") { + return Answered{}, fmt.Errorf("%q is no seat:. of no one machine, 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 +} diff --git a/node-tools/internal/bus/chunked_test.go b/node-tools/internal/bus/chunked_test.go new file mode 100644 index 0000000..a14741e --- /dev/null +++ b/node-tools/internal/bus/chunked_test.go @@ -0,0 +1,75 @@ +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() + 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) + } +} diff --git a/node-tools/internal/runtime/chunked_test.go b/node-tools/internal/runtime/chunked_test.go new file mode 100644 index 0000000..3b929c1 --- /dev/null +++ b/node-tools/internal/runtime/chunked_test.go @@ -0,0 +1,29 @@ +package runtime + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// 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 no one machine. +func TestOnlyAChunkedSeatVerbIsAskedInChunks(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@ace": 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") + } +} diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 61af8a6..960f299 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -2,6 +2,7 @@ package runtime import ( "fmt" + "strings" "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/seatdef" @@ -86,3 +87,21 @@ func (r *runner) unservedMarks(module string, regs []launch.Registration) []stri } 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 + } + seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if !ok || strings.Contains(verb, "@") { + 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/runtime.go b/node-tools/internal/runtime/runtime.go index d42b3a4..c391053 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -915,7 +915,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 } -- 2.54.0 From 14f464e8bcb5edfbd8ca56e6203a5f90c4458398 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:43:08 +0100 Subject: [PATCH 38/44] C1c-4: the tool runner sends a bundle's chunked seat answer in chunks, and a node's chunked verb is asked of the node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seat verb whose definition says chunks, served by a bundle that answers the whole file as the member `file`, is sent by the runner with buscalls.SeatChunks.Send and answered with the chunk count (Conn.AnswerChunked, called from serveSeats). A chunked verb asked of one machine (`@`) is received in chunks as the verb is. Builds: design 47 G A-257, A-249, A-254, §681 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/chunked.go | 54 +++++++++++++++++++-- node-tools/internal/bus/chunked_test.go | 41 ++++++++++++++++ node-tools/internal/runtime/chunked_test.go | 5 +- node-tools/internal/runtime/gate.go | 5 +- node-tools/internal/runtime/runtime.go | 11 +++-- 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/node-tools/internal/bus/chunked.go b/node-tools/internal/bus/chunked.go index d1da74c..7b9257b 100644 --- a/node-tools/internal/bus/chunked.go +++ b/node-tools/internal/bus/chunked.go @@ -25,9 +25,12 @@ const FileField = "file" // 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) { - seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") - if !strings.HasPrefix(key, "seat:") || !ok || strings.Contains(verb, "@") { - return Answered{}, fmt.Errorf("%q is no seat:. of no one machine, which a chunked answer needs", key) + // 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 { @@ -93,3 +96,48 @@ func (c *Conn) AskAsChunked(module, key string, body any) (Answered, error) { 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 index a14741e..5e0d74c 100644 --- a/node-tools/internal/bus/chunked_test.go +++ b/node-tools/internal/bus/chunked_test.go @@ -73,3 +73,44 @@ func TestAChunkedSeatAnswerIsReceivedByTheRunnerAndHandedWhole(t *testing.T) { 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. +func TestABundlesChunkedAnswerIsSentByTheRunner(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() + 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/runtime/chunked_test.go b/node-tools/internal/runtime/chunked_test.go index 3b929c1..34da586 100644 --- a/node-tools/internal/runtime/chunked_test.go +++ b/node-tools/internal/runtime/chunked_test.go @@ -7,7 +7,7 @@ import ( ) // 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 no one machine. +// of any machine: a verb a node holds is asked of the node that ran the ask (A-254). func TestOnlyAChunkedSeatVerbIsAskedInChunks(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"}}}} @@ -15,7 +15,8 @@ func TestOnlyAChunkedSeatVerbIsAskedInChunks(t *testing.T) { for key, want := range map[string]bool{ "seat:mesh-catalog.input": true, "seat:mesh-catalog.entry": false, - "seat:mesh-catalog.input@ace": false, + "seat:mesh-catalog.input@ace": true, + "seat:mesh-catalog.entry@ace": false, "seat:mesh-unread.input": false, "mesh-delivery-indexer.indexer": false, } { diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 960f299..02a605f 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -94,8 +94,9 @@ func (c *consumers) chunked(key string) bool { if c.definitions == nil || !strings.HasPrefix(key, "seat:") { return false } - seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") - if !ok || strings.Contains(verb, "@") { + 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 { diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index c391053..3a58b82 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -306,7 +306,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.fingerprintOf, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.events.chunked, r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -710,7 +710,7 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // 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, gated gatedRun, - admitted func(seat, verb, caller string) error, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { + admitted func(seat, verb, caller string) error, chunked func(key string) bool, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -756,8 +756,13 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, if err := admitted(v.Seat, v.Verb, caller); err != nil { return nil, err } - return gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb, + 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) -- 2.54.0 From 9fcb14e74141836935f5e1c2baf95aa085e08074 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 10:21:21 +0100 Subject: [PATCH 39/44] A3h item 1: the tool runner serves a held seat's approvals from its gate's record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat verb approvals is the gate's own, served wherever a marked verb is: the runner answers it by caller from the calls its gate holds, with no bundle naming it; a marked verb already reaches the gate by caller. Tested with zsh's node-login-shell execute as the case. Builds: design 47 G §677, §697; lead answers A-264, A-265, A-266, A-271 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/runtime/gate.go | 10 +++ .../internal/runtime/gate_approvals_test.go | 79 +++++++++++++++++++ node-tools/internal/runtime/runtime.go | 26 +++++- .../test/fixtures/login-shell-execute.py | 23 ++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 node-tools/internal/runtime/gate_approvals_test.go create mode 100755 node-tools/test/fixtures/login-shell-execute.py diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 61af8a6..4ae83fb 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -18,6 +18,8 @@ type Gate interface { 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. @@ -32,6 +34,14 @@ func (c *consumers) gated(call approval.Call, t launch.Tool, caller string) (any 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 } } 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..41d1c8b --- /dev/null +++ b/node-tools/internal/runtime/gate_approvals_test.go @@ -0,0 +1,79 @@ +package runtime + +import ( + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "github.com/novox/mesh-tools/node-tools/internal/bus" + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" +) + +// 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 +} + +// **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. +func TestTheRunnerServesApprovalsForTheSeatsItsBundlesHold(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: "shanks", User: "shanks.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 != "shanks.node-tools" { + t.Fatalf("the marked verb reached the gate as %+v, want the seat's execute by shanks.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 != "shanks.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/runtime.go b/node-tools/internal/runtime/runtime.go index d42b3a4..c5da7b0 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -306,7 +306,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.fingerprintOf, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.approvalsOf(), r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -655,6 +655,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 } @@ -710,7 +713,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // 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, gated gatedRun, - admitted func(seat, verb, caller string) error, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { + admitted func(seat, verb, caller string) error, 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 @@ -744,6 +748,24 @@ 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) 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": {}}) -- 2.54.0 From d350c9882369e66186380e656e128f9065ce2be6 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 10:41:44 +0100 Subject: [PATCH 40/44] Join 11: the merged heads compile where no area's fix is owed The join's importers name the delivery repository's moved contracts and sdk (MV1), node-tools' tests use mesh-sdk's gate, the duplicate node-engine claim rule of the reader release gives way to G's, and the assigner names its first watched branch, never a constant. The seat holders' AnswerSeat callers are area 6's. Builds: design 47 G join 11 (merge of fix round 11) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals_integration_test.go | 2 +- node-tools/internal/approval/approval_test.go | 2 +- node-tools/internal/approval/contracts.go | 2 +- node-tools/internal/approval/marks_test.go | 2 +- node-tools/internal/console/wait.go | 2 +- node-tools/internal/console/wait_test.go | 4 ++-- node-tools/internal/firstview/approval.go | 2 +- node-tools/internal/firstview/firstview_integration_test.go | 6 +++--- node-tools/internal/runtime/ledgers_test.go | 1 - node-tools/internal/runtime/runtime_test.go | 3 ++- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/node-tools/cmd/node-tools/approvals_integration_test.go b/node-tools/cmd/node-tools/approvals_integration_test.go index 2eb2ed1..529bbc1 100644 --- a/node-tools/cmd/node-tools/approvals_integration_test.go +++ b/node-tools/cmd/node-tools/approvals_integration_test.go @@ -12,7 +12,7 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + 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" diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 8bc98ff..d722adb 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -10,7 +10,7 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + 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" diff --git a/node-tools/internal/approval/contracts.go b/node-tools/internal/approval/contracts.go index 928821f..46e3b37 100644 --- a/node-tools/internal/approval/contracts.go +++ b/node-tools/internal/approval/contracts.go @@ -12,7 +12,7 @@ import ( 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/contracts/go/seats/meshcatalog" + 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" ) diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go index a18bfd7..a38f217 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -10,7 +10,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + 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" ) diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index d15df4d..ad08d72 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -17,7 +17,7 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "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" diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index c0af795..984aca2 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -16,8 +16,8 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" opseat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "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" diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index e6f911b..24a8aed 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "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" diff --git a/node-tools/internal/firstview/firstview_integration_test.go b/node-tools/internal/firstview/firstview_integration_test.go index e3afd0f..48879d9 100644 --- a/node-tools/internal/firstview/firstview_integration_test.go +++ b/node-tools/internal/firstview/firstview_integration_test.go @@ -17,9 +17,9 @@ import ( "git.novox.be/novox/mesh-sdk/go/views" "github.com/nats-io/nats.go" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "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" diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 53e9780..3b58606 100644 --- a/node-tools/internal/runtime/ledgers_test.go +++ b/node-tools/internal/runtime/ledgers_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "slices" "sort" "strings" "testing" diff --git a/node-tools/internal/runtime/runtime_test.go b/node-tools/internal/runtime/runtime_test.go index 775b920..b8e8906 100644 --- a/node-tools/internal/runtime/runtime_test.go +++ b/node-tools/internal/runtime/runtime_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/novox/mesh-tools/node-tools/internal/approval" + "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" @@ -208,6 +208,7 @@ func (g unservingGate) Unserved(module, _ string, registered []string) []string 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 -- 2.54.0 From c35c27f664048d50f2ef1000e8eff7a77cf1e4e1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 10:44:02 +0100 Subject: [PATCH 41/44] Area 1 (part): the tool runner's manifest states approval-mark/1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner states the approval mark's word beside its call families, so its artifact's states are exactly what it says (the join of A3's and A5's lines); TestTheWordsTheRunnerStatesAreItsManifestsStates failed without it. Builds: design 47 G §545, design 46 7-approval-mark §6; join 11 area 1 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/module.json | 1 + 1 file changed, 1 insertion(+) diff --git a/node-tools/module.json b/node-tools/module.json index 6fe98c5..aeb6876 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -60,6 +60,7 @@ "from": "cmd/node-tools", "binary": "node-tools", "states": [ + "approval-mark/1", "bus-state-calls", "finding-calls", "ledger-calls", -- 2.54.0 From 816df217cdb14a59783b2df028ac0b1511938b04 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 14:07:29 +0100 Subject: [PATCH 42/44] Join 11: every go.mod requires, at the zero version, each mesh module its packages load The checker's fetch pins only the zero-version requirements a directory's go.mod names (mesh-build-go workspace.go goModOf), so a module reached only through another, such as meshhandacts through meshhandactsclient or mesh-delivery-sdk through meshcatalogclient, was never downloaded and the shut-off load failed (fetch.go:267). The join's merges had dropped these indirect requirements that the trunk's go.mod files list. Builds: design 47 G (join 11, the check's fetch of the group's modules) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/node-tools/go.mod b/node-tools/go.mod index 33c8f2b..7d01aa2 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -32,3 +32,5 @@ require ( ) 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 -- 2.54.0 From 28bece26b06ec3c99304823b8fef11b26c78e7bf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:14:01 +0100 Subject: [PATCH 43/44] Join 11 (try 2): the chunked-answer tests flush the holder's verb before another connection asks it The tests of A-249 and A-257 served the verb on one connection and asked it from another at once; the subscription was still in the holder's buffer when the ask reached the bus, so the ask was answered 503 no responders (13 of 300 runs with -cpu 1 before, 0 of 2000 after). The runner flushes after serving (runtime.go:352, :807); the tests now do as it does. Builds: design 47 G A-249, A-257 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/chunked_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/node-tools/internal/bus/chunked_test.go b/node-tools/internal/bus/chunked_test.go index 5e0d74c..c034965 100644 --- a/node-tools/internal/bus/chunked_test.go +++ b/node-tools/internal/bus/chunked_test.go @@ -57,6 +57,7 @@ func TestAChunkedSeatAnswerIsReceivedByTheRunnerAndHandedWhole(t *testing.T) { 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) @@ -93,6 +94,7 @@ func TestABundlesChunkedAnswerIsSentByTheRunner(t *testing.T) { 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) -- 2.54.0 From 481a83e11c4fe8e9774c5bb8032995f4f94247a4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 22:24:02 +0100 Subject: [PATCH 44/44] Round 2 T2, T3, T4, T5: the tool runner ends G with its merge-base test count; the wait test runs its rows side by side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests G added fold into existing ones as subtests (design 47 G §626, A-306: 171 tests down to 154, integration 9 down to 7). TestMeshWaitRefusesLoudlyAndNeverLoops runs its groups of one-second rows as parallel subtests, each on a runtime of its own, 15 s down to 5 s (A-311: the one-second floor of a wait's timeout stands, no clock is injected). The go.mod no longer requires meshconditionsclient, which no package loads (T4). Node names in the lines G adds are role names (T5). Builds: design 47 G §626, design 34 every-event §19, A-306, A-311; review round 2 findings T2, T3, T4, T5 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/go.mod | 1 - node-tools/internal/approval/approval_test.go | 83 ++++++++++ node-tools/internal/approval/marks_test.go | 86 ---------- node-tools/internal/bus/chunked_test.go | 81 +++++----- node-tools/internal/bus/sent_test.go | 27 ---- node-tools/internal/bus/state_watch_test.go | 137 ++++++++-------- .../internal/bus/streams_internal_test.go | 25 ++- node-tools/internal/console/refusal_test.go | 48 ++++++ node-tools/internal/console/sent_test.go | 56 ------- node-tools/internal/console/wait_test.go | 147 +++++++++++------- .../internal/firstview/firstview_test.go | 71 +++++---- node-tools/internal/launch/seat_test.go | 60 +++++++ node-tools/internal/launch/sessions_test.go | 65 -------- node-tools/internal/ledger/ledger_test.go | 91 ++++++----- node-tools/internal/runtime/announce_test.go | 99 ++++++------ node-tools/internal/runtime/caller_test.go | 70 ++++++++- node-tools/internal/runtime/chunked_test.go | 30 ---- .../internal/runtime/gate_approvals_test.go | 53 ------- .../runtime/ledger_integration_test.go | 67 ++++++++ .../internal/runtime/own_account_test.go | 37 +++-- node-tools/internal/runtime/sessions_test.go | 118 +++++++------- .../runtime/subscribenew_integration_test.go | 84 ---------- 22 files changed, 757 insertions(+), 779 deletions(-) delete mode 100644 node-tools/internal/bus/sent_test.go delete mode 100644 node-tools/internal/console/sent_test.go delete mode 100644 node-tools/internal/runtime/chunked_test.go delete mode 100644 node-tools/internal/runtime/subscribenew_integration_test.go diff --git a/node-tools/go.mod b/node-tools/go.mod index 7d01aa2..606687c 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -5,7 +5,6 @@ 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 diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index d722adb..97c209f 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -3,6 +3,7 @@ package approval import ( "encoding/json" "errors" + "reflect" "strings" "sync" "testing" @@ -506,4 +507,86 @@ func TestTheRefusalsAndAnswers(t *testing.T) { } { 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/marks_test.go b/node-tools/internal/approval/marks_test.go index a38f217..708536c 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -2,12 +2,9 @@ package approval import ( "context" - "reflect" "sort" - "testing" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" - "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" @@ -47,89 +44,6 @@ func entry(fp string, serves []string, manifest map[string]any) catalogseat.Cata return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest} } -// Design 46 7-approval-mark §5, §3, §4 and design 34 every-event §33: the marks a module's calls are gated by, derived from -// fixture catalog halves and served entries. -func TestTheMarks(t *testing.T) { - login := map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", - Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}, {Name: "sessions"}}}} - v1 := entry("src2:1", []string{"node-login-shell.execute", "node-login-shell.sessions"}, - map[string]any{"needs-approval": []any{"wipe"}}) - v2 := entry("src2:2", []string{"node-login-shell.execute", "node-login-shell.sessions"}, - map[string]any{"needs-approval": []any{"erase", "node-login-shell.sessions"}}) - mail := catalogseat.CatalogEntry{Module: "mail", Fp: "src2:m", Manifest: map[string]any{ - "needs-approval": []any{"mail.received"}, "emits": []any{"mail.received"}, - "streams": []any{map[string]any{"name": "MAIL", "events": []any{"mail.**"}, "grant": map[string]any{"config": "readers", "events": "mail.{key}.**"}}}}} - for _, tc := range []struct { - name string - cat *fakeCatalog - module string - fp string - tools []string - verbs map[string]string - subjects []string - unread bool - }{ - {name: "a seat's mark stands for every holder, and the holder's own adds up", cat: &fakeCatalog{revision: "7", - byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login}, - module: "shell", fp: "src2:1", tools: []string{"wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell"}}, - {name: "a holder's needs-approval cannot unmark its seat's verb, and marks another", cat: &fakeCatalog{revision: "8", - byFp: map[string]catalogseat.CatalogEntry{"src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, - module: "shell", fp: "src2:2", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", - "node-login-shell.sessions": "entry shell src2:2"}}, - {name: "across a version change the union stands: a dropped mark is asked while the old bundle runs", cat: &fakeCatalog{revision: "9", - byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1, "src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, - module: "shell", fp: "src2:1", tools: []string{"erase", "wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", - "node-login-shell.sessions": "entry shell src2:2"}}, - {name: "a served entry that cannot be read leaves the newest marks, every marked call refused", cat: &fakeCatalog{revision: "9", - newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, - module: "shell", fp: "src2:1", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", - "node-login-shell.sessions": "entry shell src2:2"}, unread: true}, - {name: "a bundle with no fingerprint has no served entry", cat: &fakeCatalog{revision: "9", defs: login}, - module: "shell", unread: true}, - {name: "a marked event and a stream with a grant give their subjects and each .>", cat: &fakeCatalog{revision: "3", - byFp: map[string]catalogseat.CatalogEntry{"src2:m": mail}, newest: map[string]catalogseat.CatalogEntry{"mail": mail}}, - module: "mail", fp: "src2:m", subjects: []string{"mesh.mod.mail.event.mail.received", "mesh.mod.mail.event.mail.received.>", - "mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}}, - } { - t.Run(tc.name, func(t *testing.T) { - m := 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) - } - }) -} - // findings is the runner's held writer as a fixture: each finding raised now, by id. type findings struct { raised map[string]string diff --git a/node-tools/internal/bus/chunked_test.go b/node-tools/internal/bus/chunked_test.go index c034965..a838f2e 100644 --- a/node-tools/internal/bus/chunked_test.go +++ b/node-tools/internal/bus/chunked_test.go @@ -73,46 +73,45 @@ func TestAChunkedSeatAnswerIsReceivedByTheRunnerAndHandedWhole(t *testing.T) { 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. -func TestABundlesChunkedAnswerIsSentByTheRunner(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) + // 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) + } }) - 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/sent_test.go b/node-tools/internal/bus/sent_test.go deleted file mode 100644 index ca7d1b4..0000000 --- a/node-tools/internal/bus/sent_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package bus - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/buscalls" -) - -// 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. -func TestSentGetRefusesWhatIsNoSentObjectsReference(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) - } - } -} 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/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_test.go b/node-tools/internal/console/sent_test.go deleted file mode 100644 index 80cd477..0000000 --- a/node-tools/internal/console/sent_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package console - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "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" -) - -// 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. -func TestAnAnswerSentAsAReferenceIsReadForThePersonAsking(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) - } -} diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 984aca2..1fe7562 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -203,7 +203,7 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) { // a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a // wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant. func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { - s, mesh := waitingRuntime(t) + s, _ := waitingRuntime(t) var seventeen []any for i := 0; i < 17; i++ { seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i)) @@ -230,63 +230,80 @@ func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { } } - // 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("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) + } + }) - // The bus refusing every subscription of the call: answered after the timeout, in §16's words. - b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true, - Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{ - Subscribe: &server.SubjectPermission{Allow: []string{"_INBOX.desk.node-tools.>", "mesh.assignment.desk.*"}}, - }}}}) - if err != nil { - t.Fatal(err) - } - go b.Start() - if !b.ReadyForConnections(10 * time.Second) { - t.Fatal("the bus did not come up") - } - t.Cleanup(b.Shutdown) - conn, err := bus.Connect(bus.Credential{URL: b.ClientURL(), Module: "node-tools", Node: "desk", User: "desk.node-tools", Password: "pw"}) - if err != nil { - t.Fatal(err) - } - conn.Logf = func(string, ...any) {} - t.Cleanup(conn.Close) - start := time.Now() - text, isErr := callWait(NewSurface(conn, nil, "desk.node-tools"), map[string]any{"subjects": []any{"mesh-delivery-view.delivered"}, "timeout": 1}) - if took := time.Since(start); took < time.Second || took > 3*time.Second { - t.Errorf("a wait the bus refused whole answered after %s, want its timeout of 1s", took) - } - if !isErr || !strings.Contains(text, "the bus refused this account a subscription on mesh.seat.mesh-delivery-view.event.delivered") || - !strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") { - t.Errorf("a wait the bus refused whole: %s", text) - } // Design 34 every-event §37: a wait whose subjects overlap a marked event's, or a stream's with a `grant`, runs only on the // operator's warrant for that one wait; with none it subscribes nothing, asks once and answers at its timeout or the choice. - markedWaits(t, s, mesh) + // 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) + }) + } } -// markedWaits are §37's rows, against a gate on the test bus, the router's records written there as the router writes them. -func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { +// 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) { @@ -321,6 +338,17 @@ func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { !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. @@ -328,7 +356,7 @@ func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { done := make(chan string, 1) go func() { text, _, d := took(long); done <- fmt.Sprintf("%s after %s", text, d) }() mt.Until(t, func() error { - if gw.asks() < 4 { + if gw.asks() < 1 { return os.ErrNotExist } return nil @@ -342,7 +370,7 @@ func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { case <-time.After(10 * time.Second): t.Fatal("a long marked wait was not answered at the choice") } - before = g.conn.Subscriptions() + before := g.conn.Subscriptions() go func() { listening(t, g, before, 6) mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 2}) @@ -361,6 +389,17 @@ func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { t.Errorf("another wait than the warrant's %v: %s", other, 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() diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index eacd22f..5221d6e 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -254,43 +254,42 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { 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. -func TestTheAssignersViewsFollowTheModuleThatClaimsTheSeat(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 + // 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) } } - 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") + 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/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 index f46a5a2..e2d2c93 100644 --- a/node-tools/internal/launch/sessions_test.go +++ b/node-tools/internal/launch/sessions_test.go @@ -3,12 +3,8 @@ package launch import ( "encoding/json" "errors" - "os" - "path/filepath" "strings" "sync" - "testing" - "time" ) // puttingBundle asks the capability words, and once they are answered with put-sessions sends the one chunk of a put @@ -42,32 +38,6 @@ func (b *sessionBus) BusState(method string, params json.RawMessage) (json.RawMe } func (b *sessionBus) DropSessions() { close(b.dropped) } -// 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. -func TestABundleReadsTheCapabilityWordsRunsAPutSessionAndItsChannelsClosingDropsThem(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) - } -} - // 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 @@ -106,38 +76,3 @@ func (b *findingBus) SubscribeNew(pattern string, _ func(json.RawMessage) error) b.mu.Unlock() return func() { close(b.stopped) }, nil } - -// 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. -func TestAPublishWithAckIsAnsweredWithItsSequenceAndASubscriptionForNewMessagesEndsWithTheChannel(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) - } -} diff --git a/node-tools/internal/ledger/ledger_test.go b/node-tools/internal/ledger/ledger_test.go index cbc315d..04b7c4b 100644 --- a/node-tools/internal/ledger/ledger_test.go +++ b/node-tools/internal/ledger/ledger_test.go @@ -99,51 +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) -// 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. -func TestTheLedgerSaysItsFillAndRemovesAnInstanceWhole(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) - } + 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/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index b942524..43556e6 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -2,7 +2,6 @@ package runtime import ( "encoding/json" - "git.novox.be/novox/mesh-sdk/go/buscalls" "os" "slices" "sort" @@ -10,6 +9,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/buscalls" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/micro" @@ -150,57 +151,55 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { 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. -func TestEachCallIsStatedUnderTheWordOfItsFamily(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, - } { - var in []string - for c, methods := range callFamilies { - if slices.Contains(methods, method) { - in = append(in, c) + // 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, + } { + 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) } } - 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) } - } -} - -// 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`. -func TestTheWordsTheRunnerStatesAreItsManifestsStates(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 + var m struct { + Build struct { + Artifacts []struct { + Name string `json:"name"` + States []string `json:"states"` + } `json:"artifacts"` + } `json:"build"` } - } - slices.Sort(declared) - if got := CallCapabilities(); !slices.Equal(got, declared) { - t.Errorf("the runner states %v, its manifest's states are %v", got, declared) - } + 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/caller_test.go b/node-tools/internal/runtime/caller_test.go index 46c4c95..d44a0d2 100644 --- a/node-tools/internal/runtime/caller_test.go +++ b/node-tools/internal/runtime/caller_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats.go" @@ -121,9 +122,76 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) { {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Class: seatdef.ClassAct}}}}, ""}, } { defs := c.defs - err := (&consumers{definitions: func() map[string]seatdef.Definition { return defs }}).admitted("node-desk", "who", "shanks.node-tools") + 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/chunked_test.go b/node-tools/internal/runtime/chunked_test.go deleted file mode 100644 index 34da586..0000000 --- a/node-tools/internal/runtime/chunked_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package runtime - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/seatdef" -) - -// 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). -func TestOnlyAChunkedSeatVerbIsAskedInChunks(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@ace": true, - "seat:mesh-catalog.entry@ace": 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") - } -} diff --git a/node-tools/internal/runtime/gate_approvals_test.go b/node-tools/internal/runtime/gate_approvals_test.go index 41d1c8b..7f85aac 100644 --- a/node-tools/internal/runtime/gate_approvals_test.go +++ b/node-tools/internal/runtime/gate_approvals_test.go @@ -1,14 +1,7 @@ package runtime import ( - "encoding/json" - "testing" - "git.novox.be/novox/mesh-sdk/go/approval" - "git.novox.be/novox/mesh-sdk/go/seatdef" - - "github.com/novox/mesh-tools/node-tools/internal/bus" - mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) // gateHolding is the runner's gate as far as the runtime reaches it: it records the calls it is run for and answers @@ -31,49 +24,3 @@ func (g *gateHolding) Approvals(caller string) (approval.Approvals, error) { } return out, nil } - -// **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. -func TestTheRunnerServesApprovalsForTheSeatsItsBundlesHold(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: "shanks", User: "shanks.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 != "shanks.node-tools" { - t.Fatalf("the marked verb reached the gate as %+v, want the seat's execute by shanks.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 != "shanks.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/ledger_integration_test.go b/node-tools/internal/runtime/ledger_integration_test.go index 589afce..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" @@ -287,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/own_account_test.go b/node-tools/internal/runtime/own_account_test.go index a44d681..ff678e6 100644 --- a/node-tools/internal/runtime/own_account_test.go +++ b/node-tools/internal/runtime/own_account_test.go @@ -36,26 +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. -func TestTheRuntimesOwnFingerprintReachesNoBundle(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) + // 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) + } } - 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/sessions_test.go b/node-tools/internal/runtime/sessions_test.go index 5924c97..4d67f94 100644 --- a/node-tools/internal/runtime/sessions_test.go +++ b/node-tools/internal/runtime/sessions_test.go @@ -64,66 +64,6 @@ func asked(t *testing.T, b *moduleBus, method string, req any, into any) { } } -// 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. -func TestAPutSessionSealsOnTheLastChunkAndDropsOnADisorder(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. -func TestAPutNotSealedWithinTheBoundIsDropped(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) - } -} - // §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) { @@ -192,4 +132,62 @@ func TestAGetOverOneChunkRunsAsASession(t *testing.T) { 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/internal/runtime/subscribenew_integration_test.go b/node-tools/internal/runtime/subscribenew_integration_test.go deleted file mode 100644 index 9dc1d14..0000000 --- a/node-tools/internal/runtime/subscribenew_integration_test.go +++ /dev/null @@ -1,84 +0,0 @@ -//go:build integration - -package runtime - -import ( - "context" - "encoding/json" - "strings" - "sync" - "testing" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "github.com/novox/mesh-tools/node-tools/internal/bus" - mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" -) - -// 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. -func TestAnAckedPublishAnswersItsSequenceAndANewSubscriptionHearsOnlyWhatComesAfter(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) - } -} -- 2.54.0