From 09f0b0d5ab15075bcd775807f429f8c97f36d766 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:19:19 +0000 Subject: [PATCH 01/15] Move the delivery module to the gate: mesh-delivery-gate claims the seat with its own buckets and reads (issue 1346) --- .../README.md | 6 +- .../cmd/mesh-delivery-gate}/adr350_test.go | 0 .../cmd/mesh-delivery-gate}/answers.go | 0 .../cmd/mesh-delivery-gate}/approval.go | 10 +++- .../cmd/mesh-delivery-gate}/approval_test.go | 0 .../cmd/mesh-delivery-gate}/backup_test.go | 0 .../cmd/mesh-delivery-gate}/bus_test.go | 4 +- .../cmd/mesh-delivery-gate}/checkask_test.go | 0 .../cmd/mesh-delivery-gate}/checks.go | 0 .../cmd/mesh-delivery-gate}/checks_test.go | 0 .../cmd/mesh-delivery-gate}/covered_test.go | 0 .../cmd/mesh-delivery-gate}/effects.go | 0 .../cmd/mesh-delivery-gate}/events.go | 0 .../cmd/mesh-delivery-gate}/events_test.go | 0 .../cmd/mesh-delivery-gate}/fakes_test.go | 0 .../cmd/mesh-delivery-gate}/group_merge.go | 0 .../cmd/mesh-delivery-gate}/group_test.go | 0 .../cmd/mesh-delivery-gate}/history_test.go | 0 .../cmd/mesh-delivery-gate}/holder.go | 0 .../cmd/mesh-delivery-gate}/holder_test.go | 0 .../cmd/mesh-delivery-gate}/issue1079_test.go | 0 .../cmd/mesh-delivery-gate}/issue1083_test.go | 0 .../cmd/mesh-delivery-gate}/issue1107_test.go | 0 .../cmd/mesh-delivery-gate}/issue1114_test.go | 0 .../cmd/mesh-delivery-gate}/issue1275_test.go | 0 .../cmd/mesh-delivery-gate}/issue326_test.go | 0 .../cmd/mesh-delivery-gate}/issue341_test.go | 0 .../cmd/mesh-delivery-gate}/issue362_test.go | 0 .../cmd/mesh-delivery-gate}/issue403_test.go | 0 .../cmd/mesh-delivery-gate}/issue407_test.go | 0 .../cmd/mesh-delivery-gate}/issue438_test.go | 0 .../cmd/mesh-delivery-gate}/issue441_test.go | 0 .../cmd/mesh-delivery-gate}/issue442_test.go | 0 .../cmd/mesh-delivery-gate}/issue443_test.go | 0 .../cmd/mesh-delivery-gate}/issue451_test.go | 0 .../cmd/mesh-delivery-gate}/issue459_test.go | 0 .../cmd/mesh-delivery-gate}/issue474_test.go | 0 .../cmd/mesh-delivery-gate}/issue480_test.go | 0 .../cmd/mesh-delivery-gate}/issue529_test.go | 0 .../cmd/mesh-delivery-gate}/issue611_test.go | 0 .../cmd/mesh-delivery-gate}/issue747_test.go | 0 .../cmd/mesh-delivery-gate}/issue753_test.go | 0 .../cmd/mesh-delivery-gate}/issue783_test.go | 0 .../cmd/mesh-delivery-gate}/issue902_test.go | 0 .../cmd/mesh-delivery-gate}/main.go | 4 +- .../cmd/mesh-delivery-gate}/manifest_test.go | 55 +++++++++++++----- .../cmd/mesh-delivery-gate}/model.go | 0 .../cmd/mesh-delivery-gate}/operator_test.go | 0 .../cmd/mesh-delivery-gate}/ports.go | 0 .../cmd/mesh-delivery-gate}/pullkey.go | 0 .../cmd/mesh-delivery-gate}/pullkey_test.go | 0 .../cmd/mesh-delivery-gate}/react.go | 0 .../cmd/mesh-delivery-gate}/ready.go | 0 .../cmd/mesh-delivery-gate}/ready_test.go | 0 .../cmd/mesh-delivery-gate}/recheck_test.go | 0 .../cmd/mesh-delivery-gate}/replay362_test.go | 0 .../cmd/mesh-delivery-gate}/table.go | 0 .../cmd/mesh-delivery-gate}/table_test.go | 0 .../mesh-delivery-gate}/timers_source_test.go | 0 .../cmd/mesh-delivery-gate}/timers_test.go | 0 .../cmd/mesh-delivery-gate}/times.go | 0 .../cmd/mesh-delivery-gate}/times_test.go | 0 .../cmd/mesh-delivery-gate}/unchecked.go | 0 .../unchecked_events_test.go | 0 .../cmd/mesh-delivery-gate}/unchecked_test.go | 0 .../cmd/mesh-delivery-gate}/verbs.go | 0 .../cmd/mesh-delivery-gate}/waiting_checks.go | 0 .../go.mod | 16 ++--- .../go.sum | 12 ++++ .../module.json | 58 ++++++++++++------- 70 files changed, 111 insertions(+), 54 deletions(-) rename modules/{mesh-delivery => mesh-delivery-gate}/README.md (78%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/adr350_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/answers.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/approval.go (98%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/approval_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/backup_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/bus_test.go (99%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/checkask_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/checks.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/checks_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/covered_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/effects.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/events.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/events_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/fakes_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/group_merge.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/group_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/history_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/holder.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/holder_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue1079_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue1083_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue1107_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue1114_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue1275_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue326_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue341_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue362_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue403_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue407_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue438_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue441_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue442_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue443_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue451_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue459_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue474_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue480_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue529_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue611_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue747_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue753_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue783_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/issue902_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/main.go (99%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/manifest_test.go (71%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/model.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/operator_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/ports.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/pullkey.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/pullkey_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/react.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/ready.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/ready_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/recheck_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/replay362_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/table.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/table_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/timers_source_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/timers_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/times.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/times_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/unchecked.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/unchecked_events_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/unchecked_test.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/verbs.go (100%) rename modules/{mesh-delivery/cmd/mesh-delivery => mesh-delivery-gate/cmd/mesh-delivery-gate}/waiting_checks.go (100%) rename modules/{mesh-delivery => mesh-delivery-gate}/go.mod (53%) rename modules/{mesh-delivery => mesh-delivery-gate}/go.sum (69%) rename modules/{mesh-delivery => mesh-delivery-gate}/module.json (64%) diff --git a/modules/mesh-delivery/README.md b/modules/mesh-delivery-gate/README.md similarity index 78% rename from modules/mesh-delivery/README.md rename to modules/mesh-delivery-gate/README.md index c2b64c5b..bcc3db09 100644 --- a/modules/mesh-delivery/README.md +++ b/modules/mesh-delivery-gate/README.md @@ -1,6 +1,8 @@ -# mesh-delivery +# mesh-delivery-gate -The `mesh-delivery` module of the mesh catalogue: claims the seat `mesh-delivery`. +The `mesh-delivery-gate` module of the mesh catalogue: the gate of design 47. It claims the seat `mesh-delivery`, whose +name stays (ADR 0437 §11), and takes over from the module `mesh-delivery` (ADR 0437 §3, ADR 0450 §6). Its buckets are +`mesh-delivery-gate_*`. ## Files it does not know diff --git a/modules/mesh-delivery/cmd/mesh-delivery/adr350_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr350_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/adr350_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr350_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/answers.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/answers.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/answers.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/answers.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go similarity index 98% rename from modules/mesh-delivery/cmd/mesh-delivery/approval.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index 104f270a..bfae30d9 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -27,8 +27,12 @@ import ( // // The terminal Approve (ADR 0398 §3) is issue 953; today the proving channel is the operator's phone. -// Module is this module's name: the one the bus grants its asks under. -const Module = "mesh-delivery" +// Module is this module's name: the one the bus names its buckets and events under (novox/hq ADR 0437 §3). Seat is +// the seat it claims, whose name stays `mesh-delivery` (ADR 0437 §11). +const ( + Module = "mesh-delivery-gate" + Seat = "mesh-delivery" +) // The two answers, both at the level approve. const ( @@ -61,7 +65,7 @@ func headsString(heads map[string]string) string { // mergeAct is what an answer binds: this ask, the answer, and the heads it covers. func mergeAct(ask, option string, heads map[string]string) asks.Act { - return asks.Act{"verb": Module + ".merge", "ask": ask, "option": option, "heads": headsString(heads)} + return asks.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)} } var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`) diff --git a/modules/mesh-delivery/cmd/mesh-delivery/approval_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/approval_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/backup_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/backup_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/backup_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/backup_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go similarity index 99% rename from modules/mesh-delivery/cmd/mesh-delivery/bus_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index 363da871..9b69039c 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -75,7 +75,7 @@ func newTestBus(t *testing.T) *testBus { b := &testBus{nc: nc, js: js, kvs: map[string]jetstream.KeyValue{}} // The controller's job: a bucket per state the manifest declares. m, _ := readManifest(t) - for _, name := range m.State { + for _, name := range m.buckets() { kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: m.Module + "_" + name, History: 1}) if err != nil { t.Fatal(err) @@ -121,7 +121,7 @@ type askedOfMesh struct { func (b *testBus) start(t *testing.T) *runtime { t.Helper() cmd := exec.Command(os.Args[0], "-test.run=^$") - cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_SERVED_MODULE=mesh-delivery", "MESH_MODULE=mesh-delivery", "MESH_NODE=test") + cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_SERVED_MODULE=mesh-delivery-gate", "MESH_MODULE=mesh-delivery-gate", "MESH_NODE=test") in, err := cmd.StdinPipe() if err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery/cmd/mesh-delivery/checkask_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkask_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/checkask_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkask_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/checks.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/checks.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/checks_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/checks_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/covered_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/covered_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/covered_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/covered_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/effects.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/effects.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/events.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/events_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/fakes_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/group_merge.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/group_merge.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/group_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/group_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/history_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/history_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/history_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/history_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/holder.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/holder_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue1079_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1079_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue1079_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1079_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue1083_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1083_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue1083_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1083_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue1107_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1107_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue1107_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1107_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue1114_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1114_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue1114_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1114_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue1275_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue1275_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue326_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue326_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue326_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue326_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue341_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue341_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue341_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue341_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue362_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue362_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue362_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue362_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue403_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue403_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue403_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue403_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue407_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue407_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue407_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue407_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue438_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue438_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue438_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue438_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue441_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue441_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue441_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue441_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue442_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue442_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue442_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue442_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue443_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue443_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue443_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue443_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue451_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue451_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue451_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue451_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue459_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue459_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue459_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue459_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue474_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue474_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue474_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue474_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue480_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue480_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue480_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue480_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue529_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue529_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue529_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue529_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue611_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue611_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue611_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue611_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue747_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue747_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue747_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue747_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue753_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue753_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue753_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue753_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue783_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue783_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue783_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue783_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue902_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue902_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/issue902_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue902_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go similarity index 99% rename from modules/mesh-delivery/cmd/mesh-delivery/main.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index b61d5158..c8326410 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -1,4 +1,4 @@ -// mesh-delivery: the holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47). A Go bundle the node's +// mesh-delivery-gate: the holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47). A Go bundle the node's // runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to // every machine — and the delivery group above it: their states in one compiled table, kept in its own state // on the bus, every transition said as an event, noted on the commit and shown on the pull request through @@ -246,7 +246,7 @@ func need(a map[string]any, keys ...string) error { } func tools(h *Holder, l *listening) []stdio.Tool { - seat := "mesh-delivery." + seat := Seat + "." list := []stdio.Tool{ {Name: seat + "deliveries", Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " + diff --git a/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go similarity index 71% rename from modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 0c866f56..cafb2c4a 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -26,10 +26,25 @@ type manifest struct { Consumes []string `json:"consumes"` Emits []string `json:"emits"` Invokes []string `json:"invokes"` - State []string `json:"state"` + State []any `json:"state"` + Reads []string `json:"reads"` Tools []string `json:"tools"` } +// buckets are the names of the state the manifest declares, each a bare name or an object with a name and a history. +func (m manifest) buckets() []string { + var out []string + for _, s := range m.State { + switch v := s.(type) { + case string: + out = append(out, v) + case map[string]any: + out = append(out, v["name"].(string)) + } + } + return out +} + func readManifest(t *testing.T) (manifest, string) { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -45,35 +60,43 @@ func readManifest(t *testing.T) (manifest, string) { func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { m, raw := readManifest(t) - if m.Module != "mesh-delivery" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery" || m.Claims[0].Scope != "mesh" { + if m.Module != "mesh-delivery-gate" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery" || m.Claims[0].Scope != "mesh" { t.Fatalf("claims %+v", m.Claims) } // The seat is the mesh's own (mesh-*): a module claims it and never declares it. if len(m.Seats) != 0 { t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own") } - if !reflect.DeepEqual(m.Consumes, followed) { - t.Fatalf("consumes %v, follows %v", m.Consumes, followed) + // The thirteen verbs of the seat (ADR 0437 §3, §11). + if len(m.Claims[0].Serves) != 13 { + t.Fatalf("serves %d verbs, wanted 13: %v", len(m.Claims[0].Serves), m.Claims[0].Serves) + } + if !reflect.DeepEqual(m.Consumes, append(append([]string{}, followed...), "mesh-delivery-forge-bridge.forge.**")) { + t.Fatalf("consumes %v, follows %v and the bridge's forge messages", m.Consumes, followed) } if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) { t.Fatalf("emits %v", m.Emits) } - if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked", "pulls"}) { - t.Fatalf("state %v", m.State) + // Seven buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3. + buckets := m.buckets() + if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"}) { + t.Fatalf("state %v", buckets) } - // It calls exactly what its ports ask: the controller's verbs and the forge's tools. + // What it reads: the carry from today's module's three buckets, the teller's answers, the seat's holder and the + // machines' haves (ADR 0450 §6 and its insight of 2026-10-20). + if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls", + "mesh-delivery-teller.answers", "mesh-controller.assignments", "node-engine.have"}) { + t.Fatalf("reads %v", m.Reads) + } + // It calls exactly the controller's seven verbs (ADR 0450 §7) and reads the forge with three tools; the forge's + // writes are the bridge's (ADR 0437 §8). It uses no operator-channel: the teller asks (ADR 0437 §6). want := []string{} - for _, v := range []string{"delivery-plan", "delivery-check", "deliver", "delivery-backups-again", "delivery-stop", "delivery-walks", "forget-checks"} { + for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans"} { want = append(want, "seat:"+ControllerSeat+"."+v) } - want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", - "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls", - // The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off. - "gitea.gitea_merge_pull_request", "gitea.gitea_add_comment") - // A merge into main asks the operator for the Approve (novox/hq ADR 0398 §3, issue 948), as the knowledge base asks. - want = append(want, "seat:operator-channel.notify") - if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) { - t.Fatalf("uses %v", m.Uses) + want = append(want, "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls") + if len(m.Uses) != 0 { + t.Fatalf("uses %v: the teller asks, not the gate", m.Uses) } if !reflect.DeepEqual(m.Invokes, want) { t.Fatalf("invokes %v, wanted %v", m.Invokes, want) diff --git a/modules/mesh-delivery/cmd/mesh-delivery/model.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/model.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/operator_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/operator_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/ports.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/pullkey.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/pullkey.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/pullkey_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/pullkey_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/react.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/react.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/ready.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/ready_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/ready_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/recheck_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/recheck_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/replay362_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/replay362_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/replay362_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/replay362_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/table.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/table.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/table_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/table_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/timers_source_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_source_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/timers_source_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_source_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/timers_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/timers_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/times.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/times.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/times.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/times.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/times_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/times_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/times_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/times_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/unchecked.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked_events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_events_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/unchecked_events_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_events_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_test.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/unchecked_test.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_test.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/verbs.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/waiting_checks.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/waiting_checks.go similarity index 100% rename from modules/mesh-delivery/cmd/mesh-delivery/waiting_checks.go rename to modules/mesh-delivery-gate/cmd/mesh-delivery-gate/waiting_checks.go diff --git a/modules/mesh-delivery/go.mod b/modules/mesh-delivery-gate/go.mod similarity index 53% rename from modules/mesh-delivery/go.mod rename to modules/mesh-delivery-gate/go.mod index 5df693f9..34ed8b6e 100644 --- a/modules/mesh-delivery/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -1,22 +1,22 @@ -module mesh-delivery +module mesh-delivery-gate -go 1.25.0 +go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 + 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.51.0 + github.com/nats-io/nats.go v1.54.0 ) require ( github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/nats-io/jwt/v2 v2.8.1 // indirect - github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/modules/mesh-delivery/go.sum b/modules/mesh-delivery-gate/go.sum similarity index 69% rename from modules/mesh-delivery/go.sum rename to modules/mesh-delivery-gate/go.sum index f84463ba..aab1ed4b 100644 --- a/modules/mesh-delivery/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,11 +1,15 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +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= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= @@ -14,14 +18,22 @@ github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnY github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-delivery/module.json b/modules/mesh-delivery-gate/module.json similarity index 64% rename from modules/mesh-delivery/module.json rename to modules/mesh-delivery-gate/module.json index 1a40896b..27870113 100644 --- a/modules/mesh-delivery/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -1,7 +1,7 @@ { - "module": "mesh-delivery", + "module": "mesh-delivery-gate", "version": "1", - "slug": "deliver", + "slug": "gate", "claims": [ { "name": "mesh-delivery", @@ -31,7 +31,8 @@ "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", "mesh-controller.checked", - "mesh-controller.plan-moved" + "mesh-controller.plan-moved", + "mesh-delivery-forge-bridge.forge.**" ], "emits": [ "transition", @@ -39,31 +40,46 @@ "merged" ], "invokes": [ - "seat:mesh-controller.delivery-plan", - "seat:mesh-controller.delivery-check", "seat:mesh-controller.deliver", - "seat:mesh-controller.delivery-backups-again", "seat:mesh-controller.delivery-stop", + "seat:mesh-controller.delivery-backups-again", "seat:mesh-controller.delivery-walks", + "seat:mesh-controller.delivery-check", "seat:mesh-controller.forget-checks", - "gitea.gitea_note_append", - "gitea.gitea_delivery_view", - "gitea.gitea_commit_status", + "seat:mesh-controller.plans", "gitea.gitea_commit_statuses", "gitea.gitea_contains", - "gitea.gitea_open_pulls", - "gitea.gitea_merge_pull_request", - "gitea.gitea_add_comment", - "seat:operator-channel.notify" - ], - "uses": [ - "operator-channel" + "gitea.gitea_open_pulls" ], "state": [ "deliveries", "groups", - "asked", - "pulls" + "pulls", + { + "name": "asks", + "history": 5 + }, + { + "name": "merges", + "history": 10 + }, + { + "name": "owed", + "history": 5 + }, + { + "name": "source", + "history": 64 + } + ], + "reads": [ + "mesh-delivery.deliveries", + "mesh-delivery.groups", + "mesh-delivery.pulls", + "mesh-delivery-teller.answers", + "mesh-delivery-teller.answers-placeholder", + "mesh-controller.assignments", + "node-engine.have" ], "tools": [ "delivery_status" @@ -84,10 +100,10 @@ "kind": "bundle", "language": "go", "system": "arch", - "from": "cmd/mesh-delivery", - "binary": "mesh-delivery", + "from": "cmd/mesh-delivery-gate", + "binary": "mesh-delivery-gate", "loads": [ - "mesh-delivery" + "mesh-delivery-gate" ] } ] -- 2.54.0 From a390bb40f8e98e8cb37f3001ebd3d3c5e1d96ef0 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:19:29 +0000 Subject: [PATCH 02/15] Take a stray key out of the gate's reads (issue 1346) --- modules/mesh-delivery-gate/module.json | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index 27870113..ec838521 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -77,7 +77,6 @@ "mesh-delivery.groups", "mesh-delivery.pulls", "mesh-delivery-teller.answers", - "mesh-delivery-teller.answers-placeholder", "mesh-controller.assignments", "node-engine.have" ], -- 2.54.0 From dd462a80e26f6311e63c63a38a86e01ea02337b3 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:29:19 +0000 Subject: [PATCH 03/15] Keep the gate silent until it holds the seat, and carry today's state over once when it first does (issue 1347) --- .../cmd/mesh-delivery-gate/bus_test.go | 117 +++++++ .../cmd/mesh-delivery-gate/events.go | 3 + .../cmd/mesh-delivery-gate/fakes_test.go | 5 +- .../cmd/mesh-delivery-gate/holder.go | 10 + .../cmd/mesh-delivery-gate/main.go | 39 ++- .../cmd/mesh-delivery-gate/ports.go | 9 +- .../cmd/mesh-delivery-gate/react.go | 3 + .../cmd/mesh-delivery-gate/seat.go | 279 +++++++++++++++ .../cmd/mesh-delivery-gate/seat_test.go | 323 ++++++++++++++++++ .../testdata/live-capture-2026-10-20.json | 16 + .../cmd/mesh-delivery-gate/verbs.go | 2 +- 11 files changed, 786 insertions(+), 20 deletions(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/testdata/live-capture-2026-10-20.json diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index 9b69039c..295641f1 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -11,6 +11,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" "sync" "testing" @@ -82,6 +83,15 @@ func newTestBus(t *testing.T) *testBus { } b.kvs[name] = kv } + // The buckets of other modules the manifest reads (`.` is the bucket `_`): the gate reads + // who has the seat there, and today's state to carry (seat.go). + for _, name := range m.Reads { + kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: strings.Replace(name, ".", "_", 1), History: 1}) + if err != nil { + t.Fatal(err) + } + b.kvs[name] = kv + } // The streams the events are on (mesh.mod..event., mesh.seat..event.). if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>"}}); err != nil { @@ -191,6 +201,15 @@ func (r *runtime) read(out io.Reader) { } } +// untilHolding waits for the gate to hold the seat: it holds it when it reads the controller's walks once, the first thing it +// does after (readOnHolding), and it takes no event before. +func (r *runtime) untilHolding() { + r.t.Helper() + r.until("the gate to hold the seat", 20*time.Second, func() bool { + return len(r.asksOf("seat:"+ControllerSeat+".delivery-walks")) > 0 + }) +} + func (r *runtime) ping() { select { case r.notify <- struct{}{}: @@ -284,6 +303,8 @@ func (r *runtime) answer(m rpcMessage) { return map[string]any{}, nil case "mesh/seat.record": return nil, nil + case "mesh/state.watch": + return map[string]any{}, nil // the runtime's watch: the module also reads the buckets by key, so no change is sent here case "mesh/ask": var a struct { Key string `json:"key"` @@ -429,6 +450,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB defer rt.mu.Unlock() return len(rt.subs) >= len(followed)+1 }) + rt.untilHolding() for _, k := range followed { if strings.Contains(k, "labeled") || strings.Contains(k, "commented") { t.Fatalf("the module follows %s: a label or a comment starts nothing here (ADR 0421 §4)", k) @@ -531,6 +553,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOv defer rt.mu.Unlock() return len(rt.subs) >= len(followed)+1 }) + rt.untilHolding() const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2" for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"), pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} { @@ -628,6 +651,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA defer rt.mu.Unlock() return len(rt.subs) >= len(followed)+1 }) + rt.untilHolding() const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444" announced := pr("novox/app", 9, head, "fix/y", "src/a.go") announced.BaseSHA = "t1t1t1t1t1t1" @@ -675,3 +699,96 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA t.Fatalf("the controller was asked %d times to let the held walk go or take its backups again", n) } } + +// **AC3 of issue 1347 (ADR 0437 §7's check): the gate carries a live mesh's open deliveries and groups, once.** The buckets of +// today's module are filled from testdata/live-capture-2026-10-20.json (its source is said in the file), the gate starts with +// today's module on no machine, and every delivery and group arrives in its own buckets unchanged but for the ask an Approve +// waited on; a second start copies nothing. +func TestTheGateCarriesALiveCapturesDeliveriesAndGroupsOnceOverARealBus(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "live-capture-2026-10-20.json")) + if err != nil { + t.Fatal(err) + } + var capture struct { + Deliveries []json.RawMessage `json:"deliveries"` + Groups []json.RawMessage `json:"groups"` + } + if err := json.Unmarshal(raw, &capture); err != nil { + t.Fatal(err) + } + b := newTestBus(t) + ctx := context.Background() + want := map[string]Delivery{} + for _, one := range capture.Deliveries { + var d Delivery + if err := json.Unmarshal(one, &d); err != nil { + t.Fatal(err) + } + if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey(d.ID), one); err != nil { + t.Fatal(err) + } + if d.Ready != nil { // what the carry clears: the ask nobody answered + d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil + } + want[kvKey(d.ID)] = d + } + for _, one := range capture.Groups { + var g Group + if err := json.Unmarshal(one, &g); err != nil { + t.Fatal(err) + } + if _, err := b.kvs["mesh-delivery.groups"].Put(ctx, kvKey(g.ID), one); err != nil { + t.Fatal(err) + } + } + rt := b.start(t) + rt.untilHolding() + for key, d := range want { + e, err := b.kvs["deliveries"].Get(ctx, key) + if err != nil { + t.Fatalf("the delivery %s did not arrive: %v", key, err) + } + var got Delivery + if err := json.Unmarshal(e.Value(), &got); err != nil { + t.Fatal(err) + } + if g, w := string(mustJSON(got)), string(mustJSON(d)); g != w { + t.Fatalf("the delivery %s changed in the carry:\n got %s\nwant %s", key, g, w) + } + } + for _, one := range capture.Groups { + var g Group + _ = json.Unmarshal(one, &g) + e, err := b.kvs["groups"].Get(ctx, kvKey(g.ID)) + if err != nil { + t.Fatalf("the group %s did not arrive: %v", g.ID, err) + } + if string(mustJSON(g)) != string(mustJSON(mustDecode[Group](t, e.Value()))) { + t.Fatalf("the group %s changed in the carry", g.ID) + } + } + if _, err := b.kvs["deliveries"].Get(ctx, carriedKey); err != nil { + t.Fatalf("the mark carried was not written: %v", err) + } + // A second start copies nothing: a delivery today's module gains meanwhile stays where it is. + _ = rt.in.Close() + _ = rt.cmd.Wait() + late := mustJSON(&Delivery{ID: "novox/app@999999999999", Repository: "novox/app", Commit: "999999999999", Number: 99, State: Proposed}) + if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey("novox/app@999999999999"), late); err != nil { + t.Fatal(err) + } + again := b.start(t) + again.untilHolding() + if _, err := b.kvs["deliveries"].Get(ctx, kvKey("novox/app@999999999999")); !errors.Is(err, jetstream.ErrKeyNotFound) { + t.Fatalf("a second start copied a key (%v)", err) + } +} + +func mustDecode[T any](t *testing.T, raw []byte) T { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + return v +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go index ac92d25d..7713e463 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -42,6 +42,9 @@ func (h *Holder) takeForgeWanted() bool { // AskDecided takes the router's `decided` event for an ask this owner made: its warrant is applied to the deliveries // the ask covers, and what is said on their pull requests is said. func (h *Holder) AskDecided(id, state string, w *asks.Warrant) { + if !h.Holding() { + return + } h.mu.Lock() said := h.decided(id, state, w) h.mu.Unlock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go index e04f0196..4b85bc65 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go @@ -62,7 +62,10 @@ func (s *memStore) Deliveries() ([]*Delivery, error) { s.mu.Lock() defer s.mu.Unlock() var out []*Delivery - for _, raw := range s.deliveries { + for key, raw := range s.deliveries { + if key == carriedKey { // the gate's mark that today's state was carried (seat.go), as the real store skips it + continue + } var d Delivery if err := json.Unmarshal(raw, &d); err != nil { return nil, err diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index 362436c6..ff530ac8 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" ) @@ -31,9 +32,18 @@ type Holder struct { Operator Operator // Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none. Pulls PullKeys + // Seat says where today's module still stands; the gate holds the seat only when it stands nowhere (seat.go). Carry + // copies today's state over at the first holding, OnHold takes the reads of a holder just come. + Seat Occupancy + Carry func() (CarryReport, error) + OnHold func() // cancels are asks to take back, gathered under the lock and run with the other asks of the look. cancels []func() + seatHeld atomic.Bool + seatMu sync.Mutex + seatWhy string + mu sync.Mutex deliveries map[string]*Delivery groups map[string]*Group diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index c8326410..765d2e8d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -46,7 +46,11 @@ func main() { Logf: logf, Operator: operator, Pulls: kvPulls{}, + // The gate acts only while it holds the seat, and carries today's state over when it first does (seat.go). + Seat: busOccupancy{}, + Carry: carryFromBus, } + h.OnHold = func() { go readOnHolding(h) } h.init() l := &listening{now: "not yet: starting"} go run(h, l, operator) @@ -56,21 +60,15 @@ func main() { } } -// run reads back the state, then takes the events, then reads once what it may have missed while it was away — the -// controller's walks, the forge's open pull requests, the operator's asks — and acts on events from then on -// (novox/hq ADR 0406 §1, issue 975): no tick, no forge poll, no reconcile loop. Each step is retried, each failure -// said, never given up on quietly. +// run takes the events and the operator's answers, which a gate that does not hold the seat acknowledges and leaves +// alone, and watches who has the seat (watchSeat). The state is read back, after today's is carried, when the gate first +// holds the seat (SeatChanged), and what it may have missed meanwhile is read once then (readOnHolding): the controller's +// walks, the forge's open pull requests, the operator's asks — and it acts on events from then on (novox/hq ADR 0406 §1, +// issue 975): no tick, no forge poll, no reconcile loop. Each step is retried, each failure said, never given up on quietly. func run(h *Holder, l *listening, operator *asker.Client) { time.Sleep(500 * time.Millisecond) // Serve first: the state is reached through it - for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { - err := h.Load() - if err == nil { - break - } - logf("[mesh-delivery] cannot read back its deliveries yet (%v); asking again in %s", err, wait) - time.Sleep(wait) - } go h.Work() + go watchSeat(h, l) for _, pattern := range followed { pattern := pattern for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { @@ -103,14 +101,16 @@ func run(h *Holder, l *listening, operator *asker.Client) { logf("[mesh-delivery] not hearing the operator's answers yet (%v); asking again in %s", err, wait) time.Sleep(wait) } - l.set("listening") logf("[mesh-delivery] listening: %s", strings.Join(followed, ", ")) - // Read once, now that events arrive: what happened while this owner was away. The forge's open pull requests (ready - // marks, heads nobody checks) are read by the first look; the asks the router closed meanwhile are read here. +} + +// readOnHolding reads once, now that the gate holds the seat and events arrive, what happened while it was away: the +// forge's open pull requests (heads nobody checks) are read by the first look, the asks the router closed meanwhile are read +// here, and the controller's walks, until they have been read once; plan-moved events carry every move after. +func readOnHolding(h *Holder) { h.wantForge() h.hearAsks() h.Poke() - // retries: the controller's walks, until they have been read once; plan-moved events carry every move after for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { err := h.Reconcile() if err == nil { @@ -125,6 +125,9 @@ func run(h *Holder, l *listening, operator *asker.Client) { // handle takes one event, then asks for a look at every step the event makes possible (Work, React). An event that cannot be read is // said and acknowledged: read again it would fail again, and the controller's walks are read back by comparison anyway. func handle(h *Holder, e stdio.Envelope) error { + if !h.Holding() { + return nil // not the holder of the seat: the event is acknowledged and taken by no one (seat.go) + } taken := take(h, e) h.Poke() return taken @@ -412,7 +415,11 @@ func tools(h *Holder, l *listening) []stdio.Tool { // A verb that changes a delivery asks for a look at what it makes possible (react.go). for i := range list { run := list[i].Run + ofSeat := strings.HasPrefix(list[i].Name, seat) list[i].Run = func(a map[string]any) (any, error) { + if ofSeat && !h.Holding() { + return nil, h.notHolder() // a verb of the seat is answered by its holder only + } out, err := run(a) h.Poke() return out, err diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go index c893fe0f..7ca17df5 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strconv" "strings" @@ -458,7 +459,8 @@ func (kvStore) PutGroup(g *Group) error { } func (kvStore) DeleteGroup(id string) error { return stdio.State("groups").Delete(kvKey(id)) } -func readAll[T any](state string) ([]*T, error) { +// readAll reads every key of a state but the ones it is told to leave (the gate's mark `carried` is no delivery). +func readAll[T any](state string, leave ...string) ([]*T, error) { keys, err := stdio.State(state).Keys() if err != nil { return nil, err @@ -466,6 +468,9 @@ func readAll[T any](state string) ([]*T, error) { sort.Strings(keys) var out []*T for _, k := range keys { + if slices.Contains(leave, k) { + continue + } e, err := stdio.State(state).Get(k) if err != nil { return nil, err @@ -483,5 +488,5 @@ func readAll[T any](state string) ([]*T, error) { return out, nil } -func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries") } +func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries", carriedKey) } func (kvStore) Groups() ([]*Group, error) { return readAll[Group]("groups") } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go index a1edeff8..165c71b5 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go @@ -61,6 +61,9 @@ func (h *Holder) Work() { func (h *Holder) React() { h.reactMu.Lock() defer h.reactMu.Unlock() + if !h.Holding() { + return // not the holder of the seat: no look, no write, no ask (seat.go) + } h.wanted.mu.Lock() h.wanted.at, h.wanted.step = time.Time{}, "" h.wanted.mu.Unlock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go new file mode 100644 index 00000000..3160c98e --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go @@ -0,0 +1,279 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The gate acts only while it holds the seat, and carries today's state over when it first holds it (novox/hq ADR 0450 +// §6 and its insight of 2026-10-20; ADR 0437 §7). It holds the seat when no machine is assigned today's module +// (mesh-controller.assignments, keys `.`) and no machine's node-engine says it has the module (node-engine.have, +// the same keys): the seat passes by ADR 0449 §4, so the gate never has to take it. While it does not hold it, it takes +// no event, writes no key, asks nothing and answers no verb, and says in its log why it waits. + +// OldModule is today's module, whose place the gate takes: the module `mesh-delivery`. +const OldModule = "mesh-delivery" + +// Occupancy is where today's module still stands. +type Occupancy interface { + // Occupants says, one line each, where the module is assigned ("assigned on ") or has a have key ("on + // the node-engine has it"); none when no machine has it. + Occupants() ([]string, error) +} + +// Bucket is a bucket of the bus: today's module's, read, or the gate's own, written. +type Bucket interface { + Keys() ([]string, error) + // Get is a key's value, or nil when it has none. + Get(key string) ([]byte, error) + Put(key string, raw []byte) error +} + +// carriedKey is the key, in the gate's bucket `deliveries`, that says today's state was carried: no later start or holding +// copies again. +const carriedKey = "carried" + +// CarryReport says what a carry did. +type CarryReport struct { + // Already is true when the key `carried` was there: nothing was copied. + Already bool + // Copied is, by bucket, how many keys were copied. + Copied map[string]int + // Cleared is how many deliveries had the id of an ask of today's module taken off. + Cleared int +} + +// SeatChanged reads who has today's module and moves the gate to or from holding the seat. The first time it finds the seat +// free it carries today's state over (Carry), reads it back (Load) and only then holds; a failure of either is returned and +// leaves the gate silent, to be tried again. +func (h *Holder) SeatChanged() error { + if h.Seat == nil { + return nil + } + who, err := h.Seat.Occupants() + if err != nil { + h.Logf("[mesh-delivery] cannot tell who has the seat yet: %v", err) + return err + } + if len(who) > 0 { + why := "today's module is not gone yet: " + strings.Join(who, "; ") + if h.seatHeld.Swap(false) { + h.Logf("[mesh-delivery] the gate no longer holds the seat and is silent: %s", why) + } else if h.setSeatWhy(why) { + h.Logf("[mesh-delivery] the gate waits for the seat and is silent: %s", why) + } + h.setSeatWhy(why) + return nil + } + if h.seatHeld.Load() { + return nil + } + if h.Carry != nil { + report, err := h.Carry() + if err != nil { + h.Logf("[mesh-delivery] today's state cannot be carried yet: %v", err) + return err + } + if report.Already { + h.Logf("[mesh-delivery] today's state was carried before: nothing is copied") + } else { + h.Logf("[mesh-delivery] today's state is carried: %v copied, %d ask id(s) cleared", report.Copied, report.Cleared) + } + } + if err := h.Load(); err != nil { + h.Logf("[mesh-delivery] the carried state cannot be read back yet: %v", err) + return err + } + h.setSeatWhy("") + h.seatHeld.Store(true) + h.Logf("[mesh-delivery] the gate holds the seat: today's module is on no machine") + if h.OnHold != nil { + h.OnHold() + } + h.Poke() + return nil +} + +// Holding is whether the gate acts. A holder with no Seat to ask always holds (the tests'; main sets one). +func (h *Holder) Holding() bool { return h.Seat == nil || h.seatHeld.Load() } + +// setSeatWhy keeps why the gate waits and says whether it changed. +func (h *Holder) setSeatWhy(why string) bool { + h.seatMu.Lock() + defer h.seatMu.Unlock() + changed := h.seatWhy != why + h.seatWhy = why + return changed +} + +// SeatWhy is why the gate is silent, or empty when it holds. +func (h *Holder) SeatWhy() string { + h.seatMu.Lock() + defer h.seatMu.Unlock() + return h.seatWhy +} + +// notHolder is the answer of a verb asked of a gate that does not hold the seat. +func (h *Holder) notHolder() error { + return fmt.Errorf("not the holder: the gate does not hold the seat mesh-delivery yet, and answers nothing until it does (%s)", h.SeatWhy()) +} + +// CarryOver copies today's state into the gate's own, once. It does nothing when `carried` is in the gate's deliveries. +// +// For each of today's module's buckets `deliveries`, `groups` and `pulls` it copies every key the gate's bucket lacks, and a +// key the gate has is never touched. A delivery whose Approve nobody answered has the ask of today's module taken off, since +// that ask lives in a router the gate does not ask under: the gate asks again through the teller, as an ask that ended without +// a choice is asked again (ADR 0421 §6). A delivery whose Approve was given keeps its answer, which the merge still needs. +// The mark `carried` is written last, so a carry that stopped halfway is done again and a finished one never is. +func CarryOver(old, own func(bucket string) Bucket, at time.Time) (CarryReport, error) { + report := CarryReport{Copied: map[string]int{}} + marks := own("deliveries") + if mark, err := marks.Get(carriedKey); err != nil { + return report, fmt.Errorf("the mark that today's state was carried cannot be read: %w", err) + } else if mark != nil { + report.Already = true + return report, nil + } + for _, bucket := range []string{"deliveries", "groups", "pulls"} { + keys, err := old(bucket).Keys() + if err != nil { + return report, fmt.Errorf("the keys of %s.%s cannot be read: %w", OldModule, bucket, err) + } + sort.Strings(keys) + for _, key := range keys { + if bucket == "deliveries" && key == carriedKey { + continue + } + have, err := own(bucket).Get(key) + if err != nil { + return report, fmt.Errorf("%s %s of the gate cannot be read: %w", bucket, key, err) + } + if have != nil { + continue + } + raw, err := old(bucket).Get(key) + if err != nil { + return report, fmt.Errorf("%s.%s %s cannot be read: %w", OldModule, bucket, key, err) + } + if raw == nil { + continue + } + if bucket == "deliveries" { + var d Delivery + if err := json.Unmarshal(raw, &d); err != nil { + return report, fmt.Errorf("the delivery %s of %s.deliveries is not readable: %w", key, OldModule, err) + } + if r := d.Ready; r != nil && r.AskID != "" && r.Approved == nil && r.Declined == nil { + r.AskID, r.Ask, r.Heads = "", nil, nil + report.Cleared++ + if raw, err = json.Marshal(&d); err != nil { + return report, err + } + } + } + if err := own(bucket).Put(key, raw); err != nil { + return report, fmt.Errorf("%s %s cannot be written: %w", bucket, key, err) + } + report.Copied[bucket]++ + } + } + mark, err := json.Marshal(map[string]any{"v": 1, "at": at.UTC().Format(time.RFC3339), "from": OldModule, + "deliveries": report.Copied["deliveries"], "groups": report.Copied["groups"], "pulls": report.Copied["pulls"], "cleared": report.Cleared}) + if err != nil { + return report, err + } + if err := marks.Put(carriedKey, mark); err != nil { + return report, fmt.Errorf("the mark that today's state was carried cannot be written: %w", err) + } + return report, nil +} + +// busOccupancy reads the controller's assignments and the node-engine's have keys, both `.`. +type busOccupancy struct{} + +func (busOccupancy) Occupants() ([]string, error) { + var who []string + for _, src := range []struct{ bucket, says string }{ + {"mesh-controller.assignments", "assigned on %s"}, {"node-engine.have", "the node-engine of %s has it"}} { + keys, err := stdio.State(src.bucket).Keys() + if err != nil { + return nil, fmt.Errorf("%s cannot be read: %w", src.bucket, err) + } + for _, key := range keys { + if node, module, ok := strings.Cut(key, "."); ok && module == OldModule { + who = append(who, fmt.Sprintf(src.says, node)) + } + } + } + sort.Strings(who) + return who, nil +} + +// keptBucket is a bucket of the bus through the runtime: today's module's by `.`, the gate's by its name. +type keptBucket struct{ name string } + +func (b keptBucket) Keys() ([]string, error) { return stdio.State(b.name).Keys() } +func (b keptBucket) Get(key string) ([]byte, error) { + e, err := stdio.State(b.name).Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} +func (b keptBucket) Put(key string, raw []byte) error { + _, err := stdio.State(b.name).Put(key, json.RawMessage(raw)) + return err +} + +// carryFromBus is the carry over the runtime: today's module's buckets into the gate's own. +func carryFromBus() (CarryReport, error) { + return CarryOver(func(b string) Bucket { return keptBucket{OldModule + "." + b} }, func(b string) Bucket { return keptBucket{b} }, time.Now()) +} + +// watchSeat reads who has the seat at once, and again at every change of the assignments or the have keys, for ever. A read +// that fails is said and tried again in ten seconds; a change read meanwhile is not lost. +func watchSeat(h *Holder, l *listening) { + changed := make(chan struct{}, 1) + note := func(stdio.StateChange) error { + select { + case changed <- struct{}{}: + default: + } + return nil + } + for _, bucket := range []string{"mesh-controller.assignments", "node-engine.have"} { + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.State(bucket).Watch("", note) + if err == nil { + return + } + logf("[mesh-delivery] not hearing changes of %s yet (%v); asking again in %s", bucket, err, wait) + time.Sleep(wait) + } + }() + } + for { + err := h.SeatChanged() + switch { + case err != nil: + l.set("not yet: " + err.Error()) + select { + case <-changed: + case <-time.After(10 * time.Second): + } + default: + if h.Holding() { + l.set("listening") + } else { + l.set("waiting: not the holder of the seat (" + h.SeatWhy() + ")") + } + <-changed + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go new file mode 100644 index 00000000..cb91b961 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go @@ -0,0 +1,323 @@ +package main + +import ( + "bytes" + "errors" + "slices" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// **The gate acts only while it holds the seat, and carries today's state over once** (novox/hq ADR 0450 §6 with its +// insight of 2026-10-20, ADR 0437 §7, issue 1347). Fixture sources: the keys of mesh-controller.assignments and +// node-engine.have are `.` (mesh-controller internal/inventory/chain_assignments.go assignmentKey, +// mesh-sdk go/delivery HaveKey); a delivery is the module's own Delivery, written as ports.go kvStore writes it. + +// fakeOccupancy is where today's module stands, as the test says. +type fakeOccupancy struct { + mu sync.Mutex + who []string + err error +} + +func (o *fakeOccupancy) set(who ...string) { o.mu.Lock(); o.who = who; o.mu.Unlock() } +func (o *fakeOccupancy) Occupants() ([]string, error) { + o.mu.Lock() + defer o.mu.Unlock() + return slices.Clone(o.who), o.err +} + +// mapBucket is a bucket over a map, shared with the store a test reads. +type mapBucket struct { + mu *sync.Mutex + m map[string][]byte +} + +func (b mapBucket) Keys() ([]string, error) { + b.mu.Lock() + defer b.mu.Unlock() + var keys []string + for k := range b.m { + keys = append(keys, k) + } + return keys, nil +} +func (b mapBucket) Get(key string) ([]byte, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.m[key], nil +} +func (b mapBucket) Put(key string, raw []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + b.m[key] = raw + return nil +} + +// seatTest is a world whose holder asks where today's module stands. +func seatTest(t *testing.T, who ...string) (*world, *fakeOccupancy) { + t.Helper() + w := newWorld(t) + occ := &fakeOccupancy{who: who} + w.h.Seat = occ + return w, occ +} + +// anEvent is the runtime handing the holder a pull request's head, as handle takes it. +func anEvent(t *testing.T, w *world) { + t.Helper() + hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) +} + +// nothingWritten says the forge, the controller and the store were left alone. +func nothingWritten(t *testing.T, w *world) { + t.Helper() + w.forge.mu.Lock() + forge := len(w.forge.notes) + len(w.forge.views) + len(w.forge.statuses) + w.forge.statusWrites + len(w.forge.calls) + len(w.forge.comments) + w.forge.mu.Unlock() + w.ctl.mu.Lock() + ctl := len(w.ctl.checks) + w.ctl.asked + len(w.ctl.delivers) + len(w.ctl.stops) + w.ctl.mu.Unlock() + w.store.mu.Lock() + keys := len(w.store.deliveries) + len(w.store.groups) + w.store.mu.Unlock() + if forge != 0 || ctl != 0 || keys != 0 { + t.Fatalf("a gate that does not hold the seat wrote: %d on the forge, %d to the controller, %d keys", forge, ctl, keys) + } + if n := len(w.h.deliveries); n != 0 { + t.Fatalf("a gate that does not hold the seat took the event: %d deliveries", n) + } +} + +func toolNamed(t *testing.T, h *Holder, name string) stdio.Tool { + t.Helper() + for _, tool := range tools(h, &listening{}) { + if tool.Name == name { + return tool + } + } + t.Fatalf("no tool %s", name) + return stdio.Tool{} +} + +// AC1: with the module assigned on a machine, or its have key present, an event changes no key and a verb answers that +// the gate is not the holder. +func TestTheGateIsSilentWhileTodaysModuleIsAssignedOrHasAHaveKey(t *testing.T) { + for _, who := range []string{"assigned on novox", "the node-engine of ace has it"} { + t.Run(who, func(t *testing.T) { + w, _ := seatTest(t, who) + if err := w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + if w.h.Holding() { + t.Fatalf("the gate holds the seat while %s", who) + } + anEvent(t, w) + nothingWritten(t, w) + // Every verb of the seat answers that it is not the holder, and none changes anything. + for _, name := range []string{"mesh-delivery.deliveries", "mesh-delivery.release", "mesh-delivery.stop", "mesh-delivery.recheck"} { + _, err := toolNamed(t, w.h, name).Run(map[string]any{"id": "novox/app@111111111111", "why": "a test"}) + if err == nil || !strings.Contains(err.Error(), "not the holder") || !strings.Contains(err.Error(), who) { + t.Fatalf("%s answered %v, wanted not the holder, naming %q", name, err, who) + } + } + nothingWritten(t, w) + // Its own status still answers, and says why it is silent. + out, err := toolNamed(t, w.h, "delivery_status").Run(nil) + if err != nil { + t.Fatal(err) + } + status := out.(map[string]any) + if status["holds-the-seat"] != false || !strings.Contains(status["seat-waits"].(string), who) { + t.Fatalf("status %v", status) + } + }) + } +} + +// The seat free, the gate holds it and acts; asked again with the module back, it is silent again. +func TestTheGateActsOnceTodaysModuleIsGoneAndStopsIfItComesBack(t *testing.T) { + w, occ := seatTest(t) + if err := w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + if !w.h.Holding() { + t.Fatal("the seat is free and the gate does not hold it") + } + anEvent(t, w) + if len(w.h.deliveries) != 1 { + t.Fatalf("the gate that holds the seat took %d deliveries, wanted 1", len(w.h.deliveries)) + } + if _, err := toolNamed(t, w.h, "mesh-delivery.deliveries").Run(map[string]any{}); err != nil { + t.Fatalf("a verb of the holder: %v", err) + } + occ.set("assigned on novox") + if err := w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + if w.h.Holding() { + t.Fatal("the module is assigned again and the gate still holds") + } + hear(t, w, "gitea.pull.updated", pr("novox/app", 8, "2222222222222222222222222222222222222222", "feat/y", "src/b.go")) + if len(w.h.deliveries) != 1 { + t.Fatalf("an event was taken while the gate was silent: %d deliveries", len(w.h.deliveries)) + } +} + +// Nothing owed is flushed, and no look is taken, while the gate is silent. +func TestASilentGateFlushesNothingItOwes(t *testing.T) { + w, occ := seatTest(t) + if err := w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) // owes a note, a view and a status + occ.set("assigned on novox") + if err := w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + counts := func() [3]int { + w.forge.mu.Lock() + defer w.forge.mu.Unlock() + w.store.mu.Lock() + defer w.store.mu.Unlock() + return [3]int{len(w.forge.notes) + len(w.forge.views) + len(w.forge.statuses) + w.forge.statusWrites + len(w.forge.calls), w.store.puts, len(w.said)} + } + before := counts() + w.h.React() + if after := counts(); after != before { + t.Fatalf("a silent gate flushed what it owes: forge, store and events %v before, %v after", before, after) + } +} + +// A seat that cannot be read leaves a gate that did not hold silent, and says so. +func TestTheGateStaysSilentWhenTheSeatCannotBeRead(t *testing.T) { + w, occ := seatTest(t) + occ.err = errors.New("the bus is away") + if err := w.h.SeatChanged(); err == nil { + t.Fatal("a seat that cannot be read was taken as free") + } + if w.h.Holding() { + t.Fatal("holding without knowing") + } +} + +// --- AC2: the carry --------------------------------------------------------------------------------- + +func aDelivery(id string, number int, ready *ReadyRun) []byte { + return mustJSON(&Delivery{ID: id, Repository: "novox/app", Commit: strings.Repeat("1", 40), Number: number, Base: "main", + State: Proposed, Ready: ready}) +} + +type carryFixture struct { + oldD, oldG, oldP map[string][]byte + mu sync.Mutex + own map[string]mapBucket + w *world + occ *fakeOccupancy + approvedAt time.Time +} + +// aCarry is a world whose holder carries from these buckets into its store's: the old module's deliveries, one with an ask +// waiting, one approved and one the gate already has under another value; one group; one pull key. +func aCarry(t *testing.T) *carryFixture { + f := &carryFixture{oldD: map[string][]byte{}, oldG: map[string][]byte{}, oldP: map[string][]byte{}, + approvedAt: time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)} + f.w, f.occ = seatTest(t) + ask := &asks.Ask{ID: "merge-7-aaaaaaaaaaaa-1"} + f.oldD["novox/app@aaaaaaaaaaaa"] = aDelivery("novox/app@aaaaaaaaaaaa", 7, &ReadyRun{Head: strings.Repeat("a", 40), AskID: "merge-7-aaaaaaaaaaaa-1", + Ask: ask, Heads: map[string]string{"novox/app@aaaaaaaaaaaa": strings.Repeat("a", 40)}, Asks: 1}) + f.oldD["novox/app@bbbbbbbbbbbb"] = aDelivery("novox/app@bbbbbbbbbbbb", 8, &ReadyRun{Head: strings.Repeat("b", 40), AskID: "merge-8-bbbbbbbbbbbb-1", + Ask: &asks.Ask{ID: "merge-8-bbbbbbbbbbbb-1"}, Heads: map[string]string{"novox/app@bbbbbbbbbbbb": strings.Repeat("b", 40)}, + Approved: &f.approvedAt, Warrant: "the operator on the phone"}) + f.oldD["novox/app@cccccccccccc"] = aDelivery("novox/app@cccccccccccc", 9, &ReadyRun{Head: strings.Repeat("c", 40), AskID: "merge-9-cccccccccccc-1", Ask: ask}) + f.oldG["feat_x"] = []byte(`{"id":"feat/x","members":["novox/app@aaaaaaaaaaaa"]}`) + f.oldP["novox_app_7"] = []byte(`{"v":1,"number":7,"head":"` + strings.Repeat("a", 40) + `"}`) + // The gate already has one of the old module's deliveries, otherwise. + f.w.store.deliveries["novox/app@cccccccccccc"] = aDelivery("novox/app@cccccccccccc", 9, &ReadyRun{Head: strings.Repeat("c", 40)}) + ownG, ownP := map[string][]byte{}, map[string][]byte{} + f.own = map[string]mapBucket{ + "deliveries": {mu: &f.w.store.mu, m: f.w.store.deliveries}, + "groups": {mu: &f.mu, m: ownG}, + "pulls": {mu: &f.mu, m: ownP}, + } + old := map[string]mapBucket{"deliveries": {mu: &f.mu, m: f.oldD}, "groups": {mu: &f.mu, m: f.oldG}, "pulls": {mu: &f.mu, m: f.oldP}} + f.w.h.Carry = func() (CarryReport, error) { + return CarryOver(func(b string) Bucket { return old[b] }, func(b string) Bucket { return f.own[b] }, f.approvedAt) + } + return f +} + +// AC2: when both go, the gate carries once (old ask ids cleared, keys it already has untouched) and then acts; a restart +// copies nothing. +func TestTheGateCarriesTodaysStateOnceWhenItFirstHoldsTheSeat(t *testing.T) { + f := aCarry(t) + before := slices.Clone(f.w.store.deliveries["novox/app@cccccccccccc"]) + f.occ.set("assigned on novox") + if err := f.w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + if len(f.w.store.deliveries) != 1 || f.w.h.Holding() { + t.Fatalf("the gate carried while the module was still assigned: %d keys", len(f.w.store.deliveries)) + } + f.occ.set() + if err := f.w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + if !f.w.h.Holding() { + t.Fatal("the gate does not hold the seat after the module went") + } + d := f.w.h.deliveries + for _, id := range []string{"novox/app@aaaaaaaaaaaa", "novox/app@bbbbbbbbbbbb", "novox/app@cccccccccccc"} { + if d[id] == nil { + t.Fatalf("%s was not carried and read back: %v", id, f.w.store.deliveries) + } + } + // The ask nobody answered is cleared, so the gate asks again; the one answered keeps its answer. + if r := d["novox/app@aaaaaaaaaaaa"].Ready; r.AskID != "" || r.Ask != nil || r.Heads != nil || r.Asks != 1 { + t.Fatalf("the unanswered ask was not cleared: %+v", r) + } + if r := d["novox/app@bbbbbbbbbbbb"].Ready; r.Approved == nil || r.AskID == "" || r.Warrant == "" { + t.Fatalf("an answered Approve lost its answer: %+v", r) + } + // A key the gate had is untouched. + if !bytes.Equal(f.w.store.deliveries["novox/app@cccccccccccc"], before) { + t.Fatal("a key the gate already had was overwritten") + } + for bucket, key := range map[string]string{"groups": "feat_x", "pulls": "novox_app_7"} { + if got, _ := f.own[bucket].Get(key); got == nil { + t.Fatalf("%s %s was not carried", bucket, key) + } + } + if got, _ := f.own["deliveries"].Get(carriedKey); got == nil { + t.Fatal("the key carried was not written") + } + // A restart (a holder that holds again, or starts again) copies nothing. + f.oldD["novox/app@dddddddddddd"] = aDelivery("novox/app@dddddddddddd", 10, nil) + report, err := f.w.h.Carry() + if err != nil || !report.Already { + t.Fatalf("a second carry: %+v %v", report, err) + } + if got, _ := f.own["deliveries"].Get("novox/app@dddddddddddd"); got != nil { + t.Fatal("a second carry copied a key") + } +} + +// A carry that fails leaves the gate silent and writes no mark, so it is tried again. +func TestAFailedCarryLeavesTheGateSilentAndUnmarked(t *testing.T) { + f := aCarry(t) + f.w.h.Carry = func() (CarryReport, error) { return CarryReport{}, errors.New("the old bucket cannot be read") } + if err := f.w.h.SeatChanged(); err == nil { + t.Fatal("a failed carry was not returned") + } + if f.w.h.Holding() { + t.Fatal("the gate holds the seat on state it did not carry") + } + if got, _ := f.own["deliveries"].Get(carriedKey); got != nil { + t.Fatal("a failed carry wrote its mark") + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/testdata/live-capture-2026-10-20.json b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/testdata/live-capture-2026-10-20.json new file mode 100644 index 00000000..98d6661f --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/testdata/live-capture-2026-10-20.json @@ -0,0 +1,16 @@ +{ + "source": "mesh-delivery.deliveries and mesh-delivery.groups, called on the live mesh at 2026-10-20 about 02:45 (local) by fix-r3e-gate@shanks; there is no tool that reads a key's value (issue 1333), so each value is built from what the verbs show: id, pull request number, state, since, group, and for the group's members base, head and state. What the verbs do not show is filled with the least that reads: the commit of a delivery is its id's twelve characters (the verbs show no full head), the base is main, created is since. The two ask ids under ready are synthetic (the verbs do not show an ask id): they stand for an Approve asked and not answered, which the carry must clear.", + "deliveries": [ + {"id": "novox/mesh-catalog@b9104454f9a5", "repository": "novox/mesh-catalog", "commit": "b9104454f9a5", "number": 378, "base": "main", "state": "delivering", "since": "2026-10-19T22:58:32Z", "created": "2026-10-19T22:58:32Z"}, + {"id": "novox/mesh-sdk@1c20b9fb9b25", "repository": "novox/mesh-sdk", "commit": "1c20b9fb9b25", "number": 43, "base": "main", "state": "proposed", "since": "2026-10-19T20:39:11Z", "created": "2026-10-19T20:39:11Z"}, + {"id": "novox/de-spiegel@fdcbaefb3123", "repository": "novox/de-spiegel", "commit": "fdcbaefb3123", "number": 4, "base": "main", "state": "ready", "since": "2026-10-16T20:10:46Z", "created": "2026-10-16T20:10:46Z", "ready": {"head": "fdcbaefb3123", "seen": "2026-10-20T00:00:00Z", "last_try": "0001-01-01T00:00:00Z", "ask_id": "merge-4-fdcbaefb3123-1", "asked_at": "2026-10-20T00:00:00Z", "asks": 1, "heads": {"fdcbaefb3123": "fdcbaefb3123"}}}, + {"id": "novox/hq@1552b0e0a3ec", "repository": "novox/hq", "commit": "1552b0e0a3ec", "number": 384, "base": "main", "state": "proposed", "since": "2026-10-16T15:58:05Z", "created": "2026-10-16T15:58:05Z"}, + {"id": "novox/mesh-controller@0350b4e22276", "repository": "novox/mesh-controller", "commit": "0350b4e22276", "number": 325, "base": "main", "state": "proposed", "since": "2026-10-14T13:22:26Z", "created": "2026-10-14T13:22:26Z"}, + {"id": "novox/mesh-catalog@1fe6ea4ee473", "repository": "novox/mesh-catalog", "commit": "1fe6ea4ee473", "number": 152, "base": "main", "state": "ready", "since": "2026-10-09T07:54:44Z", "created": "2026-10-09T07:54:44Z", "ready": {"head": "1fe6ea4ee473", "seen": "2026-10-20T00:00:00Z", "last_try": "0001-01-01T00:00:00Z", "ask_id": "merge-152-1fe6ea4ee473-1", "asked_at": "2026-10-20T00:00:00Z", "asks": 1, "heads": {"1fe6ea4ee473": "1fe6ea4ee473"}}}, + {"id": "novox/mesh-catalog@795806efa7ad", "repository": "novox/mesh-catalog", "commit": "795806efa7ad95fa5cb3210b527117ad0316f4b9", "number": 350, "base": "main", "state": "stopped", "since": "2026-10-09T07:00:00Z", "created": "2026-10-09T07:00:00Z", "group": "feat/whole-modules"}, + {"id": "novox/mesh-controller@874a91a3c64f", "repository": "novox/mesh-controller", "commit": "874a91a3c64ff4a4dfa6b55b85101622e0e2e550", "number": 370, "base": "main", "state": "failed", "since": "2026-10-09T07:00:00Z", "created": "2026-10-09T07:00:00Z", "group": "feat/whole-modules"} + ], + "groups": [ + {"id": "feat/whole-modules", "members": ["novox/mesh-catalog@795806efa7ad", "novox/mesh-controller@874a91a3c64f"], "created": "2026-10-09T07:00:00Z", "updated": "2026-10-09T07:00:00Z"} + ] +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go index 3a7dbce7..9a3e5aaf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -705,7 +705,7 @@ func (h *Holder) Status(listening string) map[string]any { } out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty), "walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339), - "seat-on-record": h.held} + "seat-on-record": h.held, "holds-the-seat": h.Holding(), "seat-waits": h.SeatWhy()} // The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again, // or given up, and when the last was tried. A count since the start, said with that start: two readings in // one run give a window's tries. -- 2.54.0 From beafa55d97ee4c671262c579bc799a5b01e7e208 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:45:02 +0000 Subject: [PATCH 04/15] Ask the Approve through the gate's asks, hand merges to the bridge one member at a time, and move source.main once a group is merged (issue 1348) --- .../cmd/mesh-delivery-gate/approval.go | 155 ++++--- .../cmd/mesh-delivery-gate/approval_test.go | 58 ++- .../cmd/mesh-delivery-gate/bridge_test.go | 136 ++++++ .../cmd/mesh-delivery-gate/bus_test.go | 308 ++++++++----- .../cmd/mesh-delivery-gate/events.go | 17 +- .../cmd/mesh-delivery-gate/events_test.go | 4 +- .../cmd/mesh-delivery-gate/fakes_test.go | 50 +-- .../cmd/mesh-delivery-gate/group_merge.go | 85 +--- .../cmd/mesh-delivery-gate/holder.go | 5 +- .../cmd/mesh-delivery-gate/issue1079_test.go | 6 +- .../cmd/mesh-delivery-gate/issue1348_test.go | 175 ++++++++ .../cmd/mesh-delivery-gate/main.go | 51 ++- .../cmd/mesh-delivery-gate/manifest_test.go | 5 +- .../cmd/mesh-delivery-gate/merges.go | 406 ++++++++++++++++++ .../cmd/mesh-delivery-gate/operator_test.go | 93 ++-- .../cmd/mesh-delivery-gate/ports.go | 10 - .../cmd/mesh-delivery-gate/ready.go | 99 +---- 17 files changed, 1220 insertions(+), 443 deletions(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index bfae30d9..3fbb0070 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "regexp" "sort" @@ -8,15 +9,18 @@ import ( "strings" "time" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948, ADR 0421). // -// This owner asks the operator on the router (the operator channel, as the knowledge base asks for a record) as soon as -// a pull request's checks pass: Approve or Decline, each option binding the act "merge these heads". It merges only after -// an Approve whose warrant holds for that ask — the same asker, the same ask's digest, an option it offered, in time, and -// the act that option bound. +// This owner writes the ask into its bucket asks (novox/hq ADR 0437 §6) as soon as a pull request's checks pass: +// Approve or Decline, each option binding the act "merge these heads". The teller publishes it to the operator on the +// router and, on a warrant that holds the ask (its asker, the ask's digest, an option it offered, in time, and the act +// that option bound), writes the answer into its bucket answers. This owner reads the answer and acts on it only while +// the heads of the key it was written under are still its group's heads; it asks the router nothing. // // - Asked once per head. A group is asked once, for all its members' heads together, by the first member that reaches // this point; the Approve covers every member at those heads. @@ -43,14 +47,27 @@ const ( // AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval. const AskLasts = asks.ApproveLasts - 10*time.Minute -// Operator is the operator channel as this owner reaches it: asker.Client in the running module. +// Operator is how this owner asks the operator: through the keys of its bucket asks and the teller's answers. kvOperator in +// the running module. type Operator interface { - // Ask checks, keeps and publishes an ask under this module's name. - Ask(a asks.Ask) error - // Cancel takes an ask back. - Cancel(id string) error - // Where is an ask's state as the router keeps it, and its warrant once there is one. - Where(id string) (string, *asks.Warrant, error) + // Ask writes the ask under its key (sdk.MergeAskKey); it writes nothing when that key holds the same ask already. + Ask(key string, a sdk.Ask) error + // Cancel takes the ask at a key away, which the teller reads as the ask withdrawn. + Cancel(key string) error + // Where is the teller's answer to the ask at a key, or nil when there is none. + Where(key string) (*sdk.Answer, error) +} + +// askGroup is the name a delivery's ask and merge are kept under: its group's, or, for a lone pull request, a group of one +// under its branch name (novox/hq ADR 0437 §6). +func askGroup(d *Delivery) string { + switch { + case d.Group != "": + return d.Group + case d.Branch != "": + return d.Branch + } + return d.ID } // headsString is the heads an act names, as an act's value: "=" for each, sorted, joined by commas. @@ -287,6 +304,10 @@ func (h *Holder) endStaleAsks() { h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed")) case !h.headsHold(d, r): h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads")) + case r.Approved == nil && r.AskID != "" && !r.AskedAt.IsZero() && !h.Now().Before(r.AskedAt.Add(AskLasts)): + // The ask lapsed at the router with nobody's answer, and nothing in the gate's inputs says so: it is taken away and + // written again, which the teller asks afresh (ADR 0441 §1). + h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, "nobody answered in "+AskLasts.String())) } } } @@ -301,6 +322,9 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { r.Approved, r.Warrant, r.AskID, r.Ask, r.Heads = c.Approved, c.Warrant, c.AskID, c.Ask, c.Heads return "", nil } + if !c.AskedAt.IsZero() { + h.lookAt(c.AskedAt.Add(AskLasts), "the operator's Approve, unanswered until the ask lapses") + } return "the operator's Approve is asked (" + c.AskID + ")", nil } if r.Refused != "" { @@ -315,12 +339,18 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { group = d.Group } heads := map[string]string{} + var covered []sdk.Head for _, m := range members { heads[m.ID] = m.Commit + covered = append(covered, sdk.Head{Repository: m.Repository, Number: m.Number, Head: m.Commit}) + } + name := askGroup(d) + id, err := sdk.MergeAskKey(name, covered) + if err != nil { + return "the operator cannot be asked: " + err.Error(), nil } r.Asks++ - id := fmt.Sprintf("merge-%d-%s-%d", d.Number, shortOf(d.Commit, 12), r.Asks) - ask := composeMergeAsk(id, d, group, members, heads, now) + ask := composeMergeAsk(sdk.AskID(id), d, group, members, heads, now) taken := []*Delivery{} var reads []memberRead for _, m := range members { @@ -386,7 +416,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { h.keepGroup(g) h.mu.Unlock() } - if err := h.Operator.Ask(ask); err != nil { + if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Ask: ask}); err != nil { h.mu.Lock() defer h.mu.Unlock() undo() @@ -469,25 +499,25 @@ func (h *Holder) pendingAsks() []string { return out } -// hearAsks reads the router's record of every pending ask and applies the warrant of each that closed. +// hearAsks reads the teller's answer to every pending ask and applies each that is there. func (h *Holder) hearAsks() { if h.Operator == nil { return } h.mu.Lock() - ids := h.pendingAsks() + keys := h.pendingAsks() h.mu.Unlock() - for _, id := range ids { - state, warrant, err := h.Operator.Where(id) + for _, key := range keys { + answer, err := h.Operator.Where(key) if err != nil { - h.Logf("[mesh-delivery] the router's record of the ask %s cannot be read: %v", id, err) + h.Logf("[mesh-delivery] the teller's answer to the ask %s cannot be read: %v", key, err) continue } - if state == "" || state == "open" || state == "held" { + if answer == nil { continue } h.mu.Lock() - said := h.decided(id, state, warrant) + said := h.answered(key, *answer) h.mu.Unlock() for _, s := range said { s() @@ -495,57 +525,54 @@ func (h *Holder) hearAsks() { } } -// decided applies one closed ask's warrant to the deliveries it covers; under the lock. It returns what to say on the -// pull requests. -func (h *Holder) decided(id, state string, w *asks.Warrant) []func() { +// answered applies the teller's answer to the ask at a key to the deliveries it covers; under the lock. The teller has +// held the warrant to the ask (novox/hq ADR 0437 §6); this owner acts on the answer only if the key it was written under is +// still the key of the heads asked: an answer to an ask taken away, or to other heads, merges nothing (ADR 0441 §1). It +// returns what to say on the pull requests. +func (h *Holder) answered(key string, ans sdk.Answer) []func() { var covered []*Delivery for _, d := range h.deliveries { - if d.Ready != nil && d.Ready.AskID == id { + if d.Ready != nil && d.Ready.AskID == key { covered = append(covered, d) } } if len(covered) == 0 { + h.Logf("[mesh-delivery] the answer to the ask %s names no ask that is asked now: nothing is merged on it", key) return nil } sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID }) first, now := covered[0].Ready, h.Now() - end := func(why string) { + refuse := func(why string) { + h.Logf("[mesh-delivery] REFUSED the answer to the ask %s: %s", key, why) for _, d := range covered { d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil + d.Ready.Refused = "was answered, and the answer does not hold here: " + clip(oneLine(why), 300) h.keep(d) } - h.Logf("[mesh-delivery] the operator's ask %s ended without a choice (%s); asked again at once", id, why) } - if w == nil || w.Outcome != asks.OutcomeChosen { - why := "the router says " + state - if w != nil { - why = "ended " + string(w.Outcome) - if w.Words != "" { - why += " (" + w.Words + ")" - } + var heads []sdk.Head + for id, sha := range first.Heads { + m := h.deliveries[id] + if m == nil { + refuse("the head " + id + " it covers is no delivery of this owner") + return nil } - end(why) + heads = append(heads, sdk.Head{Repository: m.Repository, Number: m.Number, Head: sha}) + } + if want, err := sdk.MergeAskKey(askGroup(covered[0]), heads); err != nil || want != key { + refuse(fmt.Sprintf("its key %s is not the key %s of the heads asked", key, want)) return nil } - option, err := w.For(Module, *first.Ask) - if err == nil { - err = option.Performs(mergeAct(id, option.ID, first.Heads)) - } - if err != nil { - h.Logf("[mesh-delivery] REFUSED the answer to the ask %s: %v", id, err) - for _, d := range covered { - d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil - d.Ready.Refused = "was answered, and the answer does not hold here: " + clip(oneLine(err.Error()), 300) - h.keep(d) - } + if ans.Option != OptionApprove && ans.Option != OptionDecline { + refuse("it chose " + ans.Option + ", which the ask did not offer") return nil } var said []func() for _, d := range covered { r := d.Ready - r.Warrant = w.Says() + r.Warrant = ans.Words h.mergeAgainNow(d) // the operator chose: the merge is asked at once - switch option.ID { + switch ans.Option { case OptionApprove: r.Approved = &now h.Logf("[mesh-delivery] %s: the operator approved its merge into main (%s)", d.ID, r.Warrant) @@ -559,3 +586,35 @@ func (h *Holder) decided(id, state string, w *asks.Warrant) []func() { } return said } + +// kvOperator is the operator over the runtime: the ask is a key of the gate's bucket asks, the answer a key of the teller's +// bucket answers. +type kvOperator struct{} + +func (kvOperator) Ask(key string, a sdk.Ask) error { + raw, err := sdk.EncodeAsk(a) + if err != nil { + return err + } + if e, err := stdio.State("asks").Get(key); err == nil && e != nil { + if kept, err := sdk.DecodeAsk(e.Value); err == nil && sdk.SameAsk(kept, a) { + return nil // the same ask is there: the teller has it + } + } + _, err = stdio.State("asks").Put(key, json.RawMessage(raw)) + return err +} + +func (kvOperator) Cancel(key string) error { return stdio.State("asks").Delete(key) } + +func (kvOperator) Where(key string) (*sdk.Answer, error) { + e, err := stdio.State(sdk.TellerModule + ".answers").Get(key) + if err != nil || e == nil { + return nil, err + } + a, err := sdk.DecodeAnswer(e.Value) + if err != nil { + return nil, fmt.Errorf("the answer %s is not readable: %w", key, err) + } + return &a, nil +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go index d440d1af..ea7dd7bd 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go @@ -6,6 +6,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // **A merge into main waits for the operator's Approve** (novox/hq ADR 0398 §3, issue 948): a marked, green delivery @@ -84,19 +85,39 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing } } -func TestAnAnswerThatIsNotForThisAskMergesNothing(t *testing.T) { +// AC3 of issue 1348: an answer naming old heads merges nothing. The teller's answer to an ask that was withdrawn when the head +// moved is still in its bucket; the gate acts on the key of the heads asked now alone. +func TestAnAnswerNamingOldHeadsMergesNothing(t *testing.T) { w := markedGreen(t) - id := w.op.open()[0] - w.op.answer(id, OptionApprove) - w.op.mu.Lock() - w.op.warrant[id].AskDigest = "sha256:not-the-ask-that-was-shown" - w.op.mu.Unlock() + old := w.op.open()[0] + w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go")) + w.settleAll() + w.op.answer(old, OptionApprove) // the teller wrote it after the ask was taken away + w.settleAll() + if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 { + t.Fatalf("an answer to old heads merged: %v, handed %d", w.forge.merges, len(w.merges.puts)) + } + if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready != nil && d.Ready.Approved != nil { + t.Fatalf("an answer to old heads approved the old delivery: %+v", d.Ready) + } +} + +// An answer under a key that is not the key of the heads the ask covers is refused and said: the gate acts on its own +// ask's key alone (the heads hash is the key's last part). +func TestAnAnswerUnderAKeyThatIsNotTheHeadsKeyIsRefused(t *testing.T) { + w := markedGreen(t) + id := IDOf("novox/app", readyHead) + key := w.op.open()[0] + w.h.mu.Lock() + w.h.deliveries[id].Ready.Heads[id] = strings.Repeat("9", 40) // the heads no longer give the key + w.h.mu.Unlock() + w.op.answer(key, OptionApprove) w.settleAll() if len(w.forge.merges) != 0 { - t.Fatalf("a warrant for another ask merged: %v", w.forge.merges) + t.Fatalf("an answer under another key merged: %v", w.forge.merges) } - d := w.delivery(IDOf("novox/app", readyHead)) - if !strings.Contains(d.Ready.Refused, "digest") || d.Ready.Approved != nil { + d := w.delivery(id) + if !strings.Contains(d.Ready.Refused, "does not hold") || d.Ready.Approved != nil { t.Fatalf("the refused answer is not kept: %+v", d.Ready) } if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "answer") { @@ -142,13 +163,15 @@ func TestAPushAfterTheAskCancelsItAndTheNewHeadIsAskedOnItsOwnWhenItsChecksPass( } } -func TestAnExpiredAskIsAskedAgainOnce(t *testing.T) { +// The ask lapses at the router after AskLasts with nobody's answer, and the gate has no word of it: it takes its key away and +// writes it again, once, which the teller asks afresh (it asks again under the same id when the gate writes its key again). +func TestAnUnansweredAskIsWrittenAgainOnceWhenItLapses(t *testing.T) { w := markedGreen(t) first := w.op.open()[0] - w.op.end(first) + w.later(AskLasts + time.Minute) w.settleAll() - if w.op.count() != 2 || len(w.op.open()) != 1 || w.op.open()[0] == first { - t.Fatalf("an expired ask was not asked again once: %d asks, open %v", w.op.count(), w.op.open()) + if w.op.count() != 2 || len(w.op.open()) != 1 || w.op.open()[0] != first || len(w.op.cancelled) != 1 || w.op.cancelled[0] != first { + t.Fatalf("a lapsed ask was not written again once: %d asks, open %v, taken away %v", w.op.count(), w.op.open(), w.op.cancelled) } for range 3 { w.later(2 * time.Minute) @@ -176,12 +199,13 @@ func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOnePassOnOneApprove(t *testin if open := w.op.open(); len(open) != 1 || len(w.forge.merges) != 0 { t.Fatalf("a group is asked once before any merge: open %v, merges %v", w.op.open(), w.forge.merges) } - a := w.op.ask(w.op.open()[0]) + key := w.op.open()[0] + a := w.op.ask(key) if !strings.Contains(a.Headline, "group feat") || !strings.Contains(a.Details, "mesh-controller#1") || !strings.Contains(a.Details, "mesh-catalog#2") { t.Fatalf("the ask does not name the group and its members: %+v", a) } - w.op.answer(a.ID, OptionApprove) + w.op.answer(key, OptionApprove) w.settleAll() if len(w.forge.merges) != 2 || w.op.count() != 1 { t.Fatalf("both members were not merged in one pass on the one Approve: merges %v, asks %d", w.forge.merges, w.op.count()) @@ -263,7 +287,7 @@ func TestIssue1025AMergeOnTheApproveIsAnnouncedWithItsWarrant(t *testing.T) { } m := got[0] if m["owner"] != "novox" || m["repo"] != "app" || m["number"] != float64(7) || m["head_sha"] != readyHead || - m["ask_id"] != open[0] || m["option"] != OptionApprove { + m["ask_id"] != sdk.AskID(open[0]) || m["asker"] != sdk.TellerModule || m["option"] != OptionApprove { t.Fatalf("the event does not name the pull request and its warrant: %v", m) } hs, _ := m["heads"].(map[string]any) @@ -324,7 +348,7 @@ func TestIssue1025AMergeInAGroupCarriesTheGroupAndItsHeads(t *testing.T) { } for _, m := range got { hs, _ := m["heads"].(map[string]any) - if m["group"] != "feat/x" || m["ask_id"] != open[0] || m["option"] != OptionApprove || + if m["group"] != "feat/x" || m["ask_id"] != sdk.AskID(open[0]) || m["asker"] != sdk.TellerModule || m["option"] != OptionApprove || hs[ctl] != ctlHead || hs[cat] != catHead || len(hs) != 2 { t.Fatalf("the group's merge event: %v", m) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go new file mode 100644 index 00000000..fb16da09 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "sort" + "strconv" + "strings" + "sync" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// fakeMerges is the gate's bucket merges with the forge bridge behind it. Every pull request the gate writes in the state +// merging is merged on the fake forge, as the bridge's merge does (mesh-delivery modules/mesh-delivery-forge-bridge +// internal/bridge acts.go MergeActs): the forge's merge tool is asked at the head with the approved heads of the whole +// group, `@` sorted, and what the fake forge answers is written on FORGE as the bridge writes it, a +// merged message or a refused one with the forge's words. With manual set the bridge says nothing. +type fakeMerges struct { + mu sync.Mutex + forge *fakeForge + h *Holder + values map[string]sdk.Merge + puts []sdk.Merge + manual bool + failed error + // said are the messages the bridge wrote on FORGE, in order. + said []sdk.Forge +} + +func newFakeMerges(f *fakeForge) *fakeMerges { + return &fakeMerges{forge: f, values: map[string]sdk.Merge{}} +} + +func (m *fakeMerges) Get(group string) (*sdk.Merge, error) { + m.mu.Lock() + defer m.mu.Unlock() + if v, ok := m.values[group]; ok { + return &v, nil + } + return nil, nil +} + +func (m *fakeMerges) Put(group string, v sdk.Merge) error { + if _, err := sdk.EncodeMerge(v); err != nil { + return err + } + m.mu.Lock() + if m.failed != nil { + defer m.mu.Unlock() + return m.failed + } + m.values[group] = v + m.puts = append(m.puts, v) + manual := m.manual + m.mu.Unlock() + if manual { + return nil + } + for _, p := range v.Pulls { + if p.State != sdk.MergeMerging { + continue + } + owner, repo, _ := strings.Cut(p.Repository, "/") + var approved []string + for key, sha := range v.Approved { + repository, _, _ := strings.Cut(key, "@") + approved = append(approved, repository+"@"+sha) + } + sortStrings(approved) + f := m.forge + f.mu.Lock() + f.merges = append(f.merges, owner+"/"+repo+"#"+itoa(p.Number)+"@"+p.Head.Head) + f.approvedHeads = append(f.approvedHeads, approved) + err := f.refuseRepo[owner+"/"+repo] + if err == nil { + err = f.refuse + } + f.mu.Unlock() + msg := sdk.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head} + if err != nil { + msg.Kind, msg.Act, msg.Words = sdk.ForgeRefused, "merge", err.Error() + } else { + msg.Kind, msg.Merge, msg.Root = sdk.ForgeMerged, strings.Repeat("c", 40), "root" + msg.Trees = map[string]string{"app": "tree"} + } + m.say(msg) + } + return nil +} + +// say writes a message on FORGE and has the gate hear it, as the runtime does. +func (m *fakeMerges) say(msg sdk.Forge) { + body, err := sdk.EncodeForge(msg) + if err != nil { + panic(err) + } + event, err := sdk.ForgeEvent(msg.Repository, msg.Kind) + if err != nil { + panic(err) + } + m.mu.Lock() + m.said = append(m.said, msg) + m.mu.Unlock() + m.h.Forged("mesh-delivery-forge-bridge."+event, body) +} + +// last is the newest value written for a group. +func (m *fakeMerges) last(group string) sdk.Merge { + m.mu.Lock() + defer m.mu.Unlock() + return m.values[group] +} + +// fakeSource is the gate's bucket source with its one key. +type fakeSource struct { + mu sync.Mutex + main *sdk.Source + moves []sdk.Source +} + +func (s *fakeSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) error { + s.mu.Lock() + defer s.mu.Unlock() + next, write := update(s.main) + if !write { + return nil + } + if _, err := sdk.EncodeSource(next); err != nil { + return err + } + s.main = &next + s.moves = append(s.moves, next) + return nil +} + +func sortStrings(s []string) { sort.Strings(s) } +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index 295641f1..e53ac606 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -19,6 +19,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -77,7 +78,7 @@ func newTestBus(t *testing.T) *testBus { // The controller's job: a bucket per state the manifest declares. m, _ := readManifest(t) for _, name := range m.buckets() { - kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: m.Module + "_" + name, History: 1}) + kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: m.Module + "_" + name, History: 10}) if err != nil { t.Fatal(err) } @@ -86,7 +87,7 @@ func newTestBus(t *testing.T) *testBus { // The buckets of other modules the manifest reads (`.` is the bucket `_`): the gate reads // who has the seat there, and today's state to carry (seat.go). for _, name := range m.Reads { - kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: strings.Replace(name, ".", "_", 1), History: 1}) + kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: strings.Replace(name, ".", "_", 1), History: 10}) if err != nil { t.Fatal(err) } @@ -94,7 +95,8 @@ func newTestBus(t *testing.T) *testBus { } // The streams the events are on (mesh.mod..event., mesh.seat..event.). if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS", - Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>"}}); err != nil { + Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>", + "mesh.mod." + sdk.ForgeBridgeModule + ".event.>"}}); err != nil { t.Fatal(err) } return b @@ -304,7 +306,16 @@ func (r *runtime) answer(m rpcMessage) { case "mesh/seat.record": return nil, nil case "mesh/state.watch": - return map[string]any{}, nil // the runtime's watch: the module also reads the buckets by key, so no change is sent here + // The runtime's watch (ADR 0201): each current value, then each change, handed to the bundle as mesh/state under the + // id the watch was asked with. + var id string + _ = json.Unmarshal(m.ID, &id) + kv := r.b.kvs[p.State] + if kv == nil { + return nil, fmt.Errorf("the state %q is not declared", p.State) + } + go r.watch(kv, id, p.State) + return map[string]any{}, nil case "mesh/ask": var a struct { Key string `json:"key"` @@ -390,6 +401,42 @@ func (r *runtime) answer(m rpcMessage) { r.say(map[string]any{"id": m.ID, "result": result}) } +// watch hands the bundle each key of a bucket and each change of it, as the runtime does for a state watch. +func (r *runtime) watch(kv jetstream.KeyValue, id, state string) { + ctx, cancel := context.WithCancel(context.Background()) + r.t.Cleanup(cancel) + w, err := kv.Watch(ctx, ">") + if err != nil { + return + } + defer w.Stop() + current := true + for { + select { + case <-ctx.Done(): + return + case e, ok := <-w.Updates(): + if !ok { + return + } + if e == nil { + current = false + continue + } + op := "put" + if e.Operation() != jetstream.KeyValuePut { + op = "delete" + } + r.mu.Lock() + n := r.next + r.next++ + r.mu.Unlock() + r.say(map[string]any{"id": fmt.Sprintf("watch-%d", n), "method": "mesh/state", "params": map[string]any{"watch": id, + "change": stdio.StateChange{State: state, Key: e.Key(), Op: op, Value: e.Value(), Revision: e.Revision(), Current: current}}}) + } + } +} + // deliverEvents is the runtime reading the events streams and handing each event to the bundle, once, as // `mesh/event`, the way the tool runner does for a module's consumer. func (r *runtime) deliverEvents(ctx context.Context) { @@ -440,15 +487,16 @@ func (b *testBus) publish(t *testing.T, subject string, body any) time.Time { func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealBus(t *testing.T) { b := newTestBus(t) + ctx := context.Background() rt := b.start(t) - ctx, cancel := context.WithCancel(context.Background()) + evctx, cancel := context.WithCancel(ctx) defer cancel() - rt.deliverEvents(ctx) - // The module has started when it hears what it follows, and the operator's answers. + rt.deliverEvents(evctx) + // The module has started when it hears what it follows. rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool { rt.mu.Lock() defer rt.mu.Unlock() - return len(rt.subs) >= len(followed)+1 + return len(rt.subs) >= len(followed) }) rt.untilHolding() for _, k := range followed { @@ -466,67 +514,59 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB b.publish(t, "mesh.mod.gitea.event.pull.updated", announced) passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1")) - // The operator is asked, within seconds of the verdict's event, with no label, comment or mark written by anyone. - rt.until("the operator to be asked to Approve the merge", 5*time.Second, func() bool { - rt.mu.Lock() - defer rt.mu.Unlock() - return len(rt.operatorAsks) == 1 - }) - rt.mu.Lock() - ask := rt.operatorAsks[0] - asked := time.Now() - rt.mu.Unlock() - if !strings.Contains(ask.Explanation, "not reviewed at this head") { - t.Fatalf("the first ask does not say the record is missing: %s", ask.Explanation) + // The ask is written into the gate's bucket asks within seconds of the verdict's event, with no label, comment or mark + // written by anyone, and nothing is asked of a router. + var key string + rt.until("the ask to be written", 5*time.Second, func() bool { key = onlyAsk(b); return key != "" }) + ask := askAt(t, b, key) + t.Logf("the ask was written %s after the verdict's event", time.Since(passed)) + if !strings.Contains(ask.Ask.Explanation, "not reviewed at this head") || ask.Ask.ID != sdk.AskID(key) { + t.Fatalf("the first ask does not say the record is missing: %s", ask.Ask.Explanation) } - t.Logf("the operator was asked %s after the verdict's event", asked.Sub(passed)) // The description is edited to add the review record (issue 1079): the forge module's edit event carries it. The unanswered - // ask is withdrawn from the router and asked again, once, with the record read; nothing else is asked of the mesh. - first := ask + // ask is taken away and written again, once, with the record read (the heads are the same, so is the key). b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7, "title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head}) - rt.until("the first ask to be withdrawn and the operator asked again", 5*time.Second, func() bool { - rt.mu.Lock() - defer rt.mu.Unlock() - return len(rt.operatorCancels) >= 1 && len(rt.operatorAsks) >= 2 + rt.until("the ask to be written again with the record", 5*time.Second, func() bool { + return strings.Contains(askAt(t, b, key).Ask.Explanation, "reviewed by lead on shanks") }) - rt.mu.Lock() - if len(rt.operatorCancels) != 1 || rt.operatorCancels[0] != first.ID || len(rt.operatorAsks) != 2 { - t.Fatalf("withdrawn %v, asked %d; wanted the first ask %s withdrawn once and exactly one new ask", rt.operatorCancels, len(rt.operatorAsks), first.ID) - } - ask = rt.operatorAsks[1] - rt.mu.Unlock() - if ask.ID == first.ID || !strings.Contains(ask.Explanation, "reviewed by lead on shanks") || strings.Contains(ask.Explanation, "not reviewed") { - t.Fatalf("the new ask is not a fresh one naming the reviewer: %s %s", ask.ID, ask.Explanation) + if h, err := b.kvs["asks"].History(ctx, key); err != nil || len(h) != 3 || h[1].Operation() != jetstream.KeyValueDelete { + t.Fatalf("the ask's history is %d revisions (%v); wanted written, taken away, written", len(h), err) } if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 { t.Fatalf("merged before the Approve") } // The pull request's key holds its head, the trunk commit it was asked against and the verdict. rt.until("the pull request's key to hold head, trunk and verdict", 5*time.Second, func() bool { - e, err := b.kvs["pulls"].Get(context.Background(), "novox.app.7") + e, err := b.kvs["pulls"].Get(ctx, "novox.app.7") var k PullKey return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"} }) - // The operator approves (a person's Approve, proven by the router): the merge is asked within seconds. - opt, _ := ask.Option(OptionApprove) - approved := b.publish(t, "mesh.seat."+asks.Seat+".event.decided."+Module, asks.Warrant{Ask: ask.ID, Asker: Module, - Outcome: asks.OutcomeChosen, Option: OptionApprove, Label: opt.Label, Level: opt.Level, - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"}, At: time.Now(), AskDigest: ask.Digest()}) - rt.until("the merge to be asked of the forge", 5*time.Second, func() bool { return len(rt.asksOf("gitea.gitea_merge_pull_request")) >= 1 }) - merges := rt.asksOf("gitea.gitea_merge_pull_request") - if len(merges) != 1 || merges[0].Body["head"] != head || merges[0].Body["number"] != float64(7) { - t.Fatalf("the merge was asked %+v; wanted once, for pull request 7 at the approved head", merges) + // The operator approves; the teller holds the warrant and writes the answer: the merge is handed to the bridge within + // seconds, with the Approve's heads, and the gate asks the forge nothing. + approved := b.answer(t, key, OptionApprove) + var handed sdk.Merge + rt.until("the merge to be handed to the bridge", 5*time.Second, func() bool { handed = mergeAt(b, "fix/x"); return len(handed.Pulls) > 0 }) + if len(handed.Pulls) != 1 || handed.Pulls[0].Head.Head != head || handed.Pulls[0].Number != 7 || handed.Pulls[0].State != sdk.MergeMerging || + handed.Approved[sdk.ApprovedKey("novox/app", 7)] != head || handed.Ask != key { + t.Fatalf("the merge handed is %+v; wanted pull request 7 at the approved head, merging, under the ask %s", handed, key) } - if got := fmt.Sprint(merges[0].Body["approved_heads"]); got != "[novox/app@"+head+"]" { - t.Fatalf("the merge tool was handed the approved heads %s", got) - } - if took := merges[0].At.Sub(approved); took > 5*time.Second { - t.Fatalf("the merge was asked %s after the Approve's event; wanted within 5s", took) - } else { - t.Logf("the merge was asked %s after the Approve's event", took) + t.Logf("the merge was handed %s after the answer", time.Since(approved)) + if n := len(rt.asksOf("gitea.gitea_merge_pull_request")); n != 0 { + t.Fatalf("the gate asked the forge to merge %d times: the bridge merges", n) } + // The bridge says it merged; the mesh's source then holds the merge, in one write. + b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40), + Root: "r", Trees: map[string]string{"app": "t"}}) + rt.until("source.main to hold the merge", 5*time.Second, func() bool { + e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey) + if err != nil { + return false + } + src, err := sdk.DecodeSource(e.Value()) + return err == nil && src.Group == "fix/x" && src.Heads["novox/app"] == strings.Repeat("b", 40) + }) // Quiet follows: nothing asks the forge or the controller again with no event to say so. quiet := len(rt.asked) time.Sleep(1500 * time.Millisecond) @@ -538,20 +578,81 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB } } -// **The acceptance of one group, one Approve** (novox/hq ADR 0418 §4, ADR 0421, design 47 milestone 3, issues 978 and -// 1022): a group of two pull requests on one branch name, in two repositories, with green checks, gets one ask of the -// operator — no label, comment or mark written by anyone — and on the Approve both merge in one pass, each handed the -// approved heads of both. -func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOverARealBus(t *testing.T) { +// onlyAsk is the key of the one ask in the gate's bucket asks, or empty. +func onlyAsk(b *testBus) string { + keys, err := b.kvs["asks"].Keys(context.Background()) + if err != nil || len(keys) != 1 { + return "" + } + return keys[0] +} + +func askAt(t *testing.T, b *testBus, key string) sdk.Ask { + t.Helper() + e, err := b.kvs["asks"].Get(context.Background(), key) + if err != nil { + t.Fatalf("the ask %s: %v", key, err) + } + a, err := sdk.DecodeAsk(e.Value()) + if err != nil { + t.Fatal(err) + } + return a +} + +func mergeAt(b *testBus, group string) sdk.Merge { + e, err := b.kvs["merges"].Get(context.Background(), group) + if err != nil { + return sdk.Merge{} + } + m, _ := sdk.DecodeMerge(e.Value()) + return m +} + +// answer is the teller's answer to an ask, written under the ask's own key in its bucket answers. +func (b *testBus) answer(t *testing.T, key, option string) time.Time { + t.Helper() + raw, err := sdk.EncodeAnswer(sdk.Answer{Option: option, Warrant: "w-" + key, Words: "the operator, via telegram, chose " + option}) + if err != nil { + t.Fatal(err) + } + if _, err := b.kvs["mesh-delivery-teller.answers"].Put(context.Background(), key, raw); err != nil { + t.Fatal(err) + } + return time.Now() +} + +// forgeSays is the forge bridge's message on FORGE, as its stream carries it. +func (b *testBus) forgeSays(t *testing.T, f sdk.Forge) { + t.Helper() + raw, err := sdk.EncodeForge(f) + if err != nil { + t.Fatal(err) + } + event, err := sdk.ForgeEvent(f.Repository, f.Kind) + if err != nil { + t.Fatal(err) + } + if _, err := b.js.Publish(context.Background(), "mesh.mod."+sdk.ForgeBridgeModule+".event."+event, raw); err != nil { + t.Fatal(err) + } +} + +// **The acceptance of one group, one Approve** (novox/hq ADR 0418 §4, ADR 0421, ADR 0441 §5, design 47 milestone 3, issues 978 +// and 1022): a group of two pull requests on one branch name, in two repositories, with green checks, gets one ask of the +// operator — no label, comment or mark written by anyone — and on the Approve the members are handed to the bridge one at a time, +// the second after the first merged, each with the approved heads of both. +func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterTheOtherOverARealBus(t *testing.T) { b := newTestBus(t) + ctx := context.Background() rt := b.start(t) - ctx, cancel := context.WithCancel(context.Background()) + evctx, cancel := context.WithCancel(ctx) defer cancel() - rt.deliverEvents(ctx) + rt.deliverEvents(evctx) rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool { rt.mu.Lock() defer rt.mu.Unlock() - return len(rt.subs) >= len(followed)+1 + return len(rt.subs) >= len(followed) }) rt.untilHolding() const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2" @@ -577,61 +678,62 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOv if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 { t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n) } - rt.mu.Lock() - nothing := len(rt.operatorAsks) - rt.mu.Unlock() - if nothing != 0 { - t.Fatalf("the operator was asked before the group's composed check passed") + if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 0 { + t.Fatalf("the operator was asked before the group's composed check passed: %v", keys) } // The composed check passes at both heads: the operator is asked once, for the group. b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", map[string]any{"group": "feat/x", "id": "check-1", "verdict": "pass", "summary": "both compose", "members": []map[string]any{ {"owner": "novox", "repo": "mesh-controller", "commit": ctlHead, "trunk": "t1t1t1t1t1t1"}, {"owner": "novox", "repo": "mesh-catalog", "commit": catHead, "trunk": "t1t1t1t1t1t1"}}}) - rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { - rt.mu.Lock() - defer rt.mu.Unlock() - return len(rt.operatorAsks) == 1 - }) + var key string + rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" }) time.Sleep(500 * time.Millisecond) - rt.mu.Lock() - asked, ask := len(rt.operatorAsks), rt.operatorAsks[0] - rt.mu.Unlock() - if asked != 1 { - t.Fatalf("the operator was asked %d times for one group", asked) + if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 { + t.Fatalf("the operator was asked %d times for one group", len(keys)) } - if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 { + if g := askAt(t, b, key).Group; g != "feat/x" { + t.Fatalf("the ask is for the group %q", g) + } + if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 || mergeAt(b, "feat/x").Group != "" { t.Fatalf("merged before the Approve") } - opt, _ := ask.Option(OptionApprove) - approved := b.publish(t, "mesh.seat."+asks.Seat+".event.decided."+Module, asks.Warrant{Ask: ask.ID, Asker: Module, - Outcome: asks.OutcomeChosen, Option: OptionApprove, Label: opt.Label, Level: opt.Level, - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"}, At: time.Now(), AskDigest: ask.Digest()}) + approved := b.answer(t, key, OptionApprove) - // Both merge on that one Approve, in one pass: asked within seconds of the Approve's event, each with both heads. - rt.until("both merges to be asked of the forge", 5*time.Second, func() bool { return len(rt.asksOf("gitea.gitea_merge_pull_request")) >= 2 }) - merges := rt.asksOf("gitea.gitea_merge_pull_request") - if len(merges) != 2 { - t.Fatalf("the merge was asked %d times; wanted once for each member: %+v", len(merges), merges) + // One member is handed, with both heads approved, within seconds of the answer; the second only after the bridge says the + // first merged. + var first sdk.Merge + rt.until("the first member to be handed", 5*time.Second, func() bool { first = mergeAt(b, "feat/x"); return len(first.Pulls) > 0 }) + if len(first.Pulls) != 1 || first.Pulls[0].State != sdk.MergeMerging || len(first.Approved) != 2 || + first.Approved[sdk.ApprovedKey("novox/mesh-controller", 1)] != ctlHead || first.Approved[sdk.ApprovedKey("novox/mesh-catalog", 2)] != catHead { + t.Fatalf("the first merge handed is %+v", first) } - heads := map[string]bool{} - for _, m := range merges { - heads[fmt.Sprint(m.Body["repo"], "@", m.Body["head"])] = true - if got := fmt.Sprint(m.Body["approved_heads"]); got != "[novox/mesh-catalog@"+catHead+" novox/mesh-controller@"+ctlHead+"]" { - t.Fatalf("a merge was handed the approved heads %s", got) + t.Logf("the first member was handed %s after the answer", time.Since(approved)) + time.Sleep(500 * time.Millisecond) + if again := mergeAt(b, "feat/x"); len(again.Pulls) != 1 { + t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls) + } + one := first.Pulls[0] + b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head, + Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + var both sdk.Merge + rt.until("the second member to be handed", 5*time.Second, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 }) + if both.Pulls[0].State != sdk.MergeMerged || both.Pulls[1].State != sdk.MergeMerging || both.Pulls[1].Repository == one.Repository { + t.Fatalf("the second merge handed is %+v", both.Pulls) + } + two := both.Pulls[1] + b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head, + Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + rt.until("source.main to hold both merges", 5*time.Second, func() bool { + e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey) + if err != nil { + return false } - if took := m.At.Sub(approved); took > 5*time.Second { - t.Fatalf("a merge was asked %s after the Approve's event; wanted within 5s", took) - } - } - if !heads["mesh-controller@"+ctlHead] || !heads["mesh-catalog@"+catHead] { - t.Fatalf("the merges were asked for %v", heads) - } - rt.mu.Lock() - again := len(rt.operatorAsks) - rt.mu.Unlock() - if again != 1 { - t.Fatalf("a second ask was made: %d", again) + src, err := sdk.DecodeSource(e.Value()) + return err == nil && src.Group == "feat/x" && len(src.Heads) == 2 + }) + if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 { + t.Fatalf("a second ask was made: %v", keys) } } @@ -649,7 +751,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool { rt.mu.Lock() defer rt.mu.Unlock() - return len(rt.subs) >= len(followed)+1 + return len(rt.subs) >= len(followed) }) rt.untilHolding() const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444" diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go index 7713e463..8eb4bab5 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -4,8 +4,6 @@ import ( "fmt" "slices" "strings" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // Events that start a step (novox/hq ADR 0406 §1, issue 975, ADR 0421 issue 1022). @@ -39,17 +37,10 @@ func (h *Holder) takeForgeWanted() bool { return wanted } -// AskDecided takes the router's `decided` event for an ask this owner made: its warrant is applied to the deliveries -// the ask covers, and what is said on their pull requests is said. -func (h *Holder) AskDecided(id, state string, w *asks.Warrant) { - if !h.Holding() { - return - } - h.mu.Lock() - said := h.decided(id, state, w) - h.mu.Unlock() - for _, s := range said { - s() +// AnswersChanged takes a change of the teller's answers: the answers to the asks pending are read at the look it asks for. +func (h *Holder) AnswersChanged() { + if h.Holding() { + h.Poke() } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go index 84324b11..fcea942e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go @@ -66,9 +66,7 @@ func TestAFailedVerdictAsksNothing(t *testing.T) { func approve(w *world) { open := w.op.open() w.op.answer(open[0], OptionApprove) - state, warrant, _ := w.op.Where(open[0]) - w.h.AskDecided(open[0], state, warrant) - w.h.React() + w.settleAll() } func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go index 4b85bc65..3360b384 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go @@ -591,12 +591,16 @@ func (f *fakeForge) notesOn(commit string) []string { // world is a holder over fakes, with a clock a test moves. type world struct { - t *testing.T - h *Holder - store *memStore - ctl *fakeController - forge *fakeForge - op *fakeOperator + t *testing.T + h *Holder + store *memStore + ctl *fakeController + forge *fakeForge + op *fakeOperator + merges *fakeMerges + source *fakeSource + // asked is the key of the ask a test answered. + asked string now time.Time said []map[string]any mu sync.Mutex @@ -624,6 +628,9 @@ func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fak w.op = newFakeOperator(w.h.Now) w.op.auto = true w.h.Operator = w.op + w.merges, w.source = newFakeMerges(forge), &fakeSource{} + w.h.Merges, w.h.Source = w.merges, w.source + w.merges.h = w.h w.h.init() if err := w.h.Load(); err != nil { t.Fatal(err) @@ -637,28 +644,14 @@ func (w *world) later(d time.Duration) { w.mu.Unlock() } -// settleAll lets the router's answers reach the holder as its `decided` events, and takes a look, a few times. +// settleAll takes a look a few times: the answers the teller wrote are read at each, and each member of a group handed to the +// forge bridge is merged by the fake bridge, whose word is heard by the next look. func (w *world) settleAll() { for range 4 { - w.decide() w.h.React() } } -// decide hands the holder the router's `decided` event for every ask of its that has closed. -func (w *world) decide() { - w.h.mu.Lock() - ids := w.h.pendingAsks() - w.h.mu.Unlock() - for _, id := range ids { - state, warrant, _ := w.op.Where(id) - if state == "" || state == "open" || state == "held" { - continue - } - w.h.AskDecided(id, state, warrant) - } -} - func (w *world) delivery(id string) *Delivery { w.t.Helper() w.h.mu.Lock() @@ -715,18 +708,7 @@ func aWalk(id, repo, commit string, waits bool) Walk { return w } -// Merge and Comment record what was asked (novox/hq issue 722); Merge answers refuse when set. -func (f *fakeForge) Merge(owner, repo string, number int, head string, approved []string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head)) - f.approvedHeads = append(f.approvedHeads, approved) - if err := f.refuseRepo[owner+"/"+repo]; err != nil { - return err - } - return f.refuse -} - +// Comment records what was asked (novox/hq issue 722). func (f *fakeForge) Comment(owner, repo string, number int, body string) error { f.mu.Lock() defer f.mu.Unlock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go index 049d8179..c676babe 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go @@ -65,66 +65,33 @@ func (h *Holder) unmergedLive(g *Group) []*Delivery { return out } -// groupPass is the one pass that merges a group's members, made by the first of them in the group's order that may be -// merged; nil when it is not d's turn, or a member is not approved at its head. Under the lock; the pass runs outside it. -func (h *Holder) groupPass(g *Group, d *Delivery, now time.Time) func() { - order := h.unmergedLive(g) - if len(order) == 0 || order[0] != d { - return nil - } - var pass []passMember - for _, m := range order { - c := h.covering(m) - if m.Ready == nil || m.Ready.Declined != nil || c == nil || c.Approved == nil || c.Heads[m.ID] != m.Commit || h.readyWaits(m) != "" { - return nil // the others are not approved at their heads yet: nothing is merged before they are - } - pass = append(pass, passMember{read: readOf(m, c.Heads[m.ID]), heads: c.Heads}) - } - for _, m := range order { - m.Ready.Tries++ - m.Ready.LastTry, m.Ready.merging = now, true - h.keep(m) - } - gid := g.ID - return func() { h.mergeGroupPass(gid, pass) } -} - -// passMember is one member of a pass as it was taken under the lock. -type passMember struct { - read memberRead - heads map[string]string -} - -// mergeGroupPass reads every member's required statuses, and only when all are a success merges the members in order, -// stopping at the first the merge tool refuses. -func (h *Holder) mergeGroupPass(gid string, pass []passMember) { - release := func(from int, retry bool) { - for _, p := range pass[from:] { - if d := h.deliveries[p.read.id]; d != nil && d.Ready != nil { - d.Ready.merging = false - if retry { - d.Ready.LastTry = time.Time{} - } - } +// groupMergeable reads every member's required statuses at the head the Approve covers, and says whether all are a success. +// When one is not, or cannot be read, nothing is handed to the bridge: the group is held, said urgent (ADR 0441 §2, §4), and +// the target's turn is given back so the next event that decides whether it may merge asks it again. Outside the lock. +func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool { + release := func() { + if d := h.deliveries[target]; d != nil && d.Ready != nil { + d.Ready.merging = false + d.Ready.LastTry = time.Time{} } } - for _, p := range pass { - says, ownOnly, err := h.notASuccess(p.read) + for _, m := range reads { + says, ownOnly, err := h.notASuccess(m) if err == nil && says == "" { continue } h.mu.Lock() if err != nil { - h.Logf("[mesh-delivery] group %s: the required statuses of %s cannot be read, so nothing is merged yet: %v", gid, p.read.id, err) - release(0, true) + h.Logf("[mesh-delivery] group %s: the required statuses of %s cannot be read, so nothing is merged yet: %v", gid, m.id, err) + release() h.again("a group's merge whose required statuses could not be read") h.mu.Unlock() - return + return false } var own func() if g := h.groups[gid]; g != nil { - if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != p.read.id { - h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, p.read.id, shortOf(p.read.head, 12), says) + if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != m.id { + h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, m.id, shortOf(m.head, 12), says) } since := time.Time{} if g.Hold != nil && g.Hold.Kind == "blocked" { @@ -133,19 +100,19 @@ func (h *Holder) mergeGroupPass(gid string, pass []passMember) { if since.IsZero() { since = h.Now() } - g.Hold = &GroupHold{Kind: "blocked", Member: p.read.id, Says: says, Since: since} + g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since} h.keepGroup(g) } - if d := h.deliveries[p.read.id]; ownOnly && d != nil && d.Ready != nil { + if d := h.deliveries[m.id]; ownOnly && d != nil && d.Ready != nil { own = h.askOwnAfterRefusal(d) // its own repository check is what is missing: asked, once for the head } - release(0, true) + release() h.again("a group's merge held for a required status") h.mu.Unlock() if own != nil { own() } - return + return false } h.mu.Lock() if g := h.groups[gid]; g != nil && g.Hold != nil { @@ -153,19 +120,7 @@ func (h *Holder) mergeGroupPass(gid string, pass []passMember) { h.keepGroup(g) } h.mu.Unlock() - for i, p := range pass { - if h.mergeOne(p.read.id, p.heads) { - continue - } - h.mu.Lock() - release(i+1, false) - if g := h.groups[gid]; g != nil { - h.Logf("[mesh-delivery] group %s: the merge of %s was refused, so none of the members after it is merged", gid, p.read.id) - h.keepGroup(g) - } - h.mu.Unlock() - return - } + return true } // groupLines are the groups that are held or half merged, as urgent stalled lines the controller raises as conditions: diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index ff530ac8..d96772ce 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -28,8 +28,11 @@ type Holder struct { Emit func(event string, body any) error Now func() time.Time Logf func(format string, a ...any) - // Operator is the operator channel, asked for the Approve of a merge into main (approval.go). + // Operator keeps the ask of the Approve of a merge into main in the gate's asks and reads the teller's answers + // (approval.go); Merges and Source are the gate's merges and source (merges.go). Operator Operator + Merges MergeStore + Source SourceStore // Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none. Pulls PullKeys // Seat says where today's module still stands; the gate holds the seat only when it stands nowhere (seat.go). Carry diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1079_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1079_test.go index c5cb6cec..25b8fa78 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1079_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1079_test.go @@ -39,8 +39,10 @@ func TestAReviewedByLineWrittenAfterTheAskWithdrawsTheUnansweredAskAndAsksAgain( w.h.PullEdited(edited("Reviewed-by: reviewer@shanks at " + readyHead)) w.settleAll() open := w.op.open() - if len(open) != 1 || open[0] == first || w.op.count() != 2 { - t.Fatalf("after the edit the open asks are %v (first %s), %d asked; wanted the first withdrawn and one new", open, first, w.op.count()) + // The heads are the same, so the key is: the ask is taken away and written again with the record in its words, which the + // teller reads as the ask to withdraw and ask afresh. + if len(open) != 1 || open[0] != first || w.op.count() != 2 { + t.Fatalf("after the edit the open asks are %v (first %s), %d asked; wanted the first withdrawn and written again once", open, first, w.op.count()) } if len(w.op.cancelled) != 1 || w.op.cancelled[0] != first { t.Fatalf("the first ask was not withdrawn: %v", w.op.cancelled) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go new file mode 100644 index 00000000..07bb1c26 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **The gate asks through its bucket asks and merges through the bridge** (novox/hq ADR 0437 §6, §8, ADR 0441 §1 to §5, +// ADR 0450 §3, issue 1348). Fixture sources: the key and the id of an ask and the layout of a merge are mesh-sdk go/delivery's +// (MergeAskKey, AskID, Merge); the act an ask binds is spelled as mesh-controller cmd/mesh-controller/moved.go:98-106 verifies +// it and as the teller checks it (mesh-delivery modules/mesh-delivery-teller teller.go mergeAct, headsOf); the bridge's +// messages are the ones mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge acts.go writes (RefusedMessage) and +// bridge.go merged writes, and the fake bridge here (bridge_test.go) writes them in that layout. + +// AC1: a ready group writes one ask key, with today's words, and the gate calls no router. +func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T) { + w := approvedGroup(t) + allGreen(w) + if w.op.count() != 1 { + t.Fatalf("%d asks written, wanted one", w.op.count()) + } + key := w.op.open()[0] + kind, group, hash, err := sdk.ParseAskKey(key) + if err != nil || kind != "merge" || group != "feat/g" { + t.Fatalf("the key %q is not merge..: %q %q %v", key, kind, group, err) + } + ask := w.op.byKey[key] + if ask.Group != "feat/g" || len(ask.Heads) != 2 { + t.Fatalf("the ask value %+v does not name the group and both heads", ask) + } + if want, err := sdk.MergeAskKey("feat/g", ask.Heads); err != nil || want != key || !strings.HasSuffix(key, hash) { + t.Fatalf("the heads %v give the key %q, the ask is under %q (%v)", ask.Heads, want, key, err) + } + if ask.Ask.ID != sdk.AskID(key) { + t.Fatalf("the router's id of the ask is %q, wanted %q", ask.Ask.ID, sdk.AskID(key)) + } + // Today's words (approval.go composeMergeAsk): the headline names the group, the details its members. + if !strings.Contains(ask.Ask.Headline, "group feat") || !strings.Contains(ask.Ask.Details, "mesh-controller#1") || + !strings.Contains(ask.Ask.Details, "mesh-catalog#2") || len(ask.Ask.Options) != 2 || ask.Ask.Who != asks.Operator { + t.Fatalf("the ask is not today's merge ask: %+v", ask.Ask) + } + // The act the Approve binds is the one the teller checks and the controller verifies: this ask's router id, the option, and + // the heads as `owner/repo@` to the whole commit. + approve, _ := ask.Ask.Option(OptionApprove) + heads := map[string]string{IDOf(gCtl, gHc): gHc, IDOf(gCat, gHk): gHk} + want, _ := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": sdk.AskID(key), "option": "approve", + "heads": "novox/mesh-catalog@d2d2d2d2d2d2=" + gHk + ",novox/mesh-controller@c1c1c1c1c1c1=" + gHc}) + if approve.Binds != want { + t.Fatalf("the Approve binds %s, wanted the act %v of %v", approve.Binds, mergeAct(sdk.AskID(key), "approve", heads), heads) + } + if len(w.forge.calls) != 0 || len(w.merges.puts) != 0 || len(w.forge.merges) != 0 { + t.Fatalf("something was written beside the ask: forge %v, merges %d", w.forge.calls, len(w.merges.puts)) + } +} + +// answeredFirst is a group approved by the teller's answer, with the bridge saying nothing yet. +func answeredFirst(t *testing.T) *world { + w := approvedGroup(t) + allGreen(w) + w.merges.manual = true + w.asked = w.op.open()[0] + w.op.answer(w.asked, OptionApprove) + w.settleAll() + return w +} + +// AC2: an answer naming the heads asked now hands the first member only; the bridge's merged hands the next; its refused +// hands none. +func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testing.T) { + w := answeredFirst(t) + if len(w.merges.puts) != 1 { + t.Fatalf("the gate wrote %d merges, wanted the first member handed once", len(w.merges.puts)) + } + first := w.merges.last("feat/g") + if len(first.Pulls) != 1 || first.Pulls[0].Repository != gCat || first.Pulls[0].State != sdk.MergeMerging || first.Pulls[0].Head.Head != gHk { + t.Fatalf("the first member is not handed alone, merging, at its head: %+v", first.Pulls) + } + if first.Approved[sdk.ApprovedKey(gCat, 2)] != gHk || first.Approved[sdk.ApprovedKey(gCtl, 1)] != gHc || len(first.Approved) != 2 { + t.Fatalf("the merge does not carry the Approve's heads of both members: %v", first.Approved) + } + if first.Ask != w.asked { + t.Fatalf("the merge names no ask: %+v", first) + } + w.settleAll() + if len(w.merges.puts) != 1 { + t.Fatalf("the next member was handed before the bridge said the first merged: %d merges written", len(w.merges.puts)) + } + w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + w.settleAll() + if len(w.merges.puts) != 2 { + t.Fatalf("the second member was not handed after the first merged: %d merges written", len(w.merges.puts)) + } + second := w.merges.last("feat/g") + if len(second.Pulls) != 2 || second.Pulls[0].State != sdk.MergeMerged || second.Pulls[0].Commit != strings.Repeat("a", 40) || + second.Pulls[1].Repository != gCtl || second.Pulls[1].State != sdk.MergeMerging { + t.Fatalf("the next merge is not the first merged and the second merging: %+v", second.Pulls) + } +} + +// AC2: the bridge's refused hands no member, and the group is said urgent, naming the member and the forge's words. +func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) { + w := answeredFirst(t) + w.merges.say(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge", + Words: "the forge refused gitea_merge_pull_request: mesh/repo-check (missing)"}) + w.later(time30s) + w.settleAll() + if len(w.merges.puts) != 2 { + t.Fatalf("%d merges written; wanted the first handed and then written refused, and none handed after", len(w.merges.puts)) + } + refused := w.merges.last("feat/g") + if len(refused.Pulls) != 1 || refused.Pulls[0].State != sdk.MergeRefused || !strings.Contains(refused.Pulls[0].Words, "mesh/repo-check (missing)") { + t.Fatalf("the refusal is not written into the merge: %+v", refused.Pulls) + } + lines := urgentLines(w) + if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") || + !strings.Contains(lines[0].Says, "mesh/repo-check (missing)") { + t.Fatalf("the refusal is not said as one urgent line: %+v", lines) + } +} + +// ADR 0441 §2: before a member is handed, every member's required statuses are read again; one that is not a success holds +// the group, even after the first member merged. +func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) { + w := answeredFirst(t) + w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) + setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile + w.settleAll() + if len(w.merges.puts) != 1 { + t.Fatalf("the second member was handed with a required status missing: %d merges written", len(w.merges.puts)) + } + if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "mesh/repo-check (missing)") { + t.Fatalf("the held group is not said urgent: %+v", lines) + } +} + +// AC4: once every member is merged the gate writes source.main once, and each merge is announced with the ask, its asker the +// teller, and the heads. +func TestAGroupMergedWholeMovesSourceMainOnceAndAnnouncesWithTheTeller(t *testing.T) { + w := approvedGroup(t) + allGreen(w) + w.op.answer(w.op.open()[0], OptionApprove) + w.settleAll() + w.settleAll() + if len(w.forge.merges) != 2 { + t.Fatalf("merges %v", w.forge.merges) + } + if len(w.source.moves) != 1 { + t.Fatalf("source.main moved %d times, wanted once", len(w.source.moves)) + } + main := w.source.moves[0] + commit := strings.Repeat("c", 40) + if main.Group != "feat/g" || main.Heads[gCat] != commit || main.Heads[gCtl] != commit || len(main.Heads) != 2 { + t.Fatalf("source.main is %+v; wanted both repositories at their merge commits, moved by the group", main) + } + final := w.merges.last("feat/g") + if len(final.Pulls) != 2 || final.Pulls[0].State != sdk.MergeMerged || final.Pulls[1].State != sdk.MergeMerged { + t.Fatalf("the merge is not written as all merged: %+v", final.Pulls) + } + say := mergedSaid(w) + if len(say) != 2 { + t.Fatalf("announced %d merges, wanted each member's once: %v", len(say), say) + } + for _, m := range say { + if m["asker"] != sdk.TellerModule || !strings.HasPrefix(m["ask_id"].(string), "merge-feat_g-") || m["option"] != OptionApprove { + t.Fatalf("the announcement does not name the ask and its asker the teller: %v", m) + } + } + w.settleAll() + if len(w.source.moves) != 1 { + t.Fatalf("source.main moved again: %d", len(w.source.moves)) + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 765d2e8d..4588a811 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -16,8 +16,7 @@ import ( "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" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" ) func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } @@ -34,17 +33,22 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n // The events this owner follows: the forge's pull requests, and the controller's verdicts and walks. // The forge's status events say that a check answered (events.go). var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed", - "gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved"} + "gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved", + forgePattern} + +// forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one. +const forgePattern = "mesh-delivery-forge-bridge.forge.**" func main() { - operator := &asker.Client{Module: Module, State: stdio.State("asked")} h := &Holder{ Store: kvStore{}, Controller: seatController{ask: stdio.Ask}, Forge: toolForge{ask: stdio.Ask}, Emit: func(event string, body any) error { return stdio.Emit(event, body) }, Logf: logf, - Operator: operator, + Operator: kvOperator{}, + Merges: kvMerges{}, + Source: kvSource{}, Pulls: kvPulls{}, // The gate acts only while it holds the seat, and carries today's state over when it first does (seat.go). Seat: busOccupancy{}, @@ -53,7 +57,7 @@ func main() { h.OnHold = func() { go readOnHolding(h) } h.init() l := &listening{now: "not yet: starting"} - go run(h, l, operator) + go run(h, l) if err := stdio.Serve("", tools(h, l)); err != nil { logf("%v", err) os.Exit(1) @@ -65,7 +69,7 @@ func main() { // holds the seat (SeatChanged), and what it may have missed meanwhile is read once then (readOnHolding): the controller's // walks, the forge's open pull requests, the operator's asks — and it acts on events from then on (novox/hq ADR 0406 §1, // issue 975): no tick, no forge poll, no reconcile loop. Each step is retried, each failure said, never given up on quietly. -func run(h *Holder, l *listening, operator *asker.Client) { +func run(h *Holder, l *listening) { time.Sleep(500 * time.Millisecond) // Serve first: the state is reached through it go h.Work() go watchSeat(h, l) @@ -81,26 +85,17 @@ func run(h *Holder, l *listening, operator *asker.Client) { time.Sleep(wait) } } - // The operator's answers: the router's `decided` event, in place of reading every pending ask each tick. - for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { - err := operator.Listen( - func(_ asks.Option, w asks.Warrant) error { h.AskDecided(w.Ask, "answered", &w); h.Poke(); return nil }, - func(handled asker.Handled, w asks.Warrant, err error) { - if err != nil { - logf("[mesh-delivery] the answer to the ask %s: %s: %v", w.Ask, handled, err) - } - if handled == asker.Refused || handled == asker.Recorded { - h.AskDecided(w.Ask, string(handled), &w) - h.Poke() - } - }) - if err == nil { - break + // The teller's answers: a change of its bucket asks for a look, which reads the answers to the asks pending. + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.State(sdkdelivery.TellerModule+".answers").Watch("", func(stdio.StateChange) error { h.AnswersChanged(); return nil }) + if err == nil { + return + } + logf("[mesh-delivery] not hearing the teller's answers yet (%v); asking again in %s", err, wait) + time.Sleep(wait) } - l.set("not yet: " + err.Error()) - logf("[mesh-delivery] not hearing the operator's answers yet (%v); asking again in %s", err, wait) - time.Sleep(wait) - } + }() logf("[mesh-delivery] listening: %s", strings.Join(followed, ", ")) } @@ -134,6 +129,10 @@ func handle(h *Holder, e stdio.Envelope) error { } func take(h *Holder, e stdio.Envelope) error { + if strings.HasPrefix(e.Key, "mesh-delivery-forge-bridge.forge.") { + h.Forged(e.Key, e.Body) + return nil + } switch e.Key { case "gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed": var p PullEvent diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index cafb2c4a..b1ca5f0f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "sort" "strings" "testing" @@ -71,8 +72,8 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { if len(m.Claims[0].Serves) != 13 { t.Fatalf("serves %d verbs, wanted 13: %v", len(m.Claims[0].Serves), m.Claims[0].Serves) } - if !reflect.DeepEqual(m.Consumes, append(append([]string{}, followed...), "mesh-delivery-forge-bridge.forge.**")) { - t.Fatalf("consumes %v, follows %v and the bridge's forge messages", m.Consumes, followed) + if !reflect.DeepEqual(m.Consumes, followed) || !slices.Contains(m.Consumes, "mesh-delivery-forge-bridge.forge.**") { + t.Fatalf("consumes %v, follows %v (the bridge's forge messages among them)", m.Consumes, followed) } if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) { t.Fatalf("emits %v", m.Emits) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go new file mode 100644 index 00000000..0ec30658 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go @@ -0,0 +1,406 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The gate merges through the forge bridge, never itself (novox/hq ADR 0437 §8, ADR 0441 §5, ADR 0450 §3). It writes into +// `merges.` the Approve's heads and ONE member at a time, the next only after the bridge's message on FORGE says the +// one before it merged, and none after a refusal. It re-reads every member's required statuses before it hands a member +// (ADR 0441 §2). Once every member is merged it moves `source.main` in one compare-and-set write and the mesh's source has +// the group as one revision. What it announces for each merge (`merged`) names the ask, its asker the teller, and the heads. + +// MergeStore is the gate's bucket merges. +type MergeStore interface { + Get(group string) (*sdk.Merge, error) + Put(group string, m sdk.Merge) error +} + +// SourceStore is the gate's bucket source, one key, moved by compare-and-set. +type SourceStore interface { + // Move reads source.main and writes what update makes of it, unless update says nothing is to be written; a write + // another writer won is read again and tried again. + Move(update func(prev *sdk.Source) (sdk.Source, bool)) error +} + +// kvMerges is merges over the runtime. +type kvMerges struct{} + +func (kvMerges) Get(group string) (*sdk.Merge, error) { + key, err := sdk.MergesKey(group) + if err != nil { + return nil, err + } + e, err := stdio.State("merges").Get(key) + if err != nil || e == nil { + return nil, err + } + m, err := sdk.DecodeMerge(e.Value) + if err != nil { + return nil, fmt.Errorf("the merge of %s is not readable: %w", group, err) + } + return &m, nil +} + +func (kvMerges) Put(group string, m sdk.Merge) error { + key, err := sdk.MergesKey(group) + if err != nil { + return err + } + raw, err := sdk.EncodeMerge(m) + if err != nil { + return err + } + _, err = stdio.State("merges").Put(key, json.RawMessage(raw)) + return err +} + +// kvSource is source over the runtime. +type kvSource struct{} + +func (kvSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) error { + state := stdio.State("source") + for range changeAttempts { + e, err := state.Get(sdk.SourceMainKey) + if err != nil { + return err + } + var prev *sdk.Source + if e != nil { + p, err := sdk.DecodeSource(e.Value) + if err != nil { + return fmt.Errorf("source.main is not readable: %w", err) + } + prev = &p + } + next, write := update(prev) + if !write { + return nil + } + raw, err := sdk.EncodeSource(next) + if err != nil { + return err + } + if e == nil { + _, err = state.Create(sdk.SourceMainKey, json.RawMessage(raw)) + } else { + _, err = state.Update(sdk.SourceMainKey, json.RawMessage(raw), e.Revision) + } + if err == nil { + return nil + } + if !errors.Is(err, stdio.ErrChanged) { + return err + } + } + return fmt.Errorf("source.main was written by another writer first %d times: %w", changeAttempts, stdio.ErrChanged) +} + +// approvedOf are the heads an Approve covers as a merge carries them: `@` to the full head. +func (h *Holder) approvedOf(heads map[string]string) map[string]string { + out := map[string]string{} + for id, sha := range heads { + if m := h.deliveries[id]; m != nil { + out[sdk.ApprovedKey(m.Repository, m.Number)] = sha + } + } + return out +} + +// handTurn is d's turn to be handed to the bridge, when it is: the first member of its group not merged (a lone pull request +// is a group of one), every member approved at its head and ready to merge. Nil when it is not. Under the lock; the hand +// runs outside it. +func (h *Holder) handTurn(g *Group, d *Delivery, now time.Time) func() { + var reads []memberRead + c := h.covering(d) + if g != nil { + order := h.unmergedLive(g) + if len(order) == 0 || order[0] != d { + return nil + } + for _, m := range order { + mc := h.covering(m) + if m.Ready == nil || m.Ready.Declined != nil || mc == nil || mc.Approved == nil || mc.Heads[m.ID] != m.Commit || h.readyWaits(m) != "" { + return nil // the others are not approved at their heads yet: nothing is merged before they are + } + reads = append(reads, readOf(m, mc.Heads[m.ID])) + } + } + if c == nil || c.Approved == nil || c.Heads[d.ID] == "" { + return nil + } + d.Ready.Tries++ + d.Ready.LastTry, d.Ready.merging = now, true + h.keep(d) + gid, name, key, target, approved := "", askGroup(d), c.AskID, d.ID, h.approvedOf(c.Heads) + if g != nil { + gid = g.ID + } + return func() { h.hand(gid, name, key, target, reads, approved) } +} + +// hand reads a group's required statuses and, when all are a success, writes the target as the member being merged. +func (h *Holder) hand(gid, name, key, target string, reads []memberRead, approved map[string]string) { + if gid != "" && !h.groupMergeable(gid, target, reads) { + return + } + h.mu.Lock() + value, ok := h.mergeValue(name, key, approved, target, sdk.MergeMerging) + h.mu.Unlock() + if !ok { + h.giveBack(target, nil) + return + } + cur, err := h.Merges.Get(name) + if err == nil && cur != nil && sdk.SameMerge(*cur, value) { + return // handed already, the bridge has the revision + } + if err == nil { + err = h.Merges.Put(name, value) + } + if err != nil { + h.giveBack(target, err) + return + } + h.Logf("[mesh-delivery] %s is handed to the forge bridge to merge, at the heads the operator approved", target) +} + +// giveBack gives a target its turn back after a hand that could not be made, to be asked again after a while. +func (h *Holder) giveBack(target string, err error) { + h.mu.Lock() + defer h.mu.Unlock() + if d := h.deliveries[target]; d != nil && d.Ready != nil { + d.Ready.merging = false + d.Ready.LastTry = time.Time{} + } + if err != nil { + h.Logf("[mesh-delivery] %s could not be handed to the forge bridge: %v; asked again in %s", target, err, askAgainAfter) + } + h.again("a merge that could not be handed to the forge bridge") +} + +// mergeValue is the value of a group's merges key with these members merged, and the target in the state asked (merging, or +// refused with the forge's words): the group's members in order, those the Approve covers. Under the lock. False when it +// cannot be made (the target is not among the heads the Approve covers). +func (h *Holder) mergeValue(name, key string, approved map[string]string, target string, state sdk.MergeState) (sdk.Merge, bool) { + t := h.deliveries[target] + if t == nil { + return sdk.Merge{}, false + } + members := []*Delivery{t} + if g := h.groups[t.Group]; t.Group != "" && g != nil { + members = h.membersOf(g) + } + m := sdk.Merge{Group: name, Ask: key, Approved: approved} + for _, o := range members { + head := approved[sdk.ApprovedKey(o.Repository, o.Number)] + if head == "" { + continue + } + pull := sdk.MergePull{Head: sdk.Head{Repository: o.Repository, Number: o.Number, Head: head}} + switch { + case o.MergedAs != "" || (o.Ready != nil && o.Ready.Merged != nil): + commit := o.MergedAs + if o.Ready != nil && o.Ready.MergeCommit != "" { + commit = o.Ready.MergeCommit + } + if commit == "" { + continue + } + pull.State, pull.Commit = sdk.MergeMerged, commit + case o == t: + pull.State = state + if state == sdk.MergeRefused { + pull.Words = o.Ready.Answer + } + m.Pulls = append(m.Pulls, pull) + return m, true + default: + return m, false + } + m.Pulls = append(m.Pulls, pull) + } + return m, len(m.Pulls) > 0 && state == sdk.MergeMerged +} + +// Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else. +func (h *Holder) Forged(event string, body []byte) { + f, err := sdk.DecodeForge(body) + if err != nil { + h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err)) + return + } + switch { + case f.Kind == sdk.ForgeMerged: + h.ForgeMerged(f) + case f.Kind == sdk.ForgeRefused && f.Act == "merge": + h.ForgeRefused(f) + } +} + +// memberOfForge is the delivery a forge message is about: at the head it names. +func (h *Holder) memberOfForge(f sdk.Forge) *Delivery { + if d := h.deliveries[IDOf(f.Repository, f.Head)]; d != nil && d.Number == f.Number { + return d + } + return nil +} + +// ForgeMerged takes the bridge's word that a pull request the gate handed to it merged: the merge is announced once, +// with the ask, its asker the teller and the heads (issue 1025), and the next member's turn comes at the next look. +func (h *Holder) ForgeMerged(f sdk.Forge) { + h.mu.Lock() + defer h.mu.Unlock() + d := h.memberOfForge(f) + if d == nil || d.Ready == nil || d.Ready.Approved == nil || d.Ready.Merged != nil { + return + } + r, now := d.Ready, h.Now() + d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, f.Head))), Since: now}) + r.Merged, r.Answer, r.RefusedSince, r.merging, r.MergeCommit = &now, "merged", nil, false, f.Merge + h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", d.ID, shortOf(f.Head, 12)) + h.keep(d) +} + +// ForgeRefused takes the bridge's word that the forge refused a merge: nothing after it is merged, and the refusal is said +// as a stalled line (ADR 0441 §3, §4). The merge is handed again only at the next event that decides whether it may +// merge (mergeAgainNow). +func (h *Holder) ForgeRefused(f sdk.Forge) { + h.mu.Lock() + defer h.mu.Unlock() + d := h.memberOfForge(f) + if d == nil || d.Ready == nil || d.Ready.Merged != nil { + return + } + r, now := d.Ready, h.Now() + r.merging, r.refusedNoted = false, false + answer := oneLine(f.Words) + if r.RefusedSince == nil { + r.RefusedSince = &now + } + if !outOfDateRefusal(answer) { + r.Urgent = true // never a warning again for this head + } + if answer != r.Answer { + h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", d.ID, + answer) + } + r.Answer = answer + h.keep(d) +} + +// settleMerges works out, under the lock, what a merge owes its key and the source once the bridge has spoken: a refused +// member written refused, and a group whose members are all merged written merged and moved onto source.main in one write. +// The writing happens outside the lock. +func (h *Holder) settleMerges() []func() { + var out []func() + done := map[string]bool{} + ids := make([]string, 0, len(h.deliveries)) + for id := range h.deliveries { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + d := h.deliveries[id] + r := d.Ready + if r == nil || r.Approved == nil || r.AskID == "" || len(r.Heads) == 0 { + continue + } + name, key, approved := askGroup(d), r.AskID, h.approvedOf(r.Heads) + switch { + case r.Merged == nil && r.RefusedSince != nil && !r.refusedNoted && r.Answer != "" && r.Answer != "merged": + r.refusedNoted = true + target := d.ID + out = append(out, func() { + h.mu.Lock() + value, ok := h.mergeValue(name, key, approved, target, sdk.MergeRefused) + h.mu.Unlock() + if ok { + if err := h.Merges.Put(name, value); err != nil { + h.Logf("[mesh-delivery] the refusal of %s could not be written to its merge: %v", target, err) + } + } + }) + case r.Merged != nil && !r.SourceMoved && !done[key]: + if !h.allMerged(r.Heads) { + continue + } + done[key] = true + heads := map[string]string{} + var members []string + for hid := range r.Heads { + m := h.deliveries[hid] + commit := m.MergedAs + if m.Ready != nil && m.Ready.MergeCommit != "" { + commit = m.Ready.MergeCommit + } + heads[m.Repository] = commit + members = append(members, hid) + } + sort.Strings(members) + last := h.deliveries[members[len(members)-1]] + out = append(out, func() { h.finish(name, key, approved, last.ID, heads, members) }) + } + } + return out +} + +// allMerged is whether every delivery an Approve covers is merged. +func (h *Holder) allMerged(heads map[string]string) bool { + for id := range heads { + m := h.deliveries[id] + if m == nil || (m.MergedAs == "" && (m.Ready == nil || m.Ready.Merged == nil)) { + return false + } + } + return true +} + +// finish writes a group's merge as all merged and moves source.main to the merge commits in one compare-and-set write: the +// group is one revision of the mesh's source (ADR 0437 §1, §8). +func (h *Holder) finish(name, key string, approved map[string]string, last string, heads map[string]string, members []string) { + h.mu.Lock() + value, ok := h.mergeValue(name, key, approved, last, sdk.MergeMerged) + h.mu.Unlock() + var err error + if ok { + err = h.Merges.Put(name, value) + } + if err == nil { + err = h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) { + next := sdk.Source{Group: name, Heads: map[string]string{}} + if prev != nil { + for repository, head := range prev.Heads { + next.Heads[repository] = head + } + } + for repository, commit := range heads { + next.Heads[repository] = commit + } + return next, prev == nil || !reflect.DeepEqual(prev.Heads, next.Heads) + }) + } + h.mu.Lock() + defer h.mu.Unlock() + if err != nil { + h.Logf("[mesh-delivery] group %s merged, and its place in the source cannot be written yet: %v; asked again in %s", name, err, askAgainAfter) + h.again("source.main, moved once every member is merged") + return + } + for _, id := range members { + if m := h.deliveries[id]; m != nil && m.Ready != nil { + m.Ready.SourceMoved = true + h.keep(m) + } + } + h.Logf("[mesh-delivery] group %s is merged whole: source.main holds %s", name, strings.Join(members, ", ")) +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go index 6c312b21..6bc6d482 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go @@ -1,101 +1,110 @@ package main import ( + "fmt" "sync" "time" "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) -// fakeOperator is the operator channel: it keeps the asks made, and answers as the test says. In auto mode it -// approves every ask the moment it is read (the tests of other behaviour, which are not about the Approve). +// fakeOperator is the gate's bucket asks and the teller behind it: it keeps the asks written, and answers as the test says +// (the teller's answer under the ask's key). In auto mode it approves every ask the moment it is read (the tests of other +// behaviour, which are not about the Approve). An ask is known here by its key (mesh-sdk go/delivery MergeAskKey), as the +// delivery keeps it. type fakeOperator struct { mu sync.Mutex now func() time.Time auto bool asked []asks.Ask + byKey map[string]sdk.Ask + order []string + seen map[string]bool cancelled []string state map[string]string - warrant map[string]*asks.Warrant + answers map[string]*sdk.Answer refuse error } func newFakeOperator(now func() time.Time) *fakeOperator { - return &fakeOperator{now: now, state: map[string]string{}, warrant: map[string]*asks.Warrant{}} + return &fakeOperator{now: now, byKey: map[string]sdk.Ask{}, seen: map[string]bool{}, state: map[string]string{}, answers: map[string]*sdk.Answer{}} } -func (o *fakeOperator) Ask(a asks.Ask) error { +func (o *fakeOperator) Ask(key string, a sdk.Ask) error { o.mu.Lock() defer o.mu.Unlock() if o.refuse != nil { return o.refuse } - if err := a.Check(o.now()); err != nil { + if err := a.Ask.Check(o.now()); err != nil { return err } - o.asked = append(o.asked, a) - o.state[a.ID] = "open" + if _, err := sdk.EncodeAsk(a); err != nil { + return err + } + if o.state[key] == "open" && sdk.SameAsk(o.byKey[key], a) { + return nil // the same ask is there, as kvOperator leaves it + } + o.asked = append(o.asked, a.Ask) + o.byKey[key] = a + if _, known := o.seen[key]; !known { + o.order = append(o.order, key) + o.seen[key] = true + } + o.state[key] = "open" + delete(o.answers, key) return nil } -func (o *fakeOperator) Cancel(id string) error { +func (o *fakeOperator) Cancel(key string) error { o.mu.Lock() defer o.mu.Unlock() - o.cancelled = append(o.cancelled, id) - o.state[id] = "cancelled" + o.cancelled = append(o.cancelled, key) + o.state[key] = "cancelled" return nil } -func (o *fakeOperator) Where(id string) (string, *asks.Warrant, error) { +func (o *fakeOperator) Where(key string) (*sdk.Answer, error) { o.mu.Lock() defer o.mu.Unlock() - if o.auto && o.state[id] == "open" { - o.answerLocked(id, OptionApprove) + if o.auto && o.state[key] == "open" { + o.answerLocked(key, OptionApprove) } - return o.state[id], o.warrant[id], nil + return o.answers[key], nil } -func (o *fakeOperator) ask(id string) asks.Ask { - for _, a := range o.asked { - if a.ID == id { - return a - } +// ask is the router's ask at a key, as the gate composed it. +func (o *fakeOperator) ask(key string) asks.Ask { + if a, ok := o.byKey[key]; ok { + return a.Ask } - panic("no ask " + id) + panic("no ask " + key) } -func (o *fakeOperator) answerLocked(id, option string) { - a := o.ask(id) +func (o *fakeOperator) answerLocked(key, option string) { + a := o.ask(key) opt, _ := a.Option(option) - o.state[id] = "answered" - o.warrant[id] = &asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Label: opt.Label, - Level: opt.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"}, - At: o.now(), AskDigest: a.Digest()} + o.state[key] = "answered" + o.answers[key] = &sdk.Answer{Option: option, Warrant: fmt.Sprintf("%s@%s", a.ID, o.now().UTC().Format(time.RFC3339)), + Words: "the operator, via telegram, chose " + opt.Label} } -// answer is the operator's choice, proven by the router. -func (o *fakeOperator) answer(id, option string) { +// answer is the operator's choice, proven to the teller by the router and written by it. +func (o *fakeOperator) answer(key, option string) { o.mu.Lock() defer o.mu.Unlock() - o.answerLocked(id, option) + o.answerLocked(key, option) } -// end is an ask that closed without a choice. -func (o *fakeOperator) end(id string) { - o.mu.Lock() - defer o.mu.Unlock() - o.state[id] = "expired" - o.warrant[id] = &asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeExpired, At: o.now()} -} - -// open is the ids of the asks waiting for an answer, in the order asked. +// open is the keys of the asks waiting for an answer, in the order asked. func (o *fakeOperator) open() []string { o.mu.Lock() defer o.mu.Unlock() var out []string - for _, a := range o.asked { - if o.state[a.ID] == "open" { - out = append(out, a.ID) + for _, k := range o.order { + if o.state[k] == "open" { + out = append(out, k) } } return out diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go index 7ca17df5..c4f1041d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go @@ -85,11 +85,6 @@ type Forge interface { // OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue // 347). Reads only. OpenPulls() ([]OpenPull, error) - // Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq - // issue 722): the one path every merge takes. approved are the heads the operator's Approve covers, each - // `owner/repo@` (ADR 0421 §9); the tool refuses a pull request whose head differs. Its refusal is the - // error, in its words. - Merge(owner, repo string, number int, head string, approved []string) error // Comment adds a comment to a pull request. Comment(owner, repo string, number int, body string) error } @@ -386,11 +381,6 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string) return &c, nil } -func (f toolForge) Merge(owner, repo string, number int, head string, approved []string) error { - return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head, - "approved_heads": approved}) -} - func (f toolForge) Comment(owner, repo string, number int, body string) error { return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body}) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index 4a8a6fcd..f5ff6be6 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -7,6 +7,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // The Approve is asked as soon as the checks pass (novox/hq ADR 0421, issue 1022). @@ -57,8 +58,15 @@ type ReadyRun struct { Tries int `json:"tries,omitempty"` LastTry time.Time `json:"last_try"` Answer string `json:"answer,omitempty"` - // merging is whether a merge ask is on its way, outside the lock: not asked twice meanwhile. Not kept. + // merging is whether the member is handed to the forge bridge and not merged or refused yet: not handed twice + // meanwhile. Not kept. merging bool + // refusedNoted is whether the refusal was written into the group's merge key. Not kept. + refusedNoted bool + // MergeCommit is the commit the forge bridge reported the merge as, and SourceMoved whether source.main holds it + // (merges.go). + MergeCommit string `json:"merge_commit,omitempty"` + SourceMoved bool `json:"source_moved,omitempty"` // OwnAskedAt is when its own check was asked after the merge tool refused it for a pending status of its own (issue // 1049): once for the head this run is for. OwnAskedAt time.Time `json:"own_asked_at,omitempty"` @@ -205,18 +213,17 @@ func (h *Holder) readyAsks(now time.Time) []func() { if !r.LastTry.IsZero() { continue // refused: asked again at the next event that decides whether it may merge (mergeAgainNow), with no timer } - if g := h.groups[d.Group]; d.Group != "" && g != nil { - // A group is merged in one pass, in its order, and stops at the first refusal (issue 1275). - if a := h.groupPass(g, d, now); a != nil { - asks = append(asks, a) - } - continue + // A group is merged one member at a time, handed to the forge bridge, and the pass stops at the first refusal (ADR + // 0441 §3, §5); a lone pull request is a group of one. + g := h.groups[d.Group] + if d.Group == "" { + g = nil + } + if a := h.handTurn(g, d, now); a != nil { + asks = append(asks, a) } - r.Tries++ - r.LastTry, r.merging = now, true - h.keep(d) - asks = append(asks, h.askMerge(d.ID, r.Heads)) } + asks = append(asks, h.settleMerges()...) asks = append(h.cancels, asks...) h.cancels = nil return asks @@ -336,72 +343,6 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string { return "" } -// askMerge asks the forge's merge tool to merge a head the operator approved, and keeps its answer. The tool is handed -// the heads the Approve covers, each `owner/repo@` (novox/hq ADR 0421 §9), and refuses a pull request whose -// head differs from the one approved. -func (h *Holder) askMerge(id string, heads map[string]string) func() { - return func() { h.mergeOne(id, heads) } -} - -// mergeOne asks the merge tool for one approved head and keeps its answer; whether it merged. -func (h *Holder) mergeOne(id string, heads map[string]string) bool { - h.mu.Lock() - d := h.deliveries[id] - if d == nil || d.Ready == nil { - h.mu.Unlock() - return false - } - owner, repo, number, head := d.Owner(), d.Repo(), d.Number, d.Commit - if sha := heads[id]; sha != "" { - head = sha - } - h.mu.Unlock() - err := h.Forge.Merge(owner, repo, number, head, approvedHeads(heads)) - h.mu.Lock() - defer h.mu.Unlock() - d = h.deliveries[id] - if d == nil || d.Ready == nil { - return false - } - r, now := d.Ready, h.Now() - r.merging = false - if err == nil { - // Issue 1025: the announcement is owed, kept with the delivery, so a death right after the merge - // still says it once. - d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, head))), Since: now}) - r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil - h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", id, - shortOf(head, 12)) - h.keep(d) - return true - } - answer := oneLine(err.Error()) - if r.RefusedSince == nil { - r.RefusedSince = &now - } - if !outOfDateRefusal(answer) { - r.Urgent = true // never a warning again for this head - } - if answer != r.Answer { - h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", id, - answer) - } - r.Answer = answer - h.keep(d) - return false -} - -// approvedHeads are the heads an Approve covers as the merge tool is handed them: `owner/repo@`, sorted. -func approvedHeads(heads map[string]string) []string { - var out []string - for id, sha := range heads { - repository, _, _ := strings.Cut(id, "@") - out = append(out, repository+"@"+sha) - } - sort.Strings(out) - return out -} - // sayOn comments on a delivery's pull request. func (h *Holder) sayOn(d *Delivery, body string) func() { owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID @@ -468,6 +409,10 @@ func mergedEvent(d *Delivery, head string) map[string]any { "ask_id": "", "option": "", "heads": heads} if r.Approved != nil { ev["ask_id"], ev["option"] = r.AskID, OptionApprove + if _, _, _, err := sdk.ParseAskKey(r.AskID); err == nil { + // The ask of the gate: the router knows it by the id its key gives, and the teller asked it (ADR 0437 §6). + ev["ask_id"], ev["asker"] = sdk.AskID(r.AskID), sdk.TellerModule + } // an Approve carried over from today's module keeps its router id, and the asker the controller takes by default } return ev } -- 2.54.0 From 6a39da548533599d4193f78eebb4c0621ecc01bb Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:52:42 +0000 Subject: [PATCH 05/15] Owe the forge its statuses, views, notes and comments as keys, read the plan from plans as data, and call an empty check answer an error (issue 1349) --- .../cmd/mesh-delivery-gate/bridge_test.go | 65 +++++ .../cmd/mesh-delivery-gate/bus_test.go | 10 + .../cmd/mesh-delivery-gate/effects.go | 65 ++--- .../cmd/mesh-delivery-gate/fakes_test.go | 3 + .../cmd/mesh-delivery-gate/history_test.go | 60 ++--- .../cmd/mesh-delivery-gate/holder.go | 5 +- .../cmd/mesh-delivery-gate/holder_test.go | 10 +- .../cmd/mesh-delivery-gate/issue1349_test.go | 231 ++++++++++++++++++ .../cmd/mesh-delivery-gate/issue407_test.go | 8 +- .../cmd/mesh-delivery-gate/main.go | 1 + .../cmd/mesh-delivery-gate/model.go | 5 + .../cmd/mesh-delivery-gate/owed.go | 117 +++++++++ .../cmd/mesh-delivery-gate/ports.go | 39 +-- .../cmd/mesh-delivery-gate/ready.go | 14 +- .../cmd/mesh-delivery-gate/recheck_test.go | 6 +- .../cmd/mesh-delivery-gate/timers_test.go | 6 +- .../cmd/mesh-delivery-gate/verbs.go | 13 +- 17 files changed, 525 insertions(+), 133 deletions(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go index fb16da09..1749fe5a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bridge_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "sort" "strconv" "strings" @@ -134,3 +135,67 @@ func (s *fakeSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) erro func sortStrings(s []string) { sort.Strings(s) } func itoa(n int) string { return strconv.Itoa(n) } + +// fakeOwed is the gate's bucket owed with the forge bridge behind it: every act written is performed on the fake forge, as the +// bridge performs it (mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge acts.go OwedAct), once for each +// revision. With down set the bucket cannot be written (the bus is away); the forge's own refusals are the bridge's to say, +// not the gate's to retry. +type fakeOwed struct { + mu sync.Mutex + forge *fakeForge + down bool + // keys are the latest value of each key written, and writes every revision in order. + keys map[string]sdk.Owed + writes []string +} + +func newFakeOwed(f *fakeForge) *fakeOwed { return &fakeOwed{forge: f, keys: map[string]sdk.Owed{}} } + +func (o *fakeOwed) Put(key string, v sdk.Owed) error { + if _, err := sdk.EncodeOwed(v); err != nil { + return err + } + repository, number, head, act, err := sdk.ParseOwedKey(key) + if err != nil { + return err + } + o.mu.Lock() + if o.down { + defer o.mu.Unlock() + return errors.New("the bus is away") + } + if kept, ok := o.keys[key]; ok && sdk.SameOwed(kept, v) { + o.mu.Unlock() + return nil + } + o.keys[key] = v + o.writes = append(o.writes, key) + o.mu.Unlock() + owner, repo, _ := strings.Cut(repository, "/") + f := o.forge + switch v.Act { + case sdk.OwedNote: + _ = f.Note(owner, repo, head, v.Text) + case sdk.OwedView: + _ = f.View(owner, repo, number, v.Text) + case sdk.OwedStatus: + _ = f.Status(owner, repo, head, v.Context, v.State, v.Text, v.Target) + case sdk.OwedComment: + _ = f.Comment(owner, repo, number, v.Text) + } + _ = act + return nil +} + +// acts are the keys written whose act is of a kind, in order. +func (o *fakeOwed) acts(kind string) []string { + o.mu.Lock() + defer o.mu.Unlock() + var out []string + for _, k := range o.writes { + if _, _, _, act, err := sdk.ParseOwedKey(k); err == nil && actKind(act) == kind { + out = append(out, k) + } + } + return out +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index e53ac606..33edebaf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -543,6 +543,16 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"} }) + // What the transitions owe the forge is written as owed keys; the gate calls no tool of the forge that writes (issue 1349). + for _, tool := range []string{"gitea.gitea_commit_status", "gitea.gitea_delivery_view", "gitea.gitea_note_append", "gitea.gitea_add_comment"} { + if n := len(rt.asksOf(tool)); n != 0 { + t.Fatalf("the gate asked %s %d times: the bridge writes to the forge", tool, n) + } + } + rt.until("the owed keys to be written", 5*time.Second, func() bool { + keys, err := b.kvs["owed"].Keys(ctx) + return err == nil && len(keys) > 0 + }) // The operator approves; the teller holds the warrant and writes the answer: the merge is handed to the bridge within // seconds, with the Approve's heads, and the gate asks the forge nothing. approved := b.answer(t, key, OptionApprove) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go index 525a612d..b1e7a92f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/effects.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "fmt" - "regexp" "sort" "strings" "time" @@ -243,8 +242,9 @@ func (h *Holder) Flush() { group bool e Effect body string - repo [2]string + repo string num int + head string url string } h.mu.Lock() @@ -274,7 +274,7 @@ func (h *Holder) Flush() { if e.Next != nil && now.Before(*e.Next) { continue } - j := job{id: id, e: e, repo: [2]string{d.Owner(), d.Repo()}, num: d.Number, url: d.HTMLURL} + j := job{id: id, e: e, repo: d.Repository, num: d.Number, head: d.Commit, url: d.HTMLURL} if e.Kind == EffectView { j.body = ViewBody(d, h.groups[d.Group]) } @@ -295,15 +295,11 @@ func (h *Holder) Flush() { h.mu.Unlock() for _, j := range jobs { - err := h.do(j.e, j.repo, j.num, j.body, j.url, j.group) + err := h.do(j.e, j.repo, j.num, j.head, j.body, j.url, j.group) h.mu.Lock() - // Why it is given up, when it is: the forge's word that what it is for does not exist is final, and - // so is a failure tried retryLimit times, the last ones retryCap apart. + // Why it is given up, when it is: a failure of the bus tried retryLimit times, the last ones retryCap apart. dropped := "" - switch { - case err != nil && forgeKinds[j.e.Kind] && doesNotExist(err): - dropped = "the forge answers that what it is for does not exist" - case err != nil && j.e.Tries+1 >= retryLimit: + if err != nil && j.e.Tries+1 >= retryLimit { dropped = fmt.Sprintf("tried %d times since %s", j.e.Tries+1, j.e.Since.UTC().Format(time.RFC3339)) } owed := func(list []Effect) []Effect { @@ -314,7 +310,9 @@ func (h *Holder) Flush() { return append(list[:i:i], list[i+1:]...) } list[i].Tries++ - list[i].Last = err.Error() + if o.Kind != EffectStatus || !j.group { + list[i].Last = err.Error() // a group's status names its member in Last, which stays + } next := h.Now().Add(retryAfter(list[i].Tries)) list[i].Next = &next return list @@ -401,17 +399,8 @@ func retryAfter(tries int) time.Duration { return min(wait, retryCap) } -// forgeKinds are the effects done by the forge's holder. -var forgeKinds = map[string]bool{EffectNote: true, EffectView: true, EffectStatus: true} - -// notThere is the forge's own answer that what was asked of does not exist: a pull request whose number the -// forge no longer holds, a commit it does not know. Tried again it answers the same, for ever. -var notThere = regexp.MustCompile(`(?i)Gitea API \S+: (404\b|\d{3}\b.*\bdoes not exist\b)`) - -func doesNotExist(err error) bool { return err != nil && notThere.MatchString(err.Error()) } - -// do is one owed effect. -func (h *Holder) do(e Effect, repo [2]string, number int, body, url string, group bool) error { +// do is one owed effect: said on the bus, or written as the key the forge bridge performs. +func (h *Holder) do(e Effect, repository string, number int, head, body, url string, group bool) error { switch e.Kind { case EffectEmit: var payload any @@ -426,28 +415,20 @@ func (h *Holder) do(e Effect, repo [2]string, number int, body, url string, grou event = e.Event } return h.Emit(event, payload) - case EffectNote: - if h.Forge == nil { - return fmt.Errorf("no forge") - } - return h.Forge.Note(repo[0], repo[1], e.Commit, e.Line) case EffectView: - if h.Forge == nil || number == 0 { + if number == 0 { return nil } - return h.Forge.View(repo[0], repo[1], number, body) - case EffectStatus: - if h.Forge == nil { - return fmt.Errorf("no forge") - } - if group { - // A group's status names its member's repository and page in Last. - where, page, _ := strings.Cut(e.Last, " ") - ownerRepo, _, _ := strings.Cut(where, "#") - owner, name, _ := strings.Cut(ownerRepo, "/") - return h.Forge.Status(owner, name, e.Commit, e.Context, e.State, e.Line, page) - } - return h.Forge.Status(repo[0], repo[1], e.Commit, e.Context, e.State, e.Line, url) } - return nil + page := url + if group && e.Kind == EffectStatus { + // A group's status names its member's repository and page in Last. + repository, number, page = memberOfLast(e.Last) + head = e.Commit + } + act, value, num, hd := owedOf(e, repository, number, head, body, page) + if act == "" { + return nil + } + return h.owe(repository, num, hd, act, value) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go index 3360b384..61de32c0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go @@ -599,6 +599,7 @@ type world struct { op *fakeOperator merges *fakeMerges source *fakeSource + owed *fakeOwed // asked is the key of the ask a test answered. asked string now time.Time @@ -631,6 +632,8 @@ func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fak w.merges, w.source = newFakeMerges(forge), &fakeSource{} w.h.Merges, w.h.Source = w.merges, w.source w.merges.h = w.h + w.owed = newFakeOwed(forge) + w.h.Owed = w.owed w.h.init() if err := w.h.Load(); err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/history_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/history_test.go index 382964e4..9d8feccd 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/history_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/history_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" "strings" "testing" "time" @@ -177,41 +178,31 @@ func TestRetireHistoryIsRefusedForARealHeldDelivery(t *testing.T) { } } -func TestOwedWorkTheForgeSaysCanNeverBeDoneIsDropped(t *testing.T) { +// What the bus cannot take is owed and tried again later each time, and given up after retryLimit tries, saying so. What the +// forge refuses is the bridge's to say (issue 1349): the gate has no retry for it. +func TestOwedWorkTheBusCannotTakeIsTriedAgainLaterAndGivenUpAfterRetryLimit(t *testing.T) { w := newWorld(t) id := IDOf("novox/app", head) - w.forge.viewErr = errors.New(`Gitea API /repos/novox/app/issues/7/comments: 500 {"message":"issue does not ` + - `exist [id: 0, repo_id: 53, index: 7]"}`) + w.owed.down = true w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - w.h.Flush() - if w.forge.viewCalls != 1 { - t.Fatalf("the view was asked %d time(s)", w.forge.viewCalls) + tries := func() int { + for _, e := range w.delivery(id).Owed { + if e.Kind == EffectView { + return e.Tries + } + } + return -1 } - if d := w.delivery(id); len(d.Owed) != 0 { - t.Fatalf("an answer that its pull request does not exist left owed %+v", d.Owed) - } - w.settleAll() - if w.forge.viewCalls != 1 { - t.Fatalf("a dropped view was asked again: %d", w.forge.viewCalls) - } - if st := w.h.Status("listening"); !strings.Contains(strings.Join(st["refused"].([]string), "\n"), "given up") { - t.Fatalf("its drop was not said: %v", st["refused"]) - } - - // Any other failure is tried again later each time, and given up after retryLimit tries. - w.forge.viewErr = errors.New("the forge is away") - w.h.Checked(verdict("novox/app", 7, head, "pass")) - before := w.forge.viewCalls w.h.Flush() w.h.Flush() w.h.Flush() - if w.forge.viewCalls != before+1 { - t.Fatalf("a failed view was tried %d time(s) at once", w.forge.viewCalls-before) + if tries() != 1 { + t.Fatalf("a view the bus could not take was tried %d time(s) at once", tries()) } w.later(retryFirst + time.Second) w.h.Flush() - if w.forge.viewCalls != before+2 { - t.Fatalf("a failed view was not tried again after its wait: %d", w.forge.viewCalls-before) + if tries() != 2 { + t.Fatalf("a failed view was not tried again after its wait: %d", tries()) } if retryAfter(1) != retryFirst || retryAfter(2) != 2*retryFirst || retryAfter(100) != retryCap { t.Fatalf("the waits: %s %s %s", retryAfter(1), retryAfter(2), retryAfter(100)) @@ -220,14 +211,17 @@ func TestOwedWorkTheForgeSaysCanNeverBeDoneIsDropped(t *testing.T) { w.later(retryCap) w.h.Flush() } - for _, e := range w.delivery(id).Owed { - if e.Kind == EffectView { - t.Fatalf("a view failing %d times is still owed: %+v", e.Tries, e) - } + if tries() != -1 { + t.Fatalf("a view failing %d times is still owed", tries()) } - if !doesNotExist(errors.New("Gitea API /repos/a/b/issues/1/comments: 404 Not Found")) || - doesNotExist(errors.New("tool gitea.gitea_delivery_view does not exist on this machine")) || - doesNotExist(errors.New("the forge is away")) { - t.Fatal("doesNotExist reads the forge's answer wrong") + if st := w.h.Status("listening"); !strings.Contains(strings.Join(st["refused"].([]string), "\n"), "given up") { + t.Fatalf("its drop was not said: %v", st["refused"]) + } + // The bus back: what is owed anew is written. + w.owed.down = false + w.h.Checked(verdict("novox/app", 7, head, "pass")) + w.h.Flush() + if len(w.owed.acts(sdk.OwedView)) == 0 { + t.Fatal("nothing was written once the bus was back") } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index d96772ce..0f74eeec 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -33,6 +33,8 @@ type Holder struct { Operator Operator Merges MergeStore Source SourceStore + // Owed is the gate's bucket owed: what a transition owes the forge, written as keys (owed.go). + Owed OwedStore // Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none. Pulls PullKeys // Seat says where today's module still stands; the gate holds the seat only when it stands nowhere (seat.go). Carry @@ -1956,7 +1958,8 @@ func (h *Holder) askOwnCheck(id string, m Member, why string) { return } if asked == "" { - h.Logf("[mesh-delivery] %s's check was asked and the controller's answer held no id: taken as refused, asked again in a minute at most", id) + h.Logf("[mesh-delivery] ERROR: %s's check was asked and the controller's answer held no check id: it is not taken as asked, "+ + "and asked again in a minute at most", id) return } delete(h.askTried, id) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index 3f1c841b..92ad64d2 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -250,19 +250,19 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) { if len(w.said) != 1 || len(w.forge.notesOn(head)) != 2 { t.Fatalf("once kept, said %d and noted %d", len(w.said), len(w.forge.notesOn(head))) } - // A forge that is away is asked again; a note is appended once however often it is asked. - w.forge.down = true + // A bucket the bus cannot take is written again; a note is appended once however often it is asked. + w.owed.down = true w.h.Checked(verdict("novox/app", 7, head, "pass")) w.settleAll() if len(w.forge.notesOn(head)) != 2 { - t.Fatal("noted while the forge was away") + t.Fatal("noted while the bus was away") } - w.forge.down = false + w.owed.down = false w.later(time.Hour) // each failure put its next try later: past every one of them w.settleAll() w.settleAll() if notes := w.forge.notesOn(head); len(notes) != 4 { - t.Fatalf("after the forge came back the note has %d line(s): %q", len(notes), notes) + t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes) } if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 { t.Fatalf("still owed: %+v", d.Owed) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go new file mode 100644 index 00000000..bb282ec2 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "testing" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **The gate writes nothing to the forge: its statuses, views, notes and comments are owed keys, and its plan is read from +// `plans`** (novox/hq ADR 0437 §2, §8, ADR 0450 §2, §4, §7, issue 1349; the empty check answer is the mesh-delivery half of +// issue 1311). Fixture sources: the key and the value of an owed act are mesh-sdk go/delivery's (OwedKey, Owed); the arguments +// of `plans` are mesh-controller cmd/mesh-controller/seatverbs.go's (repository, paths, module-dirs, head, base, removed, data) +// and its answer the document of deliveryPlanDocument (plan, reach, mesh, gated). + +// AC1: a transition that today sets a status, a view, a note or a comment writes the matching owed key. +func TestATransitionWritesItsStatusViewAndNoteAsOwedKeys(t *testing.T) { + w := newWorld(t) + id := IDOf("novox/app", head) + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + w.settleAll() + page := "https://forge.invalid/novox/app/pulls/7" + statusKey, _ := sdk.OwedKey("novox/app", 7, head, "status=mesh/delivery") + viewKey, _ := sdk.OwedKey("novox/app", 7, head, "view") + noteKey, _ := sdk.OwedKey("novox/app", 7, head, "note") + for _, key := range []string{statusKey, viewKey, noteKey} { + if _, there := w.owed.keys[key]; !there { + t.Fatalf("the key %s was not written; the keys are %v", key, w.owed.writes) + } + } + if s := w.owed.keys[statusKey]; s.Act != sdk.OwedStatus || s.Context != "mesh/delivery" || s.State != "pending" || s.Target != page || s.Text == "" { + t.Fatalf("the owed status is %+v; wanted mesh/delivery pending, pointing at %s", s, page) + } + if v := w.owed.keys[viewKey]; v.Act != sdk.OwedView || !strings.Contains(v.Text, id) { + t.Fatalf("the owed view is %+v", v) + } + if n := w.owed.keys[noteKey]; n.Act != sdk.OwedNote || n.Ref != "mesh-plan" || !strings.Contains(n.Text, "proposed") { + t.Fatalf("the owed note is %+v", n) + } + // What the bridge would perform is what the forge holds. + if len(w.forge.notesOn(head)) == 0 || !strings.HasPrefix(statusOn(w, "novox/app", head), "pending") { + t.Fatalf("the fake bridge did not perform what was owed: %v", w.forge.notesOn(head)) + } +} + +// AC1: a note on the merge commit, which no pull request names, is keyed with the number 0. +func TestANoteOnTheMergeCommitIsOwedWithNumberZero(t *testing.T) { + w := newWorld(t) + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + w.h.Checked(verdict("novox/app", 7, head, "pass")) + w.h.PullMerged(merged("novox/app", 7, head, merge)) + w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true))) + w.settleAll() + key, _ := sdk.OwedKey("novox/app", 0, merge, "note") + if n, there := w.owed.keys[key]; !there || n.Act != sdk.OwedNote { + t.Fatalf("no note was owed on the merge commit under %s; the keys are %v", key, w.owed.writes) + } +} + +// AC1: two comments on one head are two acts: the sequence number is in the act. +func TestTwoCommentsOnOneHeadAreTwoOwedKeys(t *testing.T) { + w := newWorld(t) + id := IDOf("novox/app", head) + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + w.h.mu.Lock() + d := w.h.deliveries[id] + w.h.sayOn(d, "the first thing said") + w.h.sayOn(d, "the second thing said") + w.h.mu.Unlock() + w.settleAll() + one, _ := sdk.OwedKey("novox/app", 7, head, "comment.1") + two, _ := sdk.OwedKey("novox/app", 7, head, "comment.2") + if w.owed.keys[one].Text != "the first thing said" || w.owed.keys[two].Text != "the second thing said" { + t.Fatalf("the comments are %+v and %+v", w.owed.keys[one], w.owed.keys[two]) + } + if len(w.forge.comments) != 2 { + t.Fatalf("the bridge would say %d comments: %v", len(w.forge.comments), w.forge.comments) + } +} + +// AC2: the gate's code calls no gitea write tool and no `delivery-plan`, and no controller verb beyond the ones its manifest +// names (ADR 0450 §7). Tests are left out: they name the words to prove them gone. +func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + forbidden := []string{"delivery-plan", "gitea_commit_status\"", "gitea_delivery_view", "gitea_note_append", "gitea_add_comment", + "gitea_merge_pull_request\"", "gitea_open_pulls_write"} + // The controller's verbs the gate calls: ADR 0450 §7. + allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans"} + called := map[string]bool{} + fset := token.NewFileSet() + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, f, nil, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.BasicLit: + if x.Kind == token.STRING { + for _, word := range forbidden { + if strings.Contains(x.Value, word) { + t.Errorf("%s holds the string %s", f, x.Value) + } + } + } + case *ast.CallExpr: + if sel, ok := x.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "verb" && len(x.Args) > 0 { + if lit, ok := x.Args[0].(*ast.BasicLit); ok { + verb, _ := strconv.Unquote(lit.Value) + called[verb] = true + if !slices.Contains(allowed, verb) { + t.Errorf("%s calls the controller's verb %q", f, verb) + } + } + } + } + return true + }) + } + for _, verb := range []string{"plans", "delivery-check"} { + if !called[verb] { + t.Errorf("the gate calls no %s: the test reads nothing", verb) + } + } +} + +// stubController is a controller that answers every verb with one document, and keeps what it was asked. +type stubController struct { + mu sync.Mutex + keys []string + bodies []map[string]any + answer string +} + +func (s *stubController) ask(key string, body any) (json.RawMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.keys = append(s.keys, key) + b, _ := body.(map[string]any) + s.bodies = append(s.bodies, b) + return json.RawMessage(s.answer), nil +} + +func (s *stubController) called(key string) []map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + var out []map[string]any + for i, k := range s.keys { + if k == key { + out = append(out, s.bodies[i]) + } + } + return out +} + +// AC3: a delivery with no plan gets it from `plans`, asked as data, with the change's diffset. +func TestADeliveryWithNoPlanGetsItFromPlansAsData(t *testing.T) { + w := newWorld(t) + stub := &stubController{answer: `{"ok":true,"output":"","answer":{"asked":"check-1","held":true,"walks":[], +"plan":{"repository":"novox/app","base":"main","head":"` + head + `","moved":["app"],"summary":"builds app, sent to the node"}}}`} + w.h.Controller = seatController{ask: stub.ask} + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + c := verdict("novox/app", 7, head, "pass") + c.Plan = nil + w.h.Checked(c) + w.settleAll() + asked := stub.called("seat:mesh-controller.plans") + if len(asked) == 0 { + t.Fatalf("`plans` was not asked; the controller was asked %v", stub.keys) + } + if len(stub.called("seat:mesh-controller.delivery-plan")) != 0 { + t.Fatal("delivery-plan was asked") + } + got := asked[0] + if got["repository"] != "novox/app" || got["paths"] != "src/a.go" || got["data"] != "true" || got["head"] != head || got["base"] != "main" { + t.Fatalf("plans was asked %v; wanted the repository, the paths, the head, the base and data", got) + } + if d := w.delivery(IDOf("novox/app", head)); d.Plan == nil || d.Plan.Summary != "builds app, sent to the node" { + t.Fatalf("the delivery's plan is %+v", d.Plan) + } +} + +// AC4: a delivery-check answer that holds no id is an error said aloud, naming the delivery, and not taken as asked. +func TestAnEmptyDeliveryCheckAnswerIsAnErrorNamingTheDelivery(t *testing.T) { + stub := &stubController{answer: `{"ok":true,"output":"","answer":{}}`} + if id, err := (seatController{ask: stub.ask}).Check("", []Member{{ID: "novox/app@x"}}); err == nil || id != "" || + !strings.Contains(err.Error(), "no check id") { + t.Fatalf("an empty check answer gave %q and %v", id, err) + } + w := newWorld(t) + var said []string + var mu sync.Mutex + w.h.Logf = func(format string, a ...any) { + mu.Lock() + defer mu.Unlock() + said = append(said, strings.TrimSpace(fmt.Sprintf(format, a...))) + } + w.h.Controller = seatController{ask: stub.ask} + w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) + w.settleAll() + id := IDOf("novox/app", head) + mu.Lock() + defer mu.Unlock() + found := false + for _, line := range said { + if strings.Contains(line, id) && strings.Contains(line, "no check id") && !strings.Contains(line, "taken as refused") { + found = true + } + } + if !found { + t.Fatalf("no error naming %s and the empty answer was logged: %q", id, said) + } + if d := w.delivery(id); d.CheckAsk != nil { + t.Fatalf("an empty answer was kept as an ask: %+v", d.CheckAsk) + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue407_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue407_test.go index 551744ab..f71d08df 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue407_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue407_test.go @@ -57,15 +57,15 @@ func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) { t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339)) } - // The forge away: its effects fail and are counted failed; quiet passes try nothing more. - w.forge.down = true + // The bus away: its effects fail and are counted failed; quiet passes try nothing more. + w.owed.down = true w.later(time.Minute) w.h.Checked(verdict("novox/app", 7, head, "pass")) w.h.Flush() tried = triedSaid(t, w) failed := countOf(t, tried, "failed") if failed == 0 { - t.Fatalf("the forge away failed nothing: %v", tried) + t.Fatalf("the bus away failed nothing: %v", tried) } if countOf(t, tried, "tried") != countOf(t, tried, "done")+failed+countOf(t, tried, "given-up") { t.Fatalf("tries do not add up: %v", tried) @@ -80,7 +80,7 @@ func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) { } // The forge back, past every next try: the failed effects are done. - w.forge.down = false + w.owed.down = false w.later(time.Hour) w.h.Flush() tried = triedSaid(t, w) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 4588a811..e5f4348a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -49,6 +49,7 @@ func main() { Operator: kvOperator{}, Merges: kvMerges{}, Source: kvSource{}, + Owed: kvOwed{}, Pulls: kvPulls{}, // The gate acts only while it holds the seat, and carries today's state over when it first does (seat.go). Seat: busOccupancy{}, diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go index 863d832a..ada614f3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -38,6 +38,8 @@ type Delivery struct { // that was said, for this pull request. After []string `json:"after,omitempty"` AfterSaid bool `json:"after_said,omitempty"` + // Comments is how many comments this owner owed on the pull request at this head, which numbers the next. + Comments int `json:"comments,omitempty"` // Issues are the issues its title and body name ("issue 948"), shown to the operator who is asked to approve its merge. Issues []int `json:"issues,omitempty"` // Group is the delivery group it is in: its head branch name. Empty for a lone delivery. @@ -321,6 +323,9 @@ const ( EffectNote = "note" EffectView = "view" EffectStatus = "status" + // EffectComment is a comment on the pull request; its Context is the sequence number of the comment on the head, which + // keeps two comments on one head two acts (mesh-sdk go/delivery OwedCommentAct). + EffectComment = "comment" ) // FromForgesWord is whether the delivery was made from the forge's word at its merge: said by FromForge, or — diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go new file mode 100644 index 00000000..2e636dbe --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/owed.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The gate writes nothing to the forge: what a transition owes it — a status, the view on the pull request, a line of the +// commit's note, a comment — is one key of the gate's bucket owed, `...` (mesh-sdk go/delivery +// OwedKey), which the forge bridge performs once for each revision (novox/hq ADR 0437 §8, ADR 0450 §4). The number is 0 for +// a commit no pull request names; a comment's act carries a sequence number, so two comments on one head are two acts. +// What the gate keeps is the effect owed until its key is written (effects.go); the forge's own refusal is the bridge's +// to say on FORGE, and the gate has no retry for it. + +// noPage is the target of a status whose delivery has no page to point at. +const noPage = "none" + +// notesRef is the notes ref the commit's note is kept under. +const notesRef = "mesh-plan" + +// OwedStore is the gate's bucket owed. +type OwedStore interface { + // Put writes the act under its key, unless that key holds the same act already (a revision is an act to perform). + Put(key string, o sdk.Owed) error +} + +// kvOwed is owed over the runtime. +type kvOwed struct{} + +func (kvOwed) Put(key string, o sdk.Owed) error { + raw, err := sdk.EncodeOwed(o) + if err != nil { + return err + } + if e, err := stdio.State("owed").Get(key); err == nil && e != nil { + if kept, err := sdk.DecodeOwed(e.Value); err == nil && sdk.SameOwed(kept, o) { + return nil + } + } + _, err = stdio.State("owed").Put(key, json.RawMessage(raw)) + return err +} + +// owe writes one owed act as its key. An act that has no key (a name the key refuses, a status with no page to point at) is +// said and dropped, since writing it again would fail the same. +func (h *Holder) owe(repository string, number int, head, act string, o sdk.Owed) error { + if h.Owed == nil { + return errors.New("no owed bucket") + } + key, err := sdk.OwedKey(repository, number, head, act) + if err == nil { + o.Act = actKind(act) + _, err = sdk.EncodeOwed(o) + } + if err != nil { + h.Refuse(fmt.Errorf("the act %q on %s#%d at %s cannot be owed and is dropped: %v", act, repository, number, shortOf(head, 12), err)) + return nil + } + return h.Owed.Put(key, o) +} + +// actKind is the kind of an owed act: `status=` is a status, `comment.` a comment. +func actKind(act string) string { + kind, _, _ := sdk.ParseOwedAct(act) + return kind +} + +// statusAct is the act of a status of a context. +func statusAct(context string) string { return sdk.OwedStatus + "=" + context } + +// commentAct is the act of the seq-th comment on a head. +func commentAct(seq int) string { + act, _ := sdk.OwedCommentAct(seq) + return act +} + +// owedOf is an effect as the act its key names and its value: the repository and number are the delivery's (or, for a +// group's status, the member's the effect names in Last), the body the view's. +func owedOf(e Effect, repository string, number int, head, body, page string) (act string, value sdk.Owed, num int, hd string) { + num, hd = number, head + switch e.Kind { + case EffectNote: + hd = e.Commit + if e.Commit != head { + num = 0 // the merge commit is named by no pull request + } + return sdk.OwedNote, sdk.Owed{Text: e.Line, Ref: notesRef}, num, hd + case EffectView: + return sdk.OwedView, sdk.Owed{Text: body}, num, hd + case EffectStatus: + // The owed status must name where it points; a delivery with no pull request page points at none, which the forge's + // tool leaves out (it links only an http or https address). + if page == "" { + page = noPage + } + return statusAct(e.Context), sdk.Owed{Context: e.Context, State: e.State, Text: e.Line, Target: page}, num, e.Commit + case EffectComment: + seq, _ := strconv.Atoi(e.Context) + return commentAct(seq), sdk.Owed{Text: e.Line}, num, hd + } + return "", sdk.Owed{}, 0, "" +} + +// memberOfLast reads the repository, pull request number and page a group's status effect names in Last +// (`owner/repo#N page`). +func memberOfLast(last string) (repository string, number int, page string) { + where, page, _ := strings.Cut(last, " ") + repository, n, _ := strings.Cut(where, "#") + number, _ = strconv.Atoi(n) + return repository, number, page +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go index c4f1041d..4cb8767f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go @@ -71,12 +71,6 @@ type Controller interface { // Forge is what the forge's holder does for a delivery. type Forge interface { - // Note appends one line to a commit's note under refs/notes/mesh-plan; a line already there is not added. - Note(owner, repo, commit, line string) error - // View keeps the delivery's view current on its pull request: one comment, edited in place. - View(owner, repo string, number int, body string) error - // Status sets one status of a commit, linking to the view. - Status(owner, repo, commit, context, state, description, target string) error // Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch // it merges into would let it merge. Reads only. Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) @@ -85,8 +79,6 @@ type Forge interface { // OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue // 347). Reads only. OpenPulls() ([]OpenPull, error) - // Comment adds a comment to a pull request. - Comment(owner, repo string, number int, body string) error } // ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses). @@ -206,8 +198,10 @@ func (c seatController) verb(name string, args map[string]any) (json.RawMessage, return answer, nil } +// Plan asks the controller's `plans` for the plan of a change as data (novox/hq ADR 0450 §2): the document the old +// per-change verb answered, now the what-if of the plans every person reads as text. func (c seatController) Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error) { - args := map[string]any{"repository": repository, "paths": strings.Join(paths, ",")} + args := map[string]any{"repository": repository, "paths": strings.Join(paths, ","), "data": "true"} if base != "" { args["base"] = base } @@ -220,7 +214,7 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r if len(removed) > 0 { args["removed"] = strings.Join(removed, ",") } - raw, err := c.verb("delivery-plan", args) + raw, err := c.verb("plans", args) if err != nil { return nil, err } @@ -228,7 +222,7 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r Plan *DeliveryPlan `json:"plan"` } if err := json.Unmarshal(raw, &a); err != nil || a.Plan == nil { - return nil, errors.New("the controller's delivery-plan answered no plan") + return nil, errors.New("the controller's plans answered no plan as data") } return a.Plan, nil } @@ -250,7 +244,10 @@ func (c seatController) Check(group string, members []Member) (string, error) { if a.Asked != "" { return a.Asked, nil } - return a.Rechecked, nil + if a.Rechecked != "" { + return a.Rechecked, nil + } + return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200)) } func (c seatController) Deliver(walk, why string) error { @@ -341,20 +338,6 @@ func (f toolForge) tool(name string, args map[string]any) error { return nil } -func (f toolForge) Note(owner, repo, commit, line string) error { - return f.tool("gitea_note_append", map[string]any{"owner": owner, "repo": repo, "sha": commit, - "ref": "mesh-plan", "line": line}) -} - -func (f toolForge) View(owner, repo string, number int, body string) error { - return f.tool("gitea_delivery_view", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body}) -} - -func (f toolForge) Status(owner, repo, commit, context, state, description, target string) error { - return f.tool("gitea_commit_status", map[string]any{"owner": owner, "repo": repo, "sha": commit, - "context": context, "state": state, "description": description, "target_url": target}) -} - func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) { args := map[string]any{"owner": owner, "repo": repo} if number > 0 { @@ -381,10 +364,6 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string) return &c, nil } -func (f toolForge) Comment(owner, repo string, number int, body string) error { - return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body}) -} - func (f toolForge) OpenPulls() ([]OpenPull, error) { raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{}) if err != nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index f5ff6be6..0fd35506 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -3,6 +3,7 @@ package main import ( "fmt" "sort" + "strconv" "strings" "time" @@ -343,14 +344,13 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string { return "" } -// sayOn comments on a delivery's pull request. +// sayOn owes a comment on a delivery's pull request: an owed act numbered among the comments on its head, written as a key +// with the delivery's other effects (owed.go). Under the lock; what it returns has nothing left to do. func (h *Holder) sayOn(d *Delivery, body string) func() { - owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID - return func() { - if err := h.Forge.Comment(owner, repo, number, body); err != nil { - h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err) - } - } + d.Comments++ + d.Owed = append(d.Owed, Effect{Kind: EffectComment, Commit: d.Commit, Line: body, Context: strconv.Itoa(d.Comments), Since: h.Now()}) + h.keep(d) + return func() {} } // readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound; diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go index 97c843c8..f8bc9be4 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go @@ -80,12 +80,12 @@ func TestARecheckLeavesAStatusTheHeadNeverHadAlone(t *testing.T) { } } -// A forge that cannot be told refuses the recheck whole: nothing is asked on a stale verdict, and the +// A bus that cannot take the pending statuses refuses the recheck whole: nothing is asked on a stale verdict, and the // delivery stays where it stood, for the person to ask again. -func TestARecheckTheForgeCannotTakeIsRefused(t *testing.T) { +func TestARecheckTheBusCannotTakeIsRefused(t *testing.T) { w := newWorld(t) id := heldGreen(t, w, "novox/app", 9) - w.forge.down = true + w.owed.down = true if _, err := w.h.Recheck(id, "main moved on", "a person"); err == nil { t.Fatal("a recheck whose statuses could not be put back was taken") } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go index 370d1127..d1d6214e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go @@ -109,16 +109,16 @@ func TestAMergeNoWalkOpenedForReadsTheControllersWalksOnceAtItsBound(t *testing. } } -func TestAnEffectTheForgeCouldNotTakeIsTriedAgainAtItsTimeByTheTimerAlone(t *testing.T) { +func TestAnEffectTheBusCouldNotTakeIsTriedAgainAtItsTimeByTheTimerAlone(t *testing.T) { w := newWorld(t) - w.forge.down = true + w.owed.down = true w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) w.h.React() at, step := w.wantedAt() if at.IsZero() || !strings.Contains(step, "owed") || at.After(w.now.Add(retryFirst)) { t.Fatalf("the look wanted at %s for %q; wanted within %s for the owed effect", at, step, retryFirst) } - w.forge.down = false + w.owed.down = false w.later(retryFirst + time.Second) w.h.React() if len(w.forge.notes) == 0 { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go index 9a3e5aaf..b7bee361 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -297,9 +297,9 @@ func (h *Holder) Recheck(id, why, by string) (string, error) { h.mu.Unlock() return "", err } - owner, repo, commit, page := d.Owner(), d.Repo(), d.Commit, d.HTMLURL + owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number h.mu.Unlock() - if err := h.checkingAgain(owner, repo, commit, page, why); err != nil { + if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil { return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+ "so nothing merges on the old verdict — ask again once the forge answers", id, err) } @@ -336,10 +336,11 @@ func (h *Holder) Recheck(id, why, by string) (string, error) { // verdict sets it; the repository's own check only when the head holds one, since a verdict that says none // would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes // pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293). -func (h *Holder) checkingAgain(owner, repo, commit, page, why string) error { +func (h *Holder) checkingAgain(owner, repo string, number int, commit, page, why string) error { if h.Forge == nil { return errors.New("no forge") } + // The statuses are owed as keys the forge bridge performs, on the pull request number the delivery has. contexts := []string{ctxGate, ctxRepo} if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil { contexts = []string{ctxGate} @@ -351,8 +352,10 @@ func (h *Holder) checkingAgain(owner, repo, commit, page, why string) error { } line := clip(oneLine("checking again: "+why), 140) for _, c := range contexts { - if err := h.Forge.Status(owner, repo, commit, c, "pending", line, page); err != nil { - return fmt.Errorf("the forge did not set %s pending on %s: %w", c, shortOf(commit, 8), err) + act, value, num, head := owedOf(Effect{Kind: EffectStatus, Commit: commit, Context: c, State: "pending", Line: line}, + owner+"/"+repo, number, commit, "", page) + if err := h.owe(owner+"/"+repo, num, head, act, value); err != nil { + return fmt.Errorf("%s could not be owed as pending on %s: %w", c, shortOf(commit, 8), err) } } return nil -- 2.54.0 From d16fa1e052ad19177113b506f8ac7488e1c42c73 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:58:01 +0000 Subject: [PATCH 06/15] Hear the gate's transitions beside today's delivery module's in mesh-issues, and name the gate among the owners (issue 1350) --- .../cmd/mesh-issues/delivery_test.go | 31 ++++++++++++++++++- modules/mesh-issues/cmd/mesh-issues/main.go | 16 +++++++--- .../cmd/mesh-issues/manifest_test.go | 7 ++++- modules/mesh-issues/files/settings.json | 2 +- modules/mesh-issues/module.json | 5 +-- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery_test.go b/modules/mesh-issues/cmd/mesh-issues/delivery_test.go index 5ad2ec44..46b9d4a0 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery_test.go @@ -45,10 +45,16 @@ func (n *notified) notify(key, severity, summary, headline, explanation, resolve } func transition(t *testing.T, h *harness, number int, to, why, mergedAs string, at time.Time) { + t.Helper() + transitionOn(t, h, "mesh-delivery.transition", number, to, why, mergedAs, at) +} + +// transitionOn is a delivery's transition said under an event's name: today's module's, or the gate's. +func transitionOn(t *testing.T, h *harness, event string, number int, to, why, mergedAs string, at time.Time) { t.Helper() raw, _ := json.Marshal(map[string]any{"repository": "novox/mesh-catalog", "number": number, "to": to, "why": why, "merged_as": mergedAs, "at": at}) - if err := h.OnTransition(stdio.Envelope{Key: "mesh-delivery.transition", Body: raw}); err != nil { + if err := h.OnTransition(stdio.Envelope{Key: event, Body: raw}); err != nil { t.Fatal(err) } } @@ -333,3 +339,26 @@ func TestAResolvedIssueEndsItsStuckWarningAndIsNeverSaidStuck(t *testing.T) { t.Fatalf("a resolved issue said stuck: %q", note.said) } } + +// Issue 1350 AC1: the handler takes a transition under either name, today's module's or the gate's, and keeps the same fact. +func TestATransitionIsTakenUnderEitherNameOfTheDeliverysOwner(t *testing.T) { + for _, event := range transitionEvents { + t.Run(event, func(t *testing.T) { + h := newHarness(t) + n := h.open(t, "the counter skips a number") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusLocated, LocatedIn: []string{"mesh-catalog modules/records"}, + FixedBy: []string{"mesh-catalog#9"}}); err != nil { + t.Fatal(err) + } + at := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) + transitionOn(t, h, event, 9, "delivered", "the walk is done", "abc123abc123", at) + v, err := h.View(n) + if err != nil { + t.Fatal(err) + } + if v.Delivery != "delivered to every machine it should reach" { + t.Fatalf("the issue's delivery line is %q, heard as %s; wanted delivered", v.Delivery, event) + } + }) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 03c7d83c..932f5485 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -145,15 +145,23 @@ func start(t *Tracker) { } } +// transitionEvents are the events a delivery's transitions are said under: today's module's, and the gate's that takes its place +// (novox/hq ADR 0450 §8, issue 1350). The old name stays until today's module is forgotten; the handler takes either. +var transitionEvents = []string{"mesh-delivery.transition", "mesh-delivery-gate.transition"} + // hearOpened subscribes the triage to the tracker's own `opened` and `moved` events and to mesh-delivery's -// `transition` (ADR 0278 §1, ADR 0279 §3), each asked again until the runtime takes it: the runtime answers only +// `transition`, and the gate's (ADR 0278 §1, ADR 0279 §3), each asked again until the runtime takes it: the runtime answers only // once Serve runs. func hearOpened(t *Tracker) { - for _, s := range []struct { + type hearing struct { pattern string handle func(stdio.Envelope) error - }{{"mesh-issues." + EventOpened, t.OnOpened}, {"mesh-issues." + EventMoved, t.OnMoved}, - {"mesh-delivery.transition", t.OnTransition}} { + } + hearings := []hearing{{"mesh-issues." + EventOpened, t.OnOpened}, {"mesh-issues." + EventMoved, t.OnMoved}} + for _, event := range transitionEvents { + hearings = append(hearings, hearing{event, t.OnTransition}) + } + for _, s := range hearings { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { time.Sleep(wait) err := stdio.Subscribe(s.pattern, s.handle) diff --git a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go index 90d72212..f7ff9da7 100644 --- a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go @@ -102,9 +102,14 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) { d[3].Name != ReservationsState || d[3].History > 1 || d[3].TTLSeconds != 0 { t.Fatalf("state %+v", d) } + // The delivery's transitions are heard from today's module and from the gate that takes its place (novox/hq ADR 0450 §8, + // issue 1350); the old name stays until today's module is forgotten. The subscriptions are the same list. + if !reflect.DeepEqual(transitionEvents, []string{"mesh-delivery.transition", "mesh-delivery-gate.transition"}) { + t.Fatalf("the triage subscribes to %v", transitionEvents) + } // The duplicate check hears the tracker's own opens and asks the agent module on its node (ADR 0278 §1, §5); // the import and a reservation read the record through the records module (issue 424). - if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery.transition"}) || + if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery.transition", "mesh-delivery-gate.transition"}) || !reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge", "seat:mesh-delivery.checks", "seat:operator-channel.notify", "gitea.gitea_pull_reviews", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) { t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes) diff --git a/modules/mesh-issues/files/settings.json b/modules/mesh-issues/files/settings.json index 54466137..dd487d3d 100644 --- a/modules/mesh-issues/files/settings.json +++ b/modules/mesh-issues/files/settings.json @@ -58,7 +58,7 @@ "mesh-catalog modules/memory-pressure", "mesh-catalog modules/mesh-catalog", "mesh-catalog modules/mesh-cli", - "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", "mesh-catalog modules/mesh-issues", "mesh-catalog modules/mesh-vault", "mesh-catalog modules/mesh-watcher", diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index 36bd42b4..8d17cea4 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -57,7 +57,8 @@ "consumes": [ "mesh-issues.opened", "mesh-issues.moved", - "mesh-delivery.transition" + "mesh-delivery.transition", + "mesh-delivery-gate.transition" ], "invokes": [ "claude-code.claude_code_judge", @@ -151,7 +152,7 @@ "mesh-catalog modules/memory-pressure", "mesh-catalog modules/mesh-catalog", "mesh-catalog modules/mesh-cli", - "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", "mesh-catalog modules/mesh-issues", "mesh-catalog modules/mesh-vault", "mesh-catalog modules/mesh-watcher", -- 2.54.0 From 3b03c220ad22297a5ec64e7d7e7c75740e7b8cdf Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:59:36 +0000 Subject: [PATCH 07/15] Check that the catalogue holds no module mesh-delivery but through the names its handover needs, and say how the seat passes (issue 1351) --- modules/mesh-delivery-gate/README.md | 19 +++++ .../cmd/mesh-delivery-gate/catalogue_test.go | 69 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/catalogue_test.go diff --git a/modules/mesh-delivery-gate/README.md b/modules/mesh-delivery-gate/README.md index bcc3db09..0da8df86 100644 --- a/modules/mesh-delivery-gate/README.md +++ b/modules/mesh-delivery-gate/README.md @@ -19,3 +19,22 @@ module and the backup's words, and the pull request's comment shows it. The hold of such a delivery asks the controller (`delivery-backups-again`) to take the backups again and the delivery goes on delivering; a record from before the controller cleared the failure does not hold it again. A held delivery already ends as covered or delivered when a later merge covers it. + +## Taking the seat over from today's module + +The gate claims the seat `mesh-delivery` beside the module `mesh-delivery`, which the catalogue no longer holds. The controller +keeps a module that is assigned (it refuses to forget it), so the running module goes by a handover of verbs after the merge, +never by hand on a machine (novox/hq ADR 0449 §4, ADR 0450 §6): + +1. assign `mesh-delivery-gate` to the machine that holds the seat (`mesh-controller.assign`); +2. check that `mesh-controller.seats` still names `mesh-delivery`, held by the running module; +3. unassign `mesh-delivery` from that machine (`mesh-controller.unassign`) and push it (`mesh-controller.push`); +4. the seat passes: the gate reads the controller's assignments and the node-engine's have keys, finds the module on no machine, + carries its deliveries, groups and pull keys over once (the key `carried` in its `deliveries` says it did), reads the controller's + walks and the forge's open pull requests, and acts. + +Until then the gate is silent: it takes no event, writes no key, asks nothing and answers no verb but `delivery_status`, which +says why (`holds-the-seat`, `seat-waits`). + +The catalogue holds the names of the old module that the handover needs, and no others: the three buckets the gate carries +over, and the event the issue tracker and the tool runner hear until the module is forgotten (`catalogue_test.go`). diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/catalogue_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/catalogue_test.go new file mode 100644 index 00000000..af564adc --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/catalogue_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// **Today's module is gone from the catalogue** (novox/hq ADR 0437 §1, issue 1351; how it is checked): no `modules/mesh-delivery` +// is held, no manifest declares the module `mesh-delivery`, and no manifest names the module in what it reads, hears or +// calls but through the seat's name (`mesh-delivery` as a seat or a repository, `seat:mesh-delivery.`) and through +// the names below, each of which stays until today's module is forgotten by the controller, because it is the only way to +// reach what that module left: the three buckets the gate carries over (ADR 0450 §6) and the event the trackers hear +// (issue 1350). Removing the module from the catalogue does not stop the running module: the controller keeps it until it is +// unassigned (mesh-controller upgrades.go retireDeleted), and the handover follows the merge by verbs. +var oldModuleNames = map[string][]string{ + "mesh-delivery-gate": {"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls"}, + "mesh-issues": {"mesh-delivery.transition"}, +} + +func TestTheCatalogueHoldsNoModuleMeshDeliveryButThroughTheNamesItsHandoverNeeds(t *testing.T) { + modules := filepath.Join("..", "..", "..") + if _, err := os.Stat(filepath.Join(modules, "mesh-delivery")); err == nil { + t.Fatal("modules/mesh-delivery is still in the catalogue: its code is the gate's now") + } + dirs, err := os.ReadDir(modules) + if err != nil { + t.Fatal(err) + } + read := 0 + for _, dir := range dirs { + raw, err := os.ReadFile(filepath.Join(modules, dir.Name(), "module.json")) + if err != nil { + continue + } + read++ + var manifest any + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("%s: %v", dir.Name(), err) + } + if m, _ := manifest.(map[string]any); m["module"] == "mesh-delivery" { + t.Errorf("%s declares the module mesh-delivery", dir.Name()) + } + var walk func(v any) + walk = func(v any) { + switch x := v.(type) { + case string: + if strings.HasPrefix(x, "mesh-delivery.") && !slices.Contains(oldModuleNames[dir.Name()], x) { + t.Errorf("%s names the module mesh-delivery in %q: through the seat, or the gate's name", dir.Name(), x) + } + case []any: + for _, e := range x { + walk(e) + } + case map[string]any: + for _, e := range x { + walk(e) + } + } + } + walk(manifest) + } + if read < 20 { + t.Fatalf("only %d manifests were read: the catalogue is not where the test looks", read) + } +} -- 2.54.0 From 68117bd74db6b98da051b241fde3dac3018fb08b Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:20:09 +0000 Subject: [PATCH 08/15] Hold the seat only when no assignment stands, every have key says removed and both watches are current; fix the two bed tests that failed the try (review round 1, G1) --- .../cmd/mesh-delivery-gate/bus_test.go | 25 +++- .../cmd/mesh-delivery-gate/main.go | 9 +- .../cmd/mesh-delivery-gate/occupancy_test.go | 80 +++++++++++++ .../cmd/mesh-delivery-gate/seat.go | 108 +++++++++++++++--- 4 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/occupancy_test.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index 33edebaf..ae311e49 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "testing" @@ -93,6 +94,10 @@ func newTestBus(t *testing.T) *testBus { } b.kvs[name] = kv } + // The node-engine has said today's module removed on the one machine: the gate may hold the seat (ADR 0450 §6, issue 1369). + if _, err := b.kvs["node-engine.have"].Put(context.Background(), "test.mesh-delivery", []byte(`{"v":1,"phase":"removed","health":"unknown"}`)); err != nil { + t.Fatal(err) + } // The streams the events are on (mesh.mod..event., mesh.seat..event.). if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>", @@ -528,7 +533,8 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7, "title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head}) rt.until("the ask to be written again with the record", 5*time.Second, func() bool { - return strings.Contains(askAt(t, b, key).Ask.Explanation, "reviewed by lead on shanks") + a, ok := askNow(b, key) // the key may be absent for an instant while the ask is taken away and written again + return ok && strings.Contains(a.Ask.Explanation, "reviewed by lead on shanks") }) if h, err := b.kvs["asks"].History(ctx, key); err != nil || len(h) != 3 || h[1].Operation() != jetstream.KeyValueDelete { t.Fatalf("the ask's history is %d revisions (%v); wanted written, taken away, written", len(h), err) @@ -597,6 +603,16 @@ func onlyAsk(b *testBus) string { return keys[0] } +// askNow is the ask under a key at this moment; false in the instant a rewrite has taken the key away and not yet written it. +func askNow(b *testBus, key string) (sdk.Ask, bool) { + e, err := b.kvs["asks"].Get(context.Background(), key) + if err != nil { + return sdk.Ask{}, false + } + a, err := sdk.DecodeAsk(e.Value()) + return a, err == nil +} + func askAt(t *testing.T, b *testBus, key string) sdk.Ask { t.Helper() e, err := b.kvs["asks"].Get(context.Background(), key) @@ -875,8 +891,11 @@ func TestTheGateCarriesALiveCapturesDeliveriesAndGroupsOnceOverARealBus(t *testi if err != nil { t.Fatalf("the group %s did not arrive: %v", g.ID, err) } - if string(mustJSON(g)) != string(mustJSON(mustDecode[Group](t, e.Value()))) { - t.Fatalf("the group %s changed in the carry", g.ID) + // The carry copies the group byte for byte (TestTheCarryCopiesWhatItDoesNotClearByteForByte); by the time this reads, the gate + // holds the seat and acts, and writes a group's said state as it does, so what is compared is what only the carry could change. + got := mustDecode[Group](t, e.Value()) + if got.ID != g.ID || !slices.Equal(got.Members, g.Members) || !got.Created.Equal(g.Created) { + t.Fatalf("the group %s changed in the carry: %+v", g.ID, got) } } if _, err := b.kvs["deliveries"].Get(ctx, carriedKey); err != nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index e5f4348a..1a5ae466 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -40,6 +40,7 @@ var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.c const forgePattern = "mesh-delivery-forge-bridge.forge.**" func main() { + occupancy := &busOccupancy{} h := &Holder{ Store: kvStore{}, Controller: seatController{ask: stdio.Ask}, @@ -52,13 +53,13 @@ func main() { Owed: kvOwed{}, Pulls: kvPulls{}, // The gate acts only while it holds the seat, and carries today's state over when it first does (seat.go). - Seat: busOccupancy{}, + Seat: occupancy, Carry: carryFromBus, } h.OnHold = func() { go readOnHolding(h) } h.init() l := &listening{now: "not yet: starting"} - go run(h, l) + go run(h, l, occupancy) if err := stdio.Serve("", tools(h, l)); err != nil { logf("%v", err) os.Exit(1) @@ -70,10 +71,10 @@ func main() { // holds the seat (SeatChanged), and what it may have missed meanwhile is read once then (readOnHolding): the controller's // walks, the forge's open pull requests, the operator's asks — and it acts on events from then on (novox/hq ADR 0406 §1, // issue 975): no tick, no forge poll, no reconcile loop. Each step is retried, each failure said, never given up on quietly. -func run(h *Holder, l *listening) { +func run(h *Holder, l *listening, occupancy *busOccupancy) { time.Sleep(500 * time.Millisecond) // Serve first: the state is reached through it go h.Work() - go watchSeat(h, l) + go watchSeat(h, l, occupancy) for _, pattern := range followed { pattern := pattern for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/occupancy_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/occupancy_test.go new file mode 100644 index 00000000..d8fb63d2 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/occupancy_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "strings" + "testing" +) + +// **Where today's module stands** (novox/hq ADR 0450 §6 as corrected, issues 1347 and 1369; review round 1, G1). Fixture +// sources: an assignment is mesh-controller internal/chain/records.go Assignment (a key is never removed: an unassignment writes +// `ended`, a pending one has a `state`); a have is mesh-sdk go/delivery Have, whose phase `removed` the node-engine writes for a +// module its declaration no longer holds. +const ( + standing = `{"node":"novox","module":"mesh-delivery","assigned":"2026-10-01T10:00:00Z","build":"b1","since":"2026-10-01T10:00:00Z"}` + ended = `{"node":"novox","module":"mesh-delivery","assigned":"2026-10-01T10:00:00Z","build":"b1","since":"2026-10-01T10:00:00Z","ended":"2026-10-20T03:00:00Z"}` + pending = `{"node":"novox","module":"mesh-delivery","state":"waiting"}` + haveRun = `{"v":1,"build":"b1","phase":"running","health":"ok"}` + haveGone = `{"v":1,"build":"b1","phase":"removed","health":"unknown"}` + haveFailed = `{"v":1,"build":"b1","phase":"failed","health":"failed"}` +) + +func bucket(current bool, kv ...string) seatBucket { + b := seatBucket{values: map[string][]byte{}, current: current} + for i := 0; i+1 < len(kv); i += 2 { + b.values[kv[i]] = []byte(kv[i+1]) + } + return b +} + +func TestAnEndedAssignmentKeepsItsKeyAndIsNoOccupant(t *testing.T) { + who, err := occupantsOf(bucket(true, "novox.mesh-delivery", ended), bucket(true, "novox.mesh-delivery", haveGone)) + if err != nil || len(who) != 0 { + t.Fatalf("an ended assignment and a removed have gave %v, %v; wanted a free seat", who, err) + } + if who, _ := occupantsOf(bucket(true, "novox.mesh-delivery", standing), bucket(true, "novox.mesh-delivery", haveGone)); len(who) != 1 || !strings.Contains(who[0], "assigned on novox") { + t.Fatalf("a standing assignment gave %v", who) + } + // A pending assignment, waiting for its build, is no assignment (every reader of the chain says so). + if who, _ := occupantsOf(bucket(true, "novox.mesh-delivery", pending), bucket(true, "novox.mesh-delivery", haveGone)); len(who) != 0 { + t.Fatalf("a pending assignment gave %v", who) + } + // Other modules' keys are no business of the gate's. + if who, _ := occupantsOf(bucket(true, "novox.mesh-delivery-gate", standing), bucket(true, "novox.mesh-delivery", haveGone)); len(who) != 0 { + t.Fatalf("another module's assignment gave %v", who) + } +} + +func TestAHaveKeyCountsUnlessItsPhaseIsRemoved(t *testing.T) { + for _, have := range []string{haveRun, haveFailed, `{"v":1,"phase":"applying","health":"unknown"}`, `not json`} { + who, err := occupantsOf(bucket(true), bucket(true, "novox.mesh-delivery", have)) + if err != nil || len(who) != 1 || !strings.Contains(who[0], "node-engine of novox") { + t.Fatalf("the have %s gave %v, %v; wanted an occupant", have, who, err) + } + } + // One machine removed it, another runs it still. + who, _ := occupantsOf(bucket(true), bucket(true, "novox.mesh-delivery", haveGone, "ace.mesh-delivery", haveRun)) + if len(who) != 1 || !strings.Contains(who[0], "ace") { + t.Fatalf("a module removed on one machine and running on another gave %v", who) + } +} + +// An empty read says nothing of the module: the seat is not free until a node-engine said the module removed. +func TestAnEmptyReadOfTheHaveKeysDoesNotFreeTheSeat(t *testing.T) { + who, err := occupantsOf(bucket(true), bucket(true)) + if err != nil || len(who) != 1 || !strings.Contains(who[0], "no have key") { + t.Fatalf("an empty read gave %v, %v; wanted the seat held back", who, err) + } + if who, _ := occupantsOf(bucket(true), bucket(true, "novox.node", `{"v":1}`, "novox.mesh-delivery-gate", haveRun)); len(who) != 1 { + t.Fatalf("have keys of other modules gave %v", who) + } +} + +// A watch that has not delivered its current values yet says nothing: the seat is not judged. +func TestTheSeatIsNotJudgedBeforeBothWatchesAreCurrent(t *testing.T) { + for _, c := range [][2]bool{{false, true}, {true, false}, {false, false}} { + who, err := occupantsOf(bucket(c[0]), bucket(c[1], "novox.mesh-delivery", haveGone)) + if err == nil || len(who) != 0 { + t.Fatalf("watches current %v gave %v, %v; wanted an error", c, who, err) + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go index 3160c98e..854ce277 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go @@ -2,9 +2,11 @@ package main import ( "encoding/json" + "errors" "fmt" "sort" "strings" + "sync/atomic" "time" stdio "git.novox.be/novox/mesh-sdk/go" @@ -193,27 +195,99 @@ func CarryOver(old, own func(bucket string) Bucket, at time.Time) (CarryReport, return report, nil } -// busOccupancy reads the controller's assignments and the node-engine's have keys, both `.`. -type busOccupancy struct{} +// What a bucket says of where today's module stands: the values by key, `.`, and whether the watch of the bucket +// has delivered its current values. +type seatBucket struct { + values map[string][]byte + current bool +} -func (busOccupancy) Occupants() ([]string, error) { +// occupantsOf says where today's module stands from what the controller's assignments and the node-engine's have keys hold +// (novox/hq ADR 0450 §6 as corrected). Neither bucket loses a key: an unassignment writes `ended` on the assignment and keeps +// its key (mesh-controller internal/chain/records.go Assignment), and the node-engine writes the phase `removed` on the have +// key of a module its declaration no longer holds (issue 1369). So an assignment counts only while it stands (no `ended`, no +// `state`), a have key counts unless its phase is `removed`, and the module is gone only once both watches have delivered +// their current values and at least one have key of it exists and every one says removed. A value that cannot be read counts. +func occupantsOf(assignments, haves seatBucket) ([]string, error) { + if !assignments.current || !haves.current { + return nil, errors.New("the watches of the assignments and the have keys have not both delivered their current values yet") + } var who []string - for _, src := range []struct{ bucket, says string }{ - {"mesh-controller.assignments", "assigned on %s"}, {"node-engine.have", "the node-engine of %s has it"}} { - keys, err := stdio.State(src.bucket).Keys() - if err != nil { - return nil, fmt.Errorf("%s cannot be read: %w", src.bucket, err) + for key, raw := range assignments.values { + node, module, ok := strings.Cut(key, ".") + if !ok || module != OldModule { + continue } - for _, key := range keys { - if node, module, ok := strings.Cut(key, "."); ok && module == OldModule { - who = append(who, fmt.Sprintf(src.says, node)) - } + var a struct { + Ended time.Time `json:"ended,omitzero"` + State string `json:"state,omitempty"` } + if err := json.Unmarshal(raw, &a); err != nil || (a.Ended.IsZero() && a.State == "") { + who = append(who, "assigned on "+node) + } + } + seen := 0 + for key, raw := range haves.values { + node, module, ok := strings.Cut(key, ".") + if !ok || module != OldModule { + continue + } + seen++ + var h struct { + Phase string `json:"phase"` + } + if err := json.Unmarshal(raw, &h); err != nil || h.Phase != "removed" { + who = append(who, "the node-engine of "+node+" has it") + } + } + if seen == 0 { + who = append(who, "no node-engine has said the module removed yet: no have key of it") } sort.Strings(who) return who, nil } +// busOccupancy reads the controller's assignments and the node-engine's have keys through the runtime. It answers nothing until +// the watches of both have delivered their current values (watchSeat says so). +type busOccupancy struct { + assignmentsCurrent, haveCurrent atomic.Bool +} + +func (b *busOccupancy) Occupants() ([]string, error) { + read := func(bucket string, current bool) (seatBucket, error) { + out := seatBucket{values: map[string][]byte{}, current: current} + if !current { + return out, nil + } + keys, err := stdio.State(bucket).Keys() + if err != nil { + return out, fmt.Errorf("%s cannot be read: %w", bucket, err) + } + for _, key := range keys { + if _, module, ok := strings.Cut(key, "."); !ok || module != OldModule { + continue + } + e, err := stdio.State(bucket).Get(key) + if err != nil { + return out, fmt.Errorf("%s %s cannot be read: %w", bucket, key, err) + } + if e != nil { + out.values[key] = e.Value + } + } + return out, nil + } + a, err := read("mesh-controller.assignments", b.assignmentsCurrent.Load()) + if err != nil { + return nil, err + } + h, err := read("node-engine.have", b.haveCurrent.Load()) + if err != nil { + return nil, err + } + return occupantsOf(a, h) +} + // keptBucket is a bucket of the bus through the runtime: today's module's by `.`, the gate's by its name. type keptBucket struct{ name string } @@ -237,7 +311,7 @@ func carryFromBus() (CarryReport, error) { // watchSeat reads who has the seat at once, and again at every change of the assignments or the have keys, for ever. A read // that fails is said and tried again in ten seconds; a change read meanwhile is not lost. -func watchSeat(h *Holder, l *listening) { +func watchSeat(h *Holder, l *listening, occ *busOccupancy) { changed := make(chan struct{}, 1) note := func(stdio.StateChange) error { select { @@ -246,11 +320,17 @@ func watchSeat(h *Holder, l *listening) { } return nil } - for _, bucket := range []string{"mesh-controller.assignments", "node-engine.have"} { + for _, w := range []struct { + bucket string + current *atomic.Bool + }{{"mesh-controller.assignments", &occ.assignmentsCurrent}, {"node-engine.have", &occ.haveCurrent}} { + bucket := w.bucket go func() { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { err := stdio.State(bucket).Watch("", note) if err == nil { + w.current.Store(true) // the current values are in: the seat may be judged + note(stdio.StateChange{}) return } logf("[mesh-delivery] not hearing changes of %s yet (%v); asking again in %s", bucket, err, wait) -- 2.54.0 From 1a985d68fd321cd7ae768accf448fe08b2a7e645 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:20:37 +0000 Subject: [PATCH 09/15] Test that the carry copies a group, a pull key and an unasked delivery byte for byte (review round 1) --- .../cmd/mesh-delivery-gate/seat_test.go | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go index cb91b961..2e7ec52f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat_test.go @@ -321,3 +321,24 @@ func TestAFailedCarryLeavesTheGateSilentAndUnmarked(t *testing.T) { t.Fatal("a failed carry wrote its mark") } } + +// Review round 1, try failure: the carry copies a group, a pull key and a delivery without an unanswered ask byte for byte; what +// the gate writes after it holds the seat and acts is its own (a group's said state), not the carry's. +func TestTheCarryCopiesWhatItDoesNotClearByteForByte(t *testing.T) { + f := aCarry(t) + group := []byte(`{"id":"feat/x","members":["novox/app@aaaaaaaaaaaa"],"created":"2026-10-09T07:00:00Z","updated":"2026-10-09T07:00:00Z","said":"delivering"}`) + f.oldG["feat_x"] = group + plain := aDelivery("novox/app@eeeeeeeeeeee", 11, nil) + f.oldD["novox/app@eeeeeeeeeeee"] = plain + if err := f.w.h.SeatChanged(); err != nil { + t.Fatal(err) + } + for _, c := range []struct { + bucket, key string + want []byte + }{{"groups", "feat_x", group}, {"pulls", "novox_app_7", f.oldP["novox_app_7"]}, {"deliveries", "novox/app@eeeeeeeeeeee", plain}} { + if got, _ := f.own[c.bucket].Get(c.key); !bytes.Equal(got, c.want) { + t.Fatalf("%s %s changed in the carry:\n got %s\nwant %s", c.bucket, c.key, got, c.want) + } + } +} -- 2.54.0 From 67fe9a4dbeeabecf1d71eae71acba5eff8c95d39 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:25:52 +0000 Subject: [PATCH 10/15] Pin mesh-sdk at issue 1370's commit and read the phase removed by its name (review round 1) --- modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go | 3 ++- modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go index 854ce277..3fbff7b0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/seat.go @@ -10,6 +10,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // The gate acts only while it holds the seat, and carries today's state over when it first holds it (novox/hq ADR 0450 @@ -236,7 +237,7 @@ func occupantsOf(assignments, haves seatBucket) ([]string, error) { var h struct { Phase string `json:"phase"` } - if err := json.Unmarshal(raw, &h); err != nil || h.Phase != "removed" { + if err := json.Unmarshal(raw, &h); err != nil || h.Phase != string(sdk.PhaseRemoved) { who = append(who, "the node-engine of "+node+" has it") } } diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index 34ed8b6e..c84b7cab 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate 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-gate/go.sum b/modules/mesh-delivery-gate/go.sum index aab1ed4b..8af725aa 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qW git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= 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= -- 2.54.0 From 83adc46fe08d1ffb319d9803c827b784a9ad8636 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 01:26:28 +0000 Subject: [PATCH 11/15] Give each asking at unchanged heads its own router id, overwrite the ask instead of taking it away, and announce the answered asking (review round 1, G2; issue 1370) --- .../cmd/mesh-delivery-gate/approval.go | 97 +++++++++++++++++-- .../cmd/mesh-delivery-gate/approval_test.go | 34 ++++++- .../cmd/mesh-delivery-gate/bus_test.go | 21 +++- .../cmd/mesh-delivery-gate/holder.go | 4 + .../cmd/mesh-delivery-gate/issue1079_test.go | 4 +- .../cmd/mesh-delivery-gate/ready.go | 8 +- 6 files changed, 153 insertions(+), 15 deletions(-) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index 3fbb0070..71c3fc5b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -350,12 +350,22 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { return "the operator cannot be asked: " + err.Error(), nil } r.Asks++ - ask := composeMergeAsk(sdk.AskID(id), d, group, members, heads, now) + // Each asking at these heads has its own router id (sdk.AskingID): the router keeps an ask by its id and drops a second ask + // under it as a redelivery, so an ask written again at unchanged heads (a lapse, a status or the review record changing) is + // the next asking, with a new id (review round 1, G2; issue 1370). + asking := 1 + for _, m := range members { + if m.Ready != nil && m.Ready.AskedKey == id { + asking = max(asking, m.Ready.Asking+1) + } + } + ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, now) taken := []*Delivery{} var reads []memberRead for _, m := range members { h.runOf(m, now) m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks) + m.Ready.AskedKey, m.Ready.Asking = id, asking h.keep(m) taken = append(taken, m) reads = append(reads, readOf(m, m.Commit)) @@ -393,7 +403,23 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { } } } + // An earlier asking of this key may still stand in the bucket: not written again, it is taken away. + stale := func() { + h.mu.Lock() + standing := h.standing(id) + h.mu.Unlock() + if standing { + if err := h.Operator.Cancel(id); err != nil { + h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", id, err) + return + } + h.mu.Lock() + h.markWritten(id, false) + h.mu.Unlock() + } + } if readErr != nil || holdSays != "" { + stale() h.mu.Lock() defer h.mu.Unlock() undo() @@ -416,7 +442,11 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { h.keepGroup(g) h.mu.Unlock() } - if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Ask: ask}); err != nil { + if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Asking: asking, Ask: ask}); err == nil { + h.mu.Lock() + h.markWritten(id, true) + h.mu.Unlock() + } else { h.mu.Lock() defer h.mu.Unlock() undo() @@ -460,7 +490,11 @@ func (h *Holder) dropAsk(id, why string) func() { } if err := h.Operator.Cancel(id); err != nil { h.Logf("[mesh-delivery] the operator's ask %s could not be cancelled (%v)", id, err) + return } + h.mu.Lock() + h.markWritten(id, false) + h.mu.Unlock() } } @@ -474,15 +508,37 @@ func (h *Holder) withdrawAsk(id, why string) func() { h.keep(d) } } + // The key is not taken away: the next look writes it again with the next asking, if the group may be asked, and takes it away + // only if it may not (readyAsks, withdrawnKeys), so the key is never absent between the two. + h.withdrawn[id] = true h.Logf("[mesh-delivery] the operator's ask %s is withdrawn to be asked again: %s", id, why) - return func() { - if h.Operator == nil { - return + return func() {} +} + +// withdrawnKeys are, after a look, the closures that take away the asks withdrawn and not written again by it. Under the lock. +func (h *Holder) withdrawnKeys() []func() { + var out []func() + for key := range h.withdrawn { + delete(h.withdrawn, key) + again := false + for _, d := range h.deliveries { + again = again || (d.Ready != nil && d.Ready.AskID == key) } - if err := h.Operator.Cancel(id); err != nil { - h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", id, err) + if again || h.Operator == nil { + continue } + key := key + out = append(out, func() { + if err := h.Operator.Cancel(key); err != nil { + h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", key, err) + return + } + h.mu.Lock() + h.markWritten(key, false) + h.mu.Unlock() + }) } + return out } // pendingAsks are the asks waiting for an answer, by id; under the lock. @@ -563,6 +619,13 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() { refuse(fmt.Sprintf("its key %s is not the key %s of the heads asked", key, want)) return nil } + // The answer is to the asking asked now: the teller names the router id it was given in the warrant's id (`@