From 507fac41fee86f5b536819ac0aa9ca141695ed95 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:14:42 +0000 Subject: [PATCH 01/13] Declare the bridge's performed bucket (issue 1341) --- modules/mesh-delivery-forge-bridge/module.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/mesh-delivery-forge-bridge/module.json b/modules/mesh-delivery-forge-bridge/module.json index ec78755..641fa86 100644 --- a/modules/mesh-delivery-forge-bridge/module.json +++ b/modules/mesh-delivery-forge-bridge/module.json @@ -11,7 +11,8 @@ { "name": "forge", "events": ["forge.**"] } ], "state": [ - { "name": "heads", "history": 1 } + { "name": "heads", "history": 1 }, + { "name": "performed", "history": 1 } ], "reads": [ "mesh-delivery-gate.merges", From 7686cfc55ddbc90241e75ecc47b3f858cbc84a21 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:15:29 +0000 Subject: [PATCH 02/13] Turn an owed key into the forge call that performs it, and a refusal into a refused message (issue 1341) --- modules/mesh-delivery-forge-bridge/go.mod | 2 +- modules/mesh-delivery-forge-bridge/go.sum | 2 + .../internal/bridge/acts.go | 106 +++++++++++++++ .../internal/bridge/acts_test.go | 124 ++++++++++++++++++ 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-delivery-forge-bridge/internal/bridge/acts.go create mode 100644 modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index fb59585..691a687 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index f74bae2..d96e678 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9 h1:XL1dbtSleGAixg6FX4gEnSG/AJxRG/tWw6/7U1RuzHc= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go b/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go new file mode 100644 index 0000000..c15919b --- /dev/null +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go @@ -0,0 +1,106 @@ +package bridge + +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Call is a call of a forge tool: the tool by its manifest key and its arguments. +type Call struct { + Tool string + Args map[string]any +} + +// Act is one thing the bridge does on the forge for a key of the gate: the act's name (an owed key's act, `status=`, +// `view`, `note`, `comment.`), the pull request and head it is on (Number 0 for a commit no pull +// request names) and the forge call that performs it. +type Act struct { + Name string + Repository string + Number int + Head string + Call Call +} + +// The forge's tools, by manifest key; their arguments are the ones of mesh-catalog modules/gitea: delivery.ts +// commitStatusTool (owner, repo, sha, context, state, description, target_url), tools/index.ts gitea_delivery_view +// (owner, repo, number, body), gitea_note_append (owner, repo, sha, ref, line), gitea_add_comment (owner, repo, +// number, body). +const ( + commitStatusTool = "gitea.gitea_commit_status" + deliveryViewTool = "gitea.gitea_delivery_view" + noteAppendTool = "gitea.gitea_note_append" + addCommentTool = "gitea.gitea_add_comment" +) + +func ownerRepo(repository string) (owner, repo string) { + owner, repo, _ = strings.Cut(repository, "/") + return owner, repo +} + +// OwedAct turns an owed key and its value (mesh-sdk go/delivery OwedKey, EncodeOwed) into the forge call that performs +// it. A key that is no owed key, a value that is no owed act, a value whose act is not the key's, and a view or a comment +// on no pull request are errors, each saying why. +func OwedAct(key string, value []byte) (Act, error) { + repository, number, head, name, err := delivery.ParseOwedKey(key) + if err != nil { + return Act{}, err + } + o, err := delivery.DecodeOwed(value) + if err != nil { + return Act{}, fmt.Errorf("the owed act %s: %w", key, err) + } + kind, context, err := delivery.ParseOwedAct(name) + if err != nil { + return Act{}, err + } + if o.Act != kind { + return Act{}, fmt.Errorf("the owed act %s says %q, not the %q its key names", key, o.Act, kind) + } + owner, repo := ownerRepo(repository) + a := Act{Name: name, Repository: repository, Number: number, Head: head} + needsPull := func() error { + if number <= 0 { + return fmt.Errorf("the owed %s %s is on a commit no pull request names, and it needs the pull request", kind, key) + } + return nil + } + switch kind { + case delivery.OwedStatus: + if o.Context != context { + return Act{}, fmt.Errorf("the owed status %s says the context %q, not the %q its key names", key, o.Context, context) + } + a.Call = Call{Tool: commitStatusTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "context": o.Context, + "state": o.State, "description": o.Text, "target_url": o.Target}} + case delivery.OwedView: + if err := needsPull(); err != nil { + return Act{}, err + } + a.Call = Call{Tool: deliveryViewTool, Args: map[string]any{"owner": owner, "repo": repo, "number": number, "body": o.Text}} + case delivery.OwedNote: + a.Call = Call{Tool: noteAppendTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "ref": o.Ref, "line": o.Text}} + case delivery.OwedComment: + if err := needsPull(); err != nil { + return Act{}, err + } + a.Call = Call{Tool: addCommentTool, Args: map[string]any{"owner": owner, "repo": repo, "number": number, "body": o.Text}} + } + return a, nil +} + +// RefusedMessage is the refused message the bridge writes on FORGE when the forge refuses an act: the act, the pull +// request and head it was on, and the forge's own words. +func RefusedMessage(a Act, words string) (Message, error) { + event, err := delivery.ForgeEvent(a.Repository, delivery.ForgeRefused) + if err != nil { + return Message{}, err + } + body, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeRefused, Repository: a.Repository, Number: a.Number, + Head: a.Head, Act: a.Name, Words: words}) + if err != nil { + return Message{}, err + } + return Message{Event: event, Data: body}, nil +} diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go b/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go new file mode 100644 index 0000000..a4d414b --- /dev/null +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go @@ -0,0 +1,124 @@ +package bridge + +import ( + "reflect" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const ( + headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +// owedValue is an owed value as the SDK writes it (mesh-sdk go/delivery EncodeOwed), and its key as OwedKey spells it. +func owedValue(t *testing.T, repository string, number int, act string, o delivery.Owed) (string, []byte) { + t.Helper() + key, err := delivery.OwedKey(repository, number, headA, act) + if err != nil { + t.Fatal(err) + } + b, err := delivery.EncodeOwed(o) + if err != nil { + t.Fatal(err) + } + return key, b +} + +func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1 + key, v := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate", + delivery.Owed{Act: delivery.OwedStatus, Context: "mesh/merge-gate", State: "pending", Text: "waiting", Target: "https://forge.example/novox/mesh-delivery/pulls/12"}) + a, err := OwedAct(key, v) + if err != nil { + t.Fatal(err) + } + want := Call{Tool: "gitea.gitea_commit_status", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "sha": headA, + "context": "mesh/merge-gate", "state": "pending", "description": "waiting", "target_url": "https://forge.example/novox/mesh-delivery/pulls/12"}} + if !reflect.DeepEqual(a.Call, want) { + t.Errorf("call = %+v, want %+v", a.Call, want) + } + if a.Repository != "novox/mesh-delivery" || a.Number != 12 || a.Head != headA || a.Name != "status=mesh/merge-gate" { + t.Errorf("act = %+v", a) + } +} + +func TestOwedViewNoteAndCommentCalls(t *testing.T) { // 1341/AC2 + cases := []struct { + act string + o delivery.Owed + want Call + }{ + {"view", delivery.Owed{Act: delivery.OwedView, Text: "the view"}, + Call{Tool: "gitea.gitea_delivery_view", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "the view"}}}, + {"note", delivery.Owed{Act: delivery.OwedNote, Text: "a line", Ref: "mesh-plan"}, + Call{Tool: "gitea.gitea_note_append", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "sha": headA, "ref": "mesh-plan", "line": "a line"}}}, + {"comment.2", delivery.Owed{Act: delivery.OwedComment, Text: "hello"}, + Call{Tool: "gitea.gitea_add_comment", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "hello"}}}, + } + for _, c := range cases { + key, v := owedValue(t, "novox/mesh-delivery", 12, c.act, c.o) + a, err := OwedAct(key, v) + if err != nil { + t.Fatalf("%s: %v", c.act, err) + } + if !reflect.DeepEqual(a.Call, c.want) { + t.Errorf("%s: call = %+v, want %+v", c.act, a.Call, c.want) + } + } +} + +func TestOwedActWithoutAPullRequestNeedsOneOnlyWhereTheToolDoes(t *testing.T) { + key, v := owedValue(t, "novox/mesh-delivery", 0, "note", delivery.Owed{Act: delivery.OwedNote, Text: "l", Ref: "mesh-plan"}) + if _, err := OwedAct(key, v); err != nil { + t.Errorf("a note on a commit no pull request names: %v", err) + } + key, v = owedValue(t, "novox/mesh-delivery", 0, "view", delivery.Owed{Act: delivery.OwedView, Text: "v"}) + if _, err := OwedAct(key, v); err == nil { + t.Error("a view of no pull request was taken") + } +} + +func TestOwedActOfADottedRepositoryIsUnescaped(t *testing.T) { + key, v := owedValue(t, "novox/my.repo", 3, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "x"}) + a, err := OwedAct(key, v) + if err != nil { + t.Fatal(err) + } + if a.Call.Args["repo"] != "my.repo" || a.Repository != "novox/my.repo" { + t.Errorf("act = %+v", a) + } +} + +func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) { + key, _ := owedValue(t, "novox/mesh-delivery", 12, "view", delivery.Owed{Act: delivery.OwedView, Text: "v"}) + v, _ := delivery.EncodeOwed(delivery.Owed{Act: delivery.OwedComment, Text: "v"}) + if _, err := OwedAct(key, v); err == nil { + t.Error("a comment under a view's key was taken") + } + if _, err := OwedAct("nonsense", v); err == nil { + t.Error("a key that is no owed key was taken") + } + if _, err := OwedAct(key, []byte(`{"v":1`)); err == nil { + t.Error("a value that is not json was taken") + } +} + +func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3 + a := Act{Name: "comment.1", Repository: "novox/mesh-delivery", Number: 12, Head: headA} + m, err := RefusedMessage(a, "novox/mesh-delivery#12 is not merged: its head moved") + if err != nil { + t.Fatal(err) + } + if m.Event != "forge.novox.mesh-delivery.refused" { + t.Errorf("event = %q", m.Event) + } + f, err := delivery.DecodeForge(m.Data) + if err != nil { + t.Fatal(err) + } + want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA, + Act: "comment.1", Words: "novox/mesh-delivery#12 is not merged: its head moved"} + if !delivery.SameForge(f, want) { + t.Errorf("message = %+v", f) + } +} From a0883a174b9638a85eae87faabcb1ddc32aa2988 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:15:54 +0000 Subject: [PATCH 03/13] Test the bridge's two state buckets, heads and performed (issue 1341) --- modules/mesh-delivery-forge-bridge/manifest_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/manifest_test.go b/modules/mesh-delivery-forge-bridge/manifest_test.go index 04d3557..f1bb6ea 100644 --- a/modules/mesh-delivery-forge-bridge/manifest_test.go +++ b/modules/mesh-delivery-forge-bridge/manifest_test.go @@ -102,9 +102,11 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1 if m.SecretsOwner != "mesh-delivery-forge-bridge" { t.Errorf("secrets-owner = %q", m.SecretsOwner) } - // One bucket: the head last written on FORGE for each pull request, so a sync writes only what differs (ADR 0443 §5). - if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}}) { // 1279/AC6 - t.Errorf("state = %+v, want the one bucket heads", m.State) + // Two buckets: the head last written on FORGE for each pull request, so a sync writes only what differs (ADR 0443 + // §5), and the revision of each key of the gate's buckets the bridge performed, so a key is performed once per + // revision (ADR 0437 §8; issue 1341). + if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}}) { // 1279/AC6, 1341 + t.Errorf("state = %+v, want the buckets heads and performed", m.State) } // No other state, no bus, no seat, no tool (ADR 0437 §4). for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats"} { From eeb5561a23155a6d908d53cd78e6444c5904ff52 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:16:43 +0000 Subject: [PATCH 04/13] Perform the gate's owed acts once per revision from the bridge, a refusal written on FORGE (issue 1341) --- .../cmd/mesh-delivery-forge-bridge/gate.go | 137 ++++++++++++++++ .../mesh-delivery-forge-bridge/gate_test.go | 147 ++++++++++++++++++ .../integration_test.go | 48 ++++++ .../cmd/mesh-delivery-forge-bridge/main.go | 37 ++++- 4 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go create mode 100644 modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go new file mode 100644 index 0000000..9de2289 --- /dev/null +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "strconv" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-forge-bridge/internal/bridge" +) + +// performedBucket is the module's state bucket performed, `_` as heads is: for each key of the gate's +// buckets the bridge has acted on, the revision it acted at (manifest state entry performed, novox/hq ADR 0437 §8). +// Its keys are `owed.` and `merges.`; its value is the revision in decimal. +const performedBucket = delivery.ForgeBridgeModule + "_performed" + +// kvRevisions is the bucket performed as the performer's revisions. +type kvRevisions struct{ kv jetstream.KeyValue } + +func (s kvRevisions) Get(ctx context.Context, key string) (uint64, bool, error) { + e, err := s.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + rev, err := strconv.ParseUint(string(e.Value()), 10, 64) + if err != nil { + return 0, false, fmt.Errorf("%s holds %q, which is no revision: %w", key, e.Value(), err) + } + return rev, true, nil +} + +func (s kvRevisions) Put(ctx context.Context, key string, rev uint64) error { + _, err := s.kv.PutString(ctx, key, strconv.FormatUint(rev, 10)) + return err +} + +// revisions is what the performer keeps of the revisions it acted at. +type revisions interface { + Get(ctx context.Context, key string) (rev uint64, found bool, err error) + Put(ctx context.Context, key string, rev uint64) error +} + +// performer does on the forge what the gate's keys ask (novox/hq ADR 0437 §8): one act for each revision of an owed +// key. A refusal is written on FORGE and the act is not tried again until +// the key has a new revision; the performer holds no timer and serves no request. +type performer struct { + call caller + pub publisher + done revisions + logger *log.Logger +} + +// seen says whether the revision of the key was acted on already. A revision that cannot be read is not seen, so the +// act is done: a status set twice is the same status. +func (p *performer) seen(ctx context.Context, doneKey string, rev uint64) bool { + kept, found, err := p.done.Get(ctx, doneKey) + if err != nil { + p.logger.Printf("%s: the revision acted at could not be read, so the act is done again: %v", doneKey, err) + return false + } + return found && kept >= rev +} + +func (p *performer) keep(ctx context.Context, doneKey string, rev uint64) { + if err := p.done.Put(ctx, doneKey, rev); err != nil { + p.logger.Printf("%s: revision %d was acted on but could not be kept, so a restart acts on it again: %v", doneKey, rev, err) + } +} + +// refuse writes the refused message of an act on FORGE; it reports whether it was written. The message id makes a +// second write of one revision's refusal the same message to the stream. +func (p *performer) refuse(ctx context.Context, doneKey string, rev uint64, a bridge.Act, words string) bool { + msg, err := bridge.RefusedMessage(a, words) + if err != nil { + p.logger.Printf("%s: the forge refused %s (%s) and the refusal cannot be said on FORGE: %v", doneKey, a.Name, words, err) + return false + } + id := fmt.Sprintf("refused.%s.%d", doneKey, rev) + if _, err := p.pub.Publish(ctx, forgeSubject(msg.Event), msg.Data, jetstream.WithMsgID(id)); err != nil { + p.logger.Printf("%s: the forge refused %s (%s) and FORGE could not be written: %v", doneKey, a.Name, words, err) + return false + } + return true +} + +// owed performs the act an owed key asks at revision rev, once. +func (p *performer) owed(ctx context.Context, key string, rev uint64, value []byte) { + doneKey := "owed." + key + if p.seen(ctx, doneKey, rev) { + return + } + a, err := bridge.OwedAct(key, value) + if err != nil { + p.logger.Printf("%s at revision %d is not an act the bridge can perform, and is left: %v", key, rev, err) + p.keep(ctx, doneKey, rev) + return + } + if _, err := p.call(ctx, a.Call.Tool, a.Call.Args); err != nil { + p.logger.Printf("%s at revision %d: %s refused: %v", key, rev, a.Call.Tool, err) + if !p.refuse(ctx, doneKey, rev, a, err.Error()) { + return + } + } + p.keep(ctx, doneKey, rev) +} + +// watch hands each key of a bucket, with the revision it has now and then each new one, to react until ctx ends or the +// watch fails. A deleted key is not an act. +func watch(ctx context.Context, kv jetstream.KeyValue, react func(key string, rev uint64, value []byte)) error { + w, err := kv.Watch(ctx, ">") + if err != nil { + return err + } + defer w.Stop() + for { + select { + case <-ctx.Done(): + return nil + case e, ok := <-w.Updates(): + if !ok { + return errors.New("the watch ended") + } + if e == nil || e.Operation() != jetstream.KeyValuePut { + continue + } + react(e.Key(), e.Revision(), e.Value()) + } + } +} diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go new file mode 100644 index 0000000..1d880bf --- /dev/null +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const ( + headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +type memRevisions map[string]uint64 + +func (m memRevisions) Get(_ context.Context, key string) (uint64, bool, error) { + r, ok := m[key] + return r, ok, nil +} +func (m memRevisions) Put(_ context.Context, key string, rev uint64) error { m[key] = rev; return nil } + +type tcall struct { + key string + body string +} + +// forgeFake records the calls and answers each from refuse, a map from a tool to its refusal. +type forgeFake struct { + calls []tcall + refuse map[string]string +} + +func (f *forgeFake) call(_ context.Context, key string, body any) (json.RawMessage, error) { + b, _ := json.Marshal(body) + f.calls = append(f.calls, tcall{key, string(b)}) + if w, ok := f.refuse[key]; ok { + return nil, errors.New(w) + } + return json.RawMessage(`{}`), nil +} + +func newPerformer(f *forgeFake) (*performer, *fakePub, memRevisions, *bytes.Buffer) { + pub, done, logs := &fakePub{}, memRevisions{}, &bytes.Buffer{} + return &performer{call: f.call, pub: pub, done: done, logger: log.New(logs, "", 0)}, pub, done, logs +} + +// The values are the SDK's own encoding (mesh-sdk go/delivery EncodeOwed, EncodeMerge), the keys its OwedKey. +func owedKV(t *testing.T, repository string, number int, act string, o delivery.Owed) (string, []byte) { + t.Helper() + key, err := delivery.OwedKey(repository, number, headA, act) + if err != nil { + t.Fatal(err) + } + v, err := delivery.EncodeOwed(o) + if err != nil { + t.Fatal(err) + } + return key, v +} + +func TestAnOwedStatusIsSetOnceForARevision(t *testing.T) { // 1341/AC1 + f := &forgeFake{} + p, _, _, _ := newPerformer(f) + key, v := owedKV(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate", + delivery.Owed{Act: delivery.OwedStatus, Context: "mesh/merge-gate", State: "success", Text: "all green", Target: "https://forge.example/p/12"}) + p.owed(context.Background(), key, 7, v) + if len(f.calls) != 1 || f.calls[0].key != "gitea.gitea_commit_status" || + f.calls[0].body != `{"context":"mesh/merge-gate","description":"all green","owner":"novox","repo":"mesh-delivery","sha":"`+headA+`","state":"success","target_url":"https://forge.example/p/12"}` { + t.Fatalf("calls = %+v", f.calls) + } + p.owed(context.Background(), key, 7, v) // the same revision seen again (a restart's replay) + if len(f.calls) != 1 { + t.Errorf("the same revision called the tool again: %+v", f.calls) + } + p.owed(context.Background(), key, 8, v) // a new revision of the key is a new act + if len(f.calls) != 2 { + t.Errorf("a new revision was not performed: %+v", f.calls) + } +} + +func TestViewNoteAndCommentEachCallTheirToolOnce(t *testing.T) { // 1341/AC2 + f := &forgeFake{} + p, _, _, _ := newPerformer(f) + for _, c := range []struct { + act string + o delivery.Owed + }{ + {"view", delivery.Owed{Act: delivery.OwedView, Text: "the view"}}, + {"note", delivery.Owed{Act: delivery.OwedNote, Text: "a line", Ref: "mesh-plan"}}, + {"comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "hello"}}, + } { + key, v := owedKV(t, "novox/mesh-delivery", 12, c.act, c.o) + p.owed(context.Background(), key, 1, v) + p.owed(context.Background(), key, 1, v) + } + var tools []string + for _, c := range f.calls { + tools = append(tools, c.key) + } + if got := strings.Join(tools, " "); got != "gitea.gitea_delivery_view gitea.gitea_note_append gitea.gitea_add_comment" { + t.Errorf("calls = %s", got) + } +} + +func TestARefusedActIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1341/AC3 + f := &forgeFake{refuse: map[string]string{"gitea.gitea_add_comment": "the pull request is locked"}} + p, pub, _, _ := newPerformer(f) + key, v := owedKV(t, "novox/mesh-delivery", 12, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "hello"}) + p.owed(context.Background(), key, 3, v) + p.owed(context.Background(), key, 3, v) + if len(f.calls) != 1 { + t.Errorf("the refused act was tried %d times at one revision", len(f.calls)) + } + if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.refused" { + t.Fatalf("published on %v", pub.subjects) + } + m, err := delivery.DecodeForge(pub.data[0]) + if err != nil || m.Kind != delivery.ForgeRefused || m.Act != "comment.1" || m.Number != 12 || m.Head != headA || m.Words != "the pull request is locked" { + t.Errorf("message %+v err %v", m, err) + } + f.refuse = nil + p.owed(context.Background(), key, 4, v) // only a new revision tries again + if len(f.calls) != 2 { + t.Errorf("a new revision was not tried: %d calls", len(f.calls)) + } +} + +func TestAnOwedValueThatCannotBeReadIsLoggedAndNotRetried(t *testing.T) { + f := &forgeFake{} + p, pub, done, logs := newPerformer(f) + p.owed(context.Background(), "not-a-key", 2, []byte(`{}`)) + if len(f.calls) != 0 || len(pub.subjects) != 0 { + t.Errorf("calls %v published %v", f.calls, pub.subjects) + } + if !strings.Contains(logs.String(), "not-a-key") { + t.Errorf("log %q does not name the key", logs.String()) + } + if rev, ok := done["owed.not-a-key"]; !ok || rev != 2 { + t.Errorf("revision kept = %v %v", rev, ok) + } +} diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go index 6769ff0..4dc5e66 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go @@ -25,6 +25,9 @@ import ( // the controller's user, and the bridge's own principal . with the permissions the controller composes for // it, and no others, so a permission the bridge lacks fails a test here and not on the mesh (issue 1279). // +// The gate's buckets mesh-delivery-gate_merges and mesh-delivery-gate_owed are read and watched (the manifest's reads), the +// bucket performed is the bridge's own (its state), and the five forge tools of the gate's acts are invoked (issue 1341, 1342). +// // The lists are what mesh-controller's broker.PermissionsFor answers (internal/broker/nats.go:577) for a module principal // with the manifest of module.json: consumes the three gitea.pull events, invokes gitea.gitea_open_pulls and // gitea.gitea_commit_trees, state heads, and the stream forge; `**` is written `>` on the wire (modstreams.go @@ -58,6 +61,44 @@ accounts { "mesh.mod.gitea.tool.gitea_commit_trees.*" "mesh.mod.gitea.tool.gitea_open_pulls" "mesh.mod.gitea.tool.gitea_open_pulls.*" + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_performed.>" + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_performed.>" + "$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_performed.>" + "$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_performed" + "$JS.FC.KV_mesh-delivery-forge-bridge_performed.>" + "$KV.mesh-delivery-forge-bridge_performed.>" + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_merges.>" + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_merges.>" + "$JS.API.DIRECT.GET.KV_mesh-delivery-gate_merges.>" + "$JS.API.STREAM.INFO.KV_mesh-delivery-gate_merges" + "$JS.FC.KV_mesh-delivery-gate_merges.>" + "$KV.mesh-delivery-gate_merges.>" + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_owed.>" + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_owed.>" + "$JS.API.DIRECT.GET.KV_mesh-delivery-gate_owed.>" + "$JS.API.STREAM.INFO.KV_mesh-delivery-gate_owed" + "$JS.FC.KV_mesh-delivery-gate_owed.>" + "$KV.mesh-delivery-gate_owed.>" + "mesh.mod.gitea.call.gitea_merge_pull_request.*.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.call.gitea_merge_pull_request.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.tool.gitea_merge_pull_request" + "mesh.mod.gitea.tool.gitea_merge_pull_request.*" + "mesh.mod.gitea.call.gitea_commit_status.*.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.call.gitea_commit_status.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.tool.gitea_commit_status" + "mesh.mod.gitea.tool.gitea_commit_status.*" + "mesh.mod.gitea.call.gitea_delivery_view.*.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.call.gitea_delivery_view.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.tool.gitea_delivery_view" + "mesh.mod.gitea.tool.gitea_delivery_view.*" + "mesh.mod.gitea.call.gitea_note_append.*.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.call.gitea_note_append.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.tool.gitea_note_append" + "mesh.mod.gitea.tool.gitea_note_append.*" + "mesh.mod.gitea.call.gitea_add_comment.*.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.call.gitea_add_comment.n1~mesh-delivery-forge-bridge" + "mesh.mod.gitea.tool.gitea_add_comment" + "mesh.mod.gitea.tool.gitea_add_comment.*" "mesh.stream.mesh-delivery-forge-bridge.event.forge.>" ] } subscribe { allow: [ @@ -149,6 +190,13 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context, if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: headsBucket, History: 1}); err != nil { t.Fatal(err) } + // Its other state bucket, and the gate's buckets the bridge reads, made by the controller from the gate's manifest + // (mesh-sdk go/delivery MergesBucket, OwedBucket with their histories). + for name, history := range map[string]uint8{performedBucket: 1, delivery.MergesBucket: delivery.MergesHistory, delivery.OwedBucket: delivery.OwedHistory} { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil { + t.Fatal(err) + } + } if prepare != nil { prepare(ctx, js) } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go index c067c25..68e03c7 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go @@ -196,6 +196,35 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Everything written on FORGE, an event's and a sync's, keeps the head in the bucket. rec := &recorder{pub: js, heads: heads, logger: logger} said := func(ctx context.Context) error { return syncOpen(ctx, call, rec, heads, &turn, logger) } + // What the gate asks of the forge (ADR 0437 §8): its owed acts and its merges, each performed once per revision. + performedKV, err := js.KeyValue(ctx, performedBucket) + if err != nil { + return fmt.Errorf("the bucket %s: %w", performedBucket, err) + } + perf := &performer{call: call, pub: rec, done: kvRevisions{performedKV}, logger: logger} + watching := make(chan error, 1) + for _, w := range []struct { + bucket string + react func(ctx context.Context, key string, rev uint64, value []byte) + }{{delivery.OwedBucket, perf.owed}} { + kv, err := js.KeyValue(ctx, w.bucket) + if errors.Is(err, jetstream.ErrBucketNotFound) { + // The gate's buckets come with the gate; the bridge does not wait for them (no timer), and is started again + // by its delivery when the gate arrives. + logger.Printf("the bucket %s does not exist yet: nothing of it is performed until the bridge starts again", w.bucket) + continue + } + if err != nil { + return fmt.Errorf("the bucket %s: %w", w.bucket, err) + } + go func() { + watching <- watch(ctx, kv, func(key string, rev uint64, value []byte) { + hctx, cancel := context.WithTimeout(ctx, syncDeadline) + defer cancel() + w.react(hctx, key, rev, value) + }) + }() + } if err := said(ctx); err != nil { return err } @@ -217,6 +246,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } defer cc.Stop() - <-ctx.Done() - return nil + select { + case <-ctx.Done(): + return nil + case err := <-watching: + return fmt.Errorf("watching the gate's keys: %w", err) + } } From be5c1b371ed66ed215996814a1d327089e28f32d Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:17:44 +0000 Subject: [PATCH 05/13] Merge the pull request a gate's merges key hands the bridge, with the Approve's heads, one pass stopping at a refusal (issue 1342) --- .../cmd/mesh-delivery-forge-bridge/gate.go | 27 +++- .../mesh-delivery-forge-bridge/gate_test.go | 63 ++++++++++ .../integration_test.go | 116 ++++++++++++++++++ .../cmd/mesh-delivery-forge-bridge/main.go | 4 +- .../internal/bridge/acts.go | 50 +++++++- .../internal/bridge/acts_test.go | 47 ++++++- 6 files changed, 299 insertions(+), 8 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go index 9de2289..a98b7cd 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate.go @@ -49,7 +49,7 @@ type revisions interface { } // performer does on the forge what the gate's keys ask (novox/hq ADR 0437 §8): one act for each revision of an owed -// key. A refusal is written on FORGE and the act is not tried again until +// key, one merge for each revision of a merges key. A refusal is written on FORGE and the act is not tried again until // the key has a new revision; the performer holds no timer and serves no request. type performer struct { call caller @@ -112,6 +112,31 @@ func (p *performer) owed(ctx context.Context, key string, rev uint64, value []by p.keep(ctx, doneKey, rev) } +// merges performs the merges a merges key hands the bridge at revision rev, once: each pull request in the state +// merging, in the order the value names them, stopping at the first refusal (novox/hq ADR 0441 §3). +func (p *performer) merges(ctx context.Context, key string, rev uint64, value []byte) { + doneKey := "merges." + key + if p.seen(ctx, doneKey, rev) { + return + } + acts, err := bridge.MergeActs(value) + if err != nil { + p.logger.Printf("%s at revision %d cannot be merged, and is left: %v", key, rev, err) + p.keep(ctx, doneKey, rev) + return + } + for _, a := range acts { + if _, err := p.call(ctx, a.Call.Tool, a.Call.Args); err != nil { + p.logger.Printf("%s at revision %d: the merge of %s#%d refused: %v", key, rev, a.Repository, a.Number, err) + if !p.refuse(ctx, doneKey, rev, a, err.Error()) { + return + } + break + } + } + p.keep(ctx, doneKey, rev) +} + // watch hands each key of a bucket, with the revision it has now and then each new one, to react until ctx ends or the // watch fails. A deleted key is not an act. func watch(ctx context.Context, kv jetstream.KeyValue, react func(key string, rev uint64, value []byte)) error { diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go index 1d880bf..608685e 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/gate_test.go @@ -145,3 +145,66 @@ func TestAnOwedValueThatCannotBeReadIsLoggedAndNotRetried(t *testing.T) { t.Errorf("revision kept = %v %v", rev, ok) } } + +func mergeKV(t *testing.T, m delivery.Merge) []byte { + t.Helper() + v, err := delivery.EncodeMerge(m) + if err != nil { + t.Fatal(err) + } + return v +} + +func twoMembers(a, b delivery.MergeState) delivery.Merge { + return delivery.Merge{Group: "g", Ask: "merge.g.0123456789ab", + Approved: map[string]string{delivery.ApprovedKey("novox/mesh-sdk", 4): headB, delivery.ApprovedKey("novox/mesh-delivery", 12): headA}, + Pulls: []delivery.MergePull{ + {Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: a, Commit: map[bool]string{true: "c1"}[a == delivery.MergeMerged]}, + {Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 4, Head: headB}, State: b, Commit: map[bool]string{true: "c2"}[b == delivery.MergeMerged]}, + }} +} + +func TestAMergeRevisionMergesOnlyTheMemberHanded(t *testing.T) { // 1342/AC1 + f := &forgeFake{} + p, _, _, _ := newPerformer(f) + m := twoMembers(delivery.MergeMerging, delivery.MergeMerged) + p.merges(context.Background(), "g", 5, mergeKV(t, m)) + want := `{"approved_heads":["novox/mesh-delivery@` + headA + `","novox/mesh-sdk@` + headB + `"],"head":"` + headA + `","number":12,"owner":"novox","repo":"mesh-delivery"}` + if len(f.calls) != 1 || f.calls[0].key != "gitea.gitea_merge_pull_request" || f.calls[0].body != want { + t.Fatalf("calls = %+v", f.calls) + } + p.merges(context.Background(), "g", 5, mergeKV(t, m)) + if len(f.calls) != 1 { + t.Errorf("a revision merged twice: %+v", f.calls) + } +} + +func TestARefusedMergeIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1342/AC2 + f := &forgeFake{refuse: map[string]string{"gitea.gitea_merge_pull_request": "novox/mesh-delivery#12 is not merged: its head moved"}} + p, pub, _, _ := newPerformer(f) + v := mergeKV(t, twoMembers(delivery.MergeMerging, delivery.MergeMerged)) + p.merges(context.Background(), "g", 5, v) + p.merges(context.Background(), "g", 5, v) + if len(f.calls) != 1 || len(pub.data) != 1 { + t.Fatalf("calls %d messages %d, want one each", len(f.calls), len(pub.data)) + } + m, err := delivery.DecodeForge(pub.data[0]) + if err != nil || m.Kind != delivery.ForgeRefused || m.Repository != "novox/mesh-delivery" || m.Number != 12 || m.Head != headA || + m.Act != "merge" || m.Words != "novox/mesh-delivery#12 is not merged: its head moved" { + t.Errorf("message %+v err %v", m, err) + } + f.refuse = nil + p.merges(context.Background(), "g", 6, v) + if len(f.calls) != 2 { + t.Errorf("a new revision was not tried: %d calls", len(f.calls)) + } +} + +func TestAMergeWithTwoHandedStopsAtTheFirstRefusal(t *testing.T) { // ADR 0441 §3 + f := &forgeFake{refuse: map[string]string{"gitea.gitea_merge_pull_request": "no"}} + p, pub, _, _ := newPerformer(f) + p.merges(context.Background(), "g", 1, mergeKV(t, twoMembers(delivery.MergeMerging, delivery.MergeMerging))) + if len(f.calls) != 1 || len(pub.data) != 1 { + t.Errorf("calls %d messages %d, want the first refusal to stop the pass", len(f.calls), len(pub.data)) + } +} diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go index 4dc5e66..b95a259 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go @@ -523,3 +523,119 @@ func TestBridgeReadsTheForgeAtStartAndAfterAReconnectAndHearsNoOlderEvent(t *tes } } } + +// TestBridgeMergesAGroupOneMemberAtATime: the gate (played by the test) writes merges.g with member A handed and the +// Approve's heads of A and B; a stand-in forge merges A and announces it; the gate then hands B; both end merged on FORGE +// (1342/AC3, ADR 0437 §8's check). +func TestBridgeMergesAGroupOneMemberAtATime(t *testing.T) { // 1342/AC3 + b := startBed(t, `{"result":{"pulls":[]}}`, nil) + const headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const mergeA, mergeB = "1111111111111111111111111111111111111111", "2222222222222222222222222222222222222222" + merges := make(chan string, 4) + // The stand-in for gitea_merge_pull_request answers in the reply envelope of mesh-sdk go/process/call.go and publishes + // gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 does, with the merge commit of the member merged. + sub, err := b.ctl.Subscribe("mesh.mod.gitea.tool.gitea_merge_pull_request", func(m *nats.Msg) { + var a struct { + Repo string `json:"repo"` + Head string `json:"head"` + } + _ = json.Unmarshal(m.Data, &a) + merges <- string(m.Data) + commit, number := mergeA, 12 + if a.Repo == "mesh-sdk" { + commit, number = mergeB, 4 + } + ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": a.Repo, "number": number, "head_sha": a.Head, "head": "feat/x", + "base": "main", "merge_commit_sha": commit, "module_dirs": []string{"modules/a"}, "module_dirs_said": true}) + _, _ = b.js.Publish(b.ctx, "mesh.mod.gitea.event.pull.merged", ev) + _ = m.Respond([]byte(`{"result":{"merged":true},"node":"n0"}`)) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + trees, err := b.ctl.Subscribe("mesh.mod.gitea.tool.gitea_commit_trees", func(m *nats.Msg) { + _ = m.Respond([]byte(`{"result":{"root":"r0","trees":{"":"t0","modules/a":"t1"}},"node":"n0"}`)) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = trees.Unsubscribe() }) + if err := b.ctl.Flush(); err != nil { + t.Fatal(err) + } + + kv, err := b.js.KeyValue(b.ctx, delivery.MergesBucket) + if err != nil { + t.Fatal(err) + } + approved := map[string]string{delivery.ApprovedKey("novox/mesh-delivery", 12): head, delivery.ApprovedKey("novox/mesh-sdk", 4): headB} + pullA := delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: head}, State: delivery.MergeMerging} + pullB := delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 4, Head: headB}, State: delivery.MergeMerging} + put := func(pulls ...delivery.MergePull) { + v, err := delivery.EncodeMerge(delivery.Merge{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: pulls}) + if err != nil { + t.Fatal(err) + } + if _, err := kv.Put(b.ctx, "g", v); err != nil { + t.Fatal(err) + } + } + next := func(repo string) { + select { + case got := <-merges: + if !strings.Contains(got, `"repo":"`+repo+`"`) { + t.Errorf("the forge was asked %s, want %s", got, repo) + } + for _, want := range []string{"novox/mesh-delivery@" + head, "novox/mesh-sdk@" + headB} { + if !strings.Contains(got, want) { + t.Errorf("the merge %s does not carry the approved head %s", got, want) + } + } + case <-time.After(10 * time.Second): + t.Fatalf("%s was not merged within 10 s", repo) + } + } + put(pullA) + next("mesh-delivery") + // The gate hands the next member only after the bridge reported the one before merged (ADR 0441 §5). + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && len(forgeMerged(t, b)) < 1; time.Sleep(50 * time.Millisecond) { + } + pullA.State, pullA.Commit = delivery.MergeMerged, mergeA + put(pullA, pullB) + next("mesh-sdk") + var got []string + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if got = forgeMerged(t, b); len(got) == 2 { + break + } + } + if len(got) != 2 { + t.Fatalf("FORGE holds merged messages for %v, want both members", got) + } + select { + case extra := <-merges: + t.Errorf("a third merge was asked: %s", extra) + default: + } +} + +// forgeMerged lists the repositories FORGE holds a merged message for, sorted. +func forgeMerged(t *testing.T, b *bed) []string { + t.Helper() + forge, err := b.js.Stream(b.ctx, "FORGE") + if err != nil { + t.Fatal(err) + } + var out []string + for _, repo := range []string{"mesh-delivery", "mesh-sdk"} { + raw, err := forge.GetLastMsgForSubject(b.ctx, "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox."+repo+".merged") + if err != nil { + continue + } + if f, err := delivery.DecodeForge(raw.Data); err == nil && f.Kind == delivery.ForgeMerged { + out = append(out, f.Repository) + } + } + return out +} diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go index 68e03c7..d9dd739 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go @@ -202,11 +202,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the bucket %s: %w", performedBucket, err) } perf := &performer{call: call, pub: rec, done: kvRevisions{performedKV}, logger: logger} - watching := make(chan error, 1) + watching := make(chan error, 2) for _, w := range []struct { bucket string react func(ctx context.Context, key string, rev uint64, value []byte) - }{{delivery.OwedBucket, perf.owed}} { + }{{delivery.OwedBucket, perf.owed}, {delivery.MergesBucket, perf.merges}} { kv, err := js.KeyValue(ctx, w.bucket) if errors.Is(err, jetstream.ErrBucketNotFound) { // The gate's buckets come with the gate; the bridge does not wait for them (no timer), and is started again diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go b/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go index c15919b..f844717 100644 --- a/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/acts.go @@ -2,6 +2,7 @@ package bridge import ( "fmt" + "slices" "strings" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -14,7 +15,7 @@ type Call struct { } // Act is one thing the bridge does on the forge for a key of the gate: the act's name (an owed key's act, `status=`, -// `view`, `note`, `comment.`), the pull request and head it is on (Number 0 for a commit no pull +// `view`, `note`, `comment.`, or `merge`), the pull request and head it is on (Number 0 for a commit no pull // request names) and the forge call that performs it. type Act struct { Name string @@ -27,14 +28,18 @@ type Act struct { // The forge's tools, by manifest key; their arguments are the ones of mesh-catalog modules/gitea: delivery.ts // commitStatusTool (owner, repo, sha, context, state, description, target_url), tools/index.ts gitea_delivery_view // (owner, repo, number, body), gitea_note_append (owner, repo, sha, ref, line), gitea_add_comment (owner, repo, -// number, body). +// number, body), merge.ts gitea_merge_pull_request (owner, repo, number, head, approved_heads). const ( commitStatusTool = "gitea.gitea_commit_status" deliveryViewTool = "gitea.gitea_delivery_view" noteAppendTool = "gitea.gitea_note_append" addCommentTool = "gitea.gitea_add_comment" + mergePullTool = "gitea.gitea_merge_pull_request" ) +// MergeAct is the name of the act of merging a pull request. +const MergeAct = "merge" + func ownerRepo(repository string) (owner, repo string) { owner, repo, _ = strings.Cut(repository, "/") return owner, repo @@ -90,6 +95,47 @@ func OwedAct(key string, value []byte) (Act, error) { return a, nil } +// MergeActs turns the value of a merges key (mesh-sdk go/delivery EncodeMerge) into the merges to perform: one for each +// pull request in the state merging, in the order the value names them, at the head it expects and with the approved +// heads of every member of the group, `/@` sorted (the merge tool's approved_heads). A pull +// request merged or refused already gets none. A value with no approved heads is an error: the forge's merge tool +// needs them for a group. +func MergeActs(value []byte) ([]Act, error) { + m, err := delivery.DecodeMerge(value) + if err != nil { + return nil, err + } + var handed []delivery.MergePull + for _, p := range m.Pulls { + if p.State == delivery.MergeMerging { + handed = append(handed, p) + } + } + if len(handed) == 0 { + return nil, nil + } + if len(m.Approved) == 0 { + return nil, fmt.Errorf("the merge of the group %s names no approved heads, so the forge's merge tool has nothing to merge at", m.Group) + } + approved := make([]string, 0, len(m.Approved)) + for key, sha := range m.Approved { + repository, _, ok := strings.Cut(key, "@") + if !ok { + return nil, fmt.Errorf("the approved head %q of the group %s is not @", key, m.Group) + } + approved = append(approved, repository+"@"+sha) + } + slices.Sort(approved) + var acts []Act + for _, p := range handed { + owner, repo := ownerRepo(p.Repository) + acts = append(acts, Act{Name: MergeAct, Repository: p.Repository, Number: p.Number, Head: p.Head.Head, + Call: Call{Tool: mergePullTool, Args: map[string]any{"owner": owner, "repo": repo, "number": p.Number, "head": p.Head.Head, + "approved_heads": approved}}}) + } + return acts, nil +} + // RefusedMessage is the refused message the bridge writes on FORGE when the forge refuses an act: the act, the pull // request and head it was on, and the forge's own words. func RefusedMessage(a Act, words string) (Message, error) { diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go b/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go index a4d414b..5b555b8 100644 --- a/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/acts_test.go @@ -9,6 +9,7 @@ import ( const ( headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ) // owedValue is an owed value as the SDK writes it (mesh-sdk go/delivery EncodeOwed), and its key as OwedKey spells it. @@ -103,8 +104,48 @@ func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) { } } -func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3 - a := Act{Name: "comment.1", Repository: "novox/mesh-delivery", Number: 12, Head: headA} +func mergeValue(t *testing.T, m delivery.Merge) []byte { + t.Helper() + b, err := delivery.EncodeMerge(m) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestMergeActsHandOnlyTheMemberHandedWithEveryApprovedHead(t *testing.T) { // 1342/AC1 + approved := map[string]string{delivery.ApprovedKey("novox/mesh-sdk", 4): headB, delivery.ApprovedKey("novox/mesh-delivery", 12): headA} + v := mergeValue(t, delivery.Merge{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: []delivery.MergePull{ + {Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 4, Head: headB}, State: delivery.MergeMerged, Commit: "c0"}, + {Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: delivery.MergeMerging}, + }}) + acts, err := MergeActs(v) + if err != nil { + t.Fatal(err) + } + if len(acts) != 1 { + t.Fatalf("%d acts, want 1 (the merged member is not merged again)", len(acts)) + } + want := Call{Tool: "gitea.gitea_merge_pull_request", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "head": headA, + "approved_heads": []string{"novox/mesh-delivery@" + headA, "novox/mesh-sdk@" + headB}}} + if !reflect.DeepEqual(acts[0].Call, want) { + t.Errorf("call = %+v, want %+v", acts[0].Call, want) + } + if acts[0].Name != "merge" || acts[0].Number != 12 || acts[0].Head != headA { + t.Errorf("act = %+v", acts[0]) + } +} + +func TestMergeActsOfAMergeWithoutApprovedHeadsAreRefused(t *testing.T) { + v := mergeValue(t, delivery.Merge{Group: "g", Ask: "a", Pulls: []delivery.MergePull{ + {Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: delivery.MergeMerging}}}) + if _, err := MergeActs(v); err == nil { + t.Error("a merge with no approved heads was taken: the forge's tool would refuse it with nothing to say which") + } +} + +func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3, 1342/AC2 + a := Act{Name: "merge", Repository: "novox/mesh-delivery", Number: 12, Head: headA} m, err := RefusedMessage(a, "novox/mesh-delivery#12 is not merged: its head moved") if err != nil { t.Fatal(err) @@ -117,7 +158,7 @@ func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3 t.Fatal(err) } want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA, - Act: "comment.1", Words: "novox/mesh-delivery#12 is not merged: its head moved"} + Act: "merge", Words: "novox/mesh-delivery#12 is not merged: its head moved"} if !delivery.SameForge(f, want) { t.Errorf("message = %+v", f) } From 8ee56dbb5cd64a53765f8aa7db850d39f3e162e2 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:24:40 +0000 Subject: [PATCH 06/13] Grant the bridge's test principal only a read of the gate's buckets, as the controller composes it (issues 1341, 1342) --- .../cmd/mesh-delivery-forge-bridge/integration_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go index b95a259..aa86ce5 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go @@ -72,13 +72,11 @@ accounts { "$JS.API.DIRECT.GET.KV_mesh-delivery-gate_merges.>" "$JS.API.STREAM.INFO.KV_mesh-delivery-gate_merges" "$JS.FC.KV_mesh-delivery-gate_merges.>" - "$KV.mesh-delivery-gate_merges.>" "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_owed.>" "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_owed.>" "$JS.API.DIRECT.GET.KV_mesh-delivery-gate_owed.>" "$JS.API.STREAM.INFO.KV_mesh-delivery-gate_owed" "$JS.FC.KV_mesh-delivery-gate_owed.>" - "$KV.mesh-delivery-gate_owed.>" "mesh.mod.gitea.call.gitea_merge_pull_request.*.n1~mesh-delivery-forge-bridge" "mesh.mod.gitea.call.gitea_merge_pull_request.n1~mesh-delivery-forge-bridge" "mesh.mod.gitea.tool.gitea_merge_pull_request" From 5ba1da5b821cb60d5280c2d18c76d4b88fc1d1f8 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:28:36 +0000 Subject: [PATCH 07/13] Carry the gate's merge asks to the router and write the operator's answer back, a warrant that does not hold said as a condition (issue 1343) --- .../cmd/mesh-delivery-teller/decided.go | 115 ++++++ .../mesh-delivery-teller/integration_test.go | 275 +++++++++++++ .../cmd/mesh-delivery-teller/kv.go | 101 +++++ .../cmd/mesh-delivery-teller/main.go | 54 ++- .../cmd/mesh-delivery-teller/teller.go | 383 ++++++++++++++++++ .../cmd/mesh-delivery-teller/teller_test.go | 375 +++++++++++++++++ modules/mesh-delivery-teller/go.mod | 1 + modules/mesh-delivery-teller/go.sum | 2 + modules/mesh-delivery-teller/manifest_test.go | 16 +- modules/mesh-delivery-teller/module.json | 3 +- 10 files changed, 1314 insertions(+), 11 deletions(-) create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go new file mode 100644 index 0000000..2d20503 --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The teller's three bus adapters for mesh-sdk go/asker, which in a bundle reaches the bus through the runtime: here the +// teller is a process on its own credential. What each may do is the controller's composed grant for a module that uses +// the seat operator-channel (mesh-control internal/broker seattraffic.go SeatTrafficOf): publish its own ask, cancel and +// condition, hear its own decided event, and read the router's record of its own asks. + +// callTimeout bounds a read of the router's record. +const callTimeout = 30 * time.Second + +// publishVia publishes an accept of the seat, de-duplicated by id: the same id twice is one message. +func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string, body any, id string) error { + return func(subject string, body any, id string) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + var opts []jetstream.PublishOpt + if id != "" { + opts = append(opts, jetstream.WithMsgID(id)) + } + _, err = js.Publish(ctx, subject, data, opts...) + return err + } +} + +// recordVia reads the router's record of one of this module's asks: the key `.` of the router's bucket asks, +// by a direct get on the subject the controller grants for it, `$JS.API.DIRECT.GET.KV_asks.$KV.asks..>`. The +// record is {state, warrant}. An ask the router holds no record of is nil. +func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id string) (*stdio.StateEntry, error) { + return func(id string) (*stdio.StateEntry, error) { + ctx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_asks.$KV.asks."+module+"."+id, nil) + if errors.Is(err, nats.ErrNoResponders) { + return nil, nil + } + if err != nil { + return nil, err + } + if status := msg.Header.Get("Status"); status == "404" || len(msg.Data) == 0 { + return nil, nil + } + return &stdio.StateEntry{Key: id, Value: msg.Data}, nil + } +} + +// decidedVia hears the warrants the router says to this module alone: its own `decided` event, a subject only the router +// publishes (the grant to subscribe it names the module). The handler is the asker's: pattern is the key it expects. +func decidedVia(conn *nats.Conn, module string, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error { + return func(pattern string, handle func(stdio.Envelope) error) error { + _, err := conn.Subscribe(asks.DecidedSubject(module), func(m *nats.Msg) { + if err := handle(stdio.Envelope{Key: pattern, Body: m.Data}); err != nil { + logf("a warrant could not be handled and is read again from the router's record at the next start: %v", err) + } + }) + return err + } +} + +// sayVia publishes a condition on the seat's one subject for conditions, under the module's own name. +func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c asks.Condition) error { + return func(c asks.Condition) error { + subject, err := asks.ConditionSubject(module) + if err != nil { + return err + } + data, err := asks.EncodeCondition(c) + if err != nil { + return err + } + _, err = js.Publish(ctx, subject, data) + return err + } +} + +// watchAsks hands each key of the gate's bucket asks, with its revision, to put, and each key taken away to gone, until +// ctx ends or the watch fails. +func watchAsks(ctx context.Context, kv jetstream.KeyValue, put func(key string, rev uint64, value []byte), gone func(key string)) error { + w, err := kv.Watch(ctx, ">") + if err != nil { + return err + } + defer w.Stop() + for { + select { + case <-ctx.Done(): + return nil + case e, ok := <-w.Updates(): + if !ok { + return errors.New("the watch ended") + } + switch { + case e == nil: + case e.Operation() == jetstream.KeyValuePut: + put(e.Key(), e.Revision(), e.Value()) + default: + gone(e.Key()) + } + } + } +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go index 046b7b8..50ce770 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go @@ -16,6 +16,9 @@ import ( "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the @@ -121,3 +124,275 @@ func TestTellerWaitsForTheGate(t *testing.T) { // 1240/AC2 t.Errorf("the teller stopped with %v, want a clean stop", err) } } + +// The principals of the composed accounts file for the teller's tests, with the permissions mesh-controller composes for +// them (internal/broker): a module's own state is the read grants of its bucket and `$KV..>` to write it +// (state.go stateGrants, readGrants), a read of another module's bucket is the read grants alone, and a module that uses +// the seat operator-channel submits its own `accept.ask.`, `accept.cancel.` and `accept.condition.`, +// hears its own `event.decided.` and reads `$KV.asks..>` of the router's record (seattraffic.go +// SeatTrafficOf, with the messenger's seat declaring accepts ask, cancel, condition, emits decided, by-caller and records +// asks). The gate is a bundle on the tool runner's account (ADR 0437 §3), so the gate's writes are the account node-tools'. +// The permissions are these lists written out, not the controller's function: the broker tests of issue 1243 prove that. +func readOf(bucket string) []string { + stream := "KV_" + bucket + return []string{"$JS.API.STREAM.INFO." + stream, "$JS.API.DIRECT.GET." + stream + ".>", "$JS.API.CONSUMER.CREATE." + stream + ".>", + "$JS.API.CONSUMER.DELETE." + stream + ".>", "$JS.FC." + stream + ".>"} +} + +func keepsOf(bucket string) []string { return append(readOf(bucket), "$KV."+bucket+".>") } + +func user(name string, pub, sub []string) string { + q := func(l []string) string { + var b strings.Builder + for _, s := range l { + b.WriteString(fmt.Sprintf(" %q\n", s)) + } + return b.String() + } + return fmt.Sprintf(` { user: %q, password: "pw" + permissions { + publish { allow: [ +%s ] } + subscribe { allow: [ +%s ] } + allow_responses: true + } + } +`, name, q(pub), q(append([]string{"_INBOX." + name + ".>"}, sub...))) +} + +const ( + tellerAnswers = "mesh-delivery-teller_answers" + tellerAsked = "mesh-delivery-teller_asked" + gateAsksKV = "mesh-delivery-gate_asks" + seatAsk = "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller" + seatCancel = "mesh.seat.operator-channel.accept.cancel.mesh-delivery-teller" + seatCondition = "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" + seatDecided = "mesh.seat.operator-channel.event.decided.mesh-delivery-teller" +) + +func fullConf(dir string) string { + teller := append(append(append(keepsOf(tellerAnswers), keepsOf(tellerAsked)...), readOf(gateAsksKV)...), + seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_asks.$KV.asks.mesh-delivery-teller.>") + view := append(readOf(tellerAnswers), readOf(gateAsksKV)...) + tools := append(keepsOf(gateAsksKV), readOf(tellerAnswers)...) + return fmt.Sprintf(` +listen: 127.0.0.1:-1 +jetstream { store_dir: %q } +accounts { + MESH { + jetstream: enabled + users = [ + { user: "ctl", password: "pw" } +%s%s%s ] + } +} +`, filepath.Join(dir, "js"), + user("n1.mesh-delivery-teller", teller, []string{seatDecided}), + user("n1.mesh-delivery-view", view, nil), + user("n1.node-tools", tools, nil)) +} + +type fullBed struct { + srv *server.Server + ctl *nats.Conn + js jetstream.JetStream + ctx context.Context +} + +// startFull starts the bus with the principals above and what the controller makes before any module starts: the buckets +// of the gate's asks, the teller's answers and asked, the router's work queue SEAT_OPERATOR_CHANNEL. +func startFull(t *testing.T) *fullBed { + t.Helper() + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "t") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + conf := filepath.Join(dir, "bus.conf") + if err := os.WriteFile(conf, []byte(fullConf(dir)), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatal(err) + } + opts.NoLog, opts.NoSigs = true, true + srv, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go srv.Start() + if !srv.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(srv.Shutdown) + ctl, err := nats.Connect(srv.ClientURL(), nats.UserInfo("ctl", "pw")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(ctl.Close) + js, err := jetstream.New(ctl) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + for name, history := range map[string]uint8{gateAsksKV: delivery.AsksHistory, tellerAnswers: delivery.AnswersHistory, tellerAsked: 1} { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil { + t.Fatal(err) + } + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEAT_OPERATOR_CHANNEL", Subjects: []string{"mesh.seat.operator-channel.accept.>"}, + Retention: jetstream.WorkQueuePolicy}); err != nil { + t.Fatal(err) + } + return &fullBed{srv: srv, ctl: ctl, js: js, ctx: ctx} +} + +func (b *fullBed) as(t *testing.T, name string) jetstream.JetStream { + t.Helper() + c, err := nats.Connect(b.srv.ClientURL(), nats.UserInfo(name, "pw"), nats.CustomInboxPrefix("_INBOX."+name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(c.Close) + js, err := jetstream.New(c) + if err != nil { + t.Fatal(err) + } + return js +} + +// put writes a key as a principal, within a bounded time: a write the permissions refuse reaches the writer as a timeout. +func (b *fullBed) put(t *testing.T, js jetstream.JetStream, bucket, key string) error { + t.Helper() + ctx, cancel := context.WithTimeout(b.ctx, 3*time.Second) + defer cancel() + kv, err := js.KeyValue(ctx, bucket) + if err != nil { + t.Fatalf("%s cannot be bound: %v", bucket, err) + } + _, err = kv.Put(ctx, key, []byte(`{}`)) + return err +} + +// TestCredentialsWriteOnlyTheirOwnBuckets: the teller's credential is refused writing the gate's asks, and the gate's +// (the tool runner's) and the view's are refused writing the teller's answers; each writes where it may, so a refusal is the +// permissions' and not a bus that does not take writes (1343/AC4, ADR 0437 §6's check). +func TestCredentialsWriteOnlyTheirOwnBuckets(t *testing.T) { // 1343/AC4 + b := startFull(t) + teller, view, tools := b.as(t, "n1.mesh-delivery-teller"), b.as(t, "n1.mesh-delivery-view"), b.as(t, "n1.node-tools") + if err := b.put(t, teller, tellerAnswers, "merge.g.0123456789ab"); err != nil { + t.Errorf("the teller cannot write its own answers: %v", err) + } + if err := b.put(t, tools, gateAsksKV, "merge.g.0123456789ab"); err != nil { + t.Errorf("the gate's account cannot write the gate's asks: %v", err) + } + if err := b.put(t, teller, gateAsksKV, "merge.g.ffffffffffff"); err == nil { + t.Error("the teller's credential wrote the gate's asks") + } + if err := b.put(t, tools, tellerAnswers, "merge.g.ffffffffffff"); err == nil { + t.Error("the gate's account wrote the teller's answers") + } + if err := b.put(t, view, tellerAnswers, "merge.g.ffffffffffff"); err == nil { + t.Error("the view's credential wrote the teller's answers") + } + // What was refused is not there. + for _, c := range []struct{ bucket, key string }{{gateAsksKV, "merge.g.ffffffffffff"}, {tellerAnswers, "merge.g.ffffffffffff"}} { + kv, _ := b.js.KeyValue(b.ctx, c.bucket) + if _, err := kv.Get(b.ctx, c.key); err == nil { + t.Errorf("%s holds %s though its write was refused", c.bucket, c.key) + } + } +} + +// TestTellerCarriesAnAskAndWritesTheAnswer starts the program on the composed principal against the bus: the gate's account +// writes an ask, the teller publishes it to the seat's work queue under its own name, the router (the test) says the +// warrant on the teller's decided subject, and the answer is under the ask's key in the teller's bucket; withdrawing a +// second ask cancels it (1343/AC1, AC2). +func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2 + now = time.Now().UTC() + b := startFull(t) + tools := b.as(t, "n1.node-tools") + broker := filepath.Join(t.TempDir(), "broker") + cred, _ := json.Marshal(credential{URL: b.srv.ClientURL(), Node: "n1", Module: "mesh-delivery-teller", User: "n1.mesh-delivery-teller", Password: "pw"}) + if err := os.WriteFile(broker, cred, 0o600); err != nil { + t.Fatal(err) + } + env := map[string]string{"MESH_BROKER_FILE": broker} + logs := &syncBuffer{} + rn := startRunner(b.ctx, func(c context.Context) error { return run(c, func(k string) string { return env[k] }, logs) }) + t.Cleanup(func() { rn.finish(t, 10*time.Second) }) + + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + kv, err := tools.KeyValue(b.ctx, gateAsksKV) + if err != nil { + t.Fatal(err) + } + // The program hears warrants before it reads the asks; give it its subscription before the ask is written. + time.Sleep(500 * time.Millisecond) + if _, err := kv.Put(b.ctx, key, value); err != nil { + t.Fatal(err) + } + seat, err := b.js.Stream(b.ctx, "SEAT_OPERATOR_CHANNEL") + if err != nil { + t.Fatal(err) + } + var raw *jetstream.RawStreamMsg + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if raw, err = seat.GetLastMsgForSubject(b.ctx, seatAsk); err == nil { + break + } + } + if raw == nil { + t.Fatalf("no ask on the seat's queue: %v\n%s", err, logs.String()) + } + var asked asks.Ask + if err := json.Unmarshal(raw.Data, &asked); err != nil || asked.Digest() != ga.Ask.Digest() { + t.Fatalf("the ask on the queue is %+v (%v), want the gate's", asked, err) + } + // The router says the warrant. + w, _ := json.Marshal(warrantFor(ga.Ask, "approve")) + if err := b.ctl.Publish(seatDecided, w); err != nil { + t.Fatal(err) + } + answers, err := b.js.KeyValue(b.ctx, tellerAnswers) + if err != nil { + t.Fatal(err) + } + var e jetstream.KeyValueEntry + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if e, err = answers.Get(b.ctx, delivery.AnswerKey(key)); err == nil { + break + } + } + if e == nil { + t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String()) + } + if a, err := delivery.DecodeAnswer(e.Value()); err != nil || a.Option != "approve" { + t.Errorf("answer %+v err %v", a, err) + } + // A second ask, withdrawn by the gate, is cancelled at the router. + other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}} + key2, value2, ga2 := gateAsk(t, other, other) + if _, err := kv.Put(b.ctx, key2, value2); err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if _, err := seat.GetLastMsgForSubject(b.ctx, seatAsk); err == nil && strings.Contains(logs.String(), ga2.Ask.ID) { + break + } + } + if err := kv.Delete(b.ctx, key2); err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if raw, err = seat.GetLastMsgForSubject(b.ctx, seatCancel); err == nil { + break + } + } + if err != nil || string(raw.Data) != `{"id":"`+ga2.Ask.ID+`"}` { + t.Errorf("the withdrawn ask was not cancelled: %v %v\n%s", raw, err, logs.String()) + } +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go new file mode 100644 index 0000000..8301912 --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// kvStore is a state bucket of the teller as the asker's store (mesh-sdk go/asker Store): a compare-and-set that lost is +// stdio.ErrChanged, as the runtime's state reports it. +type kvStore struct { + ctx context.Context + kv jetstream.KeyValue +} + +func (s kvStore) Get(key string) (*stdio.StateEntry, error) { + e, err := s.kv.Get(s.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &stdio.StateEntry{Key: key, Value: e.Value(), Revision: e.Revision()}, nil +} + +func (s kvStore) Create(key string, value any) (uint64, error) { + b, err := json.Marshal(value) + if err != nil { + return 0, err + } + rev, err := s.kv.Create(s.ctx, key, b) + if errors.Is(err, jetstream.ErrKeyExists) { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return rev, err +} + +func (s kvStore) Update(key string, value any, revision uint64) (uint64, error) { + b, err := json.Marshal(value) + if err != nil { + return 0, err + } + rev, err := s.kv.Update(s.ctx, key, b, revision) + if errors.Is(err, jetstream.ErrKeyExists) { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + return rev, err +} + +func (s kvStore) Delete(key string) error { + err := s.kv.Delete(s.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil + } + return err +} + +// kvAnswers is the bucket answers: the teller's, written by compare-and-set only when the key has no answer. +type kvAnswers struct { + ctx context.Context + kv jetstream.KeyValue +} + +func (a kvAnswers) Create(key string, value []byte) (bool, error) { + _, err := a.kv.Create(a.ctx, key, value) + if errors.Is(err, jetstream.ErrKeyExists) { + return false, nil + } + return err == nil, err +} + +func (a kvAnswers) Has(key string) (bool, error) { + _, err := a.kv.Get(a.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return false, nil + } + return err == nil, err +} + +// kvGate reads the gate's bucket asks, which the teller may watch and read and never write. +type kvGate struct { + ctx context.Context + kv jetstream.KeyValue +} + +func (g kvGate) Get(key string) ([]byte, bool, error) { + e, err := g.kv.Get(g.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return e.Value(), true, nil +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go index 8005534..f9f3479 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go @@ -1,8 +1,9 @@ -// Command mesh-delivery-teller is delivery's teller (novox/hq ADR 0437 §4, issue 1240). It will carry the gate's asks to -// the operator and write the answers back (release 3d). In release 3c nothing writes asks, so it only runs: it connects -// as itself, says once that it waits for the gate's asks, and writes and publishes nothing. It does not open the asks -// bucket, for waiting for an absent bucket needs a timer (ADR 0437 §8 lets the parts hold none); the watch comes with the -// gate. It serves no request subject. +// Command mesh-delivery-teller is delivery's teller (novox/hq ADR 0437 §4, §6, §9; issues 1240, 1343). It carries the +// gate's merge asks to the operator and writes the answers back: it watches the gate's asks, asks each of the router as +// itself, and on the router's warrant, once it holds, writes the answer under the ask's key. Where the gate's asks +// bucket does not exist yet it says once that it waits for the gate's asks and does nothing: waiting for an absent bucket +// needs a timer (ADR 0437 §8 lets the parts hold none), so it is started again with the gate. It serves no request +// subject. package main import ( @@ -22,6 +23,10 @@ import ( "syscall" "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/delivery" ) func main() { @@ -103,10 +108,43 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("connecting to the bus: %w", err) } defer conn.Close() - logger.Print(waiting) - <-ctx.Done() + js, err := jetstream.New(conn) + if err != nil { + return err + } + gate, err := js.KeyValue(ctx, delivery.AsksBucket) + if errors.Is(err, jetstream.ErrBucketNotFound) { + logger.Print(waiting) + <-ctx.Done() + return nil + } + if err != nil { + return fmt.Errorf("the bucket %s: %w", delivery.AsksBucket, err) + } + var kvs [2]jetstream.KeyValue + for i, name := range []string{delivery.AnswersBucket, askedBucket} { + if kvs[i], err = js.KeyValue(ctx, name); err != nil { + return fmt.Errorf("the bucket %s: %w", name, err) + } + } + state := kvStore{ctx: ctx, kv: kvs[1]} + client := &asker.Client{Module: delivery.TellerModule, State: state, Publish: publishVia(ctx, js), + Record: recordVia(ctx, conn, delivery.TellerModule), Subscribe: decidedVia(conn, delivery.TellerModule, logger.Printf)} + t := &Teller{Asker: client, Store: state, Answers: kvAnswers{ctx: ctx, kv: kvs[0]}, Gate: kvGate{ctx: ctx, kv: gate}, + Say: sayVia(ctx, js, delivery.TellerModule), Logf: logger.Printf} + // Hear the warrants first, so none that comes while the asks are read is missed. + if err := client.Listen(t.Answer, t.Said); err != nil { + return fmt.Errorf("hearing the router's warrants: %w", err) + } + if err := watchAsks(ctx, gate, t.OnAsk, t.OnAskGone); err != nil { + return fmt.Errorf("watching the gate's asks: %w", err) + } return nil } +// askedBucket is the teller's state bucket asked, `_`: what it asked of the router and which condition it +// raised (manifest state entry asked, novox/hq ADR 0437 §4). +const askedBucket = delivery.TellerModule + "_asked" + // waiting is said once at start. -const waiting = "waiting for the gate's asks, which come with the gate in release 3d; nothing is read, written or published until then" +const waiting = "waiting for the gate's asks, whose bucket does not exist yet; the teller is started again with the gate, and nothing is read, written or published until then" diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go new file mode 100644 index 0000000..921961f --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go @@ -0,0 +1,383 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The teller carries the gate's merge asks to the operator and writes the operator's answer back (novox/hq ADR 0437 +// §6, §9). It reads the gate's asks, publishes each to the router unchanged as the asker mesh-delivery-teller +// (mesh-sdk go/asker), withdraws it when the gate takes its key away, and on the warrant the router says to it +// holds the warrant to the ask (ADR 0259 §6) and writes the answer under the ask's own key by compare-and-set. A +// warrant that does not hold writes no answer and is said as a condition. It holds no timer and serves no request: +// every reaction is a revision of the gate's key or a warrant. + +// store is where the teller keeps what it asked: the asker's own records (`ask.`) and, beside them, which gate key +// each ask was made for and at which revision of it (`key.`), and which conditions it has raised (`cond.`). +type store interface { + asker.Store + Delete(key string) error +} + +// routerAsks is the asker.Client as the teller uses it. +type routerAsks interface { + Ask(a asks.Ask) error + Cancel(id string) error + Where(id string) (state string, w *asks.Warrant, err error) + Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) +} + +// answers is the teller's bucket answers. +type answers interface { + // Create writes the answer under the ask's key only when there is none; created is false when there is one. + Create(key string, value []byte) (created bool, err error) + // Has says whether there is an answer under the key. + Has(key string) (bool, error) +} + +// gateAsks reads the gate's bucket asks. +type gateAsks interface { + Get(key string) (value []byte, found bool, err error) +} + +// Teller is the teller's logic; the program's wiring is main.go and decided.go. +type Teller struct { + Asker routerAsks + Store store + Answers answers + Gate gateAsks + // Say publishes a condition to the operator (mesh.seat.operator-channel.accept.condition.). + Say func(c asks.Condition) error + Logf func(format string, args ...any) + + mu sync.Mutex +} + +func (t *Teller) logf(format string, args ...any) { + if t.Logf != nil { + t.Logf(format, args...) + } +} + +// seen is what the teller keeps beside an ask it made: the gate key it was made for and the revision of that key it was +// made at, so a replay of the revision asks nothing again. +type seen struct { + Key string `json:"key"` + Rev uint64 `json:"rev"` +} + +func recordKey(id string) string { return "key." + id } +func keptKey(id string) string { return "ask." + id } +func condKey(id string) string { return "cond." + id } + +func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { + e, err := t.Store.Get(recordKey(id)) + if err != nil || e == nil { + return seen{}, nil, err + } + var s seen + if err := json.Unmarshal(e.Value, &s); err != nil { + return seen{}, nil, fmt.Errorf("the record %s cannot be read: %w", recordKey(id), err) + } + return s, e, nil +} + +func (t *Teller) keptOf(id string) (*asker.Kept, uint64, error) { + e, err := t.Store.Get(keptKey(id)) + if err != nil || e == nil { + return nil, 0, err + } + var k asker.Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return nil, 0, fmt.Errorf("the record %s cannot be read: %w", keptKey(id), err) + } + return &k, e.Revision, nil +} + +// open says whether an ask is made and neither answered nor ended. +func open(k *asker.Kept) bool { return k != nil && !k.Acted && k.Ended == "" } + +func (t *Teller) note(id, key string, rev uint64, prior *stdio.StateEntry) error { + s := seen{Key: key, Rev: rev} + if prior == nil { + _, err := t.Store.Create(recordKey(id), s) + return err + } + _, err := t.Store.Update(recordKey(id), s, prior.Revision) + return err +} + +// OnAsk handles the gate's asks key put at revision rev: the ask it holds is published to the router once per +// revision of its key. A key whose value is not the ask its name says, one already answered, and a revision the teller +// has handled are left. An ask still open and unchanged is only noted (and its warrant read from the router, in case it +// came while the teller was away); one whose words changed is cancelled and asked afresh; one that ended, or that the +// teller cancelled, is asked again under the same id when the gate writes its key again (ADR 0441 §1). +func (t *Teller) OnAsk(key string, rev uint64, value []byte) { + t.mu.Lock() + defer t.mu.Unlock() + ga, err := delivery.DecodeAsk(value) + if err != nil { + t.logf("the gate's key %s at revision %d is not an ask, and is left: %v", key, rev, err) + return + } + if want, err := delivery.MergeAskKey(ga.Group, ga.Heads); err != nil || want != key { + t.logf("the gate's key %s at revision %d holds the ask of %q, not its own, and is left (%v)", key, rev, want, err) + return + } + id := ga.Ask.ID + if has, err := t.Answers.Has(key); err != nil { + t.logf("%s: the answers could not be read, so the ask is left: %v", key, err) + return + } else if has { + return + } + prior, srec, err := t.seenOf(id) + if err != nil { + t.logf("%s: %v", key, err) + return + } + k, krev, err := t.keptOf(id) + if err != nil { + t.logf("%s: %v", key, err) + return + } + if srec != nil && rev <= prior.Rev { + if open(k) { + t.catchUp(id) + } + return + } + if k != nil { + if open(k) && k.Ask.Digest() == ga.Ask.Digest() { + _ = t.note(id, key, rev, srec) + t.catchUp(id) + return + } + if open(k) { + if err := t.Asker.Cancel(id); err != nil { + t.logf("%s: the ask %s changed and could not be cancelled, so it is left: %v", key, id, err) + return + } + t.logf("%s: the ask %s changed at revision %d: the old one is cancelled and the new one asked", key, id, rev) + } + if err := t.Store.Delete(keptKey(id)); err != nil { + t.logf("%s: the earlier ask %s (revision %d of its record) could not be forgotten: %v", key, id, krev, err) + return + } + } + if err := t.note(id, key, rev, srec); err != nil { + t.logf("%s: the record of the ask %s could not be kept, so it is left: %v", key, id, err) + return + } + if err := t.Asker.Ask(ga.Ask); err != nil { + t.logf("%s at revision %d: the ask %s was not asked: %v", key, rev, id, err) + if kept, _, _ := t.keptOf(id); open(kept) { + // Kept but not published: forgotten, so the next revision of the key asks it. + _ = t.Store.Delete(keptKey(id)) + } + return + } + t.logf("%s at revision %d: the ask %s is asked of the operator", key, rev, id) +} + +// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the ask is cancelled at the router, and an answer +// to it merges nothing. +func (t *Teller) OnAskGone(key string) { + t.mu.Lock() + defer t.mu.Unlock() + if _, _, _, err := delivery.ParseAskKey(key); err != nil { + t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err) + return + } + id := delivery.AskID(key) + k, krev, err := t.keptOf(id) + if err != nil { + t.logf("%s: %v", key, err) + return + } + if !open(k) { + return + } + if err := t.Asker.Cancel(id); err != nil { + t.logf("%s: the withdrawn ask %s could not be cancelled: %v", key, id, err) + return + } + // The router's word that it ended may come later: until it does, the ask is not open here. + k.Ended = asks.OutcomeCancelled + if _, err := t.Store.Update(keptKey(id), k, krev); err != nil && !errors.Is(err, stdio.ErrChanged) { + t.logf("%s: the cancelled ask %s could not be marked: %v", key, id, err) + } + t.logf("%s: the gate withdrew the ask %s: cancelled", key, id) +} + +// catchUp reads the router's record of an ask the teller made and hands its warrant on, as the router's event would: +// a warrant that came while the teller was away. +func (t *Teller) catchUp(id string) { + state, w, err := t.Asker.Where(id) + if err != nil { + t.logf("the router's record of the ask %s could not be read: %v", id, err) + return + } + if w == nil { + return + } + t.logf("the ask %s is %s at the router: its warrant is handled now", id, state) + h, err := t.Asker.Take(*w, t.Answer) + t.Said(h, *w, err) +} + +// mergeAct is what the approve option of the gate's ask binds: this ask, the answer, and the heads it covers. It is +// spelled as the gate composes it (mesh-catalog modules/mesh-delivery-gate approval.go mergeAct, from +// modules/mesh-delivery approval.go:63-65) and as mesh-controller verifies it (cmd/mesh-controller/moved.go:98-106). +func mergeAct(ask, option string, heads map[string]string) asks.Act { + var out []string + for id, sha := range heads { + out = append(out, id+"="+sha) + } + sort.Strings(out) + return asks.Act{"verb": "mesh-delivery.merge", "ask": ask, "option": option, "heads": strings.Join(out, ",")} +} + +// headsOf is the heads an ask covers as an act names them: a delivery's id, `owner/repository@` (mesh-catalog modules/mesh-delivery model.go Delivery.ID), and its whole commit. +func headsOf(heads []delivery.Head) map[string]string { + out := map[string]string{} + for _, h := range heads { + short := h.Head + if len(short) > 12 { + short = short[:12] + } + out[h.Repository+"@"+short] = h.Head + } + return out +} + +// warrantID names a warrant, which carries no id of its own: the ask and the time the router gave it. +func warrantID(w asks.Warrant) string { + return w.Ask + "@" + w.At.UTC().Format(time.RFC3339Nano) +} + +// Answer is the act of a warrant that holds the ask (asker.Client.Take has checked its asker, the ask's digest, the +// option and its level, and the time): the option's bound act must be the act of this ask and the heads it covers, +// and then the answer is written under the ask's key, once. An error is a warrant that does not hold here. +func (t *Teller) Answer(o asks.Option, w asks.Warrant) error { + s, srec, err := t.seenOf(w.Ask) + if err != nil { + return err + } + if srec == nil { + return fmt.Errorf("the teller holds no gate key for the ask %s", w.Ask) + } + raw, found, err := t.Gate.Get(s.Key) + if err != nil { + return fmt.Errorf("the gate's ask %s could not be read: %w", s.Key, err) + } + if !found { + t.logf("%s: the ask %s was answered and the gate has taken it away: an answer to a withdrawn ask merges nothing", s.Key, w.Ask) + return nil + } + ga, err := delivery.DecodeAsk(raw) + if err != nil { + return fmt.Errorf("the gate's ask %s is not an ask: %w", s.Key, err) + } + if ga.Ask.ID != w.Ask || ga.Ask.Digest() != w.AskDigest { + return fmt.Errorf("the gate's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)", + s.Key, ga.Ask.Digest(), w.AskDigest) + } + if err := o.Performs(mergeAct(w.Ask, o.ID, headsOf(ga.Heads))); err != nil { + return err + } + body, err := delivery.EncodeAnswer(delivery.Answer{Option: o.ID, Warrant: warrantID(w), Words: w.Says()}) + if err != nil { + return err + } + created, err := t.Answers.Create(delivery.AnswerKey(s.Key), body) + if err != nil { + return fmt.Errorf("the answer under %s could not be written: %w", s.Key, err) + } + if !created { + t.logf("%s: an answer is there already, and is not written again", s.Key) + return nil + } + t.logf("%s: %s", s.Key, w.Says()) + return nil +} + +// Said is what the teller does with what became of a warrant: a good answer clears the condition of its ask; a warrant +// that does not hold, or that the router refused, is said as a condition (ADR 0437 §9). Nothing is written for either. +func (t *Teller) Said(h asker.Handled, w asks.Warrant, err error) { + switch { + case h == asker.Acted && err == nil: + t.logf("the warrant of %s was %s", w.Ask, h) + t.clear(w.Ask) + case h == asker.Refused || (h == asker.Acted && err != nil): + t.logf("the warrant of %s does not hold, and no answer is written: %v", w.Ask, err) + t.raise(w.Ask, "A merge Approve did not hold", "its answer did not hold here: "+oneLine(err)) + case h == asker.Recorded && w.Outcome == asks.OutcomeRefused: + t.logf("the router refused the ask %s: %s", w.Ask, w.Words) + t.raise(w.Ask, "A merge ask was refused", "the router refused it: "+w.Words) + case err != nil: + t.logf("the warrant of %s: %v", w.Ask, err) + } +} + +func oneLine(err error) string { + if err == nil { + return "no reason was given" + } + s := strings.Join(strings.Fields(err.Error()), " ") + if r := []rune(s); len(r) > 200 { + return string(r[:199]) + "…" + } + return s +} + +// conditionKey is the key of the condition about an ask. +func conditionKey(id string) string { return delivery.TellerModule + ".merge-ask." + id } + +func (t *Teller) raise(id, headline, why string) { + if t.Say == nil { + return + } + if _, err := t.Store.Create(condKey(id), seen{}); err != nil { + if errors.Is(err, stdio.ErrChanged) { + return // raised already + } + t.logf("the condition of %s could not be kept: %v", id, err) + } + needs := "to ask for the Approve again" + c := asks.Condition{Key: conditionKey(id), Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "merge", ID: id}, State: asks.StateRaised, + Summary: "The merge ask " + id + ": " + why, Headline: headline, Needs: needs, + Explanation: asks.NeedsYou + needs + ". The merge ask " + id + ", " + why + ". Nothing was merged.", + Resolved: "A good answer to the merge ask was written."} + if err := t.Say(c); err != nil { + t.logf("the condition of %s could not be said: %v", id, err) + _ = t.Store.Delete(condKey(id)) + } +} + +func (t *Teller) clear(id string) { + if t.Say == nil { + return + } + e, err := t.Store.Get(condKey(id)) + if err != nil || e == nil { + return + } + c := asks.Condition{Key: conditionKey(id), Subject: asks.Scope{Scope: "merge", ID: id}, State: asks.StateCleared} + if err := t.Say(c); err != nil { + t.logf("the condition of %s could not be cleared: %v", id, err) + return + } + _ = t.Store.Delete(condKey(id)) +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go new file mode 100644 index 0000000..4c5f18e --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go @@ -0,0 +1,375 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const ( + headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +var now = time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) + +// memStore is a state bucket in memory with the revisions and the compare-and-set of the runtime's state. +type memStore struct { + entries map[string]*stdio.StateEntry + seq uint64 +} + +func (m *memStore) Get(key string) (*stdio.StateEntry, error) { + if e, ok := m.entries[key]; ok { + c := *e + return &c, nil + } + return nil, nil +} + +func (m *memStore) put(key string, value any) (uint64, error) { + b, err := json.Marshal(value) + if err != nil { + return 0, err + } + m.seq++ + m.entries[key] = &stdio.StateEntry{Key: key, Value: b, Revision: m.seq} + return m.seq, nil +} + +func (m *memStore) Create(key string, value any) (uint64, error) { + if _, ok := m.entries[key]; ok { + return 0, stdio.ErrChanged + } + return m.put(key, value) +} + +func (m *memStore) Update(key string, value any, revision uint64) (uint64, error) { + if e, ok := m.entries[key]; !ok || e.Revision != revision { + return 0, stdio.ErrChanged + } + return m.put(key, value) +} + +func (m *memStore) Delete(key string) error { delete(m.entries, key); return nil } + +type memAnswers struct { + written map[string][]byte + creates int +} + +func (a *memAnswers) Create(key string, value []byte) (bool, error) { + a.creates++ + if _, ok := a.written[key]; ok { + return false, nil + } + a.written[key] = value + return true, nil +} + +func (a *memAnswers) Has(key string) (bool, error) { _, ok := a.written[key]; return ok, nil } + +type memGate map[string][]byte + +func (g memGate) Get(key string) ([]byte, bool, error) { v, ok := g[key]; return v, ok, nil } + +type published struct { + subject string + body json.RawMessage + id string +} + +// bed is a teller with the asker.Client of the SDK on in-memory ends: what is published, the router's records, the +// warrants it would hear, and the conditions it says. +type bed struct { + t *Teller + client *asker.Client + store *memStore + answers *memAnswers + gate memGate + pubs []published + conds []asks.Condition + records map[string]string + hear func(stdio.Envelope) error + logs strings.Builder +} + +func newBed(t *testing.T) *bed { + t.Helper() + b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}}, + gate: memGate{}, records: map[string]string{}} + b.client = &asker.Client{Module: delivery.TellerModule, State: b.store, Now: func() time.Time { return now }, + Publish: func(subject string, body any, id string) error { + raw, err := json.Marshal(body) + b.pubs = append(b.pubs, published{subject, raw, id}) + return err + }, + Record: func(id string) (*stdio.StateEntry, error) { + if v, ok := b.records[id]; ok { + return &stdio.StateEntry{Key: id, Value: json.RawMessage(v)}, nil + } + return nil, nil + }, + Subscribe: func(_ string, handle func(stdio.Envelope) error) error { b.hear = handle; return nil }, + } + b.t = &Teller{Asker: b.client, Store: b.store, Answers: b.answers, Gate: b.gate, + Say: func(c asks.Condition) error { + if _, err := asks.EncodeCondition(c); err != nil { + t.Errorf("a condition the router would refuse: %v", err) + } + b.conds = append(b.conds, c) + return nil + }, + Logf: func(f string, a ...any) { fmt.Fprintf(&b.logs, f+"\n", a...) }} + if err := b.client.Listen(b.t.Answer, b.t.Said); err != nil { + t.Fatal(err) + } + return b +} + +// gateAsk is an ask as the gate composes it: the router's ask with the approve and decline options binding the act +// `mesh-delivery.merge` for this ask and the heads (mesh-catalog modules/mesh-delivery approval.go:123-191 composeMergeAsk +// and :63-65 mergeAct), under the id its key gives (mesh-sdk go/delivery AskID). boundTo are the heads the options bind, +// which are the ask's own unless a test says another. +func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value []byte, ask delivery.Ask) { + t.Helper() + key, err := delivery.MergeAskKey("g", heads) + if err != nil { + t.Fatal(err) + } + id := delivery.AskID(key) + a := asks.Ask{ID: id, Headline: "Merge into main: novox/mesh-delivery#12", Explanation: "Merges two pull requests.\nReview: approved.", + Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute), OnExpiry: "the merge stays held", About: "delivery.mesh-delivery.12"} + for _, o := range []struct{ id, label, does string }{{"approve", "Approve", "merges"}, {"decline", "Decline", "merges nothing"}} { + binds, err := asks.ActDigest(mergeAct(id, o.id, headsOf(boundTo))) + if err != nil { + t.Fatal(err) + } + a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + } + ask = delivery.Ask{Group: "g", Heads: heads, Ask: a} + value, err = delivery.EncodeAsk(ask) + if err != nil { + t.Fatal(err) + } + return key, value, ask +} + +func twoHeads() []delivery.Head { + return []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} +} + +// warrantFor is the router's warrant for a chosen option of an ask, as the router verified the operator. +func warrantFor(a asks.Ask, option string) asks.Warrant { + return asks.Warrant{Ask: a.ID, Asker: delivery.TellerModule, Outcome: asks.OutcomeChosen, Option: option, Label: "Approve", Level: asks.Approve, + By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id"}, Channel: "telegram", + At: now.Add(time.Hour), AskDigest: a.Digest()} +} + +func (b *bed) deliver(t *testing.T, w asks.Warrant) { + t.Helper() + raw, _ := json.Marshal(w) + if err := b.hear(stdio.Envelope{Key: asks.Seat + ".decided." + delivery.TellerModule, Body: raw}); err != nil { + t.Fatal(err) + } +} + +func (b *bed) askedOf(subject string) []published { + var out []published + for _, p := range b.pubs { + if p.subject == subject { + out = append(out, p) + } + } + return out +} + +func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1343/AC1 + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.gate[key] = value + b.t.OnAsk(key, 3, value) + asked := b.askedOf(asks.AskSubject(delivery.TellerModule)) + if len(asked) != 1 || asked[0].id != ga.Ask.ID { + t.Fatalf("published %+v, want one ask under %s with id %s", b.pubs, delivery.TellerModule, ga.Ask.ID) + } + var got asks.Ask + if err := json.Unmarshal(asked[0].body, &got); err != nil { + t.Fatal(err) + } + if got.Digest() != ga.Ask.Digest() || got.Headline != ga.Ask.Headline || got.Explanation != ga.Ask.Explanation || got.ID != delivery.AskID(key) { + t.Errorf("the router's ask differs from the gate's: %+v", got) + } + b.t.OnAsk(key, 3, value) // the revision seen again, as at a restart + b.t.OnAsk(key, 4, value) // the key written again with the same ask + if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 1 { + t.Errorf("the ask was published %d times", n) + } + delete(b.gate, key) + b.t.OnAskGone(key) + cancels := b.askedOf(asks.CancelSubject(delivery.TellerModule)) + if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+ga.Ask.ID+`"}` { + t.Fatalf("cancels = %+v", cancels) + } + b.t.OnAskGone(key) + if n := len(b.askedOf(asks.CancelSubject(delivery.TellerModule))); n != 1 { + t.Errorf("the ask was cancelled %d times", n) + } +} + +func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { // 1343/AC2 + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.gate[key] = value + b.t.OnAsk(key, 1, value) + w := warrantFor(ga.Ask, "approve") + b.deliver(t, w) + raw, ok := b.answers.written[delivery.AnswerKey(key)] + if !ok || len(b.answers.written) != 1 { + t.Fatalf("answers = %v", b.answers.written) + } + a, err := delivery.DecodeAnswer(raw) + if err != nil || a.Option != "approve" || a.Warrant != w.Ask+"@"+w.At.UTC().Format(time.RFC3339Nano) || !strings.Contains(a.Words, "chose Approve") { + t.Errorf("answer %+v err %v", a, err) + } + b.deliver(t, w) // the same warrant again + w2 := w + w2.At = w.At.Add(time.Minute) // a second warrant for the same ask + b.deliver(t, w2) + if b.answers.creates != 1 { + t.Errorf("the answer was written %d times, want once", b.answers.creates) + } + if len(b.conds) != 0 { + t.Errorf("conditions said: %+v", b.conds) + } +} + +func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T) { // 1343/AC3 + other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} + cases := map[string]func(t *testing.T) (*bed, string, asks.Warrant){ + "heads that differ from the ones the option bound": func(t *testing.T) (*bed, string, asks.Warrant) { + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), other) // the options bind other heads than the ask covers + b.gate[key] = value + b.t.OnAsk(key, 1, value) + return b, key, warrantFor(ga.Ask, "approve") + }, + "an asker that differs": func(t *testing.T) (*bed, string, asks.Warrant) { + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.gate[key] = value + b.t.OnAsk(key, 1, value) + w := warrantFor(ga.Ask, "approve") + w.Asker = "mesh-delivery" + return b, key, w + }, + "a digest that differs": func(t *testing.T) (*bed, string, asks.Warrant) { + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.gate[key] = value + b.t.OnAsk(key, 1, value) + w := warrantFor(ga.Ask, "approve") + w.AskDigest = "sha256:0000" + return b, key, w + }, + } + for name, mk := range cases { + t.Run(name, func(t *testing.T) { + b, key, w := mk(t) + b.deliver(t, w) + if len(b.answers.written) != 0 { + t.Errorf("an answer was written: %v", b.answers.written) + } + if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || b.conds[0].Subject.ID != delivery.AskID(key) || + !strings.HasPrefix(b.conds[0].Explanation, asks.NeedsYou) { + t.Fatalf("conditions = %+v", b.conds) + } + }) + } +} + +func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/AC3 (cleared when a good answer is written) + b := newBed(t) + other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} + key, bad, gaBad := gateAsk(t, twoHeads(), other) + b.gate[key] = bad + b.t.OnAsk(key, 1, bad) + b.deliver(t, warrantFor(gaBad.Ask, "approve")) + if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised { + t.Fatalf("conditions = %+v", b.conds) + } + // The gate writes the ask again, right this time, at a new revision of its key: asked again under the same id. + _, good, gaGood := gateAsk(t, twoHeads(), twoHeads()) + b.gate[key] = good + b.t.OnAsk(key, 2, good) + if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 { + t.Fatalf("the ask was published %d times, want again at the new revision", n) + } + b.deliver(t, warrantFor(gaGood.Ask, "approve")) + if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + t.Fatalf("no answer: %v\n%s", b.answers.written, b.logs.String()) + } + if len(b.conds) != 2 || b.conds[1].State != asks.StateCleared || b.conds[1].Key != b.conds[0].Key { + t.Errorf("conditions = %+v", b.conds) + } +} + +func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) { + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.gate[key] = value + b.t.OnAsk(key, 1, value) + w, _ := json.Marshal(warrantFor(ga.Ask, "approve")) + b.records[ga.Ask.ID] = `{"state":"chosen","warrant":` + string(w) + `}` + b.t.OnAsk(key, 1, value) // the teller starts again and reads the key at the same revision + if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + t.Errorf("no answer: %s", b.logs.String()) + } +} + +func TestAnAskWhoseWordsChangedIsCancelledAndAskedAgain(t *testing.T) { + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.t.OnAsk(key, 1, value) + changed := ga + changed.Ask.Explanation = "Merges two pull requests, now with a third point.\nReview: approved." + v2, err := delivery.EncodeAsk(changed) + if err != nil { + t.Fatal(err) + } + b.t.OnAsk(key, 2, v2) + if n := len(b.askedOf(asks.CancelSubject(delivery.TellerModule))); n != 1 { + t.Errorf("cancels = %d", n) + } + if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 { + t.Errorf("asks = %d", n) + } +} + +func TestAKeyWhoseValueIsNotItsOwnAskIsLeft(t *testing.T) { + b := newBed(t) + _, value, _ := gateAsk(t, twoHeads(), twoHeads()) + b.t.OnAsk("merge.g.000000000000", 1, value) + b.t.OnAsk("merge.g.000000000000", 2, []byte(`{"v":1`)) + if len(b.pubs) != 0 { + t.Errorf("published %+v", b.pubs) + } +} + +func TestARefusedAskIsSaidAsACondition(t *testing.T) { // ADR 0437 §9 + b := newBed(t) + key, value, ga := gateAsk(t, twoHeads(), twoHeads()) + b.t.OnAsk(key, 1, value) + w := asks.Warrant{Ask: ga.Ask.ID, Asker: delivery.TellerModule, Outcome: asks.OutcomeRefused, Words: "no channel can send", At: now} + b.deliver(t, w) + if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || !strings.Contains(b.conds[0].Summary, "no channel can send") { + t.Errorf("conditions = %+v", b.conds) + } +} diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index afbeb06..c977aa1 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,6 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 98dc72c..fcb33b1 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,3 +1,5 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-teller/manifest_test.go b/modules/mesh-delivery-teller/manifest_test.go index 7343f02..438964f 100644 --- a/modules/mesh-delivery-teller/manifest_test.go +++ b/modules/mesh-delivery-teller/manifest_test.go @@ -76,7 +76,9 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 if m.Module != "mesh-delivery-teller" { t.Errorf("module = %q", m.Module) } - if want := []state{{Name: "answers", History: 5}}; !reflect.DeepEqual(m.State, want) { + // answers is the teller's answer to each ask; asked is what it asked of the router, which condition it raised and which + // gate key and revision each ask was made at (issue 1343). + if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if want := []string{"mesh-delivery-gate.asks"}; !reflect.DeepEqual(sorted(m.Reads), want) { @@ -134,7 +136,10 @@ func TestManifestProcess(t *testing.T) { // 1240/AC1 } func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1240/AC3 (ADR 0437 §8) - forbidden := []string{"Subscribe(", "QueueSubscribe", "ChanSubscribe", "time.NewTicker", "time.Tick", "time.NewTimer", "time.AfterFunc", "time.After(", "time.Sleep"} + forbidden := []string{"Subscribe(", "QueueSubscribe", "ChanSubscribe", "Respond(", "RespondMsg(", "time.NewTicker", "time.Tick", "time.NewTimer", "time.AfterFunc", "time.After(", "time.Sleep"} + // The one subscription the teller makes is to its own `decided` event, the router's word on its asks (ADR 0259, ADR 0437 + // §6): an event it hears, not a request it answers. It is allowed in decided.go and there only on that subject. + const decidedFile = "decided.go" var files int for _, root := range []string{"cmd", "internal"} { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { @@ -150,6 +155,13 @@ func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1240/AC3 (ADR return err } for _, f := range forbidden { + if f == "Subscribe(" && filepath.Base(path) == decidedFile { + if n := strings.Count(string(src), "conn.Subscribe("); n != 1 || !strings.Contains(string(src), "conn.Subscribe(asks.DecidedSubject(module),") || + strings.Count(string(src), f) != n { + t.Errorf("%s holds %d subscriptions, and not just one to asks.DecidedSubject(module)", path, strings.Count(string(src), f)) + } + continue + } if strings.Contains(string(src), f) { t.Errorf("%s holds %s: the teller serves no request subject and holds no timer", path, f) } diff --git a/modules/mesh-delivery-teller/module.json b/modules/mesh-delivery-teller/module.json index cfed5f1..3c616f5 100644 --- a/modules/mesh-delivery-teller/module.json +++ b/modules/mesh-delivery-teller/module.json @@ -3,7 +3,8 @@ "version": "1", "slug": "teller", "state": [ - { "name": "answers", "history": 5 } + { "name": "answers", "history": 5 }, + { "name": "asked", "history": 1 } ], "reads": [ "mesh-delivery-gate.asks" From 409c41d4aeef215da3929f08c8a882238fadd2c2 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:30:52 +0000 Subject: [PATCH 08/13] Stamp the modules with the gate's source revision, read from source.main (issue 1344) --- .../cmd/mesh-delivery-indexer/main.go | 26 +++++++++++++++--- .../cmd/mesh-delivery-indexer/main_test.go | 21 +++++++++++++++ .../cmd/mesh-delivery-indexer/source.go | 27 +++++++++++++++++++ modules/mesh-delivery-indexer/go.mod | 2 +- modules/mesh-delivery-indexer/go.sum | 2 ++ .../internal/index/candidates.go | 4 +++ .../internal/index/modules.go | 6 +++-- 7 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index 5465157..a64e005 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -255,9 +255,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return err } - // The gate's source bucket (mesh-delivery-gate_source) is read from release 3d on; it does not exist before, and the - // indexer does not wait for it. - known := index.NewBuilds() each := func(e jetstream.KeyValueEntry) { if opOf(e) { @@ -274,6 +271,27 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro go func() { errs <- follow(ctx, w, each) }() ix := &index.Indexer{Store: kvStore{candidates}, Modules: kvStore{modules}, Builds: known, Logf: logger.Printf} + // The gate's source bucket (mesh-delivery-gate_source) comes with the gate (release 3e, novox/hq ADR 0450 §1): its key + // main is the mesh's source revision, which stamps the modules (issue 1344). Where it does not exist yet the indexer does + // not wait for it (that needs a timer, ADR 0437 §8): it says so, and is started again with the gate. + sourceErrs := make(chan error, 1) + switch source, err := js.KeyValue(ctx, delivery.SourceBucket); { + case errors.Is(err, jetstream.ErrBucketNotFound): + logger.Printf("the bucket %s does not exist yet: no source revision is read until the indexer starts again", delivery.SourceBucket) + case err != nil: + return fmt.Errorf("the bucket %s: %w", delivery.SourceBucket, err) + default: + each := func(e jetstream.KeyValueEntry) { + if opOf(e) { + applySource(ctx, ix, e.Key(), e.Revision(), e.Value(), logger) + } + } + sw, err := settle(ctx, source, each) + if err != nil { + return err + } + go func() { sourceErrs <- follow(ctx, sw, each) }() + } cons, err := js.Consumer(ctx, forgeStream, cred.Node+"_"+cred.Module) if err != nil { return fmt.Errorf("the consumer %s_%s on %s: %w", cred.Node, cred.Module, forgeStream, err) @@ -289,5 +307,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return nil case err := <-errs: return err + case err := <-sourceErrs: + return err } } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go index 4b8cda5..19b3658 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go @@ -67,3 +67,24 @@ func TestAMessageThatIsNoForgeMessageIsTerminatedAndLogged(t *testing.T) { // 12 t.Errorf("terminated %d acked %d keys %d log %q", m.termd, m.acked, len(st.vals), logs.String()) } } + +func TestTheSourceMainKeyIsTakenAtItsRevision(t *testing.T) { // 1344/AC1 (the watch's side: key, revision, value) + var logs bytes.Buffer + logger := log.New(&logs, "", 0) + mods := &memStore{vals: map[string][]byte{}} + ix := &index.Indexer{Store: &memStore{vals: map[string][]byte{}}, Modules: mods, Builds: index.NewBuilds()} + value, err := delivery.EncodeSource(delivery.Source{Group: "g", Heads: map[string]string{"novox/mesh-delivery": head}}) + if err != nil { + t.Fatal(err) + } + applySource(context.Background(), ix, "other", 3, value, logger) // not the key of the mesh's source + applySource(context.Background(), ix, delivery.SourceMainKey, 4, []byte(`{"v":1`), logger) + if !strings.Contains(logs.String(), "source") { + t.Errorf("a source that cannot be read was not said: %q", logs.String()) + } + logs.Reset() + applySource(context.Background(), ix, delivery.SourceMainKey, 5, value, logger) + if logs.Len() != 0 { + t.Errorf("a good source said %q", logs.String()) + } +} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go new file mode 100644 index 0000000..48c8513 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/source.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "log" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-indexer/internal/index" +) + +// applySource takes one put of the gate's bucket source into the indexer: the key main, at its revision, which is the mesh's +// source revision (mesh-sdk go/delivery SourceMainKey, Source). Any other key is not the mesh's source. A value that is no +// source is said; so is a write that failed, and it is tried again only at the next revision (novox/hq ADR 0437 §8). +func applySource(ctx context.Context, ix *index.Indexer, key string, rev uint64, value []byte, logger *log.Logger) { + if key != delivery.SourceMainKey { + return + } + s, err := delivery.DecodeSource(value) + if err != nil { + logger.Printf("source.%s at revision %d is not a source, and is left: %v", key, rev, err) + return + } + if err := ix.ApplySource(ctx, rev, s); err != nil { + logger.Printf("source.%s at revision %d could not be taken into the modules: %v", key, rev, err) + } +} diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index a03eb51..02011be 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index 9000f11..e7d24d6 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337 h1:cZyFnvDi git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g/Q86N8CCHq3OatJIJ0JSy6Kh980ekGxNLmE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index 8bf63af..7be504a 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strings" + "sync" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -44,6 +45,9 @@ type Indexer struct { Modules Store Builds *Builds Logf func(format string, args ...any) + + mu sync.Mutex // guards source, which the source watch and the forge messages both reach + source sourceNow } func (ix *Indexer) logf(format string, args ...any) { diff --git a/modules/mesh-delivery-indexer/internal/index/modules.go b/modules/mesh-delivery-indexer/internal/index/modules.go index 47001a6..1243ff5 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules.go +++ b/modules/mesh-delivery-indexer/internal/index/modules.go @@ -45,7 +45,9 @@ func fingerprintOf(k Known, trees map[string]string) (fp, none string) { } // applyMerged writes, for each module built from the merged repository, its fingerprint at the merge commit (or none and -// why). A module of another repository is not written. A value the module already holds is not written again. +// why), with the source revision that names the merge commit as the repository's head when the indexer has seen it +// (ApplySource), and Src 0 when it has not. A module of another repository is not written. A value the module already +// holds is not written again. func (ix *Indexer) applyMerged(ctx context.Context, f delivery.Forge) error { for _, k := range ix.Builds.Modules(f.Repository) { key, err := delivery.ModulesKey(k.Module) @@ -54,7 +56,7 @@ func (ix *Indexer) applyMerged(ctx context.Context, f delivery.Forge) error { continue } fp, none := fingerprintOf(k, f.Trees) - next := delivery.Module{Repository: f.Repository, Commit: f.Merge, FP: fp, None: none} + next := delivery.Module{Repository: f.Repository, Commit: f.Merge, FP: fp, None: none, Src: ix.srcOf(f.Repository, f.Merge)} err = write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) { if found { if cur, err := delivery.DecodeModule(value); err == nil && delivery.SameModule(cur, withV(next)) { From 05c180cf87d1a6a06dbb1e319eeb91be9b605a1b Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:31:19 +0000 Subject: [PATCH 09/13] Add the indexer's source revision logic and its tests that the last commit named (issue 1344) --- .../internal/index/source.go | 80 +++++++++++++ .../internal/index/source_test.go | 106 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 modules/mesh-delivery-indexer/internal/index/source.go create mode 100644 modules/mesh-delivery-indexer/internal/index/source_test.go diff --git a/modules/mesh-delivery-indexer/internal/index/source.go b/modules/mesh-delivery-indexer/internal/index/source.go new file mode 100644 index 0000000..19a5b7b --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/source.go @@ -0,0 +1,80 @@ +package index + +import ( + "context" + "fmt" + "maps" + "slices" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The gate's source revision (novox/hq ADR 0437 §5, ADR 0450 §1): source.main names every repository's merged head, and its +// revision in the bucket is the mesh's source revision. The indexer keeps the latest it has seen. A module is stamped +// with it (Module.Src) when the head the source names for its repository is the merge commit the module's fingerprint +// was computed at: from the modules bucket when the merge was heard first, and when the merge is heard after the source. + +// sourceNow is the latest source revision seen. +type sourceNow struct { + rev uint64 + heads map[string]string +} + +// srcOf is the source revision that names this merge commit as the repository's head, or 0 when the latest source seen +// names another head or none has been seen. +func (ix *Indexer) srcOf(repository, merge string) uint64 { + ix.mu.Lock() + defer ix.mu.Unlock() + if ix.source.rev != 0 && merge != "" && ix.source.heads[repository] == merge { + return ix.source.rev + } + return 0 +} + +// ApplySource takes a revision of the gate's source.main in. An older revision than the latest seen is left. For each +// repository the source names, each module built from it whose fingerprint was computed at the head the source names is +// written again with Src the revision; a module computed at another commit is left (a merge heard later is stamped +// when it is written, applyMerged), and a repository none of the indexer's modules is built from writes nothing. +func (ix *Indexer) ApplySource(ctx context.Context, rev uint64, s delivery.Source) error { + ix.mu.Lock() + if rev < ix.source.rev { + ix.mu.Unlock() + return nil + } + ix.source = sourceNow{rev: rev, heads: maps.Clone(s.Heads)} + ix.mu.Unlock() + if ix.Modules == nil { + return nil + } + repositories := slices.Sorted(maps.Keys(s.Heads)) + for _, repository := range repositories { + head := s.Heads[repository] + for _, k := range ix.Builds.Modules(repository) { + key, err := delivery.ModulesKey(k.Module) + if err != nil { + ix.logf("%s: the module %q has no usable key, skipped: %v", repository, k.Module, err) + continue + } + err = write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) { + if !found { + return nil, false, nil + } + cur, err := delivery.DecodeModule(value) + if err != nil { + ix.logf("modules.%s holds what is not a module, left: %v", key, err) + return nil, false, nil + } + if cur.Repository != repository || cur.Commit != head || cur.Src == rev { + return nil, false, nil + } + cur.Src = rev + data, err := delivery.EncodeModule(cur) + return data, false, err + }) + if err != nil { + return fmt.Errorf("modules.%s: %w", key, err) + } + } + } + return nil +} diff --git a/modules/mesh-delivery-indexer/internal/index/source_test.go b/modules/mesh-delivery-indexer/internal/index/source_test.go new file mode 100644 index 0000000..8f8fd7a --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/source_test.go @@ -0,0 +1,106 @@ +package index + +import ( + "context" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const otherMerge = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +func sourceOf(repo, head string) delivery.Source { + return delivery.Source{Group: "g", Heads: map[string]string{repo: head}} +} + +func TestASourceRevisionStampsTheModulesOfTheRepositoriesHeadItNames(t *testing.T) { // 1344/AC1 + ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs), + rec(t, "build-2", "c", "novox/mesh-catalog", "modules/c", "main", "", "2026-10-19T09:00:00Z", aInputs)) + ctx := context.Background() + if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})); err != nil { + t.Fatal(err) + } + before, _ := moduleOf(t, mods, "a") + if before.Src != 0 { + t.Fatalf("module before the source = %+v, want Src 0", before) + } + // The source names the head merged for the delivery repository, and another repository's head no module of ours is built from. + s := delivery.Source{Group: "g", Heads: map[string]string{"novox/mesh-delivery": merge, "novox/mesh-catalog": otherMerge}} + if err := ix.ApplySource(ctx, 7, s); err != nil { + t.Fatal(err) + } + m, ok := moduleOf(t, mods, "a") + want := before + want.Src = 7 + if !ok || m != want { + t.Errorf("module a = %+v (%v), want %+v: the fingerprint and the commit as the merge wrote them, with Src 7", m, ok, want) + } + if c, ok := moduleOf(t, mods, "c"); ok { + t.Errorf("module c = %+v: written for a head no merge message of the indexer carried", c) + } + // The same revision again changes nothing; a later revision moves Src. + if err := ix.ApplySource(ctx, 7, s); err != nil { + t.Fatal(err) + } + if err := ix.ApplySource(ctx, 9, s); err != nil { + t.Fatal(err) + } + if m, _ := moduleOf(t, mods, "a"); m.Src != 9 { + t.Errorf("module a Src = %d, want 9", m.Src) + } +} + +func TestAMergeHeardAfterTheSourceNamesItsHeadIsStampedWhenWritten(t *testing.T) { // 1344/AC1 (the source first) + ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs)) + ctx := context.Background() + if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil { + t.Fatal(err) + } + if _, ok := moduleOf(t, mods, "a"); ok { + t.Fatal("a module was written from a source revision alone: its tree is the merge message's") + } + if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})); err != nil { + t.Fatal(err) + } + if m, ok := moduleOf(t, mods, "a"); !ok || m.Src != 7 || m.Commit != merge || m.FP == "" { + t.Errorf("module a = %+v (%v), want the merge's fingerprint with Src 7", m, ok) + } +} + +func TestAMergeThatNoSourceRevisionNamesIsWrittenAsToday(t *testing.T) { // 1344/AC2 + ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs)) + ctx := context.Background() + // No source revision yet. + if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})); err != nil { + t.Fatal(err) + } + if m, ok := moduleOf(t, mods, "a"); !ok || m.Src != 0 || m.FP == "" { + t.Fatalf("module a = %+v (%v), want a fingerprint with Src 0", m, ok) + } + // A source revision that names another head of the repository: a merge of the head it does not name keeps Src 0. + if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", otherMerge)); err != nil { + t.Fatal(err) + } + if m, _ := moduleOf(t, mods, "a"); m.Src != 0 { + t.Errorf("module a Src = %d after a source naming another head, want 0", m.Src) + } + merged := mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newertree"}) + merged.Merge = "cccccccccccccccccccccccccccccccccccccccc" + if err := ix.Apply(ctx, merged); err != nil { + t.Fatal(err) + } + if m, _ := moduleOf(t, mods, "a"); m.Src != 0 || m.Commit != merged.Merge { + t.Errorf("module a = %+v, want the new merge with Src 0", m) + } +} + +func TestASourceRevisionOlderThanTheOneHeldIsLeft(t *testing.T) { + ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs)) + ctx := context.Background() + _ = ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})) + _ = ix.ApplySource(ctx, 9, sourceOf("novox/mesh-delivery", merge)) + _ = ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)) + if m, _ := moduleOf(t, mods, "a"); m.Src != 9 { + t.Errorf("module a Src = %d, want 9", m.Src) + } +} From 34f585106f46ab88c535ccc9db93ff88f05031d5 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:34:28 +0000 Subject: [PATCH 10/13] Show the open asks in the delivery view: the gate's asks with no answer, in the table, the page and the command's JSON (issue 1345) --- .../cmd/mesh-delivery-cli/main_test.go | 58 ++++++++ .../mesh-delivery-view/integration_test.go | 91 ++++++++++++ .../cmd/mesh-delivery-view/main.go | 2 +- modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 2 + .../internal/view/asks_test.go | 132 ++++++++++++++++++ .../mesh-delivery-view/internal/view/serve.go | 19 ++- .../mesh-delivery-view/internal/view/view.go | 71 +++++++++- modules/mesh-delivery-view/manifest_test.go | 2 +- modules/mesh-delivery-view/module.json | 2 + 10 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-delivery-view/internal/view/asks_test.go diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go index 7105cad..19ff059 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go @@ -8,7 +8,9 @@ import ( "path/filepath" "strings" "testing" + "time" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-view/internal/view" @@ -109,3 +111,59 @@ func mustHash(t *testing.T, h delivery.Head) string { } return s } + +// TestPrintsTheOpenAsksInTheTableAndTheJSON: the command shows what waits for the operator's Approve (1345/AC2). The ask is +// the gate's, as mesh-sdk go/delivery EncodeAsk writes it under the key MergeAskKey gives (as internal/view asks_test.go). +func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2 + m := view.New() + heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}} + key, err := delivery.MergeAskKey("feat/x", heads) + if err != nil { + t.Fatal(err) + } + ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator, + Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}} + value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask}) + if err != nil { + t.Fatal(err) + } + m.Apply(view.Entry{Bucket: delivery.AsksBucket, Key: key, Value: value}) + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + l, err := view.ListenSocket(filepath.Join(dir, "cli.sock")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go view.ServeSocket(ctx, l, m) + t.Cleanup(cancel) + socket := filepath.Join(dir, "cli.sock") + var out, errOut bytes.Buffer + if code := run(nil, socket, &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} { + if !strings.Contains(out.String(), want) { + t.Errorf("the table lacks %q:\n%s", want, out.String()) + } + } + out.Reset() + if code := run([]string{"--json"}, socket, &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + var got struct { + Asks []struct { + Key string `json:"key"` + Headline string `json:"headline"` + How string `json:"how"` + } `json:"asks"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil || len(got.Asks) != 1 || got.Asks[0].Key != key || + got.Asks[0].Headline != "Merge into main: mesh-delivery#12" || got.Asks[0].How != "answer on the phone" { + t.Errorf("json = %s (%v)", out.String(), err) + } +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go index 2b98ddd..09b17f5 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-view/internal/view" @@ -299,3 +300,93 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124 } t.Fatalf("the snapshot never held the indexer's keys; last: %+v", last) } + +// TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes starts the program whole before the gate's asks bucket and the +// teller's answers bucket exist, then creates them: an ask appears in the snapshot, and leaves it once the teller has an +// answer under its key (1345/AC1 against a real bus; the retry is the view's own, every 5 s). +func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { // 1345/AC1 + srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true}) + if err != nil { + t.Fatal(err) + } + go srv.Start() + if !srv.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(srv.Shutdown) + nc, err := nats.Connect(srv.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + broker := filepath.Join(dir, "broker") + cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"}) + if err := os.WriteFile(broker, cred, 0o600); err != nil { + t.Fatal(err) + } + socket := filepath.Join(dir, "run", "cli.sock") + env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"} + runCtx, stop := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }() + t.Cleanup(func() { stop(); <-done }) + + gate, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AsksBucket, History: delivery.AsksHistory}) + if err != nil { + t.Fatal(err) + } + teller, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AnswersBucket, History: delivery.AnswersHistory}) + if err != nil { + t.Fatal(err) + } + heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}} + key, err := delivery.MergeAskKey("feat/x", heads) + if err != nil { + t.Fatal(err) + } + ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator, + Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}} + value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask}) + if err != nil { + t.Fatal(err) + } + if _, err := gate.Put(ctx, key, value); err != nil { + t.Fatal(err) + } + wait := func(check func(view.Snapshot) bool, what string) { + t.Helper() + var last view.Snapshot + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if s, err := view.Ask(socket); err == nil { + last = s + if check(s) { + return + } + } + } + t.Fatalf("%s; last: %+v", what, last) + } + wait(func(s view.Snapshot) bool { + return len(s.Asks) == 1 && s.Asks[0].Key == key && s.Asks[0].How == "answer on the phone" + }, "the open ask never showed") + answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"}) + if err != nil { + t.Fatal(err) + } + if _, err := teller.Put(ctx, key, answer); err != nil { + t.Fatal(err) + } + wait(func(s view.Snapshot) bool { return len(s.Asks) == 0 }, "the answered ask never left the list") +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go index 9a307f8..4f3cf6c 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -136,7 +136,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return err } - for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket} { + for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket} { go follow(ctx, js, bucket, model, logger) } select { diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index 1570291..37a8ead 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index 9000f11..e7d24d6 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337 h1:cZyFnvDi git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g/Q86N8CCHq3OatJIJ0JSy6Kh980ekGxNLmE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/internal/view/asks_test.go b/modules/mesh-delivery-view/internal/view/asks_test.go new file mode 100644 index 0000000..b155085 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/asks_test.go @@ -0,0 +1,132 @@ +package view + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const askHeadA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// askEntry is a key of the gate's asks as the gate writes it (mesh-sdk go/delivery EncodeAsk, MergeAskKey): the router's ask +// whole, under the id its key gives. +func askEntry(t *testing.T, group, headline, words string) Entry { + t.Helper() + heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}} + key, err := delivery.MergeAskKey(group, heads) + if err != nil { + t.Fatal(err) + } + a := asks.Ask{ID: delivery.AskID(key), Headline: headline, Explanation: words, Who: asks.Operator, Expires: t0.Add(23 * time.Hour), OnExpiry: "the merge stays held", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}} + v, err := delivery.EncodeAsk(delivery.Ask{Group: group, Heads: heads, Ask: a}) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: delivery.AsksBucket, Key: key, Value: v, At: t0} +} + +func answerEntry(t *testing.T, key string) Entry { + t.Helper() + v, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"}) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: delivery.AnswersBucket, Key: key, Value: v, At: t0} +} + +func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/AC1 + m := New() + e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.\nReview: approved.") + fill(m, e) + s := m.Snapshot() + if len(s.Asks) != 1 { + t.Fatalf("asks = %+v, want the one open ask", s.Asks) + } + a := s.Asks[0] + if a.Key != e.Key || a.Group != "feat/x" || a.Headline != "Merge into main: mesh-delivery#12" || !strings.Contains(a.Words, "Merges one pull request.") || + a.How != "answer on the phone" || len(a.Pulls) != 1 || !strings.Contains(a.Pulls[0], "novox/mesh-delivery#12") || !strings.Contains(a.Pulls[0], askHeadA[:12]) { + t.Errorf("ask = %+v", a) + } + fill(m, answerEntry(t, e.Key)) + if got := m.Snapshot().Asks; len(got) != 0 { + t.Errorf("an answered ask is listed: %+v", got) + } + // An answer taken away leaves the ask open again; an ask taken away leaves the list. + fill(m, Entry{Bucket: delivery.AnswersBucket, Key: e.Key, Deleted: true}) + if got := m.Snapshot().Asks; len(got) != 1 { + t.Errorf("asks after the answer was taken away = %+v", got) + } + fill(m, Entry{Bucket: delivery.AsksBucket, Key: e.Key, Deleted: true}) + if got := m.Snapshot().Asks; len(got) != 0 { + t.Errorf("a withdrawn ask is listed: %+v", got) + } +} + +func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) { + m := New() + e := askEntry(t, "feat/x", "Merge", "Merges.") + fill(m, answerEntry(t, e.Key), e) + if got := m.Snapshot().Asks; len(got) != 0 { + t.Errorf("asks = %+v", got) + } +} + +func TestAnUnreadableAskIsNamedAndNotListed(t *testing.T) { // never dropped silently + m := New() + fill(m, Entry{Bucket: delivery.AsksBucket, Key: "merge.g.0123456789ab", Value: []byte("{"), At: t0}) + s := m.Snapshot() + if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != delivery.AsksBucket { + t.Errorf("snapshot = %+v", s) + } +} + +func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2 + m := New() + fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.")) + var out bytes.Buffer + Table(&out, m.Snapshot()) + for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone", "novox/mesh-delivery#12"} { + if !strings.Contains(out.String(), want) { + t.Errorf("the table lacks %q:\n%s", want, out.String()) + } + } + rec := httptest.NewRecorder() + Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("the page lacks %q:\n%s", want, rec.Body.String()) + } + } + // No ask, no section. + out.Reset() + Table(&out, New().Snapshot()) + if strings.Contains(out.String(), "ASKS") { + t.Errorf("the table shows an ASKS section with no ask:\n%s", out.String()) + } +} + +func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2 + m := New() + fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.")) + raw, err := json.Marshal(m.Snapshot()) + if err != nil { + t.Fatal(err) + } + var back struct { + Asks []struct { + Headline string `json:"headline"` + How string `json:"how"` + } `json:"asks"` + } + if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != "answer on the phone" { + t.Errorf("json %s: %+v %v", raw, back, err) + } +} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index 593a36c..73fa01e 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -152,6 +152,15 @@ func fingerprintText(s Snapshot) [][]string { return out } +// askText is one line per open ask: its key, headline, words on one line, the pull requests it covers and how to answer. +func askText(s Snapshot) [][]string { + var out [][]string + for _, a := range s.Asks { + out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), a.How}) + } + return out +} + var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"} // Table prints the snapshot as a table. @@ -184,6 +193,13 @@ func Table(w io.Writer, s Snapshot) { row(cells) } } + if a := askText(s); len(a) > 0 { + fmt.Fprintln(tw) + row([]string{"ASKS", "HEADLINE", "WORDS", "PULL REQUESTS", "ANSWER"}) + for _, cells := range a { + row(cells) + } + } if f := fingerprintText(s); len(f) > 0 { fmt.Fprintln(tw) row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"}) @@ -227,6 +243,7 @@ var page = template.Must(template.New("p").Parse(`

What each node runs

{{range .Head}}{{end}} {{range .Rows}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{if .Groups}}

CANDIDATES

{{range .Groups}}{{range .}}{{end}}{{end}}
GROUPPULL REQUESTHEADMODULES
{{.}}
{{end}} +{{if .Asks}}

ASKS

{{range .Asks}}{{range .}}{{end}}{{end}}
ASKHEADLINEWORDSPULL REQUESTSANSWER
{{.}}
{{end}} {{if .Fingerprints}}

FINGERPRINTS

{{range .Fingerprints}}{{range .}}{{end}}{{end}}
MODULESTATEINDEXER AGAINST BUILD
{{.}}
{{end}} {{if .Offline}}

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} {{if .Stuck}}

STUCK

{{range .Stuck}}{{range .}}{{end}}{{end}}
BUCKETKEYWAITED FORSINCE
{{.}}
{{end}} @@ -260,6 +277,6 @@ func Web(m *Model) http.Handler { for _, u := range snap.Unreadable { bad = append(bad, []string{u.Bucket, u.Key, u.Error}) } - page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad}) + page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad}) }) } diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index 7ab0ec4..6caaf3c 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -3,6 +3,7 @@ package view import ( "encoding/json" + "fmt" "sort" "strings" "sync" @@ -61,8 +62,25 @@ type Snapshot struct { Groups []GroupRow `json:"groups"` // Fingerprints is every module the indexer fingerprinted at a trunk merge, beside its newest successful trunk build's. Fingerprints []FingerprintRow `json:"fingerprints"` + // Asks is every ask the gate holds that has no answer yet: what waits for the operator's Approve. + Asks []AskRow `json:"asks"` } +// AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers. +type AskRow struct { + Key string `json:"key"` + Group string `json:"group"` + Headline string `json:"headline"` + Words string `json:"words"` + Pulls []string `json:"pulls"` + Expires time.Time `json:"expires,omitzero"` + How string `json:"how"` +} + +// AnswerOnThePhone is what an open ask says about answering it: the view only shows, and the Approve is answered on the +// operator's phone (ADR 0437 §10: answering in the view is release 5's). +const AnswerOnThePhone = "answer on the phone" + // GroupRow is a candidate group (novox/hq ADR 0437 §5): the branch its pull requests share and the pull requests. type GroupRow struct { Group string `json:"group"` @@ -129,6 +147,8 @@ type Model struct { builds map[string]buildEntry // by .build. groups map[string]groupEntry // the indexer's candidates, by group modules map[string]moduleEntry // the indexer's modules, by module + asks map[string]askRecord // the gate's asks, by key + answers map[string]bool // the keys the teller has an answer under newest map[string]string // module -> id haves map[string]haveEntry nodes map[string]nodeEntry @@ -194,6 +214,11 @@ type moduleEntry struct { err string } +type askRecord struct { + value delivery.Ask + err string +} + type haveEntry struct { have delivery.Have err string @@ -208,7 +233,7 @@ type nodeEntry struct { // New is an empty model. func New() *Model { - return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, newest: map[string]string{}, + return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, asks: map[string]askRecord{}, answers: map[string]bool{}, newest: map[string]string{}, haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{}, presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}} } @@ -277,6 +302,20 @@ func (m *Model) Apply(e Entry) { } mod, err := delivery.DecodeModule(e.Value) m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)} + case delivery.AsksBucket: + if e.Deleted { + delete(m.asks, e.Key) + return + } + a, err := delivery.DecodeAsk(e.Value) + m.asks[e.Key] = askRecord{value: a, err: errText(err)} + case delivery.AnswersBucket: + // An answer under the ask's key, whatever it says, ends the ask here: the gate acts on it, and the view only shows. + if e.Deleted { + delete(m.answers, e.Key) + return + } + m.answers[e.Key] = true case BuildsBucket: module, rest, ok := strings.Cut(e.Key, ".") if !ok { @@ -414,6 +453,11 @@ func (m *Model) Snapshot() Snapshot { unreadable = append(unreadable, UnreadableRow{Bucket: delivery.ModulesBucket, Key: key, Error: mod.err}) } } + for key, a := range m.asks { + if a.err != "" { + unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AsksBucket, Key: key, Error: a.err}) + } + } sort.Slice(offline, func(i, j int) bool { a, b := offline[i], offline[j] return a.Kind+a.Node+a.Part+a.Instance < b.Kind+b.Node+b.Part+b.Instance @@ -425,7 +469,7 @@ func (m *Model) Snapshot() Snapshot { return unreadable[i].Bucket+"."+unreadable[i].Key < unreadable[j].Bucket+"."+unreadable[j].Key }) return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable, - Groups: m.groupRows(), Fingerprints: m.fingerprintRows()} + Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows()} } // groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number. @@ -524,3 +568,26 @@ const ( StatusAssigned = "assigned" StatusLeftBehind = "left behind" ) + +// askRows are the readable asks with no answer under their key, ordered by key: each with its headline, its words, the +// pull requests it covers (repository, number, head shortened to 12 digits) and how to answer. +func (m *Model) askRows() []AskRow { + out := []AskRow{} + for key, a := range m.asks { + if a.err != "" || m.answers[key] { + continue + } + row := AskRow{Key: key, Group: a.value.Group, Headline: a.value.Ask.Headline, Words: a.value.Ask.Explanation, + Expires: a.value.Ask.Expires, How: AnswerOnThePhone, Pulls: []string{}} + for _, h := range a.value.Heads { + head := h.Head + if len(head) > 12 { + head = head[:12] + } + row.Pulls = append(row.Pulls, fmt.Sprintf("%s#%d @%s", h.Repository, h.Number, head)) + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 1a751e3..f3bc40e 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -74,7 +74,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4 + want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index f7a16ea..eb33058 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -6,8 +6,10 @@ "reads": [ "mesh-controller.assignments", "mesh-controller.builds", + "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", + "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have" From 14e9d56a22116fc431835effbd65c8b6ff04198f Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:30:54 +0000 Subject: [PATCH 11/13] Ask under each asking's own router id, spell the heads in lower case as the gate does, show a removed have plainly; pin mesh-sdk ba684e5 (review round 1: G2, T1, issue 1370) --- modules/mesh-delivery-forge-bridge/go.mod | 2 +- modules/mesh-delivery-forge-bridge/go.sum | 2 + modules/mesh-delivery-indexer/go.mod | 2 +- modules/mesh-delivery-indexer/go.sum | 2 + .../cmd/mesh-delivery-teller/teller.go | 162 +++++++++++------- .../cmd/mesh-delivery-teller/teller_test.go | 101 ++++++++++- modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 2 + modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 2 + .../internal/view/asks_test.go | 24 +++ .../mesh-delivery-view/internal/view/serve.go | 4 +- .../mesh-delivery-view/internal/view/view.go | 5 + 13 files changed, 236 insertions(+), 76 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index 691a687..0e8c1ba 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index d96e678..489afbc 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9 h1:XL1dbtSl git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 02011be..c329852 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index e7d24d6..04a9085 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go index 921961f..a4ed730 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go @@ -69,29 +69,44 @@ func (t *Teller) logf(format string, args ...any) { } } -// seen is what the teller keeps beside an ask it made: the gate key it was made for and the revision of that key it was -// made at, so a replay of the revision asks nothing again. +// seen is what the teller keeps beside an ask it made. Under `key.` it names the gate key the asking was made for; +// under `gate.` it names the router id of the latest asking of that key and the revision of the key it was made +// at, so a replay of the revision asks nothing again and an overwrite with a new id is known for a new asking. type seen struct { - Key string `json:"key"` + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` Rev uint64 `json:"rev"` } -func recordKey(id string) string { return "key." + id } -func keptKey(id string) string { return "ask." + id } -func condKey(id string) string { return "cond." + id } +func gateRecordKey(key string) string { return "gate." + key } +func recordKey(id string) string { return "key." + id } +func keptKey(id string) string { return "ask." + id } +func condKey(id string) string { return "cond." + id } -func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { - e, err := t.Store.Get(recordKey(id)) +// condID is what a condition of an ask is kept and named by: the gate key it was asked for, so the asking that follows a bad +// answer clears the condition of the one before it. An id the teller holds no key for is its own. +func (t *Teller) condID(id string) string { + if s, e, _ := t.seenOf(id); e != nil && s.Key != "" { + return s.Key + } + return id +} + +func (t *Teller) seenAt(rk string) (seen, *stdio.StateEntry, error) { + e, err := t.Store.Get(rk) if err != nil || e == nil { return seen{}, nil, err } var s seen if err := json.Unmarshal(e.Value, &s); err != nil { - return seen{}, nil, fmt.Errorf("the record %s cannot be read: %w", recordKey(id), err) + return seen{}, nil, fmt.Errorf("the record %s cannot be read: %w", rk, err) } return s, e, nil } +// seenOf is the record of a router id: the gate key it was asked for. +func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { return t.seenAt(recordKey(id)) } + func (t *Teller) keptOf(id string) (*asker.Kept, uint64, error) { e, err := t.Store.Get(keptKey(id)) if err != nil || e == nil { @@ -107,21 +122,27 @@ func (t *Teller) keptOf(id string) (*asker.Kept, uint64, error) { // open says whether an ask is made and neither answered nor ended. func open(k *asker.Kept) bool { return k != nil && !k.Acted && k.Ended == "" } +// note keeps that the asking with this router id is the latest of the gate key, made at revision rev of it. func (t *Teller) note(id, key string, rev uint64, prior *stdio.StateEntry) error { - s := seen{Key: key, Rev: rev} - if prior == nil { - _, err := t.Store.Create(recordKey(id), s) + if _, err := t.Store.Create(recordKey(id), seen{Key: key, Rev: rev}); err != nil && !errors.Is(err, stdio.ErrChanged) { return err } - _, err := t.Store.Update(recordKey(id), s, prior.Revision) + g := seen{ID: id, Rev: rev} + if prior == nil { + _, err := t.Store.Create(gateRecordKey(key), g) + return err + } + _, err := t.Store.Update(gateRecordKey(key), g, prior.Revision) return err } -// OnAsk handles the gate's asks key put at revision rev: the ask it holds is published to the router once per -// revision of its key. A key whose value is not the ask its name says, one already answered, and a revision the teller -// has handled are left. An ask still open and unchanged is only noted (and its warrant read from the router, in case it -// came while the teller was away); one whose words changed is cancelled and asked afresh; one that ended, or that the -// teller cancelled, is asked again under the same id when the gate writes its key again (ADR 0441 §1). +// OnAsk handles the gate's asks key put at revision rev. Each asking has its own router id, which is the value's ask.id and +// never one recomputed from the key (mesh-sdk go/delivery Ask.Asking, AskingID): the router keeps an ended ask and drops a +// second ask under its id. So the ask is published once per id; a key overwritten at a higher revision under a new id is a +// new asking, and the earlier asking's router ask is cancelled first. A key whose value is not the ask its name says, one +// already answered, and a revision the teller has handled are left. An ask still open and unchanged is only noted (and its +// warrant read from the router, in case it came while the teller was away). A value that changed its words under the id +// it was asked under is cancelled and not asked again: the router would drop it, so the gate must give it a new asking. func (t *Teller) OnAsk(key string, rev uint64, value []byte) { t.mu.Lock() defer t.mu.Unlock() @@ -141,41 +162,42 @@ func (t *Teller) OnAsk(key string, rev uint64, value []byte) { } else if has { return } - prior, srec, err := t.seenOf(id) + prior, grec, err := t.seenAt(gateRecordKey(key)) if err != nil { t.logf("%s: %v", key, err) return } - k, krev, err := t.keptOf(id) - if err != nil { - t.logf("%s: %v", key, err) - return - } - if srec != nil && rev <= prior.Rev { - if open(k) { - t.catchUp(id) + if grec != nil && rev <= prior.Rev { + if k, _, err := t.keptOf(prior.ID); err == nil && open(k) { + t.catchUp(prior.ID) } return } + if grec != nil && prior.ID != id { + // A new asking of the group at these heads: the earlier one is taken back. + t.cancelOpen(key, prior.ID) + } + k, _, err := t.keptOf(id) + if err != nil { + t.logf("%s: %v", key, err) + return + } if k != nil { - if open(k) && k.Ask.Digest() == ga.Ask.Digest() { - _ = t.note(id, key, rev, srec) + switch { + case open(k) && k.Ask.Digest() == ga.Ask.Digest(): + _ = t.note(id, key, rev, grec) t.catchUp(id) - return - } - if open(k) { - if err := t.Asker.Cancel(id); err != nil { - t.logf("%s: the ask %s changed and could not be cancelled, so it is left: %v", key, id, err) - return - } - t.logf("%s: the ask %s changed at revision %d: the old one is cancelled and the new one asked", key, id, rev) - } - if err := t.Store.Delete(keptKey(id)); err != nil { - t.logf("%s: the earlier ask %s (revision %d of its record) could not be forgotten: %v", key, id, krev, err) - return + case open(k): + t.cancelOpen(key, id) + _ = t.note(id, key, rev, grec) + t.logf("%s: the ask %s changed its words at revision %d under the same id: cancelled, and not asked again, for the router drops a second ask under an id (the gate gives a new asking a new id)", key, id, rev) + default: + _ = t.note(id, key, rev, grec) + t.logf("%s: the ask %s ended, and the gate wrote it again under the same id at revision %d: not asked again, the router would drop it", key, id, rev) } + return } - if err := t.note(id, key, rev, srec); err != nil { + if err := t.note(id, key, rev, grec); err != nil { t.logf("%s: the record of the ask %s could not be kept, so it is left: %v", key, id, err) return } @@ -187,19 +209,12 @@ func (t *Teller) OnAsk(key string, rev uint64, value []byte) { } return } - t.logf("%s at revision %d: the ask %s is asked of the operator", key, rev, id) + t.logf("%s at revision %d: the ask %s is asked of the operator (asking %d)", key, rev, id, ga.Asking) } -// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the ask is cancelled at the router, and an answer -// to it merges nothing. -func (t *Teller) OnAskGone(key string) { - t.mu.Lock() - defer t.mu.Unlock() - if _, _, _, err := delivery.ParseAskKey(key); err != nil { - t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err) - return - } - id := delivery.AskID(key) +// cancelOpen takes an open ask back at the router and marks it ended here, since the router's word that it ended may come +// later. An ask that is not open is left. +func (t *Teller) cancelOpen(key, id string) { k, krev, err := t.keptOf(id) if err != nil { t.logf("%s: %v", key, err) @@ -209,15 +224,33 @@ func (t *Teller) OnAskGone(key string) { return } if err := t.Asker.Cancel(id); err != nil { - t.logf("%s: the withdrawn ask %s could not be cancelled: %v", key, id, err) + t.logf("%s: the ask %s could not be cancelled: %v", key, id, err) return } - // The router's word that it ended may come later: until it does, the ask is not open here. k.Ended = asks.OutcomeCancelled if _, err := t.Store.Update(keptKey(id), k, krev); err != nil && !errors.Is(err, stdio.ErrChanged) { t.logf("%s: the cancelled ask %s could not be marked: %v", key, id, err) } - t.logf("%s: the gate withdrew the ask %s: cancelled", key, id) + t.logf("%s: the ask %s is cancelled", key, id) +} + +// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the latest asking's router ask is cancelled, and an +// answer to it merges nothing. +func (t *Teller) OnAskGone(key string) { + t.mu.Lock() + defer t.mu.Unlock() + if _, _, _, err := delivery.ParseAskKey(key); err != nil { + t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err) + return + } + prior, grec, err := t.seenAt(gateRecordKey(key)) + if err != nil || grec == nil { + if err != nil { + t.logf("%s: %v", key, err) + } + return + } + t.cancelOpen(key, prior.ID) } // catchUp reads the router's record of an ask the teller made and hands its warrant on, as the router's event would: @@ -249,7 +282,8 @@ func mergeAct(ask, option string, heads map[string]string) asks.Act { } // headsOf is the heads an ask covers as an act names them: a delivery's id, `owner/repository@` (mesh-catalog modules/mesh-delivery model.go Delivery.ID), and its whole commit. +// commit>` with the repository in lower case, as the gate's IDOf spells it (mesh-catalog modules/mesh-delivery-gate model.go), +// and its whole commit. func headsOf(heads []delivery.Head) map[string]string { out := map[string]string{} for _, h := range heads { @@ -257,7 +291,7 @@ func headsOf(heads []delivery.Head) map[string]string { if len(short) > 12 { short = short[:12] } - out[h.Repository+"@"+short] = h.Head + out[strings.ToLower(h.Repository)+"@"+short] = h.Head } return out } @@ -290,7 +324,11 @@ func (t *Teller) Answer(o asks.Option, w asks.Warrant) error { if err != nil { return fmt.Errorf("the gate's ask %s is not an ask: %w", s.Key, err) } - if ga.Ask.ID != w.Ask || ga.Ask.Digest() != w.AskDigest { + if ga.Ask.ID != w.Ask { + t.logf("%s: the ask %s was answered, and the gate has asked again under %s: an answer to an earlier asking merges nothing", s.Key, w.Ask, ga.Ask.ID) + return nil + } + if ga.Ask.Digest() != w.AskDigest { return fmt.Errorf("the gate's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)", s.Key, ga.Ask.Digest(), w.AskDigest) } @@ -345,10 +383,11 @@ func oneLine(err error) string { // conditionKey is the key of the condition about an ask. func conditionKey(id string) string { return delivery.TellerModule + ".merge-ask." + id } -func (t *Teller) raise(id, headline, why string) { +func (t *Teller) raise(askID, headline, why string) { if t.Say == nil { return } + id := t.condID(askID) if _, err := t.Store.Create(condKey(id), seen{}); err != nil { if errors.Is(err, stdio.ErrChanged) { return // raised already @@ -366,10 +405,11 @@ func (t *Teller) raise(id, headline, why string) { } } -func (t *Teller) clear(id string) { +func (t *Teller) clear(askID string) { if t.Say == nil { return } + id := t.condID(askID) e, err := t.Store.Get(condKey(id)) if err != nil || e == nil { return diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go index 4c5f18e..20cdcf5 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go @@ -97,6 +97,11 @@ type bed struct { pubs []published conds []asks.Condition records map[string]string + // seenIDs are the ids the fake router has taken an ask under, and dropped the asks it took a second time under: the + // messenger keeps an ask after it ends and returns for an ask whose id it holds (mesh-catalog modules/messenger + // cmd/messenger/asks.go:487-489, keepAsk), so a reused id reaches no phone. + seenIDs map[string]bool + dropped []string hear func(stdio.Envelope) error logs strings.Builder } @@ -104,10 +109,16 @@ type bed struct { func newBed(t *testing.T) *bed { t.Helper() b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}}, - gate: memGate{}, records: map[string]string{}} + gate: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}} b.client = &asker.Client{Module: delivery.TellerModule, State: b.store, Now: func() time.Time { return now }, Publish: func(subject string, body any, id string) error { raw, err := json.Marshal(body) + if subject == asks.AskSubject(delivery.TellerModule) { + if b.seenIDs[id] { + b.dropped = append(b.dropped, id) + } + b.seenIDs[id] = true + } b.pubs = append(b.pubs, published{subject, raw, id}) return err }, @@ -139,12 +150,19 @@ func newBed(t *testing.T) *bed { // and :63-65 mergeAct), under the id its key gives (mesh-sdk go/delivery AskID). boundTo are the heads the options bind, // which are the ask's own unless a test says another. func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value []byte, ask delivery.Ask) { + t.Helper() + return gateAskN(t, heads, boundTo, 1) +} + +// gateAskN is the asking-th writing of the group's ask at these heads: its router id is AskingID(key, asking) (mesh-sdk +// go/delivery Ask.Asking), the first asking's the key's own. +func gateAskN(t *testing.T, heads, boundTo []delivery.Head, asking int) (key string, value []byte, ask delivery.Ask) { t.Helper() key, err := delivery.MergeAskKey("g", heads) if err != nil { t.Fatal(err) } - id := delivery.AskID(key) + id := delivery.AskingID(key, asking) a := asks.Ask{ID: id, Headline: "Merge into main: novox/mesh-delivery#12", Explanation: "Merges two pull requests.\nReview: approved.", Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute), OnExpiry: "the merge stays held", About: "delivery.mesh-delivery.12"} for _, o := range []struct{ id, label, does string }{{"approve", "Approve", "merges"}, {"decline", "Decline", "merges nothing"}} { @@ -154,7 +172,7 @@ func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value [] } a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) } - ask = delivery.Ask{Group: "g", Heads: heads, Ask: a} + ask = delivery.Ask{Group: "g", Heads: heads, Asking: asking, Ask: a} value, err = delivery.EncodeAsk(ask) if err != nil { t.Fatal(err) @@ -287,7 +305,7 @@ func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T if len(b.answers.written) != 0 { t.Errorf("an answer was written: %v", b.answers.written) } - if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || b.conds[0].Subject.ID != delivery.AskID(key) || + if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || b.conds[0].Subject.ID != key || !strings.HasPrefix(b.conds[0].Explanation, asks.NeedsYou) { t.Fatalf("conditions = %+v", b.conds) } @@ -305,8 +323,8 @@ func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/ if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised { t.Fatalf("conditions = %+v", b.conds) } - // The gate writes the ask again, right this time, at a new revision of its key: asked again under the same id. - _, good, gaGood := gateAsk(t, twoHeads(), twoHeads()) + // The gate writes the ask again, right this time, at a new revision of its key: the second asking, under a new id. + _, good, gaGood := gateAskN(t, twoHeads(), twoHeads(), 2) b.gate[key] = good b.t.OnAsk(key, 2, good) if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 { @@ -334,7 +352,45 @@ func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) { } } -func TestAnAskWhoseWordsChangedIsCancelledAndAskedAgain(t *testing.T) { +func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T) { // G2 of review round 1 + b := newBed(t) + key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1) + b.gate[key] = one + b.t.OnAsk(key, 1, one) + _, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2) + if g1.Ask.ID == g2.Ask.ID { + t.Fatal("the two askings share an id") + } + b.gate[key] = two + b.t.OnAsk(key, 2, two) + cancels := b.askedOf(asks.CancelSubject(delivery.TellerModule)) + if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+g1.Ask.ID+`"}` { + t.Fatalf("cancels = %+v, want asking 1's router ask", cancels) + } + asked := b.askedOf(asks.AskSubject(delivery.TellerModule)) + if len(asked) != 2 || asked[1].id != g2.Ask.ID { + t.Fatalf("asks = %+v, want the second under %s", asked, g2.Ask.ID) + } + if len(b.dropped) != 0 { + t.Errorf("the router dropped asks under a reused id: %v", b.dropped) + } + // The same revision again, and a warrant for the first asking (the gate has moved on), change nothing. + b.t.OnAsk(key, 2, two) + b.deliver(t, warrantFor(g1.Ask, "approve")) + if len(b.answers.written) != 0 || len(b.askedOf(asks.AskSubject(delivery.TellerModule))) != 2 { + t.Errorf("answers %v asks %d", b.answers.written, len(b.askedOf(asks.AskSubject(delivery.TellerModule)))) + } + // The warrant of the second names it: its answer's warrant is the router id and the time. + w := warrantFor(g2.Ask, "approve") + b.deliver(t, w) + raw, ok := b.answers.written[delivery.AnswerKey(key)] + a, err := delivery.DecodeAnswer(raw) + if !ok || err != nil || a.Warrant != g2.Ask.ID+"@"+w.At.UTC().Format(time.RFC3339Nano) { + t.Errorf("answer %+v (%v) err %v", a, ok, err) + } +} + +func TestAnAskWhoseWordsChangedUnderTheSameIDIsCancelledAndNotAskedAgain(t *testing.T) { b := newBed(t) key, value, ga := gateAsk(t, twoHeads(), twoHeads()) b.t.OnAsk(key, 1, value) @@ -348,8 +404,35 @@ func TestAnAskWhoseWordsChangedIsCancelledAndAskedAgain(t *testing.T) { if n := len(b.askedOf(asks.CancelSubject(delivery.TellerModule))); n != 1 { t.Errorf("cancels = %d", n) } - if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 { - t.Errorf("asks = %d", n) + if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 1 || len(b.dropped) != 0 { + t.Errorf("asks = %d dropped %v: the router would drop a second ask under the id", n, b.dropped) + } +} + +func TestACapitalisedRepositoryIsSpelledInLowerCaseInTheAct(t *testing.T) { // T1 of review round 1 + b := newBed(t) + heads := []delivery.Head{{Repository: "novox/Mesh-Delivery", Number: 12, Head: headA}} + key, err := delivery.MergeAskKey("g", heads) + if err != nil { + t.Fatal(err) + } + id := delivery.AskID(key) + // The act as the gate binds it: its IDOf lowercases the repository (mesh-catalog modules/mesh-delivery-gate model.go). + binds, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": id, "option": "approve", "heads": "novox/mesh-delivery@" + headA[:12] + "=" + headA}) + if err != nil { + t.Fatal(err) + } + a := asks.Ask{ID: id, Headline: "Merge into main: Mesh-Delivery#12", Explanation: "Merges.", Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute), + OnExpiry: "the merge stays held", Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: binds}}} + value, err := delivery.EncodeAsk(delivery.Ask{Group: "g", Heads: heads, Ask: a}) + if err != nil { + t.Fatal(err) + } + b.gate[key] = value + b.t.OnAsk(key, 1, value) + b.deliver(t, warrantFor(a, "approve")) + if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + t.Errorf("no answer for the gate's spelling: %s", b.logs.String()) } } diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index c977aa1..e2ff8ac 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index fcb33b1..ad79b8e 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index 37a8ead..33ebd9a 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index e7d24d6..04a9085 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/internal/view/asks_test.go b/modules/mesh-delivery-view/internal/view/asks_test.go index b155085..1d32b1b 100644 --- a/modules/mesh-delivery-view/internal/view/asks_test.go +++ b/modules/mesh-delivery-view/internal/view/asks_test.go @@ -130,3 +130,27 @@ func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2 t.Errorf("json %s: %+v %v", raw, back, err) } } + +func TestAHaveOfARemovedModuleIsShownRemovedNotLeftBehind(t *testing.T) { // 1370: the node-engine writes phase removed + m := New() + fill(m, haveE(t, "novox", "mesh-delivery", have(t, "b1", delivery.PhaseRemoved, delivery.HealthOK, ""), t0), + haveE(t, "novox", "stray", have(t, "b2", delivery.PhaseRunning, delivery.HealthOK, ""), t0)) + s := m.Snapshot() + if r := find(t, s, "novox", "mesh-delivery"); r.Status != StatusRemoved { + t.Errorf("row = %+v, want status %q", r, StatusRemoved) + } + if r := find(t, s, "novox", "stray"); r.Status != StatusLeftBehind { + t.Errorf("row = %+v, want left behind", r) + } + var out bytes.Buffer + Table(&out, s) + line := "" + for _, l := range strings.Split(out.String(), "\n") { + if strings.Contains(l, "mesh-delivery") { + line = l + } + } + if !strings.Contains(line, "removed") || strings.Contains(line, "left behind") { + t.Errorf("table line %q", line) + } +} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index 73fa01e..3678c6c 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -111,9 +111,9 @@ func rowText(r Row) []string { if r.Error != "" { what = "unreadable: " + r.Error } - if r.Status == StatusLeftBehind && what == "" { + if (r.Status == StatusLeftBehind || r.Status == StatusRemoved) && what == "" { what = r.Status - } else if r.Status == StatusLeftBehind { + } else if r.Status == StatusLeftBehind || r.Status == StatusRemoved { what = r.Status + "; " + what } return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what} diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index 6caaf3c..0406e39 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -396,6 +396,10 @@ func (m *Model) Snapshot() Snapshot { } node, module, _, _ := delivery.ParseHaveKey(key) r := Row{Node: node, Module: module, Status: StatusLeftBehind, Since: h.at, Error: h.err} + if h.err == "" && h.have.Phase == delivery.PhaseRemoved { + // The node-engine says it took the module away: plainly removed, not left behind. + r.Status = StatusRemoved + } if h.err == "" { r.Have, r.Phase, r.Health, r.Detail = h.have.Build, string(h.have.Phase), string(h.have.Health), h.have.Detail } @@ -567,6 +571,7 @@ const ( const ( StatusAssigned = "assigned" StatusLeftBehind = "left behind" + StatusRemoved = "removed" ) // askRows are the readable asks with no answer under their key, ordered by key: each with its headline, its words, the From bf59b5a27b79bd67d3a78ba3217d35f1cc13a89a Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:35:26 +0000 Subject: [PATCH 12/13] An answer to an earlier asking no longer blocks the next asking: the teller and the view tell the asking by the answer's warrant id (review round 2, R2-1) --- .../cmd/mesh-delivery-teller/kv.go | 26 ++++--- .../cmd/mesh-delivery-teller/teller.go | 43 +++++++++--- .../cmd/mesh-delivery-teller/teller_test.go | 67 ++++++++++++++++--- .../mesh-delivery-view/integration_test.go | 2 +- .../internal/view/asks_test.go | 34 +++++++++- .../mesh-delivery-view/internal/view/view.go | 15 +++-- 6 files changed, 150 insertions(+), 37 deletions(-) diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go index 8301912..288d07d 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/kv.go @@ -61,23 +61,31 @@ func (s kvStore) Delete(key string) error { return err } -// kvAnswers is the bucket answers: the teller's, written by compare-and-set only when the key has no answer. +// kvAnswers is the bucket answers: the teller's, written by compare-and-set: created when the key has no answer, updated at the revision read when it holds an earlier asking's. type kvAnswers struct { ctx context.Context kv jetstream.KeyValue } -func (a kvAnswers) Create(key string, value []byte) (bool, error) { - _, err := a.kv.Create(a.ctx, key, value) - if errors.Is(err, jetstream.ErrKeyExists) { - return false, nil +func (a kvAnswers) Get(key string) ([]byte, uint64, bool, error) { + e, err := a.kv.Get(a.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, 0, false, nil } - return err == nil, err + if err != nil { + return nil, 0, false, err + } + return e.Value(), e.Revision(), true, nil } -func (a kvAnswers) Has(key string) (bool, error) { - _, err := a.kv.Get(a.ctx, key) - if errors.Is(err, jetstream.ErrKeyNotFound) { +func (a kvAnswers) Write(key string, value []byte, rev uint64, found bool) (bool, error) { + var err error + if found { + _, err = a.kv.Update(a.ctx, key, value, rev) + } else { + _, err = a.kv.Create(a.ctx, key, value) + } + if errors.Is(err, jetstream.ErrKeyExists) { return false, nil } return err == nil, err diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go index a4ed730..a04c742 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go @@ -39,10 +39,11 @@ type routerAsks interface { // answers is the teller's bucket answers. type answers interface { - // Create writes the answer under the ask's key only when there is none; created is false when there is one. - Create(key string, value []byte) (created bool, err error) - // Has says whether there is an answer under the key. - Has(key string) (bool, error) + // Get is the answer under the ask's key and its revision; found is false when there is none. + Get(key string) (value []byte, rev uint64, found bool, err error) + // Write creates the answer when found is false (the key had none when it was read) and otherwise updates it at the + // revision read; written is false when another writer was first. + Write(key string, value []byte, rev uint64, found bool) (written bool, err error) } // gateAsks reads the gate's bucket asks. @@ -156,11 +157,11 @@ func (t *Teller) OnAsk(key string, rev uint64, value []byte) { return } id := ga.Ask.ID - if has, err := t.Answers.Has(key); err != nil { + if raw, _, has, err := t.Answers.Get(key); err != nil { t.logf("%s: the answers could not be read, so the ask is left: %v", key, err) return - } else if has { - return + } else if has && answeredAsking(raw) == id { + return // this asking is answered; an earlier asking's answer does not stop a later asking } prior, grec, err := t.seenAt(gateRecordKey(key)) if err != nil { @@ -296,6 +297,17 @@ func headsOf(heads []delivery.Head) map[string]string { return out } +// answeredAsking is the router id of the asking an answer is for: its warrant's id before the "@". An answer that cannot be +// read answers no asking. +func answeredAsking(raw []byte) string { + a, err := delivery.DecodeAnswer(raw) + if err != nil { + return "" + } + id, _, _ := strings.Cut(a.Warrant, "@") + return id +} + // warrantID names a warrant, which carries no id of its own: the ask and the time the router gave it. func warrantID(w asks.Warrant) string { return w.Ask + "@" + w.At.UTC().Format(time.RFC3339Nano) @@ -339,12 +351,23 @@ func (t *Teller) Answer(o asks.Option, w asks.Warrant) error { if err != nil { return err } - created, err := t.Answers.Create(delivery.AnswerKey(s.Key), body) + // Compare-and-set: created when the key has no answer; updated at the revision read when it holds an earlier asking's + // answer (the gate ignores that one); left when it holds this asking's. + akey := delivery.AnswerKey(s.Key) + cur, crev, found, err := t.Answers.Get(akey) + if err != nil { + return fmt.Errorf("the answer under %s could not be read: %w", s.Key, err) + } + if found && answeredAsking(cur) == w.Ask { + t.logf("%s: an answer is there already, and is not written again", s.Key) + return nil + } + written, err := t.Answers.Write(akey, body, crev, found) if err != nil { return fmt.Errorf("the answer under %s could not be written: %w", s.Key, err) } - if !created { - t.logf("%s: an answer is there already, and is not written again", s.Key) + if !written { + t.logf("%s: another writer answered first, and the answer is not written again", s.Key) return nil } t.logf("%s: %s", s.Key, w.Says()) diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go index 20cdcf5..394f022 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go @@ -62,20 +62,31 @@ func (m *memStore) Delete(key string) error { delete(m.entries, key); return nil type memAnswers struct { written map[string][]byte - creates int + revs map[string]uint64 + seq uint64 + writes int } -func (a *memAnswers) Create(key string, value []byte) (bool, error) { - a.creates++ - if _, ok := a.written[key]; ok { +func (a *memAnswers) Get(key string) ([]byte, uint64, bool, error) { + v, ok := a.written[key] + return v, a.revs[key], ok, nil +} + +// Write is the bucket's compare-and-set: a create of a key that has an answer, or an update at another revision, loses. +func (a *memAnswers) Write(key string, value []byte, rev uint64, found bool) (bool, error) { + _, has := a.written[key] + if found != has || (found && rev != a.revs[key]) { return false, nil } - a.written[key] = value + a.seq++ + a.writes++ + if a.revs == nil { + a.revs = map[string]uint64{} + } + a.written[key], a.revs[key] = value, a.seq return true, nil } -func (a *memAnswers) Has(key string) (bool, error) { _, ok := a.written[key]; return ok, nil } - type memGate map[string][]byte func (g memGate) Get(key string) ([]byte, bool, error) { v, ok := g[key]; return v, ok, nil } @@ -108,7 +119,7 @@ type bed struct { func newBed(t *testing.T) *bed { t.Helper() - b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}}, + b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}, revs: map[string]uint64{}}, gate: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}} b.client = &asker.Client{Module: delivery.TellerModule, State: b.store, Now: func() time.Time { return now }, Publish: func(subject string, body any, id string) error { @@ -261,8 +272,8 @@ func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { // w2 := w w2.At = w.At.Add(time.Minute) // a second warrant for the same ask b.deliver(t, w2) - if b.answers.creates != 1 { - t.Errorf("the answer was written %d times, want once", b.answers.creates) + if b.answers.writes != 1 { + t.Errorf("the answer was written %d times, want once", b.answers.writes) } if len(b.conds) != 0 { t.Errorf("conditions said: %+v", b.conds) @@ -456,3 +467,39 @@ func TestARefusedAskIsSaidAsACondition(t *testing.T) { // ADR 0437 §9 t.Errorf("conditions = %+v", b.conds) } } + +func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R2-1 of review round 2 + b := newBed(t) + key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1) + b.gate[key] = one + b.t.OnAsk(key, 1, one) + b.deliver(t, warrantFor(g1.Ask, "approve")) + first, ok := b.answers.written[delivery.AnswerKey(key)] + if a, err := delivery.DecodeAnswer(first); !ok || err != nil || !strings.HasPrefix(a.Warrant, g1.Ask.ID+"@") { + t.Fatalf("asking 1's answer: %+v %v %v", a, ok, err) + } + // The gate rewrites the key at asking 2 (a review record changed). Asking 1's answer stays in the bucket. + _, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2) + b.gate[key] = two + b.t.OnAsk(key, 2, two) + asked := b.askedOf(asks.AskSubject(delivery.TellerModule)) + if len(asked) != 2 || asked[1].id != g2.Ask.ID { + t.Fatalf("asks = %+v, want asking 2 asked under %s", asked, g2.Ask.ID) + } + w := warrantFor(g2.Ask, "approve") + b.deliver(t, w) + raw := b.answers.written[delivery.AnswerKey(key)] + a, err := delivery.DecodeAnswer(raw) + if err != nil || a.Warrant != g2.Ask.ID+"@"+w.At.UTC().Format(time.RFC3339Nano) { + t.Fatalf("the key holds %+v (%v), want asking 2's answer", a, err) + } + // A second warrant for the same asking writes nothing, and a replay of asking 2 asks nothing. + w2 := w + w2.At = w.At.Add(time.Minute) + b.deliver(t, w2) + b.t.OnAsk(key, 2, two) + b.t.OnAsk(key, 3, two) + if b.answers.writes != 2 || len(b.askedOf(asks.AskSubject(delivery.TellerModule))) != 2 || len(b.dropped) != 0 { + t.Errorf("writes %d asks %d dropped %v", b.answers.writes, len(b.askedOf(asks.AskSubject(delivery.TellerModule))), b.dropped) + } +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go index 09b17f5..52d601e 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go @@ -381,7 +381,7 @@ func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { // wait(func(s view.Snapshot) bool { return len(s.Asks) == 1 && s.Asks[0].Key == key && s.Asks[0].How == "answer on the phone" }, "the open ask never showed") - answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"}) + answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: ask.ID + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Approve"}) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-view/internal/view/asks_test.go b/modules/mesh-delivery-view/internal/view/asks_test.go index 1d32b1b..d5e9c08 100644 --- a/modules/mesh-delivery-view/internal/view/asks_test.go +++ b/modules/mesh-delivery-view/internal/view/asks_test.go @@ -33,9 +33,13 @@ func askEntry(t *testing.T, group, headline, words string) Entry { return Entry{Bucket: delivery.AsksBucket, Key: key, Value: v, At: t0} } -func answerEntry(t *testing.T, key string) Entry { +// answerEntry is the teller's answer to the first asking of the key; answerToAsking to the asking with this router id. The +// answer's warrant is `@