mesh/delivery merged: source.main moved to novox/mesh-catalog@d2de18fd6bdd
mesh/check checking 326897d8772f on d2de18fd6bdd
The controller ended by its unassignment, so its lease is never handed and both stayed in their controller-era branch (issue 1126, A5). mesh-config no longer trusts the controller's relay: a trusted value needs the operator's Approve. The condition keeper carries nothing from the controller, clears once at start what it carried, and refuses take-over as the one source. Both drop their manifest read of mesh-controller.lease. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
228 lines
9.2 KiB
Go
228 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// The trunk's manifests aMesh declares.
|
|
const (
|
|
claudeDeclared = `{"module":"claude-code","settings":{
|
|
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`
|
|
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.
|
|
func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) {
|
|
t.Helper()
|
|
c, m, r := aProposingMesh(t)
|
|
indexerList(t, m, "aaaaaaaaaaaa", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
|
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c, m, r
|
|
}
|
|
|
|
// indexerList is the indexer's `predefined.list` at a commit.
|
|
func indexerList(t *testing.T, m *memStore, commit, list string) {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(delivery.PredefinedKept{V: 1, Commit: commit, List: json.RawMessage(list)})
|
|
m.mirror(t, IndexerPredefinedRead, delivery.PredefinedListKey, string(raw))
|
|
}
|
|
|
|
// candidate is the indexer's config candidate of a pull request.
|
|
func candidate(t *testing.T, m *memStore, pull, head, declares string) {
|
|
t.Helper()
|
|
key, err := delivery.ConfigCandidateKey(pull)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
steps := []struct {
|
|
name string
|
|
do func() (string, error)
|
|
want string // what the answer or the error says
|
|
}{
|
|
{"declared is written when it differs, and once", func() (string, error) {
|
|
_ = m.Delete(DeclaredState, "dunst")
|
|
first, err := c.Reconcile()
|
|
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) {
|
|
_, err := c.PullDeclares(Call{Caller: ControllerCaller, Pull: "novox/mesh-catalog#8", Head: "h1",
|
|
Declares: map[string]any{}})
|
|
return "", err
|
|
}, "config-candidates"},
|
|
{"the list comes from the indexer", func() (string, error) {
|
|
indexerList(t, m, "cccccccccccc", `{"font-size":{"type":"number","description":"text size","minimum":6,"maximum":72},
|
|
"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
|
"theme":{"type":"string","description":"the desktop's theme"}}`)
|
|
res, err := c.Reconcile()
|
|
return res.Published, err
|
|
}, "theme"},
|
|
{"a pull request's config comes from the indexer", func() (string, error) {
|
|
candidate(t, m, "novox/mesh-catalog#9", "h9", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
|
|
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()
|
|
text := got
|
|
if err != nil {
|
|
text = err.Error()
|
|
}
|
|
if !strings.Contains(text, s.want) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|