Give the gate a settings file for the bounds ADR 0505 sets on saying a refusal and a needed new head

This commit is contained in:
2026-10-22 14:12:31 +02:00
parent aa57bbcd44
commit b5908b486e
6 changed files with 186 additions and 1 deletions
@@ -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")
}
}
@@ -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
@@ -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},
@@ -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)
}
@@ -0,0 +1,4 @@
{
"refusal-stall-floor-minutes": 10,
"author-new-head-minutes": 120
}
+39 -1
View File
@@ -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"
}
]
}