From b5908b486edf0866ef502e7c7cdb6348d8257c25 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 14:12:31 +0200 Subject: [PATCH] Give the gate a settings file for the bounds ADR 0505 sets on saying a refusal and a needed new head --- .../cmd/mesh-delivery-gate/adr505_test.go | 41 +++++++++ .../cmd/mesh-delivery-gate/holder.go | 2 + .../cmd/mesh-delivery-gate/main.go | 17 ++++ .../cmd/mesh-delivery-gate/settings.go | 83 +++++++++++++++++++ .../mesh-delivery-gate/files/settings.json | 4 + modules/mesh-delivery-gate/module.json | 40 ++++++++- 6 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go create mode 100644 modules/mesh-delivery-gate/files/settings.json diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go new file mode 100644 index 00000000..ee8bfdbf --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// The tests of novox/hq ADR 0505 "How it is checked" for the gate: a refusal the gate is already answering is no +// stall yet (§4), and a delivery whose cure is its author's new head is its author's for two hours (§5). + +// ADR 0505 §4, §5: the two bounds are settings with their decided defaults. +func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) { + read := func(body string) (Settings, error) { + p := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return readSettings(p) + } + h := &Holder{} + if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour { + t.Fatalf("no settings: floor %s, author %s", h.refusalFloor(), h.authorBound()) + } + s, err := read(`{"refusal-stall-floor-minutes": 5, "author-new-head-minutes": 60}`) + if err != nil { + t.Fatal(err) + } + h.Settings = func() Settings { return s } + if h.refusalFloor() != 5*time.Minute || h.authorBound() != time.Hour { + t.Fatalf("set: floor %s, author %s", h.refusalFloor(), h.authorBound()) + } + s, _ = read(`{}`) + if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour { + t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound()) + } + if _, err := read(`{"author-new-head-minutes": 0}`); err == nil { + t.Fatalf("a bound of zero minutes was taken") + } +} 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 ef4b0045..272f102d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -22,6 +22,8 @@ import ( // Holder is the seat's holder. type Holder struct { + // Settings reads the operator's values (settings.go); nil keeps every default. + Settings func() Settings Store Store Controller Controller Forge Forge 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 03855f7c..e5ef06db 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -41,7 +41,24 @@ const forgePattern = "mesh-delivery-forge-bridge.forge.**" func main() { occupancy := &busOccupancy{} + settingsFile := os.Getenv("MESH_DELIVERY_GATE_SETTINGS") + var saidMu sync.Mutex + lastSaid := "" + settings := func() Settings { + s, err := readSettings(settingsFile) + saidMu.Lock() + defer saidMu.Unlock() + if err != nil && err.Error() != lastSaid { + logf("[mesh-delivery-gate] settings cannot be read, the defaults hold: %v", err) + } + lastSaid = "" + if err != nil { + lastSaid = err.Error() + } + return s + } h := &Holder{ + Settings: settings, Store: kvStore{}, Controller: seatController{ask: stdio.Ask}, Forge: toolForge{ask: stdio.Ask}, diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go new file mode 100644 index 00000000..8bda6e77 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go @@ -0,0 +1,83 @@ +package main + +// The gate's settings (novox/hq ADR 0505 §4, §5), merged by the mesh into one JSON file the env names. Each has a +// default here, so a gate started without the file, or with a value it cannot read, keeps the decided bound and says +// why once. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" +) + +const ( + // DefaultRefusalFloor is the least a refusal the gate is already answering (the trunk moved and its check was + // asked again) waits before it is said as stalled, when the setting refusal-stall-floor-minutes gives none. + DefaultRefusalFloor = 10 * time.Minute + // DefaultAuthorBound is how long a delivery whose cure is its author's new head waits for one before the operator + // hears of it, when the setting author-new-head-minutes gives none (ADR 0505 §5). + DefaultAuthorBound = 2 * time.Hour +) + +// Settings are the operator's values for this module. +type Settings struct { + RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"` + AuthorBoundMinutes *int `json:"author-new-head-minutes"` +} + +// readSettings reads the settings file; a value that is not a whole number of minutes, one or more, is refused. +func readSettings(path string) (Settings, error) { + var s Settings + if path == "" { + return s, errors.New("started without a settings file") + } + raw, err := os.ReadFile(path) + if err != nil { + return s, err + } + var loose map[string]any + if err := json.Unmarshal(raw, &loose); err != nil { + return s, fmt.Errorf("the settings file is not JSON: %v", err) + } + for name, into := range map[string]**int{"refusal-stall-floor-minutes": &s.RefusalFloorMinutes, + "author-new-head-minutes": &s.AuthorBoundMinutes} { + switch v := loose[name].(type) { + case nil: + case float64: + n := int(v) + if v < 1 || v != float64(n) { + return Settings{}, fmt.Errorf("%s is not a whole number of minutes, one or more", name) + } + *into = &n + default: + return Settings{}, fmt.Errorf("%s is not a number", name) + } + } + return s, nil +} + +// minutesOr is a setting's minutes, or the default when it gives none. +func minutesOr(n *int, def time.Duration) time.Duration { + if n == nil { + return def + } + return time.Duration(*n) * time.Minute +} + +// refusalFloor is the least an answered refusal waits before it is said as stalled (ADR 0505 §4). +func (h *Holder) refusalFloor() time.Duration { + if h.Settings == nil { + return DefaultRefusalFloor + } + return minutesOr(h.Settings().RefusalFloorMinutes, DefaultRefusalFloor) +} + +// authorBound is how long a delivery that needs its author's new head waits before the operator hears of it (§5). +func (h *Holder) authorBound() time.Duration { + if h.Settings == nil { + return DefaultAuthorBound + } + return minutesOr(h.Settings().AuthorBoundMinutes, DefaultAuthorBound) +} diff --git a/modules/mesh-delivery-gate/files/settings.json b/modules/mesh-delivery-gate/files/settings.json new file mode 100644 index 00000000..848f5158 --- /dev/null +++ b/modules/mesh-delivery-gate/files/settings.json @@ -0,0 +1,4 @@ +{ + "refusal-stall-floor-minutes": 10, + "author-new-head-minutes": 120 +} diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index a80fad26..d3faa1dd 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -84,6 +84,24 @@ "tools": [ "delivery_status" ], + "config": { + "refusal-stall-floor-minutes": { + "type": "number", + "description": "minutes, at least, before a merge refused because its trunk moved, whose check the gate asked again, is said as stalled; twice the repository's last check time when that is longer (ADR 0505 §4).", + "minimum": 1, + "maximum": 1440, + "trusted": false, + "default": 10 + }, + "author-new-head-minutes": { + "type": "number", + "description": "minutes a delivery that needs its author's new head (a conflict, a failed check, a rejected group) waits for one before the operator hears of it (ADR 0505 §5).", + "minimum": 1, + "maximum": 10080, + "trusted": false, + "default": 120 + } + }, "resources": [ { "id": "state", @@ -91,6 +109,18 @@ "mode": "0700", "place": ".", "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": [ + "refusal-stall-floor-minutes", + "author-new-head-minutes" + ], + "merge": "json", + "template": "files/settings.json" } ], "build": { @@ -104,7 +134,15 @@ "binary": "mesh-delivery-gate", "loads": [ "mesh-delivery-gate" - ] + ], + "env": { + "MESH_DELIVERY_GATE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }