Drop the keeper's and config's take-over verbs with the controller's sources
mesh-sdk drops compare, take-over and hand-back from the keeper's and config's seats (ADR 0590): there is no controller to compare with or hand back to. mesh-conditions loses those verbs, its take-over key and asks; mesh-config reads its own sources always and pull-declares is gone; and mesh-hand-acts no longer names the controller's log (issue 1126). Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -1,185 +0,0 @@
|
||||
package main
|
||||
|
||||
// **Built dark, compared, then switched by the operator** (novox/hq ADR 0564 clause 9; issue 1952 C5, C8): `compare`
|
||||
// lists the open keys of the keeper and of the controller, each difference with its reason; the kinds that end with the
|
||||
// controller, the kinds only the keeper raises and the keys held while their module is delivered are listed apart and
|
||||
// not counted. `take-over` is refused (issue 1126): the keeper is the one source since the controller ended by its
|
||||
// unassignment; a yes to an ask raised before still writes the take-over key naming the keeper. `hand-back` is gone (issue 774): nothing
|
||||
// reads the take-over key any more, so it is answered as gone and changes nothing.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// Difference is one key the two sides do not agree on, or one listed apart.
|
||||
type Difference struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Comparison is compare's answer.
|
||||
type Comparison struct {
|
||||
Differences []Difference `json:"differences"`
|
||||
// Apart are the keys not counted: of kinds that end with the controller, raised only by the keeper, or held.
|
||||
Apart []Difference `json:"apart"`
|
||||
Same int `json:"same"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Empty says nothing differs.
|
||||
func (c Comparison) Empty() bool { return len(c.Differences) == 0 }
|
||||
|
||||
// Compare is the keeper's open conditions against the controller's.
|
||||
func (k *Keeper) Compare() (Comparison, error) {
|
||||
mine, err := k.Open()
|
||||
if err != nil {
|
||||
return Comparison{}, err
|
||||
}
|
||||
k.mu.Lock()
|
||||
k.init()
|
||||
theirs := map[string]health.Condition{}
|
||||
for key, c := range k.ctrl {
|
||||
theirs[key] = c
|
||||
}
|
||||
heldNow := map[string]held{}
|
||||
for key, h := range k.held {
|
||||
heldNow[key] = h
|
||||
}
|
||||
k.mu.Unlock()
|
||||
out := Comparison{Differences: []Difference{}, Apart: []Difference{}, At: k.Now().UTC()}
|
||||
open := map[string]health.Condition{}
|
||||
for _, c := range mine {
|
||||
open[c.Key] = c
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for key := range open {
|
||||
keys[key] = true
|
||||
}
|
||||
for key := range theirs {
|
||||
keys[key] = true
|
||||
}
|
||||
for key := range heldNow {
|
||||
keys[key] = true
|
||||
}
|
||||
sorted := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
sorted = append(sorted, key)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
for _, key := range sorted {
|
||||
c, inMine := open[key]
|
||||
t, inTheirs := theirs[key]
|
||||
kind := c.Kind
|
||||
if !inMine {
|
||||
kind = t.Kind
|
||||
}
|
||||
home, homed := k.homeOf(kind)
|
||||
if h, ok := heldNow[key]; ok {
|
||||
out.Apart = append(out.Apart, Difference{Key: key, Kind: kind,
|
||||
Reason: fmt.Sprintf("held while %s is delivered on %s (version %s)", h.Module, h.Node, short(h.Version))})
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case homed && (home.Carried || home.Replaced):
|
||||
why := "ends with the controller: no longer carried (issue 1126)"
|
||||
if home.Replaced {
|
||||
why = "ends with the controller: its fault is observed otherwise"
|
||||
}
|
||||
if home.Why != "" {
|
||||
why += " (" + home.Why + ")"
|
||||
}
|
||||
out.Apart = append(out.Apart, Difference{Key: key, Kind: kind, Reason: why})
|
||||
case inMine && !inTheirs && (!homed || !home.Controller):
|
||||
out.Apart = append(out.Apart, Difference{Key: key, Kind: kind, Reason: "keeper only: the controller never raises this kind"})
|
||||
case inMine && !inTheirs:
|
||||
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
|
||||
Reason: "open at the keeper, not at the controller"})
|
||||
case !inMine && !homed:
|
||||
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
|
||||
Reason: fmt.Sprintf("open at the controller; no home is decided for the kind %s (ADR 0564's gap)", kind)})
|
||||
case !inMine:
|
||||
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
|
||||
Reason: fmt.Sprintf("open at the controller; %s has not written its finding", home.Observer)})
|
||||
case c.Severity != t.Severity:
|
||||
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
|
||||
Reason: fmt.Sprintf("%s at the keeper, %s at the controller", c.Severity, t.Severity)})
|
||||
default:
|
||||
out.Same++
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Asks is the operator channel as the keeper asks it: an approve ask under its own name, and where one stands.
|
||||
type Asks interface {
|
||||
Ask(a asks.Ask) error
|
||||
}
|
||||
|
||||
// TakeOver is refused: the keeper is the one source of conditions since the controller ended (issue 1126).
|
||||
func (k *Keeper) TakeOver(a Asks, by, why string) (map[string]any, error) {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return nil, errors.New("a take-over says why")
|
||||
}
|
||||
return nil, errors.New("the keeper is the one source already: the controller ended by its unassignment (issue 1126)")
|
||||
}
|
||||
|
||||
// Warranted is the act on the operator's answer: a yes, compare still empty, writes the take-over key naming the keeper.
|
||||
func (k *Keeper) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
if o.ID != "yes" {
|
||||
k.Logf("the take-over %s was answered %s: alarms stay with the controller", w.Ask, o.ID)
|
||||
return nil
|
||||
}
|
||||
cmp, err := k.Compare()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cmp.Empty() {
|
||||
return fmt.Errorf("the take-over %s was approved, and compare now shows %d difference(s): not switched", w.Ask,
|
||||
len(cmp.Differences))
|
||||
}
|
||||
return k.writeTakeover(health.Takeover{Follow: health.FollowKeeper, At: k.Now().UTC(), By: w.Says(), Ask: w.Ask})
|
||||
}
|
||||
|
||||
// HandBack is gone (issue 774): the readers of conditions hear the keeper alone and nothing reads the take-over key, so
|
||||
// a hand-back written there would answer success and do nothing (ADR 0564 clause 9). It is answered as gone, naming the
|
||||
// record, and changes nothing.
|
||||
func (k *Keeper) HandBack(by, why string) (map[string]any, error) {
|
||||
return nil, errors.New("hand-back is gone: the readers of conditions hear the condition keeper alone, and nothing " +
|
||||
"reads the take-over key, so there is no controller to hand them back to (ADR 0564 clause 9)")
|
||||
}
|
||||
|
||||
// writeTakeover writes the take-over key, and keeps it in history as the record of the switch.
|
||||
func (k *Keeper) writeTakeover(t health.Takeover) error {
|
||||
raw, err := health.EncodeTakeover(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, rev, found, err := k.Store.Get(TakeoverState, health.TakeoverKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
_, err = k.Store.Update(TakeoverState, health.TakeoverKey, json.RawMessage(raw), rev)
|
||||
} else {
|
||||
_, err = k.Store.Create(TakeoverState, health.TakeoverKey, json.RawMessage(raw))
|
||||
}
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return fmt.Errorf("the take-over key moved under this write: read it again and ask again")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k.Takeover(t)
|
||||
k.Logf("the readers of conditions follow the %s from now (%s)", t.Follow, t.By)
|
||||
return nil
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func short(fp string) string {
|
||||
return fp
|
||||
}
|
||||
|
||||
// Held is every key held now, for the read tool and compare.
|
||||
// Held is every key held now, for the read tool.
|
||||
func (k *Keeper) Held() []held {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
@@ -73,17 +73,6 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) {
|
||||
if got := r.changes(); got != "raised "+other+", raised "+machine {
|
||||
t.Errorf("%s: during the delivery said %q: another module's and the machine's at once, nothing of its own", c.name, got)
|
||||
}
|
||||
// C8: compare lists the held keys as held, never as a difference.
|
||||
cmp, _ := r.k.Compare()
|
||||
held := 0
|
||||
for _, d := range cmp.Apart {
|
||||
if strings.HasPrefix(d.Reason, "held while mesh-controller is delivered on novox") {
|
||||
held++
|
||||
}
|
||||
}
|
||||
if held != 1 {
|
||||
t.Errorf("%s: compare lists %d held, %+v", c.name, held, cmp)
|
||||
}
|
||||
// Its own finding goes unless it stands.
|
||||
if !c.standing {
|
||||
_ = r.k.Finding(watchman, own, nil, true)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package main
|
||||
|
||||
// **Where each condition kind lives after the take-over** (novox/hq ADR 0564 clauses 4, 6, 7 and 9, the Context's probe
|
||||
// table; issue 1952 C3, C5): data, so a kind's home is a row and never code. `compare` reads it to tell a difference from
|
||||
// a kind that ends with the controller or that the controller never raises, and the keeper reads it to clear, once, what
|
||||
// it carried of the kinds that ended with the controller (EndCarried, issue 1126).
|
||||
// **Where each condition kind the controller raised lives now** (novox/hq ADR 0564 clauses 4, 6, 7 and 9, the Context's
|
||||
// probe table; issue 1952 C3, C5): data, so a kind's home is a row and never code. The keeper reads it to clear, once,
|
||||
// what it carried of the kinds that ended with the controller (EndCarried, issue 1126); `compare`, which read it too,
|
||||
// went with the controller (ADR 0590).
|
||||
//
|
||||
// The rows are ADR 0564's and the lead's table of 2026-10-29, by the controller's source of each kind. A kind with no
|
||||
// row is named by `compare` as "no home decided".
|
||||
// The rows are ADR 0564's and the lead's table of 2026-10-29, by the controller's source of each kind.
|
||||
|
||||
// Home is where one condition kind lives.
|
||||
type Home struct {
|
||||
|
||||
@@ -28,12 +28,9 @@ const (
|
||||
Seat = "mesh-conditions"
|
||||
|
||||
ConditionsState = health.ConditionsState
|
||||
TakeoverState = health.TakeoverState
|
||||
HistoryState = "history"
|
||||
HeldState = "held"
|
||||
BoundsState = "bounds"
|
||||
// AskedState keeps the keeper's asks of the operator (go/asker).
|
||||
AskedState = "asked"
|
||||
)
|
||||
|
||||
// HistoryKeptFor is how long a transition is kept (clause 3).
|
||||
@@ -94,7 +91,7 @@ type Keeper struct {
|
||||
Say func(e health.Event) error
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
// Homes says, by kind, where each controller kind lives after the take-over (homes.go); Homes when nil.
|
||||
// Homes says, by kind, where each kind the controller raised lives now (homes.go); Homes when nil.
|
||||
Homes map[string]Home
|
||||
// Bound is a delivery's hold bound; delivery.DefaultDeliveryBound when zero (hold.go).
|
||||
Bound time.Duration
|
||||
@@ -103,10 +100,6 @@ type Keeper struct {
|
||||
findings map[string]map[string]health.Finding
|
||||
blind map[string]string
|
||||
attempts map[string]health.Attempt
|
||||
// ctrl is the controller's conditions as Controller hands them: compare's other side alone, never a condition's
|
||||
// input (issue 1126). Nothing feeds it since issue 774; it goes with compare.
|
||||
ctrl map[string]health.Condition
|
||||
takeover health.Takeover
|
||||
wants map[string]delivery.Want
|
||||
haves map[string]delivery.Have
|
||||
// holders are, by `<node>.<seat>`, the module the placement says holds the seat there.
|
||||
@@ -125,11 +118,10 @@ type Keeper struct {
|
||||
|
||||
func (k *Keeper) init() {
|
||||
if k.findings == nil {
|
||||
k.findings, k.blind, k.attempts, k.ctrl = map[string]map[string]health.Finding{}, map[string]string{},
|
||||
map[string]health.Attempt{}, map[string]health.Condition{}
|
||||
k.findings, k.blind, k.attempts = map[string]map[string]health.Finding{}, map[string]string{},
|
||||
map[string]health.Attempt{}
|
||||
k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]delivery.Want{}, map[string]delivery.Have{},
|
||||
map[string]string{}, map[string]held{}, map[string]clearedMemo{}
|
||||
k.takeover = health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}
|
||||
k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{}
|
||||
k.placement = delivery.NewPlacementKeys()
|
||||
}
|
||||
@@ -209,26 +201,6 @@ func (k *Keeper) Attempt(key string, value []byte, deleted bool) error {
|
||||
return k.Settle(condition)
|
||||
}
|
||||
|
||||
// Controller takes one change of the controller's `conditions` bucket, for compare alone: no condition is carried from
|
||||
// it (issue 1126).
|
||||
func (k *Keeper) Controller(key string, value []byte, deleted bool) error {
|
||||
k.mu.Lock()
|
||||
k.init()
|
||||
if deleted {
|
||||
delete(k.ctrl, key)
|
||||
} else {
|
||||
var c health.Condition
|
||||
if err := json.Unmarshal(value, &c); err != nil {
|
||||
k.mu.Unlock()
|
||||
k.Logf("the controller's condition %s is not readable, so compare does not list it: %v", key, err)
|
||||
return nil
|
||||
}
|
||||
k.ctrl[key] = c
|
||||
}
|
||||
k.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// endedWhy is why a condition of a kind that ended with the controller is cleared.
|
||||
const endedWhy = "its kind ended with the controller, which was unassigned (issue 1126): no finding raises it again"
|
||||
|
||||
@@ -273,14 +245,6 @@ func (k *Keeper) EndCarried() (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Takeover takes the keeper's own take-over key as read.
|
||||
func (k *Keeper) Takeover(t health.Takeover) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.init()
|
||||
k.takeover = t
|
||||
}
|
||||
|
||||
// Settle derives the condition key should be and writes the difference (clause 2), retried while another write moves
|
||||
// it under this one.
|
||||
func (k *Keeper) Settle(key string) error {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
@@ -242,28 +241,6 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.said = nil
|
||||
until := r.now.Add(time.Hour)
|
||||
for _, c := range []health.Condition{
|
||||
{Key: "core.controller.deaf", Kind: "controller-deaf", Source: "S4", Subject: health.Subject{Scope: health.ScopeCore, ID: "controller"},
|
||||
Severity: health.Urgent, Summary: "deaf", Resolver: "self"},
|
||||
{Key: key, Kind: "silent", Severity: health.Urgent, Resolver: health.ResolverOperator,
|
||||
Tried: []health.Tried{{At: r.now, What: "x", Outcome: "escalated", By: "healer H1"}},
|
||||
Silenced: &health.Silence{Until: until}},
|
||||
} {
|
||||
raw, _ := json.Marshal(c)
|
||||
if err := r.k.Controller(c.Key, raw, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.k.Resync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, open := r.open("core.controller.deaf"); open || r.changes() != "" {
|
||||
t.Error("the controller's condition was carried")
|
||||
}
|
||||
if c, _ := r.open(key); c.Severity != health.Warning || c.Resolver != health.ResolverSelf || len(c.Tried) != 0 || c.Silenced != nil {
|
||||
t.Errorf("the controller's escalation reached the keeper's condition: %+v", c)
|
||||
}
|
||||
// Carried while the controller ran: two of kinds that ended with it, still open in conditions.
|
||||
for _, c := range []health.Condition{
|
||||
{Key: "core.controller.deaf", Kind: "controller-deaf", Severity: health.Urgent, Resolver: "self", Count: 1},
|
||||
@@ -285,51 +262,10 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0564 clause 9 (issue 1952 C5, C8; the lead 2026-10-29): compare counts only what both sides can raise. A key held
|
||||
// while its module is delivered, a kind that ends with the controller and a kind the controller never raises are listed
|
||||
// apart; a kind with no home decided is a difference naming the gap; take-over is refused (issue 1126), and
|
||||
// hand-back is gone (issue 774, review g1): answered as gone, naming its record, and writing nothing.
|
||||
func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
|
||||
r := newRig()
|
||||
put := func(key, kind string) []byte {
|
||||
raw, _ := json.Marshal(health.Condition{Key: key, Kind: kind, Severity: health.Warning, Source: "x"})
|
||||
return raw
|
||||
}
|
||||
key, raw := finding(t, watchman, health.ScopeMachine, "g14", "silent", "S1", health.Warning, r.now)
|
||||
_ = r.k.Finding(watchman, key, raw, false)
|
||||
_ = r.k.Controller(key, put(key, "silent"), false)
|
||||
only, onlyRaw := finding(t, delivery.AssignerModule, health.ScopeSeat, "mesh-placement.ace", "states-unbound", "say", health.Warning, r.now)
|
||||
_ = r.k.Finding(delivery.AssignerModule, only, onlyRaw, false)
|
||||
_ = r.k.Controller("core.controller.deaf", put("core.controller.deaf", "controller-deaf"), false)
|
||||
_ = r.k.Controller("delivery.x.mystery", put("delivery.x.mystery", "mystery"), false)
|
||||
_ = r.k.Controller("bus.x.consumer-wrong", put("bus.x.consumer-wrong", "consumer-wrong"), false)
|
||||
_ = r.k.Controller("seat.vault.ace.holder-silent", put("seat.vault.ace.holder-silent", "holder-silent"), false)
|
||||
_ = r.k.Controller("bus.nats.upgrade-failed", put("bus.nats.upgrade-failed", "bus-upgrade-failed"), false)
|
||||
cmp, err := r.k.Compare()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reasons := map[string]string{}
|
||||
for _, d := range append(append([]Difference(nil), cmp.Differences...), cmp.Apart...) {
|
||||
reasons[d.Key] = d.Reason
|
||||
}
|
||||
for key, says := range map[string]string{
|
||||
only: "keeper only",
|
||||
"core.controller.deaf": "carried",
|
||||
"delivery.x.mystery": "no home is decided for the kind mystery",
|
||||
"bus.x.consumer-wrong": "mesh-self-check has not written its finding",
|
||||
// A kind of a probe the operator dropped (2026-10-30) is carried, never missing.
|
||||
"seat.vault.ace.holder-silent": "no longer carried (issue 1126) (dropped 2026-10-30 by the operator",
|
||||
// The controller's own bus upgrade ends with its lease (the operator, 2026-10-31; issue 1952).
|
||||
"bus.nats.upgrade-failed": "ends with the controller",
|
||||
} {
|
||||
if !strings.Contains(reasons[key], says) {
|
||||
t.Errorf("%s: %q, want it to say %q", key, reasons[key], says)
|
||||
}
|
||||
}
|
||||
if cmp.Same != 1 || len(cmp.Differences) != 2 {
|
||||
t.Errorf("compare: %+v", cmp)
|
||||
}
|
||||
// ADR 0564 (issue 1952 C5; the lead 2026-10-29): every kind the controller raised has its home in the table, and the
|
||||
// kinds that ended with it are marked carried, which EndCarried clears once (issue 1126). compare, take-over and
|
||||
// hand-back went with the controller (ADR 0590): there is no other side to compare and nothing to hand back to.
|
||||
func TestEveryControllerKindHasItsHome(t *testing.T) {
|
||||
// The lead's table of 2026-10-29: each kind has its home, lease-split and image-missing do not end, and the 22
|
||||
// that end are carried; with the 11 kinds of the probes the operator dropped on 2026-10-30, 33.
|
||||
carried := 0
|
||||
@@ -355,14 +291,4 @@ func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
|
||||
if len(Homes) != 84 || carried != 34 { // 19 of ADR 0564, the 63 of the table, plan-failed and stalled
|
||||
t.Errorf("the table holds %d kinds, %d carried", len(Homes), carried)
|
||||
}
|
||||
if _, err := r.k.TakeOver(nil, "jochen", "now"); err == nil || !strings.Contains(err.Error(), "one source already") {
|
||||
t.Errorf("take-over: %v", err)
|
||||
}
|
||||
if _, err := r.k.HandBack("jochen", "back"); err == nil || !strings.Contains(err.Error(), "hand-back is gone") ||
|
||||
!strings.Contains(err.Error(), "ADR 0564 clause 9") {
|
||||
t.Errorf("hand-back: %v, want it answered as gone naming its record", err)
|
||||
}
|
||||
if _, _, found, _ := r.k.Store.Get(TakeoverState, health.TakeoverKey); found {
|
||||
t.Error("hand-back or take-over wrote the take-over key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ 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"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
@@ -90,15 +88,6 @@ func Keep(k *Keeper) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if e, err := stdio.State(TakeoverState).Get(health.TakeoverKey); err == nil {
|
||||
var raw []byte
|
||||
if e != nil {
|
||||
raw = e.Value
|
||||
}
|
||||
if t, err := health.DecodeTakeover(raw, e != nil); err == nil {
|
||||
k.Takeover(t)
|
||||
}
|
||||
}
|
||||
said := func(err error) error {
|
||||
if err != nil {
|
||||
logf("%v", err)
|
||||
@@ -134,20 +123,11 @@ func Keep(k *Keeper) {
|
||||
|
||||
func main() {
|
||||
k := NewKeeper()
|
||||
client := asker.New(AskedState)
|
||||
client.Module = Module
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
Keep(k)
|
||||
if err := client.Listen(k.Warranted, func(h asker.Handled, w asks.Warrant, err error) {
|
||||
if err != nil {
|
||||
logf("the answer to %s (%s): %v", w.Ask, h, err)
|
||||
}
|
||||
}); err != nil {
|
||||
logf("the operator's answers cannot be heard: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := stdio.Serve("", Tools(k, client)); err != nil {
|
||||
if err := stdio.Serve("", Tools(k)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -49,11 +49,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
ttl[HistoryState], ttl[BoundsState], keyTTL[BoundsState])
|
||||
}
|
||||
// Clause 12: each state with the tool that reads it, each act with the tool that starts it.
|
||||
readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool, TakeoverState: TakeoverTool,
|
||||
HeldState: HeldTool, BoundsState: HeldTool, AskedState: Seat + ".take-over", "a resync": Seat + ".resync",
|
||||
"what it reads": InputsTool}
|
||||
readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool,
|
||||
HeldState: HeldTool, BoundsState: HeldTool, "a resync": Seat + ".resync", "what it reads": InputsTool}
|
||||
tools := map[string]bool{}
|
||||
for _, tool := range Tools(&Keeper{}, nil) {
|
||||
for _, tool := range Tools(&Keeper{}) {
|
||||
tools[tool.Name] = true
|
||||
}
|
||||
for name := range ttl {
|
||||
|
||||
@@ -17,14 +17,13 @@ import (
|
||||
)
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest claims them.
|
||||
var Verbs = []string{"conditions", "silence", "compare", "take-over", "hand-back", "resync"}
|
||||
var Verbs = []string{"conditions", "silence", "resync"}
|
||||
|
||||
// The keeper's own read tools.
|
||||
const (
|
||||
HistoryTool = "conditions_history"
|
||||
HeldTool = "conditions_held"
|
||||
TakeoverTool = "conditions_takeover"
|
||||
InputsTool = "conditions_inputs"
|
||||
HistoryTool = "conditions_history"
|
||||
HeldTool = "conditions_held"
|
||||
InputsTool = "conditions_inputs"
|
||||
)
|
||||
|
||||
// briefEvidence is how many observations a condition carries in a list: its newest (the controller's, issue 314).
|
||||
@@ -50,7 +49,7 @@ func str(description string) map[string]any {
|
||||
}
|
||||
|
||||
// Tools are what the runtime serves.
|
||||
func Tools(k *Keeper, a Asks) []stdio.Tool {
|
||||
func Tools(k *Keeper) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: Seat + ".conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then " +
|
||||
"oldest, raised from the observers' findings and cleared when the finding goes, never by hand. Given a key, that " +
|
||||
@@ -70,19 +69,6 @@ func Tools(k *Keeper, a Asks) []stdio.Tool {
|
||||
}
|
||||
return k.Silence(strArg(args, "key"), d, stdio.CallerOf(args), strArg(args, "why"))
|
||||
}},
|
||||
{Name: Seat + ".compare", Description: "The keeper's open conditions against the controller's, each difference with " +
|
||||
"its reason; what ends with the controller, what only the keeper raises and what is held listed apart.",
|
||||
Run: func(map[string]any) (any, error) { return k.Compare() }},
|
||||
{Name: Seat + ".take-over", Description: "Refused: the keeper is the one source of conditions since the " +
|
||||
"controller ended by its unassignment (issue 1126); kept as the seat's verb until the seat drops it.",
|
||||
Input: obj(map[string]any{"why": str("why now")}, "why"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return k.TakeOver(a, stdio.CallerOf(args), strArg(args, "why"))
|
||||
}},
|
||||
{Name: Seat + ".hand-back", Description: "Gone: the readers of conditions hear the keeper alone, so there is nothing " +
|
||||
"to hand back; answered as gone and changes nothing (ADR 0564 clause 9).",
|
||||
Input: obj(map[string]any{"why": str("why")}, "why"),
|
||||
Run: func(args map[string]any) (any, error) { return k.HandBack(stdio.CallerOf(args), strArg(args, "why")) }},
|
||||
{Name: Seat + ".resync", Description: "Read every finding again and raise and clear what differs.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
n, err := k.Resync()
|
||||
@@ -99,16 +85,8 @@ func Tools(k *Keeper, a Asks) []stdio.Tool {
|
||||
bounds, err := k.Store.Keys(BoundsState)
|
||||
return map[string]any{"held": k.Held(), "bounds": bounds}, err
|
||||
}},
|
||||
{Name: TakeoverTool, Description: "The take-over key: which source the router, the grants part and mesh-watcher hear.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
raw, _, found, err := k.Store.Get(TakeoverState, health.TakeoverKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return health.DecodeTakeover(raw, found)
|
||||
}},
|
||||
{Name: InputsTool, Description: "What the keeper reads, as it stands: each observer's findings counted, an observer it " +
|
||||
"cannot read and why, the healers' attempts, the controller's conditions and whether its lease is handed away.",
|
||||
"cannot read and why, the healers' attempts, the wants and the haves.",
|
||||
Run: func(map[string]any) (any, error) { return k.Inputs(), nil }},
|
||||
}
|
||||
}
|
||||
@@ -212,7 +190,6 @@ func (k *Keeper) Inputs() map[string]any {
|
||||
findings[o] = len(fs)
|
||||
}
|
||||
return map[string]any{"findings": findings, "blind": k.blind, "attempts": len(k.attempts),
|
||||
"controller-conditions": len(k.ctrl), "follow": k.takeover.Follow,
|
||||
"wants": len(k.wants), "haves": len(k.haves)}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) {
|
||||
for _, o := range Observers {
|
||||
buckets = append(buckets, o+"_findings")
|
||||
}
|
||||
for _, s := range []string{ConditionsState, HistoryState, TakeoverState, HeldState, BoundsState, AskedState} {
|
||||
for _, s := range []string{ConditionsState, HistoryState, HeldState, BoundsState} {
|
||||
buckets = append(buckets, Module+"_"+s)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
"serves": [
|
||||
"conditions",
|
||||
"silence",
|
||||
"compare",
|
||||
"take-over",
|
||||
"hand-back",
|
||||
"resync"
|
||||
]
|
||||
}
|
||||
@@ -38,13 +35,11 @@
|
||||
"name": "history",
|
||||
"ttl-seconds": 7776000
|
||||
},
|
||||
"takeover",
|
||||
"held",
|
||||
{
|
||||
"name": "bounds",
|
||||
"key-ttl": true
|
||||
},
|
||||
"asked"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
|
||||
@@ -66,14 +66,9 @@ Each directory or archive this module declares says how it treats a file it did
|
||||
|
||||
- `mesh-config_declared`: each placed module's declarations at the trunk, with the manifest and commit they come
|
||||
from, written only when they differ.
|
||||
- `mesh-config_takeover`: the key `switch` names the source that holds: none (declarations from the modules' own
|
||||
manifests, the list from the indexer's `predefined`, pull requests' config from the controller's `pull-declares`),
|
||||
or `indexer` after the operator's take-over (pull requests' config from the indexer's `config-candidates`; the
|
||||
controller's `pull-declares` refused). A switch still naming `controller` (the old hand-back) is read as `indexer` and
|
||||
said gone. Each take-over ask is kept beside it.
|
||||
- `config_compare` lists every difference between each open pull request's candidate at the indexer and what the
|
||||
controller told by `pull-declares`; the controller's mirrors are no longer read (ADR 0565 §4, issue 774).
|
||||
`config_take_over` asks the operator's Approve, refused while any difference stands and switched on the Approve only
|
||||
if none stands then. `config_hand_back` is gone: it answers so and changes nothing. `config_declared` and `config_resync` read and write again what it keeps.
|
||||
- Its own sources hold always (novox/hq ADR 0590): declarations from the modules' own manifests, the list from the
|
||||
indexer's `predefined`, pull requests' config from the indexer's `config-candidates`. The controller's
|
||||
`pull-declares`, the take-over, compare and hand-back went with the controller. `config_declared` and
|
||||
`config_resync` read and write again what it keeps.
|
||||
- A trusted value is set only through propose and the operator's Approve: no controller relay is trusted (issue 1126).
|
||||
- Its sets, clears and warrants are recorded in the hand-act log of the seat `mesh-hand-acts` alone.
|
||||
|
||||
@@ -124,7 +124,7 @@ func resolving(c *Config) {
|
||||
// A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is
|
||||
// every change of what a module declares where (issue 1953); the builds themselves are read, not watched.
|
||||
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead,
|
||||
CandidatesRead, TakeoverState} {
|
||||
CandidatesRead} {
|
||||
// Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow).
|
||||
go stdio.State(read).Follow("", poke)
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ func TestEveryStateAndActHasItsTool(t *testing.T) {
|
||||
via := map[string]string{
|
||||
ValuesState: "config list", ResolvedState: "config list", PredefinedState: "config list",
|
||||
ProposalsState: "config proposals", PullsState: "config list", KeptState: "config list", HistoryLog: "config history",
|
||||
DeclaredState: DeclaredTool, TakeoverState: DeclaredTool,
|
||||
PlacementRead: CompareTool, WantsRead: CompareTool, BuildsRead: CompareTool, ModulesRead: DeclaredTool,
|
||||
ManifestsRead: DeclaredTool, IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool,
|
||||
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool,
|
||||
DeclaredState: DeclaredTool,
|
||||
PlacementRead: "config list", WantsRead: "config list", BuildsRead: "config list", ModulesRead: DeclaredTool,
|
||||
ManifestsRead: DeclaredTool, IndexerPredefinedRead: "config list", CandidatesRead: ResyncTool,
|
||||
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool,
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range Tools(&Config{}) {
|
||||
|
||||
@@ -758,7 +758,6 @@ func (c *Config) Decide() ([]string, error) {
|
||||
for _, r := range ready {
|
||||
said = append(said, c.decide(r.p, r.w))
|
||||
}
|
||||
said = append(said, c.decideTakeovers()...)
|
||||
c.recordWarrants(now)
|
||||
return said, nil
|
||||
}
|
||||
|
||||
@@ -136,59 +136,15 @@ type PullDeclaresAnswer struct {
|
||||
Unsaid []string `json:"unsaid,omitempty"`
|
||||
}
|
||||
|
||||
// PullDeclares keeps what a pull request's head declares, from the controller alone, and judges again the values
|
||||
// held for it: a value whose key is declared the same (by digest) and still passes is kept at the new head; any
|
||||
// other is dropped, recorded and raised, and must be proposed again (ADR 0502 clause 6).
|
||||
func (c *Config) PullDeclares(call Call) (*PullDeclaresAnswer, error) {
|
||||
if call.Caller != ControllerCaller {
|
||||
return nil, fmt.Errorf("pull-declares is the controller's: it reads a pull request's manifests and tells "+
|
||||
"mesh-config what the head declares; %s is not the controller", call.Caller)
|
||||
}
|
||||
if sw, err := c.source(); err != nil {
|
||||
return nil, err
|
||||
} else if sw.Source == SourceIndexer {
|
||||
return nil, errors.New("pull-declares is refused: since the take-over mesh-config reads a pull request's config " +
|
||||
"declarations from the indexer's config-candidates (novox/hq ADR 0565 §4)")
|
||||
}
|
||||
if _, _, _, err := parsePull(call.Pull); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head := strings.TrimSpace(call.Head)
|
||||
if head == "" {
|
||||
return nil, errors.New("name the head the declarations are read at")
|
||||
}
|
||||
raw, err := json.Marshal(call.Declares)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the declarations cannot be read: %v", err)
|
||||
}
|
||||
declares := map[string]map[string]Declaration{}
|
||||
if call.Declares != nil {
|
||||
if err := json.Unmarshal(raw, &declares); err != nil {
|
||||
return nil, fmt.Errorf("declares is the modules' declarations, a key to a declaration for each module: %v", err)
|
||||
}
|
||||
}
|
||||
for m, keys := range declares {
|
||||
if !nodeOrModule.MatchString(m) {
|
||||
return nil, fmt.Errorf("%q is not a module's name", m)
|
||||
}
|
||||
for k, d := range keys {
|
||||
if !keyName.MatchString(k) {
|
||||
return nil, fmt.Errorf("%q is not a config key", k)
|
||||
}
|
||||
if !d.HasDefault() {
|
||||
d.Default = nil
|
||||
}
|
||||
keys[k] = d
|
||||
}
|
||||
}
|
||||
pull := strings.TrimSpace(call.Pull)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.declarePull(pull, head, declares)
|
||||
// PullDeclares is gone: a pull request's config declarations are read from the indexer's config-candidates
|
||||
// (takeCandidates), and the controller that told them is not placed (novox/hq ADR 0565 §4, ADR 0590).
|
||||
func (c *Config) PullDeclares(Call) (*PullDeclaresAnswer, error) {
|
||||
return nil, errors.New("pull-declares is gone: mesh-config reads a pull request's config declarations from the " +
|
||||
"indexer's config-candidates (novox/hq ADR 0565 §4, ADR 0590)")
|
||||
}
|
||||
|
||||
// declarePull keeps what a pull request's head declares and judges again the values held for it; from the
|
||||
// controller's pull-declares, or after the take-over from the indexer's config candidates. The caller holds the lock.
|
||||
// declarePull keeps what a pull request's head declares and judges again the values held for it, from the indexer's
|
||||
// config candidates. The caller holds the lock.
|
||||
func (c *Config) declarePull(pull, head string, declares map[string]map[string]Declaration) (*PullDeclaresAnswer, error) {
|
||||
if err := c.Store.Put(PullsState, pullKey(pull), PullDecl{Pull: pull, Head: head, Declares: declares,
|
||||
At: c.now()}); err != nil {
|
||||
|
||||
@@ -51,13 +51,21 @@ func (f *standForge) Pull(owner, repo string, n int) (bool, bool, string, error)
|
||||
return st[0], st[1], f.head, nil
|
||||
}
|
||||
|
||||
// tell keeps what a pull request's head declares as the indexer's candidate is taken (takeCandidates).
|
||||
func tell(t *testing.T, c *Config, pull, head string, declares map[string]any) *PullDeclaresAnswer {
|
||||
t.Helper()
|
||||
a, err := c.Do(Call{Action: "pull-declares", Pull: pull, Head: head, Declares: declares, Caller: ControllerCaller})
|
||||
if err != nil {
|
||||
t.Fatalf("pull-declares: %v", err)
|
||||
raw, _ := json.Marshal(declares)
|
||||
d := map[string]map[string]Declaration{}
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a.(*PullDeclaresAnswer)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
a, err := c.declarePull(pull, head, d)
|
||||
if err != nil {
|
||||
t.Fatalf("declare: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// holdIt proposes media-gid=2000 for n8n on ace held for the pull request and has the operator approve it.
|
||||
@@ -75,21 +83,15 @@ func holdIt(t *testing.T, c *Config, m *memStore, r *standIn) string {
|
||||
|
||||
func keptCount(m *memStore) int { k, _ := m.Keys(KeptState); return len(k) }
|
||||
|
||||
// 1826/K1: mesh-config takes what a pull request declares from the controller alone, and keeps it per pull request.
|
||||
func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) {
|
||||
// 1826/K1: mesh-config keeps what a pull request declares per pull request; pull-declares is gone (ADR 0590).
|
||||
func TestAPullRequestsDeclarationsAreKeptPerPullRequest(t *testing.T) {
|
||||
c, m, _, _ := aHoldingMesh(t)
|
||||
for _, caller := range []string{"g14.node-tools", Unproven, ""} {
|
||||
for _, caller := range []string{"g14.node-tools", Unproven, "", ControllerCaller} {
|
||||
if _, err := c.Do(Call{Action: "pull-declares", Pull: thePull, Head: "abc1234", Declares: n8nPullDeclares(),
|
||||
Caller: caller}); err == nil || !strings.Contains(err.Error(), "controller") {
|
||||
Caller: caller}); err == nil || !strings.Contains(err.Error(), "config-candidates") {
|
||||
t.Errorf("pull-declares from %q: %v", caller, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []Call{{Pull: "mesh-catalog#434", Head: "a"}, {Pull: thePull}, {Pull: "a/b#0", Head: "a"}} {
|
||||
bad.Action, bad.Caller = "pull-declares", ControllerCaller
|
||||
if _, err := c.Do(bad); err == nil {
|
||||
t.Errorf("pull-declares %+v was taken", bad)
|
||||
}
|
||||
}
|
||||
a := tell(t, c, thePull, "abc1234def", n8nPullDeclares())
|
||||
if len(a.Modules) != 1 || a.Modules[0] != "n8n" {
|
||||
t.Errorf("answered %+v", a)
|
||||
|
||||
@@ -365,10 +365,6 @@ func (c *Config) Reconcile() (Resolution, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var r Resolution
|
||||
if sw, serr := c.source(); serr == nil && sw.Gone != "" {
|
||||
r.SourceGone = sw.Gone
|
||||
c.logf("[mesh-config] %s", sw.Gone)
|
||||
}
|
||||
published, err := c.publish()
|
||||
if err != nil {
|
||||
c.logf("[mesh-config] the list of predefined keys is not published: %v", err)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
// The tools of the switch (novox/hq ADR 0565 §4, §15): compare, take-over and hand-back, served as the seat's verbs
|
||||
// and as the module's own tools beside them, the read of `declared` and resync.
|
||||
// The module's own tools beside the seat's verb (novox/hq ADR 0565 §15): the read of `declared` and resync. compare,
|
||||
// take-over and hand-back went with the controller (ADR 0590).
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -11,56 +11,17 @@ import (
|
||||
|
||||
// The switch's tools.
|
||||
const (
|
||||
CompareTool = "config_compare"
|
||||
TakeOverTool = "config_take_over"
|
||||
HandBackTool = "config_hand_back"
|
||||
DeclaredTool = "config_declared"
|
||||
ResyncTool = "config_resync"
|
||||
)
|
||||
|
||||
// SeatVerbs are the seat's verbs, as the manifest claims them.
|
||||
var SeatVerbs = []string{Verb, CompareVerb, TakeOverVerb, HandBackVerb}
|
||||
var SeatVerbs = []string{Verb}
|
||||
|
||||
func switchTools(c *Config) []stdio.Tool {
|
||||
own := ownSwitchTools(c)
|
||||
byName := map[string]stdio.Tool{}
|
||||
for _, t := range own {
|
||||
byName[t.Name] = t
|
||||
}
|
||||
for verb, tool := range map[string]string{CompareVerb: CompareTool, TakeOverVerb: TakeOverTool, HandBackVerb: HandBackTool} {
|
||||
t := byName[tool]
|
||||
t.Name = Seat + "." + verb
|
||||
own = append(own, t)
|
||||
}
|
||||
return own
|
||||
}
|
||||
func switchTools(c *Config) []stdio.Tool { return ownSwitchTools(c) }
|
||||
|
||||
func ownSwitchTools(c *Config) []stdio.Tool {
|
||||
why := map[string]any{"type": "object", "properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, in a sentence"}}, "required": []string{"why"}}
|
||||
whyOf := func(a map[string]any) string { s, _ := a["why"].(string); return strings.TrimSpace(s) }
|
||||
return []stdio.Tool{
|
||||
{Name: CompareTool,
|
||||
Description: "Every difference between the indexer's records and the controller's pull-declares (novox/hq ADR 0565 §4): " +
|
||||
"each open pull request's config (the indexer's candidate against the controller's pull-declares), each " +
|
||||
"with its reason. The controller's mirrors are no longer read, so nothing else is compared. The " +
|
||||
"take-over is refused while any stands.",
|
||||
Run: func(map[string]any) (any, error) { return c.Compare() }},
|
||||
{Name: TakeOverTool,
|
||||
Description: "Ask the operator's Approve, under mesh-config's own name, to switch config to its own sources: " +
|
||||
"the indexer's list of predefined keys and pull request config, refusing the controller's pull-declares. " +
|
||||
"Refused while compare shows a difference; on Approve switched if nothing differs then.",
|
||||
Input: why,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return c.TakeOver(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
|
||||
}},
|
||||
{Name: HandBackTool,
|
||||
Description: "Gone: the controller's records end with its lease and config no longer reads them (novox/hq ADR 0565 §4, " +
|
||||
"issue 774); answers that, and changes nothing.",
|
||||
Input: why,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return c.HandBack(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
|
||||
}},
|
||||
{Name: DeclaredTool,
|
||||
Description: "Each placed module's declarations as config keeps them (its state declared): the manifest and " +
|
||||
"commit they come from and how many keys, or one module whole; and which source holds now.",
|
||||
@@ -72,7 +33,7 @@ func ownSwitchTools(c *Config) []stdio.Tool {
|
||||
}},
|
||||
{Name: ResyncTool,
|
||||
Description: "Read every source again and write what differs: the published list of predefined keys, the " +
|
||||
"resolved values, declared, and after the take-over the indexer's config candidates.",
|
||||
"resolved values, declared, and the indexer's config candidates.",
|
||||
Run: func(map[string]any) (any, error) { return c.Resync() }},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,44 @@
|
||||
package main
|
||||
|
||||
// Config's records out of the controller (novox/hq ADR 0565 §3, §4, §13, §15, and the progressive insight of
|
||||
// 2026-10-29): mesh-config keeps each placed module's declarations in its own state `declared`; `compare` lists every
|
||||
// difference between each open pull request's candidate at the indexer and what the controller told by pull-declares;
|
||||
// `take-over` asks the operator's Approve under mesh-config's own name and, refused while anything differs, writes the
|
||||
// key `takeover.switch` naming the indexer.
|
||||
// Config's records out of the controller (novox/hq ADR 0565 §3, §15, and the progressive insight of 2026-10-29):
|
||||
// mesh-config keeps each placed module's declarations in its own state `declared`, reads the list of predefined keys
|
||||
// from the indexer's `predefined` and a pull request's config from the indexer's `config-candidates`.
|
||||
//
|
||||
// **The controller's records are no longer read** (issue 774, ADR 0571 §13): its mirrors `declared-config` and
|
||||
// `predefined` are gone from the reads, so declarations come from each placed module's own manifests and the list of
|
||||
// predefined keys from the indexer's `predefined`, whatever the switch says. `hand-back` is answered as gone (ADR 0565
|
||||
// §4: it ends with the lease), and a switch that still names the controller is read as the indexer, said so.
|
||||
//
|
||||
// **Which source holds** is the key `switch` alone, read by mesh-config alone:
|
||||
// - none written: a pull request's declarations from the controller's pull-declares;
|
||||
// - `indexer` (taken over): a pull request's from the indexer's `config-candidates`; pull-declares refused;
|
||||
// - `controller` (the old hand-back): gone, read as `indexer`.
|
||||
// **Its own sources hold always** (ADR 0590, issue 1126): the controller ended by its unassignment, so its pull-declares,
|
||||
// the take-over that switched away from them, compare and hand-back are gone, and the switch is the indexer's whatever
|
||||
// a key once said.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The states and reads of the switch.
|
||||
// The states and reads.
|
||||
const (
|
||||
// DeclaredState is each placed module's declarations at the trunk, one key per module (ADR 0565 §3).
|
||||
DeclaredState = "declared"
|
||||
// TakeoverState holds the key SwitchKey and each take-over ask, `ask.<id>`.
|
||||
TakeoverState = "takeover"
|
||||
SwitchKey = "switch"
|
||||
|
||||
// IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2).
|
||||
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
|
||||
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
|
||||
)
|
||||
|
||||
// The sources the switch names.
|
||||
const (
|
||||
SourceInterim = "interim"
|
||||
SourceIndexer = "indexer"
|
||||
// SourceController is a switch the old hand-back wrote; the controller's records are gone, so it is read as the indexer.
|
||||
SourceController = "controller"
|
||||
)
|
||||
// SourceIndexer is the one source of a pull request's config and of the predefined list: the indexer's.
|
||||
const SourceIndexer = "indexer"
|
||||
|
||||
// TakeOverVerb and HandBackVerb are the seat's verbs.
|
||||
const (
|
||||
TakeOverVerb = "take-over"
|
||||
HandBackVerb = "hand-back"
|
||||
CompareVerb = "compare"
|
||||
)
|
||||
|
||||
// Switch is the key `takeover.switch`: the source that holds, who chose it, why and on which ask.
|
||||
// Switch is which source holds: the indexer's, always, since the controller ended (ADR 0590).
|
||||
type Switch struct {
|
||||
Source string `json:"source"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
// Gone says the stored switch named the controller, whose records config no longer reads (ADR 0565 §4); the switch
|
||||
// returned is then the indexer's. Never stored.
|
||||
Gone string `json:"-"`
|
||||
Source string `json:"source"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// goneSource is what is said of a switch that still names the controller.
|
||||
const goneSource = "the switch names the controller, which is gone: config no longer reads its records (novox/hq ADR 0565 §4, " +
|
||||
"issue 774) and reads the indexer's"
|
||||
|
||||
// source is the source that holds now.
|
||||
// source is the source that holds: the indexer's.
|
||||
func (c *Config) source() (Switch, error) {
|
||||
e, err := c.Store.Get(TakeoverState, SwitchKey)
|
||||
if err != nil {
|
||||
return Switch{}, fmt.Errorf("the take-over's switch cannot be read: %w", err)
|
||||
}
|
||||
if e == nil {
|
||||
return Switch{Source: SourceInterim}, nil
|
||||
}
|
||||
var s Switch
|
||||
if err := json.Unmarshal(e.Value, &s); err != nil || (s.Source != SourceIndexer && s.Source != SourceController) {
|
||||
return Switch{}, fmt.Errorf("the take-over's switch is not one: %s", e.Value)
|
||||
}
|
||||
if s.Source == SourceController {
|
||||
s.Source, s.Gone = SourceIndexer, goneSource
|
||||
}
|
||||
return s, nil
|
||||
return Switch{Source: SourceIndexer, Why: "config reads its own sources since the controller ended by its unassignment (novox/hq ADR 0590)"}, nil
|
||||
}
|
||||
|
||||
// ---- declared --------------------------------------------------------------------------------------------
|
||||
@@ -254,368 +203,6 @@ func declaresDigest(d map[string]map[string]Declaration) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ---- compare ----------------------------------------------------------------------------------------------
|
||||
|
||||
// Difference is one thing the indexer's records and the controller's pull-declares say differently, and why.
|
||||
type Difference struct {
|
||||
What string `json:"what"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Pull string `json:"pull,omitempty"`
|
||||
Own json.RawMessage `json:"own,omitempty"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// The kinds of difference.
|
||||
const (
|
||||
DiffPull = "pull"
|
||||
)
|
||||
|
||||
// Comparison is what compare answers.
|
||||
type Comparison struct {
|
||||
Source string `json:"source"`
|
||||
Differences []Difference `json:"differences"`
|
||||
Count int `json:"count"`
|
||||
Said string `json:"said"`
|
||||
// Gone is said when the stored switch still names the controller.
|
||||
Gone string `json:"gone,omitempty"`
|
||||
}
|
||||
|
||||
// Compare lists every difference (ADR 0565 §4 and its insight). The caller holds no lock.
|
||||
func (c *Config) Compare() (*Comparison, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.compare()
|
||||
}
|
||||
|
||||
// compare is Compare; the caller holds the lock. The controller's mirrors are no longer read (issue 774, ADR 0565 §4),
|
||||
// so the declarations, the list and the resolved values have no second side: what remains to compare is each open pull
|
||||
// request's candidate at the indexer against what the controller told by pull-declares.
|
||||
func (c *Config) compare() (*Comparison, error) {
|
||||
sw, err := c.source()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var own Sources
|
||||
if err := c.readPulls(&own); err != nil {
|
||||
return nil, fmt.Errorf("mesh-config's own pull request records cannot be read: %w", err)
|
||||
}
|
||||
var diffs []Difference
|
||||
|
||||
// Each open pull request: the indexer's candidate against what the controller told by pull-declares.
|
||||
cands, cerr := c.candidates()
|
||||
if cerr != nil {
|
||||
diffs = append(diffs, Difference{What: DiffPull, Why: "the indexer's config candidates cannot be read: " + cerr.Error()})
|
||||
}
|
||||
settled := map[string]string{} // a pull request the forge says is no longer open → how it ended
|
||||
for _, k := range unionKeys(cands, own.Pulls) {
|
||||
a, aok := cands[k]
|
||||
b, bok := own.Pulls[k]
|
||||
isSettled, forgeNote := c.pullSettled(firstNonEmpty(a.Pull, b.Pull), settled)
|
||||
if isSettled {
|
||||
continue // a merged or closed pull request is no candidate to agree on (the operator, 2026-11-01)
|
||||
}
|
||||
before := len(diffs)
|
||||
switch {
|
||||
case !aok:
|
||||
diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"})
|
||||
case !bok:
|
||||
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the indexer holds a candidate and the controller told nothing"})
|
||||
case a.Head != b.Head:
|
||||
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: fmt.Sprintf("the indexer's candidate is at %s, "+
|
||||
"the controller told %s", short(a.Head), short(b.Head))})
|
||||
case declaresDigest(a.Declares) != declaresDigest(b.Declares):
|
||||
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"})
|
||||
}
|
||||
if forgeNote != "" && len(diffs) > before {
|
||||
diffs[len(diffs)-1].Why += "; " + forgeNote // the row stays because the forge was not heard: say so in the row
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(diffs, func(i, j int) bool {
|
||||
a, b := diffs[i], diffs[j]
|
||||
return a.What+a.Module+a.Node+a.Key+a.Pull < b.What+b.Module+b.Node+b.Key+b.Pull
|
||||
})
|
||||
if diffs == nil {
|
||||
diffs = []Difference{}
|
||||
}
|
||||
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs), Gone: sw.Gone}
|
||||
if len(diffs) == 0 {
|
||||
cmp.Said = "nothing differs: the take-over may be asked (the controller's mirrors are no longer read, so only the pull requests are compared)"
|
||||
} else {
|
||||
cmp.Said = fmt.Sprintf("%d difference(s): the take-over is refused while any stands", len(diffs))
|
||||
}
|
||||
return cmp, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// pullSettled is whether the forge says a pull request merged or closed. Without a forge, or when it does not say, the
|
||||
// pull request counts as open, so its row stays (failure is loud).
|
||||
func (c *Config) pullSettled(pull string, memo map[string]string) (bool, string) {
|
||||
if c.Forge == nil || pull == "" {
|
||||
return false, ""
|
||||
}
|
||||
if v, ok := memo[pull]; ok {
|
||||
return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v)
|
||||
}
|
||||
owner, repo, n, err := parsePull(pull)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
open, merged, _, err := c.Forge.Pull(owner, repo, n)
|
||||
switch {
|
||||
case err != nil:
|
||||
c.logf("[mesh-config] compare: the forge does not say whether %s is open (%v); its row stays", pull, err)
|
||||
memo[pull] = forgeSilent + err.Error()
|
||||
case open:
|
||||
memo[pull] = ""
|
||||
case merged:
|
||||
memo[pull] = "merged"
|
||||
default:
|
||||
memo[pull] = "closed"
|
||||
}
|
||||
v := memo[pull]
|
||||
return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v)
|
||||
}
|
||||
|
||||
// forgeSilent marks a memo entry for a pull request the forge could not be asked about; the rest of the entry is why.
|
||||
const forgeSilent = "forge-silent: "
|
||||
|
||||
// forgeNoteOf is the words a row carries when the forge was not heard on its pull request, or "".
|
||||
func forgeNoteOf(v string) string {
|
||||
if why, ok := strings.CutPrefix(v, forgeSilent); ok {
|
||||
return "the forge does not say whether the pull request is open (" + why + ")"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unionKeys[A, B any](a map[string]A, b map[string]B) []string {
|
||||
seen := map[string]bool{}
|
||||
for k := range a {
|
||||
seen[k] = true
|
||||
}
|
||||
for k := range b {
|
||||
seen[k] = true
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// ---- take-over and hand-back ---------------------------------------------------------------------------------
|
||||
|
||||
// TakeoverAsk is one take-over asked of the operator, `takeover.ask.<id>`: what the ask is composed from again, and
|
||||
// where it stands.
|
||||
type TakeoverAsk struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
State string `json:"state"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Decided time.Time `json:"decided,omitzero"`
|
||||
Answered string `json:"answered,omitempty"`
|
||||
Recorded bool `json:"recorded,omitempty"`
|
||||
}
|
||||
|
||||
func askKey(id string) string { return "ask." + id }
|
||||
|
||||
// act is what an answer to the take-over binds.
|
||||
func (t TakeoverAsk) act(option string) asks.Act {
|
||||
return asks.Act{"verb": Seat + "." + TakeOverVerb, "option": option, "ask": t.ID, "from": t.From,
|
||||
"at": t.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// ask is the take-over's ask, composed from it alone.
|
||||
func (t TakeoverAsk) ask() asks.Ask {
|
||||
why, _ := sayable("Why: " + t.Why)
|
||||
a := asks.Ask{ID: t.ID, Headline: "Switch config to its own sources",
|
||||
Explanation: strings.Join([]string{
|
||||
"mesh-config would read each pull request's config from the delivery's " +
|
||||
"indexer, no longer from the controller. Nothing any node resolves changes: compare showed no difference.",
|
||||
point(why)}, "\n"),
|
||||
Who: asks.Operator, Expires: t.At.Add(ProposalLasts),
|
||||
OnExpiry: "nothing changes; a pull request's config keeps coming from the controller's pull-declares", About: "config.takeover",
|
||||
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back is gone."}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", "config switches to its own sources at once, if nothing differs then"},
|
||||
{OptionDecline, "Decline", "nothing changes"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(t.act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// TakeoverAnswer is what take-over and hand-back answer.
|
||||
type TakeoverAnswer struct {
|
||||
Said string `json:"said"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// TakeOver asks the operator's Approve to switch to the own sources, refused while compare shows a difference.
|
||||
func (c *Config) TakeOver(call Call) (*TakeoverAnswer, error) {
|
||||
if strings.TrimSpace(call.Why) == "" {
|
||||
return nil, errors.New("say why the take-over is asked now: it is shown to the operator")
|
||||
}
|
||||
if call.Caller == "" || call.Caller == Unproven {
|
||||
return nil, errors.New("the call names no proven caller")
|
||||
}
|
||||
if c.Asks == nil {
|
||||
return nil, errors.New("the operator cannot be asked from here, so nothing is switched")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
sw, err := c.source()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sw.Source == SourceIndexer {
|
||||
return &TakeoverAnswer{Source: sw.Source, Said: "config reads its own sources already, since " +
|
||||
sw.At.UTC().Format(time.RFC3339) + " (" + sw.By + ")"}, nil
|
||||
}
|
||||
cmp, err := c.compare()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmp.Count > 0 {
|
||||
first := cmp.Differences[0]
|
||||
return nil, fmt.Errorf("the take-over is refused: compare shows %d difference(s), the first %s %s%s%s%s: %s. "+
|
||||
"Nothing was asked", cmp.Count, first.What, first.Module, first.Node, first.Key, first.Pull, first.Why)
|
||||
}
|
||||
now := c.now()
|
||||
var open []string
|
||||
_ = c.each(TakeoverState, func(key string, raw []byte) error {
|
||||
var t TakeoverAsk
|
||||
if strings.HasPrefix(key, "ask.") && json.Unmarshal(raw, &t) == nil && t.State == ProposalWaiting &&
|
||||
now.Before(t.At.Add(ProposalLasts)) {
|
||||
open = append(open, t.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if len(open) > 0 {
|
||||
return nil, fmt.Errorf("the take-over is asked already, as %s, and waits for the operator's answer", open[0])
|
||||
}
|
||||
t := TakeoverAsk{ID: "takeover-" + newProposalID(), From: call.Caller, Why: strings.TrimSpace(call.Why), At: now.UTC(),
|
||||
State: ProposalWaiting}
|
||||
ask := t.ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Store.Put(TakeoverState, askKey(t.ID), t); err != nil {
|
||||
return nil, fmt.Errorf("the take-over could not be kept, so the operator was not asked: %w", err)
|
||||
}
|
||||
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
t.State, t.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error()
|
||||
_ = c.Store.Put(TakeoverState, askKey(t.ID), t)
|
||||
return nil, fmt.Errorf("the operator could not be asked: %w", err)
|
||||
}
|
||||
return &TakeoverAnswer{Ask: t.ID, Source: sw.Source, Said: fmt.Sprintf("the operator is asked %q under "+
|
||||
"mesh-config's own name; nothing changes until they approve, and on Approve config switches if nothing differs "+
|
||||
"then", ask.Headline)}, nil
|
||||
}
|
||||
|
||||
// decideTakeovers acts on each take-over ask the router has closed. The caller holds the lock.
|
||||
func (c *Config) decideTakeovers() []string {
|
||||
var said []string
|
||||
var waiting []TakeoverAsk
|
||||
_ = c.each(TakeoverState, func(key string, raw []byte) error {
|
||||
var t TakeoverAsk
|
||||
if strings.HasPrefix(key, "ask.") && json.Unmarshal(raw, &t) == nil && t.State == ProposalWaiting {
|
||||
waiting = append(waiting, t)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
now := c.now()
|
||||
for _, t := range waiting {
|
||||
keep := func(state, outcome string) string {
|
||||
t.State, t.Outcome, t.Decided = state, outcome, now
|
||||
_ = c.Store.Put(TakeoverState, askKey(t.ID), t)
|
||||
return "take-over " + t.ID + ": " + outcome
|
||||
}
|
||||
e, err := c.Asks.Record(t.ID)
|
||||
if err != nil || e == nil {
|
||||
if err == nil && now.After(t.At.Add(ProposalLasts+time.Hour)) {
|
||||
said = append(said, keep(ProposalEnded, "the router keeps no record of its ask; nothing was switched"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(e.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
||||
continue
|
||||
}
|
||||
w := *rec.Warrant
|
||||
if w.Asker != Module || w.Ask != t.ID {
|
||||
said = append(said, keep(ProposalRefused, "the router's record is for another ask; nothing was switched"))
|
||||
continue
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
said = append(said, keep(ProposalEnded, "the ask "+string(w.Outcome)+"; nothing was switched"))
|
||||
continue
|
||||
}
|
||||
option, err := w.For(Module, t.ask())
|
||||
if err == nil {
|
||||
err = option.Performs(t.act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
said = append(said, keep(ProposalRefused, "the answer is refused: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
t.Answered = w.Says()
|
||||
if option.ID != OptionApprove {
|
||||
said = append(said, keep(ProposalDeclined, "declined by the operator; nothing was switched"))
|
||||
continue
|
||||
}
|
||||
cmp, err := c.compare()
|
||||
switch {
|
||||
case err != nil:
|
||||
said = append(said, keep(ProposalRefused, "approved, and compare could not be read, so nothing was switched: "+err.Error()))
|
||||
continue
|
||||
case cmp.Count > 0:
|
||||
said = append(said, keep(ProposalRefused, fmt.Sprintf("approved, and refused: compare shows %d difference(s) "+
|
||||
"now; nothing was switched, ask again once it is empty", cmp.Count)))
|
||||
continue
|
||||
}
|
||||
sw := Switch{Source: SourceIndexer, At: now.UTC(), By: approvedTakeover(w), Why: t.Why, Ask: t.ID}
|
||||
if err := c.Store.Put(TakeoverState, SwitchKey, sw); err != nil {
|
||||
said = append(said, fmt.Sprintf("take-over %s: approved, and the switch could not be written (%v); read again "+
|
||||
"on the next look", t.ID, err))
|
||||
continue
|
||||
}
|
||||
if err := c.Asks.Warranted(t.ID); err == nil {
|
||||
t.Recorded = true
|
||||
}
|
||||
said = append(said, keep(ProposalApproved, "approved by the operator: config reads its own sources from now"))
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
func approvedTakeover(w asks.Warrant) string {
|
||||
if w.By == nil {
|
||||
return "the operator"
|
||||
}
|
||||
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
|
||||
}
|
||||
|
||||
// HandBack is answered as gone: the controller's records end with its lease, and config no longer reads them (novox/hq
|
||||
// ADR 0565 §4, issue 774). Nothing is written.
|
||||
func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
|
||||
if strings.TrimSpace(call.Why) == "" {
|
||||
return nil, errors.New("say why config hands back: it is kept with the switch")
|
||||
}
|
||||
if call.Caller == "" || call.Caller == Unproven {
|
||||
return nil, errors.New("the call names no proven caller")
|
||||
}
|
||||
return nil, errors.New("hand-back is gone: the controller's records end with its lease and config no longer reads " +
|
||||
"them, so there is nothing to hand back to (novox/hq ADR 0565 §4); nothing was changed")
|
||||
}
|
||||
|
||||
// Declared is the read of `declared`: every module's key, or one whole.
|
||||
func (c *Config) Declared(module string) (any, error) {
|
||||
if module != "" {
|
||||
@@ -641,7 +228,7 @@ func (c *Config) Declared(module string) (any, error) {
|
||||
}
|
||||
|
||||
// Resync reads every source again and writes what differs: the published list, the resolved values, `declared`, and
|
||||
// after the take-over the indexer's config candidates (ADR 0565 §15).
|
||||
// the indexer's config candidates (ADR 0565 §15).
|
||||
func (c *Config) Resync() (any, error) {
|
||||
r, err := c.Reconcile()
|
||||
if err != nil {
|
||||
|
||||
@@ -2,11 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -18,7 +15,7 @@ const (
|
||||
oldDeclared = `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`
|
||||
)
|
||||
|
||||
// anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request: compare is empty.
|
||||
// anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request.
|
||||
func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) {
|
||||
t.Helper()
|
||||
c, m, r := aProposingMesh(t)
|
||||
@@ -47,80 +44,11 @@ func candidate(t *testing.T, m *memStore, pull, head, declares string) {
|
||||
m.mirror(t, CandidatesRead, key, `{"v":1,"pull":"`+pull+`","head":"`+head+`","declares":`+declares+`}`)
|
||||
}
|
||||
|
||||
func kinds(cmp *Comparison) []string {
|
||||
var out []string
|
||||
for _, d := range cmp.Differences {
|
||||
k := d.What + ":" + d.Module + d.Pull + d.Key
|
||||
if d.Node != "" {
|
||||
k += "@" + d.Node
|
||||
}
|
||||
if !slices.Contains(out, k) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compare lists every difference with its reason — a pull request's config at the indexer against what the controller
|
||||
// told — and the take-over is refused while one stands (ADR 0565 §4 and its insight of 2026-10-29; issue 1953 L5-C4).
|
||||
// The controller's mirrors are no longer read, so no declaration, list or value is compared (issue 774, S9).
|
||||
func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(t *testing.T, m *memStore)
|
||||
want []string
|
||||
}{
|
||||
{"all agree", func(*testing.T, *memStore) {}, nil},
|
||||
{"a pull request only the indexer holds", func(t *testing.T, m *memStore) {
|
||||
candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
|
||||
}, []string{"pull:novox/mesh-catalog#9"}},
|
||||
{"a candidate of a merged pull request is no difference", func(t *testing.T, m *memStore) {
|
||||
candidate(t, m, "novox/mesh-catalog#11", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
|
||||
candidate(t, m, "novox/mesh-catalog#10", "h2", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
|
||||
}, []string{"pull:novox/mesh-catalog#10"}},
|
||||
{"a forge that cannot be asked keeps the row and says why in it", func(t *testing.T, m *memStore) {
|
||||
candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
|
||||
}, []string{"pull:novox/mesh-catalog#12"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, m, r := anAgreeingMesh(t)
|
||||
c.Forge = &standForge{state: map[string][2]bool{"novox/mesh-catalog#11": {false, true}}}
|
||||
if strings.Contains(tc.name, "forge that cannot be asked") {
|
||||
c.Forge = &standForge{err: errors.New("forge down")}
|
||||
}
|
||||
tc.change(t, m)
|
||||
cmp, err := c.Compare()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if got := kinds(cmp); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("%s: compare lists %v, want %v (%+v)", tc.name, got, tc.want, cmp.Differences)
|
||||
}
|
||||
for _, d := range cmp.Differences {
|
||||
if d.Why == "" {
|
||||
t.Errorf("%s: a difference without its reason: %+v", tc.name, d)
|
||||
}
|
||||
if strings.Contains(tc.name, "forge that cannot be asked") && !strings.Contains(d.Why, "forge down") {
|
||||
t.Errorf("%s: the row does not say the forge was not heard: %q", tc.name, d.Why)
|
||||
}
|
||||
}
|
||||
a, err := c.TakeOver(Call{Why: "compare is empty", Caller: "person.jochen"})
|
||||
if len(tc.want) > 0 {
|
||||
if err == nil || !strings.Contains(err.Error(), "refused") || len(r.subjects) != 0 {
|
||||
t.Errorf("%s: the take-over was asked while compare differs: %v", tc.name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || a.Ask == "" || len(r.subjects) != 1 {
|
||||
t.Errorf("%s: the take-over was not asked: %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's Approve switches config to the indexer: pull-declares is refused, the list and the pull requests'
|
||||
// config come from the indexer; hand-back is gone, and a switch naming the controller is read as the indexer; declared is written once per manifest (ADR 0565 §3, §4, §13).
|
||||
func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
c, m, r := anAgreeingMesh(t)
|
||||
// ADR 0590 (issue 1126): config reads its own sources always, with no take-over: declared is written once per
|
||||
// manifest, pull-declares is refused naming the indexer's candidates, and the list and a pull request's config come from
|
||||
// the indexer. compare, take-over and hand-back went with the controller.
|
||||
func TestConfigReadsItsOwnSourcesAlways(t *testing.T) {
|
||||
c, m, _ := anAgreeingMesh(t)
|
||||
steps := []struct {
|
||||
name string
|
||||
do func() (string, error)
|
||||
@@ -132,22 +60,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
again, _ := c.Reconcile()
|
||||
return strings.Join(first.Declared, ",") + "|" + strings.Join(again.Declared, ","), err
|
||||
}, "dunst|"},
|
||||
{"pull-declares taken before the take-over", func() (string, error) {
|
||||
a, err := c.PullDeclares(Call{Caller: ControllerCaller, Pull: "novox/mesh-catalog#7", Head: "h0",
|
||||
Declares: map[string]any{}})
|
||||
return answered(a), err
|
||||
}, "keeps what"},
|
||||
{"the take-over is asked", func() (string, error) {
|
||||
_ = m.Delete(PullsState, "novox.mesh-catalog.7") // compare holds the told pull request against no candidate
|
||||
a, err := c.TakeOver(Call{Why: "the release is delivered", Caller: "person.jochen"})
|
||||
return answered(a), err
|
||||
}, "is asked"},
|
||||
{"the operator approves: switched to the indexer", func() (string, error) {
|
||||
r.choose(t, r.lastAsk(t), OptionApprove, nil)
|
||||
out, err := c.Decide()
|
||||
return strings.Join(out, "; "), err
|
||||
}, "reads its own sources from now"},
|
||||
{"pull-declares refused after it, naming the indexer", func() (string, error) {
|
||||
{"pull-declares refused, naming the indexer", func() (string, error) {
|
||||
_, err := c.PullDeclares(Call{Caller: ControllerCaller, Pull: "novox/mesh-catalog#8", Head: "h1",
|
||||
Declares: map[string]any{}})
|
||||
return "", err
|
||||
@@ -164,15 +77,6 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
res, err := c.Reconcile()
|
||||
return strings.Join(res.Candidates, "; "), err
|
||||
}, "from the indexer"},
|
||||
{"hand-back is gone, naming the record (774/S9/AC1)", func() (string, error) {
|
||||
_, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"})
|
||||
return "", err
|
||||
}, "ADR 0565 §4"},
|
||||
{"a switch naming the controller is read as gone, and the indexer holds (774/S9/AC2)", func() (string, error) {
|
||||
m.mirror(t, TakeoverState, SwitchKey, `{"source":"controller","by":"person.jochen","why":"old hand-back"}`)
|
||||
res, err := c.Reconcile()
|
||||
return res.SourceGone + "|" + string(m.resolved(t, "g14", "dunst", "width").Value), err
|
||||
}, "is gone: config no longer reads its records"},
|
||||
}
|
||||
for _, s := range steps {
|
||||
got, err := s.do()
|
||||
@@ -184,44 +88,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
t.Errorf("%s: said %q, want %q", s.name, text, s.want)
|
||||
}
|
||||
}
|
||||
if len(m.handActs) != 0 {
|
||||
t.Errorf("a hand-back that is gone recorded an act: %v", m.handActs)
|
||||
}
|
||||
if e, _ := m.Get(DeclaredState, "dunst"); e == nil || !strings.Contains(string(e.Value), `"manifest":"`) || !strings.Contains(string(e.Value), `"commit":"c1"`) {
|
||||
t.Errorf("declared holds no dunst with its manifest: %v", e)
|
||||
}
|
||||
// An Approve that finds compare differing at the answer switches nothing.
|
||||
c2, m2, r2 := anAgreeingMesh(t)
|
||||
if _, err := c2.TakeOver(Call{Why: "now", Caller: "person.jochen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate(t, m2, "novox/mesh-catalog#9", "h9", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
|
||||
r2.choose(t, r2.lastAsk(t), OptionApprove, nil)
|
||||
out, _ := c2.Decide()
|
||||
if sw, _ := c2.source(); sw.Source != SourceInterim || !strings.Contains(strings.Join(out, ""), "difference") {
|
||||
t.Errorf("an Approve while compare differs switched: %s, %v", sw.Source, out)
|
||||
if sw, _ := c.source(); sw.Source != SourceIndexer {
|
||||
t.Errorf("the source is %q", sw.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// lastAsk is the newest ask the router took.
|
||||
func (r *standIn) lastAsk(t *testing.T) string {
|
||||
t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var newest string
|
||||
var at time.Time
|
||||
for id, a := range r.asks {
|
||||
if newest == "" || a.Expires.After(at) {
|
||||
newest, at = id, a.Expires
|
||||
}
|
||||
}
|
||||
if newest == "" {
|
||||
t.Fatal("the router took no ask")
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
func answered(a any) string {
|
||||
raw, _ := json.Marshal(a)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
"name": "mesh-config",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"config",
|
||||
"compare",
|
||||
"take-over",
|
||||
"hand-back"
|
||||
"config"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -40,9 +37,6 @@
|
||||
"mesh-delivery-indexer.config-candidates"
|
||||
],
|
||||
"tools": [
|
||||
"config_compare",
|
||||
"config_take_over",
|
||||
"config_hand_back",
|
||||
"config_declared",
|
||||
"config_resync"
|
||||
],
|
||||
@@ -53,8 +47,7 @@
|
||||
"proposals",
|
||||
"pulls",
|
||||
"kept",
|
||||
"declared",
|
||||
"takeover"
|
||||
"declared"
|
||||
],
|
||||
"logs": [
|
||||
"history"
|
||||
|
||||
@@ -30,11 +30,10 @@ import (
|
||||
|
||||
// The seat, the log, the states and the event, as mesh-sdk's handacts names them.
|
||||
const (
|
||||
Module = handacts.Module
|
||||
Seat = handacts.Seat
|
||||
ActsLog = handacts.ActsLog
|
||||
WarrantsState = handacts.WarrantsState
|
||||
ControllerActs = handacts.ControllerActs
|
||||
Module = handacts.Module
|
||||
Seat = handacts.Seat
|
||||
ActsLog = handacts.ActsLog
|
||||
WarrantsState = handacts.WarrantsState
|
||||
// RouterRecords is the router's record of every ask, `<asker>.<ask>` (ADR 0259): the warrant is read there.
|
||||
RouterRecords = "messenger.asks"
|
||||
RecordedEvent = handacts.EventRecorded
|
||||
|
||||
@@ -248,8 +248,8 @@ func TestHandActsReadsItsOwnLogAlone(t *testing.T) {
|
||||
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", time.Hour)
|
||||
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", 2*time.Hour)
|
||||
put(ActsLog, VerbRecord, "config", "ace.mesh-config", 30*24*time.Hour)
|
||||
put(ControllerActs, VerbRecord, "never-read", "ace.old", time.Hour)
|
||||
store.keysErr[ControllerActs] = errors.New("the controller's bucket must not be read")
|
||||
put(controllerActs, VerbRecord, "never-read", "ace.old", time.Hour)
|
||||
store.keysErr[controllerActs] = errors.New("the controller's bucket must not be read")
|
||||
cases := []struct {
|
||||
name string
|
||||
days int
|
||||
@@ -291,12 +291,12 @@ func TestHandActsReadsItsOwnLogAlone(t *testing.T) {
|
||||
// read and write nothing of the controller's bucket nor of the log (774/S10; research 072 row 26).
|
||||
func TestTheCopyOfTheControllersHandActsIsOver(t *testing.T) {
|
||||
l, store, _, now := newLog()
|
||||
store.keysErr[ControllerActs] = errors.New("the controller's bucket must not be read")
|
||||
store.keysErr[controllerActs] = errors.New("the controller's bucket must not be read")
|
||||
st, err := l.Copy("test", true)
|
||||
if err != nil || st.Done || !strings.Contains(st.Said, "no longer read") {
|
||||
t.Errorf("without a marker: %+v, %v", st, err)
|
||||
}
|
||||
_ = store.Put(CopiedState, CopyKey, CopyMarker{At: *now, By: "start", Source: ControllerActs, Found: 606, Copied: 606})
|
||||
_ = store.Put(CopiedState, CopyKey, CopyMarker{At: *now, By: "start", Source: controllerActs, Found: 606, Copied: 606})
|
||||
st, err = l.Copy("test", false)
|
||||
if err != nil || !st.Done || st.Marker.Copied != 606 || !strings.Contains(st.Said, "no longer read") || len(store.logKeys(ActsLog)) != 0 {
|
||||
t.Errorf("with a marker: %+v, %v", st, err)
|
||||
@@ -305,3 +305,6 @@ func TestTheCopyOfTheControllersHandActsIsOver(t *testing.T) {
|
||||
t.Errorf("copied says %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// controllerActs is the controller's hand-act log as it was named: a test reads it never.
|
||||
const controllerActs = "mesh-controller.hand-acts"
|
||||
|
||||
Reference in New Issue
Block a user