Modules stop depending on the controller before the lease write (issue 774) #590
@@ -0,0 +1,39 @@
|
||||
// Package controllerdeps is the catalogue's side of the lease write's manifest test (novox/hq ADR 0571 §13, ADR 0582
|
||||
// clause 2, issue 774): no module of this tree reads, invokes, consumes, waits on or uses the controller beyond what the
|
||||
// named list of mesh-sdk's catalogue.KeepList keeps to the write itself, and the lease key, which names the lease's holder
|
||||
// whoever that is. It decodes every manifest under root/modules and judges them with mesh-sdk's ManifestRefusals, the
|
||||
// judge the grants part's grants_lease_check and the other two repositories' tests run, so the rule is said once.
|
||||
package controllerdeps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Refusals is each reason the lease write's check gives over the manifests under root/modules in one phase
|
||||
// (catalogue.PhaseBefore or catalogue.PhaseWrite), none when it passes. A module.json that does not decode is an error:
|
||||
// the manifests check says which, and nothing here is judged on a tree it cannot read.
|
||||
func Refusals(root, phase string) ([]string, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
manifests := map[string]catalogue.Manifest{}
|
||||
for _, f := range files {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
manifests[m.Module] = m
|
||||
}
|
||||
return catalogue.ManifestRefusals(manifests, phase), nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package controllerdeps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// 774/S13/AC1: every manifest of the catalogue at this head, at the phase of the lease write's own delivery, reads,
|
||||
// invokes and consumes nothing of the controller beyond the lease and the entries kept to the write.
|
||||
func TestNoManifestDependsOnTheControllerBeyondTheLeaseAndTheEntriesKeptToTheWrite(t *testing.T) {
|
||||
got, err := Refusals("../..", catalogue.PhaseWrite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) > 0 {
|
||||
t.Errorf("the catalogue still declares on the controller (novox/hq ADR 0571 §13):\n%s", strings.Join(got, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The check refuses what it must and lets the lease and the kept entries through, on a tree of its own.
|
||||
func TestRefusalsJudgeATreeByPhase(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name, phase string
|
||||
manifests map[string]string
|
||||
want string // empty: passes
|
||||
}{
|
||||
{"the lease is no dependency", catalogue.PhaseWrite, map[string]string{"a": `{"module":"a","reads":["mesh-controller.lease"]}`}, ""},
|
||||
{"a read of the controller's states", catalogue.PhaseWrite, map[string]string{"a": `{"module":"a","reads":["mesh-controller.conditions"]}`}, "a declares read mesh-controller.conditions"},
|
||||
{"a kept entry passes before the delivery", catalogue.PhaseBefore, map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","invokes":["seat:mesh-controller.collect"]}`}, ""},
|
||||
{"a kept-to-the-write entry passes at the write", catalogue.PhaseWrite, map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","invokes":["seat:mesh-controller.collect"]}`}, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for module, text := range row.manifests {
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := Refusals(root, row.phase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if joined := strings.Join(got, "\n"); (row.want == "") != (joined == "") || !strings.Contains(joined, row.want) {
|
||||
t.Errorf("refusals %q, want %q", joined, row.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module checks/controllerdeps
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
@@ -0,0 +1,3 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (issue 1936).
|
||||
# every module's directory (its manifest), decoded and judged for what it still declares on the controller.
|
||||
modules
|
||||
@@ -4,8 +4,8 @@ package main
|
||||
// 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` raises an approve ask under the keeper's own name (ADR 0259), refused while compare shows a
|
||||
// difference, and on the warrant writes the take-over key naming the keeper. `hand-back` writes it back to the
|
||||
// controller until the lease write, and is refused after it.
|
||||
// difference, and on the warrant 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"
|
||||
@@ -179,19 +179,12 @@ func (k *Keeper) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
return k.writeTakeover(health.Takeover{Follow: health.FollowKeeper, At: k.Now().UTC(), By: w.Says(), Ask: w.Ask})
|
||||
}
|
||||
|
||||
// HandBack switches the readers back to the controller, until the lease write.
|
||||
// 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) {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return nil, errors.New("a hand-back says why")
|
||||
}
|
||||
if k.LeaseAway() {
|
||||
return nil, errors.New("refused: the controller's lease is handed away, so there is no controller to hand back to")
|
||||
}
|
||||
t := health.Takeover{Follow: health.FollowController, At: k.Now().UTC(), By: by, Why: why}
|
||||
if err := k.writeTakeover(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"follow": t.Follow, "at": t.At}, nil
|
||||
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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
// The condition keeper (novox/hq ADR 0564 clauses 2, 3, 7, 8, 9 and 13; issue 1952 C2, C3, C7): the one writer of the
|
||||
// mesh's open conditions. It watches every observer's `findings`, the healers' `attempts`, the controller's lease and,
|
||||
// until the lease write, the controller's `conditions`, and the assigner's wants and the nodes' haves for the hold. From
|
||||
// mesh's open conditions. It watches every observer's `findings`, the healers' `attempts`, the controller's lease
|
||||
// alone (its `conditions` are no longer read, issue 774), and the assigner's wants and the nodes' haves for the hold. From
|
||||
// all of them it derives, per key, the condition that should be open, and writes the difference by compare-and-set,
|
||||
// saying each transition as the controller says it (go/health) and keeping it in `history`. Nothing clears a condition
|
||||
// but its finding going (ADR 0231 §14).
|
||||
@@ -46,11 +46,10 @@ var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.H
|
||||
|
||||
// The other states it reads, as `<module>.<name>`.
|
||||
const (
|
||||
AttemptsRead = health.HealersModule + "." + health.AttemptsState
|
||||
ControllerConditions = "mesh-controller.conditions"
|
||||
ControllerLease = "mesh-controller.lease"
|
||||
ControllerLeaseKey = "holder"
|
||||
WantsRead = delivery.AssignerModule + ".wants"
|
||||
AttemptsRead = health.HealersModule + "." + health.AttemptsState
|
||||
ControllerLease = "mesh-controller.lease"
|
||||
ControllerLeaseKey = "holder"
|
||||
WantsRead = delivery.AssignerModule + ".wants"
|
||||
// PlacementRead is the mesh-placement seat's placement, read for which module holds a seat on a node (hold.go; the
|
||||
// lead, 2026-10-29, for ADR 0564 clause 2's inputs).
|
||||
PlacementRead = delivery.PlacementRead
|
||||
|
||||
@@ -265,7 +265,7 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) {
|
||||
// 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 while compare differs, and
|
||||
// hand-back after the lease write.
|
||||
// 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 {
|
||||
@@ -335,11 +335,14 @@ func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
|
||||
if _, err := r.k.TakeOver(nil, "jochen", "now"); err == nil || !strings.Contains(err.Error(), "2 difference") {
|
||||
t.Errorf("take-over while compare differs: %v", err)
|
||||
}
|
||||
if _, err := r.k.HandBack("jochen", "back"); err != nil {
|
||||
t.Errorf("hand-back before the lease write: %v", err)
|
||||
}
|
||||
_ = r.k.Lease(true)
|
||||
if _, err := r.k.HandBack("jochen", "back"); err == nil {
|
||||
t.Error("hand-back after the lease write was taken")
|
||||
for _, away := range []bool{false, true} {
|
||||
_ = r.k.Lease(away)
|
||||
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 with the lease away %v: %v, want it answered as gone naming its record", away, err)
|
||||
}
|
||||
if _, _, found, _ := r.k.Store.Get(TakeoverState, health.TakeoverKey); found {
|
||||
t.Errorf("hand-back with the lease away %v wrote the take-over key", away)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +119,6 @@ func Keep(k *Keeper) {
|
||||
watch(k, AttemptsRead, health.HealersModule+" attempts", func(c stdio.StateChange) error {
|
||||
return said(k.Attempt(c.Key, c.Value, c.Op != "put"))
|
||||
})
|
||||
watch(k, ControllerConditions, "", func(c stdio.StateChange) error {
|
||||
if k.LeaseAway() {
|
||||
return nil
|
||||
}
|
||||
return said(k.Controller(c.Key, c.Value, c.Op != "put"))
|
||||
})
|
||||
for _, o := range Observers {
|
||||
watch(k, o+"."+health.FindingsState, o, func(c stdio.StateChange) error {
|
||||
return said(k.Finding(o, c.Key, c.Value, c.Op != "put"))
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// ADR 0564 clauses 1, 2, 3 and 12 (issue 1952 C1): the manifest says what the code does. It claims the seat
|
||||
// mesh-conditions once for the mesh with the verbs mesh-sdk defines for it; reads every observer's findings, the healers'
|
||||
// attempts, the controller's conditions and lease, the wants and the haves, and never the healers' would; keeps its
|
||||
// attempts, the controller's lease alone (never its conditions, ADR 0564 clause 9), the wants and the haves, and never the healers' would; keeps its
|
||||
// states, history ninety days and each delivery's bound as long as the bound; and every state has a tool that reads it,
|
||||
// every act a tool that starts it.
|
||||
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
@@ -29,14 +29,14 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
!slices.Equal(Verbs, catalogue.VerbNames(seat.Serves)) {
|
||||
t.Errorf("module %s runs as %s and claims %+v; the seat serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(seat.Serves))
|
||||
}
|
||||
reads := []string{ControllerConditions, ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead}
|
||||
reads := []string{ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead}
|
||||
for _, o := range Observers {
|
||||
reads = append(reads, o+"."+health.FindingsState)
|
||||
}
|
||||
slices.Sort(reads)
|
||||
got := slices.Clone(m.Reads)
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) {
|
||||
if slices.Contains(m.Reads, "mesh-controller.conditions") || !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) {
|
||||
t.Errorf("reads %v, want %v and never the healers' would", got, reads)
|
||||
}
|
||||
ttl, keyTTL := map[string]int{}, map[string]bool{}
|
||||
|
||||
@@ -79,8 +79,8 @@ func Tools(k *Keeper, a Asks) []stdio.Tool {
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return k.TakeOver(a, stdio.CallerOf(args), strArg(args, "why"))
|
||||
}},
|
||||
{Name: Seat + ".hand-back", Description: "Switch the readers of conditions back to the controller, until its lease " +
|
||||
"is handed away.",
|
||||
{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.",
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-conditions
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"mesh-provisioner.findings",
|
||||
"artifact-store-tools.findings",
|
||||
"mesh-healers.attempts",
|
||||
"mesh-controller.conditions",
|
||||
"mesh-controller.lease",
|
||||
"mesh-delivery-assigner.wants",
|
||||
"node-engine.have",
|
||||
|
||||
@@ -12,8 +12,8 @@ account alone writes its buckets.
|
||||
- `mesh-config_resolved`: each assigned module's value for each key it declares on its node, keyed
|
||||
`<node>.<module>.<key>`, with its source: `default`, `mesh`, `node`, `module`, or `unset` for a required value
|
||||
nothing sets.
|
||||
- `mesh-config_predefined`: the list of predefined keys, published from the list the controller kept (its mirror
|
||||
`mesh-controller.predefined`), the newest trunk build winning. A newer list dropping a key that still holds a
|
||||
- `mesh-config_predefined`: the list of predefined keys, published from the indexer's list (the controller's mirror is no longer read, issue 774), the newest trunk
|
||||
build winning. A newer list dropping a key that still holds a
|
||||
value at the mesh or a node is withheld, and every listing says why, until that value is cleared.
|
||||
- `mesh-config_proposals`: each value proposed for the operator's Approve, whole, with the value stored at its level
|
||||
when it was proposed (what the operator is shown the change against), who proposed it, why, and what came of it.
|
||||
@@ -66,16 +66,15 @@ 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 (the interim since mesh-catalog #558:
|
||||
declarations from the modules' own manifests, the list of predefined keys and pull requests' config from the
|
||||
controller), `indexer` after the operator's take-over (the list and pull requests' config from the indexer's
|
||||
`predefined` and `config-candidates`; the controller's `pull-declares` refused), `controller` after a hand-back
|
||||
(declarations, list and pull requests' config from the controller's records). Each take-over ask is kept beside it.
|
||||
- `config_compare` lists every difference between the own sources and the controller's records: each module's
|
||||
declarations, the list, each open pull request's config, and every placed module's value on every node resolved
|
||||
both ways. `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` returns to the controller's records until its lease is handed
|
||||
away. `config_declared` and `config_resync` read and write again what it keeps.
|
||||
- `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.
|
||||
- From the controller's lease write a trusted value is set only through propose and the operator's Approve.
|
||||
- Its sets, clears and warrants are recorded in the hand-act log of the seat `mesh-hand-acts`, falling back to the
|
||||
controller's while that seat has no holder and the controller holds its lease.
|
||||
|
||||
@@ -244,10 +244,9 @@ const dunstDeclared = `{"module":"dunst","settings":{
|
||||
"position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]},
|
||||
"origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}`
|
||||
|
||||
// theList is the controller's predefined mirror, as it writes it.
|
||||
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
||||
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}},
|
||||
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
|
||||
// theList is the indexer's list of predefined keys, as predefined.json holds it.
|
||||
const theList = `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
||||
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`
|
||||
|
||||
func aMesh(t *testing.T) (*Config, *memStore) {
|
||||
t.Helper()
|
||||
@@ -256,7 +255,7 @@ func aMesh(t *testing.T) (*Config, *memStore) {
|
||||
m.declare(t, "claude-code", `{"module":"claude-code","settings":{
|
||||
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
|
||||
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
|
||||
indexerList(t, m, "aaaaaaaaaaaa", theList)
|
||||
for _, node := range []string{"g14", "novox"} {
|
||||
m.assign(t, node, "dunst")
|
||||
}
|
||||
@@ -507,7 +506,7 @@ func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
|
||||
expect(t, m, "g14", "12", SourceMesh)
|
||||
}
|
||||
|
||||
// **The newest list of predefined keys is published, and an older one heard later is not** (ADR 0348 §7).
|
||||
// **The newest list of predefined keys is published** (ADR 0348 §7; the indexer never keeps an older merge's).
|
||||
func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
published := func() map[string]Published {
|
||||
@@ -521,24 +520,17 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
|
||||
}
|
||||
return out
|
||||
}
|
||||
if p := published(); len(p) != 2 || p["font-size"].Generation != 5 || !p["agent-managed-settings"].Trusted {
|
||||
if p := published(); len(p) != 2 || p["font-size"].Commit != "aaaaaaaaaaaa" || !p["agent-managed-settings"].Trusted {
|
||||
t.Fatalf("published %+v", p)
|
||||
}
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"},
|
||||
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`)
|
||||
indexerList(t, m, "bbbb", `{"font-size":{"type":"number","description":"x"},
|
||||
"theme":{"type":"string","description":"the theme"}}`)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" {
|
||||
if p := published(); len(p) != 2 || p["theme"].Commit != "bbbb" || p["agent-managed-settings"].Type != "" {
|
||||
t.Fatalf("a newer list published %+v", p)
|
||||
}
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := published(); p["theme"].Generation != 7 {
|
||||
t.Fatalf("an older list replaced the newer one: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the
|
||||
@@ -546,8 +538,7 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
|
||||
func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object",
|
||||
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
|
||||
indexerList(t, m, "cccccccccc", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}}`)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ type PredefinedKey struct {
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
}
|
||||
|
||||
// PredefinedList is the list the controller kept, as its predefined mirror holds it under PredefinedMirrorKey.
|
||||
// PredefinedList is the list of predefined keys with the commit it came from, as the indexer keeps it.
|
||||
type PredefinedList struct {
|
||||
Keys map[string]PredefinedKey `json:"keys"`
|
||||
Repository string `json:"repository"`
|
||||
@@ -138,9 +138,6 @@ type PredefinedList struct {
|
||||
Build string `json:"build"`
|
||||
}
|
||||
|
||||
// PredefinedMirrorKey is the controller's predefined mirror's one key.
|
||||
const PredefinedMirrorKey = "list"
|
||||
|
||||
// Published is one key of mesh-config_predefined: the key's declaration, and the build of the list it came from.
|
||||
type Published struct {
|
||||
PredefinedKey
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// asked under this module's own name, design 57 K4).
|
||||
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own
|
||||
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the
|
||||
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
|
||||
// list of predefined keys comes from the indexer's (ADR 0565 §2); the controller's records are no longer read (issue 774). stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -132,8 +132,8 @@ 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, PredefinedRead, IndexerPredefinedRead,
|
||||
CandidatesRead, MirrorDeclaredRead, TakeoverState} {
|
||||
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead,
|
||||
CandidatesRead, TakeoverState} {
|
||||
// Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow).
|
||||
go stdio.State(read).Follow("", poke)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,7 @@ func TestEveryStateAndActHasItsTool(t *testing.T) {
|
||||
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, PredefinedRead: CompareTool, MirrorDeclaredRead: CompareTool,
|
||||
IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
|
||||
ManifestsRead: DeclaredTool, IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
|
||||
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool,
|
||||
}
|
||||
served := map[string]bool{}
|
||||
|
||||
@@ -34,9 +34,6 @@ const (
|
||||
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
|
||||
ModulesRead = delivery.IndexerModule + ".modules"
|
||||
ManifestsRead = delivery.IndexerModule + ".manifests"
|
||||
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
|
||||
// `predefined`: read here until that state exists.
|
||||
PredefinedRead = "mesh-controller.predefined"
|
||||
|
||||
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
|
||||
HistoryLog = "history"
|
||||
|
||||
@@ -14,17 +14,8 @@ import (
|
||||
|
||||
// sources reads everything a resolution reads, from the bus as it is now, from the source the switch names
|
||||
// (takeover.go): which modules are placed where from the seat mesh-placement, and each placed module's declarations
|
||||
// from its own manifests as built (issue 1953, ADR 0565 §3), or after a hand-back from the controller's mirror.
|
||||
// from its own manifests as built (issue 1953, ADR 0565 §3); the controller's records are no longer read.
|
||||
func (c *Config) sources() (Sources, error) {
|
||||
sw, err := c.source()
|
||||
if err != nil {
|
||||
return Sources{}, err
|
||||
}
|
||||
return c.sourcesFrom(sw.Source)
|
||||
}
|
||||
|
||||
// sourcesFrom reads the sources with declarations from one source: the own (interim and indexer) or the mirror.
|
||||
func (c *Config) sourcesFrom(source string) (Sources, error) {
|
||||
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
|
||||
Predefined: map[string]Published{}, Values: map[string]Value{}}
|
||||
var problems []string
|
||||
@@ -34,9 +25,6 @@ func (c *Config) sourcesFrom(source string) (Sources, error) {
|
||||
if err := c.readDeclarations(&s); err != nil {
|
||||
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
|
||||
}
|
||||
if source == SourceController {
|
||||
c.mirrorDeclarations(&s)
|
||||
}
|
||||
for _, u := range s.Unresolved {
|
||||
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
|
||||
}
|
||||
@@ -366,6 +354,8 @@ type Resolution struct {
|
||||
// did after the take-over.
|
||||
Declared []string `json:"declared,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
// SourceGone is said while the stored switch still names the controller, whose records are no longer read.
|
||||
SourceGone string `json:"source_gone,omitempty"`
|
||||
}
|
||||
|
||||
// Reconcile publishes the newest list of predefined keys and brings mesh-config_resolved to what the sources
|
||||
@@ -375,6 +365,10 @@ 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)
|
||||
@@ -401,24 +395,16 @@ func (c *Config) Reconcile() (Resolution, error) {
|
||||
return r, err
|
||||
}
|
||||
|
||||
// publish makes mesh-config_predefined hold the list its source keeps — the indexer's after the take-over, the
|
||||
// controller's mirror otherwise — when it is newer than the one published (ADR 0348 §7: the newest trunk build wins;
|
||||
// publish makes mesh-config_predefined hold the indexer's list (the controller's mirror is no longer read, issue 774)
|
||||
// when it is newer than the one published (ADR 0348 §7: the newest trunk build wins;
|
||||
// the indexer never keeps an older merge's, so its list is newer when its commit differs). **A list dropping a key that still holds a value is withheld**: the
|
||||
// list published stays, and why is said in every listing and in the log, because a value for a key no longer on
|
||||
// the list could neither be resolved nor cleared by name (§7: removing a key still holding a value is refused).
|
||||
// It answers what it published, empty when nothing changed.
|
||||
func (c *Config) publish() (string, error) {
|
||||
sw, err := c.source()
|
||||
list, found, err := c.indexerList()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
read, whose := c.mirrorList, "the controller's list"
|
||||
if sw.Source == SourceIndexer {
|
||||
read, whose = c.indexerList, "the indexer's list"
|
||||
}
|
||||
list, found, err := read()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s cannot be read: %w", whose, err)
|
||||
return "", fmt.Errorf("the indexer's list cannot be read: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return "", nil // no list kept yet
|
||||
|
||||
@@ -41,11 +41,10 @@ func ownSwitchTools(c *Config) []stdio.Tool {
|
||||
whyOf := func(a map[string]any) string { s, _ := a["why"].(string); return strings.TrimSpace(s) }
|
||||
return []stdio.Tool{
|
||||
{Name: CompareTool,
|
||||
Description: "Every difference between config's own sources and the controller's records (novox/hq ADR 0565 §4): " +
|
||||
"each placed module's declarations (declared against the mirror declared-config), the list of predefined " +
|
||||
"keys (the indexer's against the mirror's), each open pull request's config (the indexer's candidate " +
|
||||
"against the controller's pull-declares), and every placed module's value on every node resolved both " +
|
||||
"ways; each with its reason. The take-over is refused while any stands.",
|
||||
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: " +
|
||||
@@ -56,8 +55,8 @@ func ownSwitchTools(c *Config) []stdio.Tool {
|
||||
return c.TakeOver(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
|
||||
}},
|
||||
{Name: HandBackTool,
|
||||
Description: "Return config to the controller's records (its mirrors declared-config and predefined, and " +
|
||||
"pull-declares) until the controller hands its lease away; gone after it. Recorded as a hand act.",
|
||||
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)})
|
||||
|
||||
@@ -2,19 +2,19 @@ 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 its own sources and the controller's mirrors — each module's declarations, the predefined list,
|
||||
// each open pull request's config declarations, and every placed module's resolved value on every node both ways;
|
||||
// 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; `hand-back` returns to the mirrors until the lease write.
|
||||
// key `takeover.switch` naming the indexer.
|
||||
//
|
||||
// **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 (the interim of mesh-catalog #558): declarations from each placed module's own manifests, the list
|
||||
// of predefined keys from the controller's mirror, a pull request's declarations from the controller's
|
||||
// pull-declares;
|
||||
// - `indexer` (taken over): declarations as before, the list from the indexer's `predefined`, a pull request's from
|
||||
// the indexer's `config-candidates`; pull-declares refused;
|
||||
// - `controller` (handed back): declarations from the mirror `declared-config`, the list from the mirror,
|
||||
// pull-declares taken.
|
||||
// - 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`.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -38,9 +38,6 @@ const (
|
||||
TakeoverState = "takeover"
|
||||
SwitchKey = "switch"
|
||||
|
||||
// MirrorDeclaredRead is the controller's mirror of every registered module's declarations, one key per module:
|
||||
// what `compare` holds the own source to, and the source after a hand-back.
|
||||
MirrorDeclaredRead = "mesh-controller.declared-config"
|
||||
// IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2).
|
||||
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
|
||||
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
|
||||
@@ -50,8 +47,9 @@ const (
|
||||
|
||||
// The sources the switch names.
|
||||
const (
|
||||
SourceInterim = "interim"
|
||||
SourceIndexer = "indexer"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -69,8 +67,15 @@ type Switch struct {
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) source() (Switch, error) {
|
||||
e, err := c.Store.Get(TakeoverState, SwitchKey)
|
||||
@@ -84,6 +89,9 @@ func (c *Config) source() (Switch, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -172,31 +180,6 @@ func (c *Config) writeDeclared(s Sources) ([]string, error) {
|
||||
return written, errors.Join(problems...)
|
||||
}
|
||||
|
||||
// mirrorDeclarations makes the sources' declarations the controller's mirror's, for every placed module: the source
|
||||
// after a hand-back, and the other side of compare.
|
||||
func (c *Config) mirrorDeclarations(s *Sources) {
|
||||
s.Declared, s.OnNode, s.Unresolved = map[string]ModuleDeclarations{}, map[string]ModuleDeclarations{}, nil
|
||||
for _, module := range modulesOf(s.Assignments) {
|
||||
e, err := c.Store.Get(MirrorDeclaredRead, module)
|
||||
var d ModuleDeclarations
|
||||
if err == nil && e != nil {
|
||||
d, err = ParseModuleDeclarations(e.Value)
|
||||
} else if err == nil {
|
||||
err = errors.New("the mirror holds no key for it")
|
||||
}
|
||||
if err != nil {
|
||||
for _, a := range s.Assignments {
|
||||
if a.Module == module {
|
||||
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: module,
|
||||
Why: "the controller's mirror declared-config: " + err.Error()})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.Declared[module] = d
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the predefined list and the pull requests from the indexer ------------------------------------------
|
||||
|
||||
// indexerList is the indexer's predefined list as the mirror's list holds one: the keys, and the commit.
|
||||
@@ -216,19 +199,6 @@ func (c *Config) indexerList() (PredefinedList, bool, error) {
|
||||
return list, true, nil
|
||||
}
|
||||
|
||||
// mirrorList is the controller's mirror's list.
|
||||
func (c *Config) mirrorList() (PredefinedList, bool, error) {
|
||||
e, err := c.Store.Get(PredefinedRead, PredefinedMirrorKey)
|
||||
if err != nil || e == nil {
|
||||
return PredefinedList{}, false, err
|
||||
}
|
||||
var list PredefinedList
|
||||
if err := json.Unmarshal(e.Value, &list); err != nil || list.Keys == nil {
|
||||
return PredefinedList{}, false, fmt.Errorf("the controller's list is not one: %s", e.Value)
|
||||
}
|
||||
return list, true, nil
|
||||
}
|
||||
|
||||
// candidates is every open pull request's config declarations as the indexer keeps them, by pull key.
|
||||
func (c *Config) candidates() (map[string]PullDecl, error) {
|
||||
out := map[string]PullDecl{}
|
||||
@@ -300,7 +270,7 @@ func declaresDigest(d map[string]map[string]Declaration) string {
|
||||
|
||||
// ---- compare ----------------------------------------------------------------------------------------------
|
||||
|
||||
// Difference is one thing the own sources and the controller's mirrors say differently, and why.
|
||||
// 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"`
|
||||
@@ -308,16 +278,12 @@ type Difference struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Pull string `json:"pull,omitempty"`
|
||||
Own json.RawMessage `json:"own,omitempty"`
|
||||
Mirror json.RawMessage `json:"mirror,omitempty"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// The kinds of difference.
|
||||
const (
|
||||
DiffDeclared = "declared"
|
||||
DiffPredefined = "predefined"
|
||||
DiffPull = "pull"
|
||||
DiffValue = "value"
|
||||
DiffPull = "pull"
|
||||
)
|
||||
|
||||
// Comparison is what compare answers.
|
||||
@@ -326,6 +292,8 @@ type Comparison struct {
|
||||
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.
|
||||
@@ -335,93 +303,20 @@ func (c *Config) Compare() (*Comparison, error) {
|
||||
return c.compare()
|
||||
}
|
||||
|
||||
// compare is Compare; the caller holds the lock.
|
||||
// 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
|
||||
}
|
||||
own, err := c.sourcesFrom(SourceInterim)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mesh-config's own sources cannot be read: %w", 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)
|
||||
}
|
||||
mirror := own
|
||||
mirror.Declared, mirror.OnNode, mirror.Unresolved = nil, nil, nil
|
||||
c.mirrorDeclarations(&mirror)
|
||||
var diffs []Difference
|
||||
|
||||
// A placed module no catalog manifest names (mesh-wireguard, which the mesh ships computed) whose mirror key holds
|
||||
// no config key declares nothing on both sides: nothing resolves from it, so nothing differs (issue 1953, L5d).
|
||||
declaresNothing := map[string]bool{}
|
||||
for module, d := range mirror.Declared {
|
||||
if len(d.Config) == 0 {
|
||||
// Only an indexer that was read and holds no modules.<module>: a failed read says nothing, and the row stays.
|
||||
if key, kerr := delivery.ModulesKey(module); kerr == nil {
|
||||
if e, err := c.Store.Get(ModulesRead, key); err == nil && e == nil {
|
||||
declaresNothing[module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each placed module's declarations: `declared` against the mirror's key for it.
|
||||
for _, module := range modulesOf(own.Assignments) {
|
||||
var ownRaw []byte
|
||||
if e, err := c.Store.Get(DeclaredState, module); err == nil && e != nil {
|
||||
var k DeclaredKept
|
||||
if json.Unmarshal(e.Value, &k) == nil {
|
||||
ownRaw, _ = json.Marshal(normal(k.Config))
|
||||
}
|
||||
}
|
||||
var mirrorRaw []byte
|
||||
if d, ok := mirror.Declared[module]; ok {
|
||||
mirrorRaw, _ = json.Marshal(normal(d.Config))
|
||||
}
|
||||
switch {
|
||||
case ownRaw == nil && declaresNothing[module]:
|
||||
case ownRaw == nil && mirrorRaw == nil:
|
||||
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"})
|
||||
case ownRaw == nil:
|
||||
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Mirror: mirrorRaw,
|
||||
Why: "the mirror holds its declarations and declared does not"})
|
||||
case mirrorRaw == nil:
|
||||
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw,
|
||||
Why: "declared holds its declarations and the mirror does not"})
|
||||
case !bytes.Equal(ownRaw, mirrorRaw):
|
||||
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw, Mirror: mirrorRaw,
|
||||
Why: "the trunk's manifest declares it otherwise than the mirror"})
|
||||
}
|
||||
}
|
||||
|
||||
// The predefined list: the indexer's against the mirror's.
|
||||
il, iok, ierr := c.indexerList()
|
||||
ml, mok, merr := c.mirrorList()
|
||||
switch {
|
||||
case ierr != nil || merr != nil:
|
||||
diffs = append(diffs, Difference{What: DiffPredefined, Why: "a list cannot be read: " + errors.Join(ierr, merr).Error()})
|
||||
case !iok || !mok:
|
||||
if iok != mok {
|
||||
diffs = append(diffs, Difference{What: DiffPredefined, Why: fmt.Sprintf("the indexer holds a list: %v; the "+
|
||||
"mirror holds one: %v", iok, mok)})
|
||||
}
|
||||
default:
|
||||
for _, k := range unionKeys(il.Keys, ml.Keys) {
|
||||
a, aok := il.Keys[k]
|
||||
b, bok := ml.Keys[k]
|
||||
ar, _ := json.Marshal(a)
|
||||
br, _ := json.Marshal(b)
|
||||
switch {
|
||||
case !aok:
|
||||
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Mirror: br, Why: "only the mirror's list holds it"})
|
||||
case !bok:
|
||||
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Why: "only the indexer's list holds it"})
|
||||
case !bytes.Equal(ar, br):
|
||||
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Mirror: br,
|
||||
Why: "the two lists declare it otherwise"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each open pull request: the indexer's candidate against what the controller told by pull-declares.
|
||||
cands, cerr := c.candidates()
|
||||
if cerr != nil {
|
||||
@@ -452,39 +347,6 @@ func (c *Config) compare() (*Comparison, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every placed module's value on every node, resolved both ways.
|
||||
ownValues, mirrorValues := Resolve(own), Resolve(mirror)
|
||||
for _, k := range unionKeys(ownValues, mirrorValues) {
|
||||
a, aok := ownValues[k]
|
||||
b, bok := mirrorValues[k]
|
||||
ar, _ := json.Marshal(a)
|
||||
br, _ := json.Marshal(b)
|
||||
node, module, key := a.Node, a.Module, a.Key
|
||||
if !aok {
|
||||
node, module, key = b.Node, b.Module, b.Key
|
||||
}
|
||||
d := Difference{What: DiffValue, Node: node, Module: module, Key: key}
|
||||
switch {
|
||||
case !aok:
|
||||
d.Mirror, d.Why = br, "only the mirror's declarations resolve it on this node"
|
||||
case !bok:
|
||||
d.Own, d.Why = ar, "only mesh-config's own declarations (with this node's build) resolve it"
|
||||
case !bytes.Equal(ar, br):
|
||||
d.Own, d.Mirror, d.Why = ar, br, "it resolves to another value or source"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
diffs = append(diffs, d)
|
||||
}
|
||||
for _, u := range mirror.Unresolved {
|
||||
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why})
|
||||
}
|
||||
for _, u := range own.Unresolved {
|
||||
if declaresNothing[u.Module] {
|
||||
continue
|
||||
}
|
||||
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why})
|
||||
}
|
||||
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
|
||||
@@ -492,9 +354,9 @@ func (c *Config) compare() (*Comparison, error) {
|
||||
if diffs == nil {
|
||||
diffs = []Difference{}
|
||||
}
|
||||
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs)}
|
||||
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"
|
||||
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))
|
||||
}
|
||||
@@ -548,20 +410,6 @@ func forgeNoteOf(v string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// normal is declarations in one encoding: a null default as none.
|
||||
func normal(d map[string]Declaration) map[string]Declaration {
|
||||
out := map[string]Declaration{}
|
||||
for k, decl := range d {
|
||||
if !decl.HasDefault() {
|
||||
decl.Default = nil
|
||||
} else {
|
||||
decl.Default = compact(decl.Default)
|
||||
}
|
||||
out[k] = decl
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func unionKeys[A, B any](a map[string]A, b map[string]B) []string {
|
||||
seen := map[string]bool{}
|
||||
for k := range a {
|
||||
@@ -602,13 +450,12 @@ 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 the list of predefined keys and each pull request's config from the delivery's " +
|
||||
"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; config keeps reading the controller's records", About: "config.takeover",
|
||||
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back returns " +
|
||||
"to the controller's records until the controller's lease is handed away."}
|
||||
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"},
|
||||
@@ -770,7 +617,8 @@ func approvedTakeover(w asks.Warrant) string {
|
||||
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
|
||||
}
|
||||
|
||||
// HandBack returns to the controller's records until the controller's lease is handed away.
|
||||
// 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")
|
||||
@@ -778,30 +626,8 @@ func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
|
||||
if call.Caller == "" || call.Caller == Unproven {
|
||||
return nil, errors.New("the call names no proven caller")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.leaseAway() {
|
||||
return nil, errors.New("hand-back is gone: the controller handed its lease away, and its records end with it " +
|
||||
"(novox/hq ADR 0565 §4)")
|
||||
}
|
||||
sw, err := c.source()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sw.Source == SourceController {
|
||||
return &TakeoverAnswer{Source: sw.Source, Said: "config reads the controller's records already"}, nil
|
||||
}
|
||||
now := c.now()
|
||||
back := Switch{Source: SourceController, At: now.UTC(), By: call.Caller, Why: strings.TrimSpace(call.Why)}
|
||||
if err := c.Store.Put(TakeoverState, SwitchKey, back); err != nil {
|
||||
return nil, fmt.Errorf("the switch could not be written: %w", err)
|
||||
}
|
||||
answer := &TakeoverAnswer{Source: back.Source, Said: "config reads the controller's records again: its mirrors " +
|
||||
"declared-config and predefined, and pull-declares"}
|
||||
if err := c.Store.HandAct("handed config back to the controller's records", back.Why, "config-hand-back"); err != nil {
|
||||
answer.Said += "; the hand-act log does not hold it: " + err.Error()
|
||||
}
|
||||
return answer, nil
|
||||
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.
|
||||
|
||||
@@ -11,21 +11,17 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The trunk's manifests aMesh declares, as the controller's mirror declared-config holds the same declarations.
|
||||
// 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 whose controller mirrors say what mesh-config's own sources say: the mirror's
|
||||
// declarations of every placed module, and the indexer's predefined list equal to the mirror's.
|
||||
// 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)
|
||||
m.mirror(t, MirrorDeclaredRead, "dunst", dunstDeclared)
|
||||
m.mirror(t, MirrorDeclaredRead, "claude-code", claudeDeclared)
|
||||
m.mirror(t, MirrorDeclaredRead, "old", oldDeclared)
|
||||
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 {
|
||||
@@ -65,9 +61,9 @@ func kinds(cmp *Comparison) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// compare lists every difference with its reason — a module's declarations, the predefined list, a pull request's
|
||||
// config, and each value a node resolves both ways — and the take-over is refused while one stands (ADR 0565 §4 and
|
||||
// its insight of 2026-10-29; issue 1953 L5-C4).
|
||||
// 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
|
||||
@@ -75,17 +71,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
|
||||
want []string
|
||||
}{
|
||||
{"all agree", func(*testing.T, *memStore) {}, nil},
|
||||
{"the mirror declares a default otherwise", func(t *testing.T, m *memStore) {
|
||||
m.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
|
||||
}, []string{"declared:dunst", "value:dunstwidth@g14", "value:dunstwidth@novox"}},
|
||||
{"the mirror lacks a placed module", func(t *testing.T, m *memStore) { _ = m.Delete(MirrorDeclaredRead, "old") },
|
||||
[]string{"declared:old", "value:old@g14", "value:oldworkers@g14"}},
|
||||
{"a node's build adds a key the mirror lacks", func(t *testing.T, m *memStore) {
|
||||
m.want(t, "g14", "dunst", "src2:"+strings.Repeat("ab", 32), strings.Replace(dunstDeclared, `"width"`, `"height":{"type":"number","default":5},"width"`, 1))
|
||||
}, []string{"value:dunstheight@g14", "value:dunstheight@novox"}}, // a key only a build declares resolves on every node (#558)
|
||||
{"the lists differ", func(t *testing.T, m *memStore) {
|
||||
indexerList(t, m, "bbbbbbbbbbbb", `{"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
|
||||
}, []string{"predefined:agent-managed-settings"}},
|
||||
{"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"}},
|
||||
@@ -96,10 +81,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
|
||||
{"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"}},
|
||||
{"a module no manifest names and the mirror declares nothing of", func(t *testing.T, m *memStore) {
|
||||
m.assign(t, "g14", "mesh-wireguard")
|
||||
m.mirror(t, MirrorDeclaredRead, "mesh-wireguard", `{"module":"mesh-wireguard","settings":{}}`)
|
||||
}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, m, r := anAgreeingMesh(t)
|
||||
@@ -137,7 +118,7 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 returns to the mirrors until the lease write and is gone after it; from the
|
||||
// config come from the indexer; hand-back is gone, and a switch naming the controller is read as the indexer; from the
|
||||
// lease write a trusted value is set only on an Approve; declared is written once per manifest (ADR 0565 §3, §4, §13).
|
||||
func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
c, m, r := anAgreeingMesh(t)
|
||||
@@ -184,20 +165,19 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
res, err := c.Reconcile()
|
||||
return strings.Join(res.Candidates, "; "), err
|
||||
}, "from the indexer"},
|
||||
{"hand-back returns to the mirrors", func() (string, error) {
|
||||
a, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"})
|
||||
return answered(a), err
|
||||
}, "controller's records again"},
|
||||
{"after it, declarations resolve from the mirror", func() (string, error) {
|
||||
m.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
|
||||
_, err := c.Reconcile()
|
||||
return string(m.resolved(t, "g14", "dunst", "width").Value), err
|
||||
}, "400"},
|
||||
{"hand-back is gone after the lease write", func() (string, error) {
|
||||
m.mirror(t, LeaseRead, "holder", `{"handed-to":"the operator's hand-over"}`)
|
||||
_, err := c.HandBack(Call{Why: "again", Caller: "person.jochen"})
|
||||
{"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
|
||||
}, "gone"},
|
||||
}, "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"},
|
||||
{"the lease is written", func() (string, error) {
|
||||
m.mirror(t, LeaseRead, "holder", `{"handed-to":"the operator's hand-over"}`)
|
||||
return "lease", nil
|
||||
}, "lease"},
|
||||
{"the terminal sets no trusted value after the lease write", func() (string, error) {
|
||||
_, err := c.Change(Call{Action: "set", Key: "managed", Module: "claude-code", Node: "g14", Value: map[string]any{},
|
||||
Why: "x", Caller: ControllerCaller, By: "person.jochen"})
|
||||
@@ -214,8 +194,8 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
t.Errorf("%s: said %q, want %q", s.name, text, s.want)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(m.handActs, "handed config back to the controller's records | a difference was found after the switch | config-hand-back") {
|
||||
t.Errorf("the hand-back is not recorded: %v", m.handActs)
|
||||
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)
|
||||
@@ -225,7 +205,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
|
||||
if _, err := c2.TakeOver(Call{Why: "now", Caller: "person.jochen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
|
||||
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") {
|
||||
|
||||
@@ -2,7 +2,7 @@ module mesh-config
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -36,8 +36,6 @@
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-controller.predefined",
|
||||
"mesh-controller.declared-config",
|
||||
"mesh-delivery-indexer.predefined",
|
||||
"mesh-delivery-indexer.config-candidates",
|
||||
"mesh-controller.lease"
|
||||
|
||||
@@ -10,15 +10,14 @@ package main
|
||||
// **Who did it is the runtime's word, never the caller's**: `by` is the bus user the runtime proved. A warrant is the
|
||||
// router's word, never the caller's: `warranted` reads the router's own record of the asker's ask and records who
|
||||
// chose, through which channel, with which proofs and which answer, once per ask. The controller's bucket
|
||||
// `mesh-controller_hand-acts` stays its own until it loses its lease and is read, never written: `hand-acts` answers
|
||||
// over both logs, and no act is written to both (ADR 0565 §11).
|
||||
// `mesh-controller_hand-acts` is no longer read (issue 774, ADR 0571 §13): its acts were copied into this log once, and
|
||||
// `hand-acts` answers from this log alone.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -282,33 +281,16 @@ type Listing struct {
|
||||
Fill *Fill `json:"fill,omitempty"`
|
||||
}
|
||||
|
||||
// HandActs reads both logs.
|
||||
// HandActs reads this seat's log alone: what was copied from the controller's is in it (copy.go).
|
||||
func (l *Log) HandActs(days int, cause, who string) Listing {
|
||||
now := l.Now()
|
||||
since := now.Add(-time.Duration(days) * 24 * time.Hour)
|
||||
out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}}
|
||||
var all []Act
|
||||
// What was copied from the controller's log is in this one: it is read from this one only (copy.go).
|
||||
copied := map[string]bool{}
|
||||
for _, log := range []string{ActsLog, ControllerActs} {
|
||||
acts, kept, err := l.read(log, now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin)))
|
||||
if log == ActsLog && err == nil {
|
||||
for _, a := range acts {
|
||||
copied[a.ID] = a.ID != ""
|
||||
}
|
||||
}
|
||||
if log == ControllerActs {
|
||||
acts = slices.DeleteFunc(acts, func(a Act) bool { return a.ID != "" && copied[a.ID] })
|
||||
}
|
||||
if err != nil {
|
||||
if out.Unread == nil {
|
||||
out.Unread = map[string]string{}
|
||||
}
|
||||
out.Unread[log] = err.Error()
|
||||
continue
|
||||
}
|
||||
out.Kept[log] = kept
|
||||
all = append(all, acts...)
|
||||
all, kept, err := l.read(now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin)))
|
||||
if err != nil {
|
||||
out.Unread = map[string]string{ActsLog: err.Error()}
|
||||
} else {
|
||||
out.Kept[ActsLog] = kept
|
||||
}
|
||||
for _, a := range all {
|
||||
if now.Sub(a.At) <= RepeatedWithin && a.Repair() {
|
||||
@@ -331,56 +313,20 @@ func (l *Log) HandActs(days int, cause, who string) Listing {
|
||||
return out
|
||||
}
|
||||
|
||||
// read is every act of one log since a moment, and how many it keeps: this seat's log entry by entry, the
|
||||
// controller's bucket by its keys, each of which names its time, so only those within are read whole.
|
||||
func (l *Log) read(log string, since time.Time) ([]Act, int, error) {
|
||||
// read is every act of this seat's log since a moment, entry by entry, and how many it keeps.
|
||||
func (l *Log) read(since time.Time) ([]Act, int, error) {
|
||||
var out []Act
|
||||
keep := func(raw []byte, from string) {
|
||||
var a Act
|
||||
if json.Unmarshal(raw, &a) != nil || a.At.Before(since) {
|
||||
return
|
||||
}
|
||||
a.Log = from
|
||||
out = append(out, a)
|
||||
}
|
||||
if log == ActsLog {
|
||||
entries, err := l.Store.Entries(ActsLog)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, raw := range entries {
|
||||
keep(raw, handacts.LogSeat)
|
||||
}
|
||||
return out, len(entries), nil
|
||||
}
|
||||
keys, err := l.Store.Keys(log)
|
||||
entries, err := l.Store.Entries(ActsLog)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if at, ok := keyTime(k); ok && at.Before(since) {
|
||||
for _, raw := range entries {
|
||||
var a Act
|
||||
if json.Unmarshal(raw, &a) != nil || a.At.Before(since) {
|
||||
continue
|
||||
}
|
||||
raw, found, err := l.Store.Get(log, k)
|
||||
if err != nil {
|
||||
return nil, len(keys), err
|
||||
}
|
||||
if found {
|
||||
keep(raw, handacts.LogController)
|
||||
}
|
||||
a.Log = handacts.LogSeat
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, len(keys), nil
|
||||
}
|
||||
|
||||
// keyTime is the time an act's key names (`act-<ns>-<n>`), when it names one.
|
||||
func keyTime(key string) (time.Time, bool) {
|
||||
parts := strings.Split(key, "-")
|
||||
if len(parts) != 3 || parts[0] != "act" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ns, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(0, ns), true
|
||||
return out, len(entries), nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -229,9 +228,10 @@ func TestWarrantedRecordsTheRoutersWordOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hand-acts answers over both logs, newest first, narrowed by days, cause and who, and counts each cause repaired by
|
||||
// hand twice in a fortnight across both, never a person's decision; a log it cannot read is said, never read as empty.
|
||||
func TestHandActsReadsBothLogs(t *testing.T) {
|
||||
// hand-acts answers from this seat's log alone, newest first, narrowed by days, cause and who, and counts each cause
|
||||
// repaired by hand twice in a fortnight, never a person's decision; a log it cannot read is said, never read as empty.
|
||||
// The controller's bucket holds acts here and is made to fail: it is not read (774/S10/AC1).
|
||||
func TestHandActsReadsItsOwnLogAlone(t *testing.T) {
|
||||
l, store, _, now := newLog()
|
||||
put := func(log, verb, cause, by string, ago time.Duration) {
|
||||
at := now.Add(-ago)
|
||||
@@ -243,21 +243,22 @@ func TestHandActsReadsBothLogs(t *testing.T) {
|
||||
_ = store.Put(log, actKey(at), a)
|
||||
}
|
||||
put(ActsLog, VerbRecord, "relogin", "person.jochen", time.Hour)
|
||||
put(ControllerActs, VerbRecord, "relogin", "shanks.claude-code", 3*24*time.Hour)
|
||||
put(ControllerActs, VerbDrill, CauseDrill, "person.jochen", time.Hour)
|
||||
put(ActsLog, VerbRecord, "relogin", "shanks.claude-code", 3*24*time.Hour)
|
||||
put(ActsLog, VerbDrill, CauseDrill, "person.jochen", 2*time.Hour)
|
||||
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", time.Hour)
|
||||
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", 2*time.Hour)
|
||||
put(ControllerActs, VerbRecord, "config", "ace.mesh-config", 30*24*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")
|
||||
cases := []struct {
|
||||
name string
|
||||
days int
|
||||
cause, who string
|
||||
want int
|
||||
}{
|
||||
{"a fortnight", 14, "", "", 6},
|
||||
{"a day", 1, "", "", 5},
|
||||
{"forty days", 40, "", "", 7},
|
||||
{"a fortnight", 14, "", "", 5},
|
||||
{"a day", 1, "", "", 4},
|
||||
{"forty days", 40, "", "", 6},
|
||||
{"by cause", 14, "relogin", "", 2},
|
||||
{"by who", 14, "", "claude-code", 1},
|
||||
}
|
||||
@@ -272,12 +273,12 @@ func TestHandActsReadsBothLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := l.HandActs(14, "", ""); got.Acts[0].Log != Seat || got.Kept[ControllerActs] != 3 || got.Fill == nil ||
|
||||
got.Fill.Entries != 4 {
|
||||
if got := l.HandActs(14, "", ""); got.Acts[0].Log != Seat || got.Kept[ActsLog] != 6 || len(got.Kept) != 1 || got.Fill == nil ||
|
||||
got.Fill.Entries != 6 {
|
||||
t.Errorf("the log of an act or the kept counts are not said: %+v", got.Kept)
|
||||
}
|
||||
store.keysErr[ControllerActs] = errors.New("the bus is away")
|
||||
if got := l.HandActs(14, "", ""); got.Unread[ControllerActs] == "" || len(got.Acts) != 4 {
|
||||
store.keysErr[ActsLog] = errors.New("the bus is away")
|
||||
if got := l.HandActs(14, "", ""); got.Unread[ActsLog] == "" || len(got.Acts) != 0 {
|
||||
t.Errorf("an unreadable log: %+v", got.Unread)
|
||||
}
|
||||
w, err := l.Warrants()
|
||||
@@ -286,86 +287,21 @@ func TestHandActsReadsBothLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's hand acts are copied into this seat's log once, each with its own key, time and author, and
|
||||
// never twice: a marker makes it once, a key already in the log is not appended again, a log that cannot be read or
|
||||
// written leaves no marker so the next call copies again, and hand-acts counts a copied act once (research 072 row 26).
|
||||
func TestTheControllersHandActsAreCopiedOnce(t *testing.T) {
|
||||
base := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC)
|
||||
old := []string{fmt.Sprintf("act-%d-1", base.UnixNano()), fmt.Sprintf("act-%d-2", base.UnixNano()+1),
|
||||
fmt.Sprintf("act-%d-3", base.UnixNano()+2), "warrant-x-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
already []string // keys in the new log before the copy
|
||||
oldErr, newErr error
|
||||
junk bool
|
||||
wantErr string
|
||||
wantCopied, wantAlready int
|
||||
wantMarker bool
|
||||
}{
|
||||
{"all", nil, nil, nil, false, "", 4, 0, true},
|
||||
{"finishes a half copy", []string{old[0], old[1]}, nil, nil, false, "", 2, 2, true},
|
||||
{"an entry that is no act is copied unchanged", nil, nil, nil, true, "", 5, 0, true},
|
||||
{"the old log cannot be read", nil, errors.New("away"), nil, false, "cannot be read", 0, 0, false},
|
||||
{"the new log cannot be written", nil, nil, errors.New("full"), false, "could not be copied", 0, 0, false},
|
||||
// The copy of the controller's hand acts is over: the tools answer so, with the marker of the copy that was made, and
|
||||
// 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")
|
||||
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)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
l, store, _, _ := newLog()
|
||||
for _, k := range old {
|
||||
_ = store.Put(ControllerActs, k, Act{ID: k, At: base, By: "ace.mesh-config", Verb: VerbRecord, Why: "w", Cause: "c"})
|
||||
}
|
||||
if tc.junk {
|
||||
_ = store.Put(ControllerActs, fmt.Sprintf("act-%d-4", base.UnixNano()+3), "not an act")
|
||||
}
|
||||
for _, k := range tc.already {
|
||||
_ = store.Append(ActsLog, k, Act{ID: k})
|
||||
}
|
||||
store.keysErr[ControllerActs], store.keysErr[ActsLog] = tc.oldErr, tc.newErr
|
||||
st, err := l.Copy("test", false)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("%s: %v", tc.name, err)
|
||||
}
|
||||
} else if err != nil || !st.Done || st.Marker.Copied != tc.wantCopied || st.Marker.Already != tc.wantAlready {
|
||||
t.Fatalf("%s: %+v, %v", tc.name, st, err)
|
||||
}
|
||||
_, marked, _ := store.Get(CopiedState, CopyKey)
|
||||
if marked != tc.wantMarker || len(store.logKeys(ActsLog)) != map[bool]int{true: tc.wantCopied + tc.wantAlready, false: len(tc.already)}[tc.wantMarker] {
|
||||
t.Errorf("%s: marker %v, log %v", tc.name, marked, store.logKeys(ActsLog))
|
||||
}
|
||||
if !tc.wantMarker {
|
||||
continue
|
||||
}
|
||||
// Each act is kept with its own time and author, as the controller wrote it.
|
||||
raw, _, _ := store.Last(ActsLog, old[2])
|
||||
var kept Act
|
||||
_ = json.Unmarshal(raw, &kept)
|
||||
if kept.By != "ace.mesh-config" || !kept.At.Equal(base) {
|
||||
t.Errorf("%s: kept %+v", tc.name, kept)
|
||||
}
|
||||
// Asked again: nothing more is appended, and hand-acts counts each act once.
|
||||
before := len(store.logKeys(ActsLog))
|
||||
if again, err := l.Copy("test", false); err != nil || !again.Done || !strings.Contains(again.Said, "already copied") ||
|
||||
len(store.logKeys(ActsLog)) != before {
|
||||
t.Errorf("%s: a second copy: %+v, %v", tc.name, again, err)
|
||||
}
|
||||
if got := l.HandActs(30, "", ""); got.Kept[ControllerActs] != len(old)+boolInt(tc.junk) || len(got.Acts) != len(old) {
|
||||
t.Errorf("%s: %d acts listed from %d kept: %+v", tc.name, len(got.Acts), before, got.Kept)
|
||||
}
|
||||
// An act the controller wrote since is appended by a top-up, and only that one.
|
||||
late := fmt.Sprintf("act-%d-9", base.UnixNano()+9)
|
||||
_ = store.Put(ControllerActs, late, Act{ID: late, At: base, By: "ace.late", Verb: VerbRecord, Why: "w", Cause: "c"})
|
||||
if up, err := l.Copy("test", true); err != nil || up.Marker.Copied != 1 || len(store.logKeys(ActsLog)) != before+1 {
|
||||
t.Errorf("%s: a top-up: %+v, %v", tc.name, up, err)
|
||||
}
|
||||
if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil {
|
||||
t.Errorf("%s: copied says %+v, %v", tc.name, s, err)
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil {
|
||||
t.Errorf("copied says %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
package main
|
||||
|
||||
// The one-time copy of the controller's hand-act log into this seat's log (novox/hq research 072 row 26, ADR 0571
|
||||
// §13). The controller kept every act of its life in its bucket `mesh-controller_hand-acts` (574 on 2026-10-31); from
|
||||
// the lease write only this seat reads them, so they are copied once into `acts`, each as the controller wrote it: the
|
||||
// same key, the same bytes, so its time and its author are what they were.
|
||||
//
|
||||
// **When it runs.** The lease write has no hook to run from (it is a person's step, ADR 0561 §6), so the copy runs at
|
||||
// this module's start while the old log is readable and no marker says it was copied, and on the tool
|
||||
// `hand_acts_copy`. Both do the same thing, and neither does it twice (a `top_up` of the tool appends only what is new): the marker `controller-hand-acts` of the state
|
||||
// `copied` is created once, after every act is in the log; an act already in the log (by its key) is never appended
|
||||
// again, so a copy that stopped half way is finished by the next one. Until the marker exists `hand-acts` hides an
|
||||
// old act that is already copied, so the two logs never count one act twice. `hand_acts_copied` says where it stands.
|
||||
// The one-time copy of the controller's hand-act log into this seat's log is over (novox/hq research 072 row 26, ADR 0571
|
||||
// §13, issue 774). The controller kept every act of its life in its bucket `mesh-controller_hand-acts`; they were copied
|
||||
// once into `acts`, each as the controller wrote it, by the earlier builds of this module, which left the marker
|
||||
// `controller-hand-acts` of the state `copied`. This module no longer reads that bucket, so the copy cannot run: the
|
||||
// tools `hand_acts_copy` and `hand_acts_copied` stay and say so, with the marker's figures when it exists.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -47,92 +39,32 @@ type CopyStatus struct {
|
||||
Marker *CopyMarker `json:"marker,omitempty"`
|
||||
// Said is a sentence for a person: what this call did or found.
|
||||
Said string `json:"said"`
|
||||
// Pending are the old acts not yet in this seat's log (counted only while the copy is not whole).
|
||||
Pending *int `json:"pending,omitempty"`
|
||||
Old *int `json:"old-log-keys,omitempty"`
|
||||
New *int `json:"new-log-entries,omitempty"`
|
||||
// New is how many acts this seat's log holds.
|
||||
New *int `json:"new-log-entries,omitempty"`
|
||||
}
|
||||
|
||||
// Copy copies the old log into this one, once. by says what asked (`start` or a caller). With topUp it also runs after
|
||||
// the marker exists, appending only the acts the log does not hold yet (the controller's, written since the copy), and
|
||||
// leaves the marker as it was. An old log that cannot be
|
||||
// read, or an act that cannot be appended, is an error and leaves no marker, so the next call copies again.
|
||||
// gone is what a call to copy answers.
|
||||
const gone = "the controller's hand-act log is no longer read, so nothing can be copied (novox/hq ADR 0571 §13, issue 774); " +
|
||||
"the acts copied before are in the log `acts`"
|
||||
|
||||
// Copy answers that the copy is over: it reads and writes nothing of the controller's. The marker, when the copy once
|
||||
// ran to its end, is returned with the answer.
|
||||
func (l *Log) Copy(by string, topUp bool) (CopyStatus, error) {
|
||||
marked := false
|
||||
if raw, found, err := l.Store.Get(CopiedState, CopyKey); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("whether the controller's hand acts were copied cannot be read: %w", err)
|
||||
} else if found {
|
||||
var m CopyMarker
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the copy's marker is not readable: %w", err)
|
||||
}
|
||||
if !topUp {
|
||||
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("already copied at %s: %d acts of %s, nothing done",
|
||||
m.At.Format(time.RFC3339), m.Copied+m.Already, m.Source)}, nil
|
||||
}
|
||||
marked = true
|
||||
}
|
||||
keys, err := l.Store.Keys(ControllerActs)
|
||||
st, err := l.Copied()
|
||||
if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the controller's hand acts cannot be read, so none were copied: %w", err)
|
||||
return st, err
|
||||
}
|
||||
sortByTime(keys)
|
||||
m := CopyMarker{By: by, Source: ControllerActs, Found: len(keys)}
|
||||
l.Logf("[%s] copying the controller's hand acts into %s: %d found in %s", Module, ActsLog, len(keys), ControllerActs)
|
||||
for i, k := range keys {
|
||||
raw, found, err := l.Store.Get(ControllerActs, k)
|
||||
if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("%s of %s cannot be read after %d of %d acts were copied: %w", k, ControllerActs, m.Copied, len(keys), err)
|
||||
}
|
||||
if !found {
|
||||
continue // removed since it was listed
|
||||
}
|
||||
if _, there, err := l.Store.Last(ActsLog, k); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("whether %s is in %s cannot be read, after %d acts were copied: %w", k, ActsLog, m.Copied, err)
|
||||
} else if there {
|
||||
m.Already++
|
||||
continue
|
||||
}
|
||||
var a Act
|
||||
if json.Unmarshal(raw, &a) != nil {
|
||||
m.Unreadable = append(m.Unreadable, k)
|
||||
l.Logf("[%s] %s of %s is not an act; copied unchanged", Module, k, ControllerActs)
|
||||
}
|
||||
if err := l.Store.Append(ActsLog, k, json.RawMessage(raw)); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("%s could not be copied into %s after %d acts: %w", k, ActsLog, m.Copied, err)
|
||||
}
|
||||
m.Copied++
|
||||
if m.FirstKey == "" {
|
||||
m.FirstKey = k
|
||||
}
|
||||
m.LastKey = k
|
||||
if (i+1)%100 == 0 {
|
||||
l.Logf("[%s] copied %d of %d", Module, i+1, len(keys))
|
||||
}
|
||||
}
|
||||
m.At = l.Now().UTC()
|
||||
if marked {
|
||||
l.Logf("[%s] topped up the copy: %d acts copied, %d already there, of %d found", Module, m.Copied, m.Already, m.Found)
|
||||
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("topped up: %d acts copied that the first copy did not hold (%d already there)", m.Copied, m.Already)}, nil
|
||||
}
|
||||
if err := l.Store.Create(CopiedState, CopyKey, m); errors.Is(err, ErrExists) {
|
||||
return CopyStatus{Said: "another instance finished the copy first; nothing more done"}, nil
|
||||
} else if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("every act was copied but the marker could not be written, so the next call checks them again: %w", err)
|
||||
}
|
||||
l.Logf("[%s] copied the controller's hand acts: %d copied, %d already there, %d not acts, of %d found (%s .. %s)",
|
||||
Module, m.Copied, m.Already, len(m.Unreadable), m.Found, m.FirstKey, m.LastKey)
|
||||
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("copied %d acts (%d were already there) of %d found in %s",
|
||||
m.Copied, m.Already, m.Found, ControllerActs)}, nil
|
||||
st.Said = gone + "; " + st.Said
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Copied says where the copy stands without doing anything: the marker, or how many old acts wait.
|
||||
// Copied says where the copy ended: the marker with its figures, or that none was left, and how many acts the log holds.
|
||||
func (l *Log) Copied() (CopyStatus, error) {
|
||||
raw, found, err := l.Store.Get(CopiedState, CopyKey)
|
||||
if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the copy's marker cannot be read: %w", err)
|
||||
}
|
||||
var st CopyStatus
|
||||
st := CopyStatus{Said: "no marker of a finished copy is left"}
|
||||
if found {
|
||||
var m CopyMarker
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
@@ -145,39 +77,5 @@ func (l *Log) Copied() (CopyStatus, error) {
|
||||
n := int(f.Entries)
|
||||
st.New = &n
|
||||
}
|
||||
keys, err := l.Store.Keys(ControllerActs)
|
||||
if err != nil {
|
||||
if !st.Done {
|
||||
st.Said = "not copied yet, and the controller's log cannot be read: " + err.Error()
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
n := len(keys)
|
||||
st.Old = &n
|
||||
if !st.Done {
|
||||
pending := 0
|
||||
for _, k := range keys {
|
||||
if _, there, err := l.Store.Last(ActsLog, k); err != nil || !there {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
st.Pending = &pending
|
||||
st.Said = fmt.Sprintf("not copied yet: %d of %d old acts wait; hand_acts_copy copies them", pending, n)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// sortByTime orders keys oldest first by the time they name, those that name none after, by key.
|
||||
func sortByTime(keys []string) {
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
ti, oi := keyTime(keys[i])
|
||||
tj, oj := keyTime(keys[j])
|
||||
switch {
|
||||
case oi && oj && !ti.Equal(tj):
|
||||
return ti.Before(tj)
|
||||
case oi != oj:
|
||||
return oi
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// mesh-hand-acts: the holder of the seat mesh-hand-acts (novox/hq ADR 0565 §10). A Go bundle served by a runtime of
|
||||
// its own, on its own bus account (`runs-as`), so that its account alone writes the hand-act log (`acts`, its module
|
||||
// log: a stream that keeps every act in order) and `warrants`. It reads the router's record of each ask for a warrant and the controller's log for
|
||||
// `hand-acts`. stdout is the MCP channel; what this module says, it says on stderr.
|
||||
// log: a stream that keeps every act in order) and `warrants`. It reads the router's record of each ask for a warrant; the controller's log is no longer read
|
||||
// (issue 774). stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -77,15 +77,6 @@ func (runtimeStore) Fill(log string) (Fill, error) {
|
||||
func main() {
|
||||
l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf,
|
||||
Publish: stdio.SeatPublish}
|
||||
// No hook of the lease write runs this module, so the one-time copy of the controller's hand acts runs at start
|
||||
// and on the tool hand_acts_copy; the marker makes it once (copy.go).
|
||||
go func() {
|
||||
if st, err := l.Copy("start", false); err != nil {
|
||||
logf("[%s] the copy of the controller's hand acts is NOT done: %v", Module, err)
|
||||
} else {
|
||||
logf("[%s] %s", Module, st.Said)
|
||||
}
|
||||
}()
|
||||
if err := stdio.Serve("", Tools(l)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -43,10 +43,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
t.Errorf("claims %+v", m.Claims)
|
||||
}
|
||||
// act-recorded is the seat's event, said on the seat's subject: a module lists no event of a seat it claims.
|
||||
if len(m.Emits) != 0 || !slices.Equal(m.Reads, []string{ControllerActs, RouterRecords}) {
|
||||
if len(m.Emits) != 0 || !slices.Equal(m.Reads, []string{RouterRecords}) {
|
||||
t.Errorf("emits %v, reads %v", m.Emits, m.Reads)
|
||||
}
|
||||
readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool, ControllerActs: HandActsTool,
|
||||
readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool,
|
||||
RouterRecords: WarrantedTool, CopiedState: CopiedTool}
|
||||
tools := map[string]bool{}
|
||||
for _, tool := range Tools(&Log{}) {
|
||||
|
||||
@@ -52,8 +52,7 @@ func Tools(l *Log) []stdio.Tool {
|
||||
return l.HandAct(stdio.CallerOf(a), arg(a, "verb"), arg(a, "what"), arg(a, "why"), arg(a, "cause"), arg(a, "condition"))
|
||||
}},
|
||||
{Name: HandActsTool,
|
||||
Description: "What was done by hand lately, over both logs (this seat's and the controller's until its lease " +
|
||||
"ends), newest first: each act with who, why, its cause and which log holds it, and the causes repaired by " +
|
||||
Description: "What was done by hand lately, from this seat's log (the controller's acts were copied into it), newest first: each act with who, why, its cause and which log holds it, and the causes repaired by " +
|
||||
"hand twice or more within a fortnight (a healer is wanted). Narrowed by cause and who; warrants=true lists " +
|
||||
"every warrant recorded instead.",
|
||||
Input: obj(map[string]any{"days": map[string]any{"type": "number", "description": "how many days back (default 14)"},
|
||||
@@ -74,16 +73,14 @@ func Tools(l *Log) []stdio.Tool {
|
||||
return l.Warranted(stdio.CallerOf(a), arg(a, "asker"), arg(a, "ask"))
|
||||
}},
|
||||
{Name: CopiedTool,
|
||||
Description: "Where the one-time copy of the controller's hand acts into this seat's log stands (novox/hq research 072 " +
|
||||
"row 26): done, with when, by what and how many acts were copied, or how many old acts still wait, and how " +
|
||||
"full both logs are. Changes nothing.",
|
||||
Description: "Where the one-time copy of the controller's hand acts into this seat's log ended (novox/hq research 072 " +
|
||||
"row 26): the marker with when, by what and how many acts were copied, and how full the log is. The copy " +
|
||||
"cannot run any more: the controller's log is no longer read. Changes nothing.",
|
||||
Input: obj(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return l.Copied() }},
|
||||
{Name: CopyTool,
|
||||
Description: "Copy the controller's hand acts (its bucket mesh-controller_hand-acts) into this seat's log once, each " +
|
||||
"with its own key, time and author. Does nothing when the copy is already done; an act already in the log is " +
|
||||
"never appended again, so a copy that stopped is finished by asking again. With top_up=true it also runs after the " +
|
||||
"copy is done and appends only the acts written since. This also runs at the module's start.",
|
||||
Description: "Answers that the copy is over (the controller's hand-act log, its bucket mesh-controller_hand-acts, " +
|
||||
"is no longer read, novox/hq ADR 0571 §13) with the marker of the copy that was made; copies nothing.",
|
||||
Input: obj(map[string]any{"top_up": str("\"true\": after the copy is done, append the acts written since")}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return l.Copy("tool, called by "+stdio.CallerOf(a), a["top_up"] == true || strings.EqualFold(arg(a, "top_up"), "true"))
|
||||
|
||||
@@ -354,7 +354,7 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// One act of the controller's, kept before the module starts: the start copies it into the log.
|
||||
// One act of the controller's, kept before the module starts: the module no longer reads that bucket, so it never copies it.
|
||||
oldActs, _ := js.KeyValue(ctx, "mesh-controller_hand-acts")
|
||||
oldKey := fmt.Sprintf("act-%d-1", time.Now().Add(-time.Hour).UnixNano())
|
||||
if _, err := oldActs.Put(ctx, oldKey, []byte(`{"id":"`+oldKey+`","at":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+
|
||||
@@ -402,22 +402,17 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
if c, err := p.call(CopiedTool, "person.jochen", map[string]any{}); err == nil && strings.Contains(string(c), `"done":true`) {
|
||||
break
|
||||
} else if i > 50 {
|
||||
t.Fatalf("the controller's hand acts were not copied at the start: %s, %v", c, err)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if c, err := p.call(CopyTool, "person.jochen", map[string]any{}); err != nil || !strings.Contains(string(c), "no longer read") || strings.Contains(string(c), `"done":true`) {
|
||||
t.Errorf("the copy's tool answered %s, %v", c, err)
|
||||
}
|
||||
if e, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err != nil || !strings.Contains(string(e.Data), `"by":"ace.old"`) {
|
||||
t.Errorf("the controller's act is not copied with its author: %v", err)
|
||||
if _, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err == nil {
|
||||
t.Errorf("the controller's act was copied although its bucket is no longer read")
|
||||
}
|
||||
if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 3 {
|
||||
t.Errorf("the log keeps %v, want the copied act, the act and one warrant (%v)", info, err)
|
||||
if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 2 {
|
||||
t.Errorf("the log keeps %v, want the act and one warrant (%v)", info, err)
|
||||
}
|
||||
listed, err := p.call(HandActsTool, "person.jochen", map[string]any{})
|
||||
if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) {
|
||||
if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) || strings.Contains(string(listed), oldKey) {
|
||||
t.Errorf("hand-acts answered %s, %v", listed, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-hand-acts
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.hand-acts",
|
||||
"messenger.asks"
|
||||
],
|
||||
"logs": [
|
||||
|
||||
@@ -45,7 +45,6 @@ const (
|
||||
LeaseRead = buscompose.ControllerLease
|
||||
LeaseKey = buscompose.ControllerLeaseKey
|
||||
ConditionsRead = "mesh-conditions.conditions"
|
||||
HandActsRead = "mesh-controller.hand-acts"
|
||||
ActedEvent = health.EventHealerActed
|
||||
// ActedSubject is where the seat says it, which its consumers hear (catalogue.SeatSays).
|
||||
ActedSubject = "mesh.seat." + Seat + ".event." + ActedEvent
|
||||
|
||||
@@ -374,16 +374,19 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) {
|
||||
w := newWorld()
|
||||
act := func(key, verb, cause string, ago time.Duration) {
|
||||
raw, _ := json.Marshal(HandAct{ID: key, At: w.now.Add(-ago), By: "person.jochen", Verb: verb, Why: "x", Cause: cause})
|
||||
w.h.HandActHeard(key, "put", raw)
|
||||
w.h.ActRecorded(raw)
|
||||
}
|
||||
act("a1", "broker consumer-reset", "consumer-behind", 10*24*time.Hour)
|
||||
if keys := w.store.keys(FindingsState); len(keys) != 0 {
|
||||
t.Fatalf("one act is a finding: %v", keys)
|
||||
}
|
||||
// The second of the cause is the seat's own act, heard as act-recorded: one in each log counts two (ADR 0565 §11).
|
||||
// The second of the cause is heard as act-recorded; an act heard again and listed by the seat counts once (774/S10/AC2).
|
||||
seatAct, _ := json.Marshal(HandAct{ID: "a2", At: w.now.Add(-24 * time.Hour), By: "person.jochen",
|
||||
Verb: "broker consumer-reset", Why: "x", Cause: "consumer-behind"})
|
||||
w.h.ActRecorded(seatAct)
|
||||
if n, err := w.h.Seeded([]byte(`{"acts":[` + string(seatAct) + `]}`)); err != nil || n != 1 {
|
||||
t.Fatalf("the seat's listing was not taken: %d, %v", n, err)
|
||||
}
|
||||
// A drill in each log counts none.
|
||||
act("a3", "hand-act drill", "drill", time.Hour)
|
||||
drill, _ := json.Marshal(HandAct{ID: "a4", At: w.now.Add(-2 * time.Hour), Verb: "hand-act drill", Why: "x", Cause: "drill"})
|
||||
@@ -408,9 +411,8 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) {
|
||||
if keys := w.store.keys(FindingsState); len(keys) != 0 {
|
||||
t.Errorf("past the fortnight the finding stays: %v", keys)
|
||||
}
|
||||
w.h.HandActHeard("a5", "delete", nil)
|
||||
if len(w.h.acts) != 7 {
|
||||
t.Errorf("a deleted entry is kept: %d", len(w.h.acts))
|
||||
if len(w.h.acts) != 8 {
|
||||
t.Errorf("an act heard twice or listed after it was heard is kept twice: %d acts", len(w.h.acts))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// own, on its own bus account (`runs-as`), so that its account alone writes its buckets: `attempts` (every act begun,
|
||||
// made and finished, and every escalation, kept a week), `would` (what it would have done while the controller holds
|
||||
// its lease, kept a week) and `findings` (the brake, its blindness, and S15). It hears the condition keeper's
|
||||
// condition-raised and condition-changed on a durable consumer, watches the controller's lease, and watches the
|
||||
// controller's hand-act log for S15. stdout is the MCP channel; what this module says, it says on stderr.
|
||||
// condition-raised and condition-changed on a durable consumer, watches the controller's lease, and counts the seat's hand acts
|
||||
// for S15. stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -68,11 +68,25 @@ func main() {
|
||||
h.WakeHeard(c.Key, c.Op)
|
||||
return nil
|
||||
})
|
||||
go stdio.State(HandActsRead).Follow("", func(c stdio.StateChange) error {
|
||||
h.HandActHeard(c.Key, c.Op, c.Value)
|
||||
return nil
|
||||
})
|
||||
// S15 over both logs: the seat's act-recorded on a durable consumer, beside the controller's bucket's watch.
|
||||
// S15 over the seat's log: the fortnight's acts asked of the seat once at the start, then act-recorded on a
|
||||
// durable consumer (the controller's bucket is no longer read, issue 774).
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
answer, err := stdio.Ask("seat:mesh-hand-acts.hand-acts", map[string]any{"days": int(Within.Hours() / 24)})
|
||||
if err == nil {
|
||||
n, err := h.Seeded(answer)
|
||||
if err == nil {
|
||||
logf("[%s] took %d hand acts of the last fortnight from the seat", Module, n)
|
||||
return
|
||||
}
|
||||
logf("[%s] %v", Module, err)
|
||||
} else {
|
||||
logf("[%s] the seat's hand acts cannot be asked yet (%v); asking again in %s", Module, err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
|
||||
@@ -49,8 +49,8 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
}{
|
||||
{"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil},
|
||||
{"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}},
|
||||
{"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act"}},
|
||||
{"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}},
|
||||
{"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts"}},
|
||||
{"reads", m.Reads, []string{LeaseRead, ConditionsRead}},
|
||||
} {
|
||||
if !slices.Equal(c.got, c.want) {
|
||||
t.Errorf("%s %v, want %v", c.name, c.got, c.want)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
|
||||
// S15: a cause repaired by hand twice within a fortnight is a healer wanted (ADR 0231 §27, §28; ADR 0564 Limits 2).
|
||||
// The hand-act log is the controller's bucket mesh-controller_hand-acts, a key-value bucket with no events: mesh-healers
|
||||
// watches it, the one of the four holders granted its read, counts each cause again on every put, and writes the
|
||||
// finding `healer-wanted` per cause. **A heal is never a hand act**, and an act that is a person's decision by design
|
||||
// (a drill above all) never counts. The finding clears when fewer than two acts of its cause remain within the
|
||||
// fortnight: the key `fortnight` of the healers' wakes expires when the oldest counted act leaves it. From the log's home (issue 1953) they
|
||||
// watch that instead.
|
||||
// The hand-act log is the seat mesh-hand-acts' (ADR 0565 §10, §11), which says each act as act-recorded: mesh-healers
|
||||
// hears them, counts each cause again on every act, and writes the finding `healer-wanted` per cause. At its start it
|
||||
// asks the seat for the fortnight's acts once, so a restart does not forget them. The controller's bucket of hand acts
|
||||
// is no longer read (issue 774, ADR 0571 §13). **A heal is never a hand act**, and an act that is a person's decision by
|
||||
// design (a drill above all) never counts. The finding clears when fewer than two acts of its cause remain within the
|
||||
// fortnight: the key `fortnight` of the healers' wakes expires when the oldest counted act leaves it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// Within is how far back a repeated cause counts.
|
||||
const Within = 14 * 24 * time.Hour
|
||||
|
||||
// HandAct is what S15 reads of one entry of the controller's hand-act log (mesh-controller internal/link HandAct).
|
||||
// HandAct is what S15 reads of one act of the hand-act log (mesh-sdk handacts.Act).
|
||||
type HandAct struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
@@ -35,7 +35,7 @@ type HandAct struct {
|
||||
}
|
||||
|
||||
// decided are the verbs whose entries are a person's decision by design, with the causes they hold for (every cause
|
||||
// when none): the controller's handActVerbs, read as S15 reads them. A verb not listed counts as a repair.
|
||||
// when none): the controller's handActVerbs as the log still names them, read as S15 reads them. A verb not listed counts as a repair.
|
||||
var decided = map[string][]string{
|
||||
"warrant": nil,
|
||||
"seat": {"seat-passed"},
|
||||
@@ -68,34 +68,37 @@ func personsDecision(a HandAct) bool {
|
||||
return len(causes) == 0 || slices.Contains(causes, a.Cause)
|
||||
}
|
||||
|
||||
// ActRecorded is one act the seat mesh-hand-acts recorded, heard as act-recorded: counted beside the controller's
|
||||
// log's, under a key of its own log, so an act is counted once whichever log holds it (ADR 0565 §11).
|
||||
// ActRecorded is one act the seat mesh-hand-acts recorded, heard as act-recorded or listed by its verb `hand-acts`:
|
||||
// kept under a key of its own log, so an act heard and listed is counted once (ADR 0565 §11).
|
||||
func (h *Healers) ActRecorded(body []byte) {
|
||||
var a HandAct
|
||||
if json.Unmarshal(body, &a) != nil || a.ID == "" {
|
||||
return
|
||||
}
|
||||
h.HandActHeard(handacts.LogSeat+":"+a.ID, "put", body)
|
||||
}
|
||||
|
||||
// HandActHeard is one change of the hand-act log: kept or forgotten, and every cause counted again.
|
||||
func (h *Healers) HandActHeard(key, op string, value []byte) {
|
||||
h.mu.Lock()
|
||||
if h.acts == nil {
|
||||
h.acts = map[string]HandAct{}
|
||||
}
|
||||
if op != "put" {
|
||||
delete(h.acts, key)
|
||||
} else {
|
||||
var a HandAct
|
||||
if json.Unmarshal(value, &a) == nil {
|
||||
h.acts[key] = a
|
||||
}
|
||||
}
|
||||
h.acts[handacts.LogSeat+":"+a.ID] = a
|
||||
h.mu.Unlock()
|
||||
h.Wanted()
|
||||
}
|
||||
|
||||
// Seeded takes the acts of the seat's answer to `hand-acts` (its `acts` list) as heard, at the healers' start; it
|
||||
// answers how many it took.
|
||||
func (h *Healers) Seeded(answer []byte) (int, error) {
|
||||
var l struct {
|
||||
Acts []json.RawMessage `json:"acts"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &l); err != nil {
|
||||
return 0, fmt.Errorf("the seat's hand-acts answer is not a list of acts: %w", err)
|
||||
}
|
||||
for _, raw := range l.Acts {
|
||||
h.ActRecorded(raw)
|
||||
}
|
||||
return len(l.Acts), nil
|
||||
}
|
||||
|
||||
// Wanted counts every cause repaired by hand within the fortnight and writes a finding for each counted twice or more,
|
||||
// deleting the rest; its wait `fortnight` expires when the oldest counted act of a wanted cause leaves the fortnight.
|
||||
func (h *Healers) Wanted() []Observation {
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-healers
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
],
|
||||
"invokes": [
|
||||
"mesh-grants.grants_assert",
|
||||
"seat:mesh-hand-acts.hand-act"
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-hand-acts.hand-acts"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.lease",
|
||||
"mesh-conditions.conditions",
|
||||
"mesh-controller.hand-acts"
|
||||
"mesh-conditions.conditions"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
|
||||
@@ -2,33 +2,33 @@ package main
|
||||
|
||||
// The self-check's heartbeat, read in this one place (novox/hq to-be 45 §4, S10).
|
||||
//
|
||||
// **The contract this reads, and every assumption it makes**, because the controller's side was built
|
||||
// **The contract this reads, and every assumption it makes**, because the self-check's side was built
|
||||
// at the same time from the same design, which says only "every run ends with a heartbeat event
|
||||
// carrying the run's id and counts":
|
||||
//
|
||||
// - The event is the mesh-controller seat's own, `doctor-heartbeat`, consumed as
|
||||
// `mesh-controller.doctor-heartbeat` (subject `mesh.seat.mesh-controller.event.doctor-heartbeat`).
|
||||
// - The event is the seat mesh-self-check's `checked`, consumed as `mesh-self-check.checked` (subject
|
||||
// `mesh.seat.mesh-self-check.event.checked`). The controller's `doctor-heartbeat` is no longer heard (novox/hq ADR
|
||||
// 0564 clause 9, issue 774).
|
||||
// - The body is one JSON object: `run` (the run's id), `at` (when the run ended, RFC 3339),
|
||||
// `interval-seconds` (how often doctor runs), and `counts` ({pass, fail, failed-to-run}).
|
||||
// `interval-seconds` (how often the check runs), and `counts` ({pass, fail, failed-to-run}).
|
||||
// `finished`/`time` are read for `at`; `interval_seconds`, `interval` (seconds, or a duration such
|
||||
// as "5m") for the interval.
|
||||
// - Without a time, the heartbeat is taken as made when it arrived (said in the status as such).
|
||||
// Without an interval, the design's five minutes stand.
|
||||
// - The counts are kept for the status only. A run that found failures is still a heartbeat: the
|
||||
// watcher watches that the checker runs; what it finds is the controller's to raise.
|
||||
// watcher watches that the checker runs; what it finds is the checker's to raise.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// HeartbeatEvent is the event's local name under the controller's seat.
|
||||
const (
|
||||
HeartbeatEvent = "doctor-heartbeat"
|
||||
ControllerSeat = "mesh-controller"
|
||||
)
|
||||
// HeartbeatEvent is the event's local name under the seat mesh-self-check.
|
||||
const HeartbeatEvent = health.EventChecked
|
||||
|
||||
// Beat is one heartbeat.
|
||||
type Beat struct {
|
||||
@@ -38,8 +38,8 @@ type Beat struct {
|
||||
Counts map[string]int
|
||||
}
|
||||
|
||||
// isHeartbeat says whether an envelope's key is the controller's heartbeat.
|
||||
func isHeartbeat(key string) bool { return key == ControllerSeat+"."+HeartbeatEvent }
|
||||
// isHeartbeat says whether an envelope's key is the self-check's heartbeat.
|
||||
func isHeartbeat(key string) bool { return key == health.SelfCheckModule+"."+HeartbeatEvent }
|
||||
|
||||
// DecodeBeat reads one heartbeat's body.
|
||||
func DecodeBeat(body []byte) (Beat, error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// mesh-watcher: the watcher's watcher (novox/hq to-be 45 §5, S10, ADR 0227 rule 6). A Go bundle the
|
||||
// node's runtime launches on one machine that is not the control node. It hears the controller's
|
||||
// self-check heartbeat and makes a round trip through the bus every minute; when either goes silent
|
||||
// node's runtime launches on one machine that is not the control node. It hears the self-check's
|
||||
// heartbeat and makes a round trip through the bus every minute; when either goes silent
|
||||
// past its bound, it tells the operator on Telegram directly over HTTPS — the one sender that does not
|
||||
// pass through the control node — and tells them again when it returns. stdout is the MCP channel;
|
||||
// what this module says, it says on stderr.
|
||||
@@ -100,10 +100,8 @@ func run(w *Watcher, l *listening, node string) {
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}()
|
||||
follow := newTakeover()
|
||||
go followTakeover(follow)
|
||||
handle := func(e stdio.Envelope) error {
|
||||
if !follow.heard(e.Key) {
|
||||
if !isHeartbeat(e.Key) {
|
||||
return nil
|
||||
}
|
||||
b, err := DecodeBeat(e.Body)
|
||||
@@ -116,10 +114,7 @@ func run(w *Watcher, l *listening, node string) {
|
||||
}
|
||||
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: a heartbeat sent meanwhile is heard
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(ControllerSeat+".*", handle)
|
||||
if err == nil {
|
||||
err = stdio.Subscribe(health.SelfCheckModule+".*", handle)
|
||||
}
|
||||
err := stdio.Subscribe(health.SelfCheckModule+".*", handle)
|
||||
if err == nil {
|
||||
l.set("listening")
|
||||
logf("[mesh-watcher] listening for the self-check's heartbeat; watching from %s", node)
|
||||
@@ -199,7 +194,7 @@ func tools(w *Watcher, l *listening) []stdio.Tool {
|
||||
{Name: "watcher_status",
|
||||
Description: "The watcher's own health first — whether it can tell the operator anything (the Telegram token and " +
|
||||
"chat id), whether it runs on the machine it watches, whether heartbeats reach it — then each watched " +
|
||||
"signal (the controller's self-check heartbeat, a round trip through the bus): last heard, how long ago, " +
|
||||
"signal (the self-check's heartbeat, a round trip through the bus): last heard, how long ago, " +
|
||||
"its bound, whether it is said silent, and any message not sent yet.",
|
||||
Run: func(map[string]any) (any, error) { return w.Status(l.get()), nil }},
|
||||
{Name: "watcher_last_heard",
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{ControllerSeat + "." + HeartbeatEvent, "mesh-self-check.checked"}) {
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-self-check.checked"}) {
|
||||
t.Fatalf("consumes %v", m.Consumes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, []string{m.Module + ".watcher_ping"}) {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// takeover is which self-check run the watcher hears: the controller's `doctor-heartbeat` until the keeper's
|
||||
// take-over key names the keeper, then mesh-self-check's `checked`, whose body is the same run (novox/hq ADR 0564
|
||||
// clause 9, issue 1952 W1). An absent key follows the controller.
|
||||
type takeover struct {
|
||||
mu sync.Mutex
|
||||
t health.Takeover
|
||||
}
|
||||
|
||||
func newTakeover() *takeover {
|
||||
return &takeover{t: health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}}
|
||||
}
|
||||
|
||||
// heard says whether key is the followed self-check event.
|
||||
func (f *takeover) heard(key string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
seat, event := f.t.SelfCheckEvent()
|
||||
return key == seat+"."+event
|
||||
}
|
||||
|
||||
func (f *takeover) set(value []byte, found bool) error {
|
||||
t, err := health.DecodeTakeover(value, found)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.t = t
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// followTakeover watches the key for the life of the process; one it cannot read leaves the event it heard.
|
||||
func followTakeover(f *takeover) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.State(health.KeeperModule+"."+health.TakeoverState).Watch(health.TakeoverKey, func(c stdio.StateChange) error {
|
||||
if err := f.set(c.Value, c.Op != "delete" && len(c.Value) > 0); err != nil {
|
||||
logf("[mesh-watcher] the take-over key is unreadable (%v); no change in what it hears", err)
|
||||
} else {
|
||||
logf("[mesh-watcher] the take-over key now follows %s", f.t.Follow)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("[mesh-watcher] not watching the take-over key yet (%v)", err)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ package main
|
||||
//
|
||||
// Two signals:
|
||||
//
|
||||
// - self-check: the controller's `doctor` heartbeat. Bound: twice the interval the heartbeat says
|
||||
// - self-check: the self-check's heartbeat (mesh-self-check.checked). Bound: twice the interval the heartbeat says
|
||||
// it runs at, else twice five minutes (to-be 45 §3, S10). Heard by the time the heartbeat says it
|
||||
// was made, not by when it arrived: a backlog delivered after a restart is old news, not a sign of
|
||||
// life.
|
||||
@@ -224,7 +224,7 @@ func (w *Watcher) Tick() {
|
||||
|
||||
func (w *Watcher) what(s *signal) string {
|
||||
if s.name == SelfCheck {
|
||||
return "the controller's self-check (doctor)"
|
||||
return "the self-check"
|
||||
}
|
||||
return "the bus"
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ func TestTheHeartbeatAsTheDesignSaysIt(t *testing.T) {
|
||||
t.Errorf("%s: %v", body, err)
|
||||
}
|
||||
}
|
||||
if !isHeartbeat("mesh-controller.doctor-heartbeat") || isHeartbeat("mesh-controller.applied") {
|
||||
if !isHeartbeat("mesh-self-check.checked") || isHeartbeat("mesh-controller.doctor-heartbeat") {
|
||||
t.Fatalf("the heartbeat's key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ module mesh-watcher
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
"version": "1",
|
||||
"slug": "watch",
|
||||
"consumes": [
|
||||
"mesh-controller.doctor-heartbeat",
|
||||
"mesh-self-check.checked"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-conditions.takeover",
|
||||
"mesh-delivery-assigner.seat-holders"
|
||||
],
|
||||
"invokes": [
|
||||
|
||||
@@ -10,8 +10,8 @@ channel's client and no channel's secret.
|
||||
a record each asker reads under its own name), and the two kinded benches its channels hold, `channel`
|
||||
(`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`,
|
||||
and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded.
|
||||
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its
|
||||
condition events**; the events are read in one place, `cmd/messenger/condition.go`.
|
||||
- **Reads the condition keeper's open conditions** (`seat:mesh-conditions.conditions`) and **consumes its
|
||||
condition events** (the controller's are no longer heard, issue 774); the events are read in one place, `cmd/messenger/condition.go`.
|
||||
- **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves
|
||||
its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist
|
||||
and what each promises is the controller's word, in this module's membership; whether each can send now
|
||||
@@ -94,7 +94,7 @@ handed over three hours of raised-and-cleared conditions at once, and each was s
|
||||
what is said is decided by the state now and by when a thing happened, never by when it arrived:
|
||||
|
||||
- **The state first.** On start, every 10 min, and at once when an old event arrives, the holder reads
|
||||
what is open now from the controller (`mesh-controller.conditions`). An event older than that
|
||||
what is open now from the condition keeper (`mesh-conditions.conditions`). An event older than that
|
||||
reading is already in it and is not acted on. What is open here and not there ended meanwhile: it is
|
||||
forgotten quietly (Telegram's message is edited, which notifies nobody; the desktop is not woken). An
|
||||
urgent condition open and never said is said — all of them in **one** message. A warning raised in
|
||||
|
||||
@@ -19,7 +19,7 @@ func (b *conditionBed) controllerEvent(t *testing.T, event string, body any) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": ControllerSeat + "." + event,
|
||||
if _, err := b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": ConditionSeat + "." + event,
|
||||
"node": "test", "body": json.RawMessage(raw)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -479,8 +479,8 @@ func answerLevel(a asks.Ask) asks.Level {
|
||||
return asks.Acknowledge
|
||||
}
|
||||
|
||||
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which
|
||||
// raises the conditions.
|
||||
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which raised the
|
||||
// conditions; the condition keeper (ConditionSeat), which raises them now, is one too.
|
||||
const ConditionOwner = "mesh-controller"
|
||||
|
||||
// TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus
|
||||
@@ -651,7 +651,7 @@ func (h *Holder) recordFor(asker, key string, a asks.Ask) string {
|
||||
defer h.mu.Unlock()
|
||||
// Shown on a condition's message only when asked by the condition's owner: another asker naming a
|
||||
// condition's key does not take its message over.
|
||||
if a.About != "" && asker == ConditionOwner {
|
||||
if a.About != "" && (asker == ConditionOwner || asker == ConditionSeat) {
|
||||
if r := h.open[a.About]; r != nil && r.Cleared.IsZero() && r.Origin == "condition" {
|
||||
return r.Key
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package main
|
||||
|
||||
// The controller's condition events, read in this one place (novox/hq to-be 45 §2).
|
||||
// The condition keeper's condition events, read in this one place (novox/hq to-be 45 §2, ADR 0564 clause 9).
|
||||
//
|
||||
// **The contract this reads, and every assumption it makes about it**, because the controller's
|
||||
// **The contract this reads, and every assumption it makes about it**, because the keeper's
|
||||
// side was built at the same time from the same design and the design names the events and the
|
||||
// fields but not their spelling on the wire:
|
||||
// fields but not their spelling on the wire (the controller's own events are no longer heard, issue 774):
|
||||
//
|
||||
// - The events are the mesh-controller seat's own: `condition-raised`, `condition-changed` and
|
||||
// `condition-cleared`, consumed as `mesh-controller.<event>` (subject
|
||||
// `mesh.seat.mesh-controller.event.<event>`), exactly as `applied` and `built-before` are.
|
||||
// - The events are the seat mesh-conditions': `condition-raised`, `condition-changed` and
|
||||
// `condition-cleared`, consumed as `mesh-conditions.<event>` (subject
|
||||
// `mesh.seat.mesh-conditions.event.<event>`).
|
||||
// - The body is the condition as the store holds it, one JSON object, with the field names of the
|
||||
// design's table in kebab-case: key, kind, subject, severity, summary, evidence, source, raised,
|
||||
// last-observed, observations, tried, resolver, silenced, epoch. snake_case and camelCase
|
||||
@@ -27,7 +27,7 @@ package main
|
||||
// - Every event carries `at`, when the transition happened (the controller's conditions.Event).
|
||||
// It is how this holder tells news from history: a stream replays what it holds to a consumer
|
||||
// that is new or was away, and an event's delivery time says nothing about when it happened.
|
||||
// - The `conditions` verb of the controller's seat answers `{conditions: [...], open, note}`, each
|
||||
// - The `conditions` verb of the keeper's seat answers `{conditions: [...], open, note}`, each
|
||||
// entry the condition as the store holds it — the same shape as an event's body.
|
||||
//
|
||||
// An event this cannot read is refused by name — which event, which field, why — counted, said in
|
||||
@@ -40,15 +40,16 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// The events, by their local names under the controller's seat.
|
||||
// The events, by their local names under the keeper's seat.
|
||||
const (
|
||||
EventRaised = "condition-raised"
|
||||
EventChanged = "condition-changed"
|
||||
EventCleared = "condition-cleared"
|
||||
// ControllerSeat is the role the events are stated under.
|
||||
ControllerSeat = "mesh-controller"
|
||||
// ConditionSeat is the role the events are stated under, and the verb `conditions` is asked of.
|
||||
ConditionSeat = health.KeeperModule
|
||||
)
|
||||
|
||||
// Severities: two levels, no more (to-be 45 §2).
|
||||
@@ -120,14 +121,11 @@ func (c Condition) SilencedAt(now time.Time) bool {
|
||||
return !c.SilencedTill.IsZero() && now.Before(c.SilencedTill)
|
||||
}
|
||||
|
||||
// eventOf is the local event name of an envelope's key: `mesh-controller.condition-raised` is
|
||||
// `condition-raised`. Anything not of the controller's seat is not a condition event.
|
||||
func eventOf(key string) (string, bool) { return eventOfSeat(key, ControllerSeat) }
|
||||
|
||||
// eventOfSeat is eventOf for the seat the take-over key says is followed: the controller's, or the keeper's (ADR 0564 clause 9).
|
||||
func eventOfSeat(key, seat string) (string, bool) {
|
||||
// eventOf is the local event name of an envelope's key: `mesh-conditions.condition-raised` is
|
||||
// `condition-raised`. Anything not of the keeper's seat is not a condition event.
|
||||
func eventOf(key string) (string, bool) {
|
||||
emitter, event, ok := strings.Cut(key, ".")
|
||||
if !ok || emitter != seat {
|
||||
if !ok || emitter != ConditionSeat {
|
||||
return "", false
|
||||
}
|
||||
switch event {
|
||||
|
||||
@@ -6,19 +6,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The contract with the controller's condition events (to-be 45 §2), as condition.go states it.
|
||||
// The contract with the condition keeper's condition events (to-be 45 §2), as condition.go states it.
|
||||
|
||||
func TestTheEventsAreTheControllersSeat(t *testing.T) {
|
||||
func TestTheEventsAreTheKeepersSeat(t *testing.T) {
|
||||
for key, want := range map[string]string{
|
||||
"mesh-controller.condition-raised": EventRaised,
|
||||
"mesh-controller.condition-changed": EventChanged,
|
||||
"mesh-controller.condition-cleared": EventCleared,
|
||||
"mesh-conditions.condition-raised": EventRaised,
|
||||
"mesh-conditions.condition-changed": EventChanged,
|
||||
"mesh-conditions.condition-cleared": EventCleared,
|
||||
} {
|
||||
if got, ok := eventOf(key); !ok || got != want {
|
||||
t.Errorf("%s: %q %v", key, got, ok)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"mesh-controller.applied", "gitea.condition-raised", "condition-raised"} {
|
||||
for _, key := range []string{"mesh-controller.condition-raised", "mesh-conditions.applied", "gitea.condition-raised", "condition-raised"} {
|
||||
if _, ok := eventOf(key); ok {
|
||||
t.Errorf("%s read as a condition event", key)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func openAt(t *testing.T, backlog []backlogEvent, at time.Time) []Condition {
|
||||
func replay(t *testing.T, h *Holder, backlog []backlogEvent, skip func(Condition, string) bool) {
|
||||
t.Helper()
|
||||
for _, e := range backlog {
|
||||
event, ok := eventOf(ControllerSeat + "." + e.event)
|
||||
event, ok := eventOf(ConditionSeat + "." + e.event)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not a condition event", e.event)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
@@ -413,12 +412,11 @@ func run(h *Holder, l *listening) {
|
||||
logf("[messenger] %s cannot send: %v", ch.Name(), err)
|
||||
}
|
||||
}
|
||||
// The state first: what is open now is read from the controller before any event is taken, so
|
||||
// The state first: what is open now is read from the keeper before any event is taken, so
|
||||
// what the stream replays is history (novox/hq issue 271). Not reachable yet, it is asked for
|
||||
// again in the loop below, and old events meanwhile are kept as state, never said.
|
||||
follow := newTakeover()
|
||||
h.Start()
|
||||
syncFromController(h, follow)
|
||||
syncFromKeeper(h)
|
||||
go func() {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
@@ -431,11 +429,10 @@ func run(h *Holder, l *listening) {
|
||||
}
|
||||
if h.WantsSync() && now.Sub(lastAsk) >= time.Minute {
|
||||
lastAsk = now
|
||||
syncFromController(h, follow)
|
||||
syncFromKeeper(h)
|
||||
}
|
||||
}
|
||||
}()
|
||||
go followTakeover(follow, h)
|
||||
handle := func(e stdio.Envelope) error {
|
||||
if event, ok := strings.CutPrefix(e.Key, ViewModule+"."); ok {
|
||||
// A module in motion on a node, settled or stuck: a walk's start or end now that plans are gone (ADR 0308 §1, ADR 0539 §17).
|
||||
@@ -449,9 +446,8 @@ func run(h *Holder, l *listening) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Only the seat the take-over key names is heard: the other's events are dropped, so the keeper's conditions
|
||||
// are never held a second time beside the controller's (ADR 0564 clause 9).
|
||||
event, ok := eventOfSeat(e.Key, follow.seat())
|
||||
// Only the keeper's condition events are heard (ADR 0564 clause 9; the controller's are no longer consumed).
|
||||
event, ok := eventOf(e.Key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -467,7 +463,7 @@ func run(h *Holder, l *listening) {
|
||||
go takeAsks(h)
|
||||
go answerProofs(h)
|
||||
go followKinds(h)
|
||||
// The delivery view's events say what is in motion (ADR 0539 §17), heard beside the controller's condition events.
|
||||
// The delivery view's events say what is in motion (ADR 0539 §17), heard beside the keeper's condition events.
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.Subscribe(ViewModule+".*", handle); err == nil {
|
||||
@@ -480,13 +476,10 @@ func run(h *Holder, l *listening) {
|
||||
}
|
||||
}()
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(ControllerSeat+".*", handle)
|
||||
if err == nil {
|
||||
err = stdio.Subscribe(health.KeeperModule+".*", handle)
|
||||
}
|
||||
err := stdio.Subscribe(ConditionSeat+".*", handle)
|
||||
if err == nil {
|
||||
l.set("listening")
|
||||
logf("[messenger] listening for the controller's condition events")
|
||||
logf("[messenger] listening for the condition keeper's condition events")
|
||||
return
|
||||
}
|
||||
l.set("not yet: " + err.Error())
|
||||
@@ -632,8 +625,8 @@ func followKinds(h *Holder) {
|
||||
}
|
||||
}
|
||||
|
||||
// syncFromController reads the controller's open conditions and gives them to the holder.
|
||||
func syncFromController(h *Holder, follow *takeover) {
|
||||
// syncFromKeeper reads the condition keeper's open conditions and gives them to the holder.
|
||||
func syncFromKeeper(h *Holder) {
|
||||
asked := h.Now()
|
||||
type answer struct {
|
||||
raw json.RawMessage
|
||||
@@ -641,7 +634,7 @@ func syncFromController(h *Holder, follow *takeover) {
|
||||
}
|
||||
done := make(chan answer, 1)
|
||||
go func() {
|
||||
raw, err := stdio.Ask("seat:"+follow.seat()+".conditions", map[string]any{})
|
||||
raw, err := stdio.Ask("seat:"+ConditionSeat+".conditions", map[string]any{})
|
||||
done <- answer{raw, err}
|
||||
}()
|
||||
var a answer
|
||||
|
||||
@@ -80,14 +80,21 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
|
||||
t.Fatalf("uses %v", m.Uses)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{
|
||||
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
|
||||
"mesh-conditions." + EventRaised, "mesh-conditions." + EventChanged, "mesh-conditions." + EventCleared,
|
||||
ViewModule + "." + EventMoving, ViewModule + "." + EventSettled, ViewModule + "." + EventStuck, "intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
|
||||
t.Fatalf("consumes %v", m.Consumes)
|
||||
}
|
||||
// It consumes no mesh-controller event and invokes no mesh-controller verb (774/S12/AC2).
|
||||
for _, list := range [][]string{m.Consumes, m.Invokes} {
|
||||
for _, x := range list {
|
||||
if strings.Contains(x, "mesh-controller") {
|
||||
t.Errorf("%s names the controller", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
// It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues
|
||||
// track which conditions.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions",
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{
|
||||
"seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ var previewable = []string{"raised", "reminder", "quiet-reminder", "escalated",
|
||||
func (h *Holder) Preview(key, what string) (map[string]any, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, errorf("key is required: the condition's key, as the controller names it — `mesh-controller.conditions` says which are open")
|
||||
return nil, errorf("key is required: the condition's key, as the keeper names it — `mesh-conditions.conditions` says which are open")
|
||||
}
|
||||
what = strings.TrimSpace(what)
|
||||
if what == "" {
|
||||
@@ -75,7 +75,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) {
|
||||
return nil, errorf("%s is not a condition but this router's own %s record, which is said as part of an ask, never on its own; it holds the conditions %s",
|
||||
key, r.Origin, strings.Join(conditions, ", "))
|
||||
case len(conditions) == 0:
|
||||
return nil, errorf("no condition %s is open here, and this router holds none: `mesh-controller.conditions` says what the controller holds open", key)
|
||||
return nil, errorf("no condition %s is open here, and this router holds none: `mesh-conditions.conditions` says what the keeper holds open", key)
|
||||
default:
|
||||
return nil, errorf("no condition %s is open here; it holds %s", key, strings.Join(conditions, ", "))
|
||||
}
|
||||
|
||||
@@ -195,8 +195,9 @@ func TestAPreviewOfWhatThisRouterDoesNotHoldIsRefusedByName(t *testing.T) {
|
||||
t.Errorf("the refusal says neither what was asked nor what is held: %v", err)
|
||||
}
|
||||
|
||||
if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") {
|
||||
t.Errorf("no key: %v", err)
|
||||
if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") ||
|
||||
!strings.Contains(err.Error(), "mesh-conditions.conditions") || strings.Contains(err.Error(), "mesh-controller") {
|
||||
t.Errorf("no key: %v (it says which conditions are open by the keeper's verb, never the controller's; 774/S12/AC1)", err)
|
||||
}
|
||||
if _, err := h.Preview(k.Key, "shouted"); err == nil || !strings.Contains(err.Error(), "shouted") {
|
||||
t.Errorf("an unknown message: %v", err)
|
||||
|
||||
@@ -180,11 +180,3 @@ func (h *Holder) WantsSync() bool {
|
||||
h.init()
|
||||
return h.wantSync || h.syncedAt.IsZero() || h.Now().Sub(h.syncedAt) >= ResyncEvery
|
||||
}
|
||||
|
||||
// AskSync has the next pass of the loop read the open conditions again (the take-over key named another seat).
|
||||
func (h *Holder) AskSync() {
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.wantSync = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// takeover is which seat's condition events the messenger hears: the controller's until the keeper's take-over key
|
||||
// says otherwise (novox/hq ADR 0564 clause 9, issue 1952 R1). An absent key follows the controller.
|
||||
type takeover struct {
|
||||
mu sync.Mutex
|
||||
t health.Takeover
|
||||
}
|
||||
|
||||
func newTakeover() *takeover {
|
||||
return &takeover{t: health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}}
|
||||
}
|
||||
|
||||
func (f *takeover) seat() string { f.mu.Lock(); defer f.mu.Unlock(); return f.t.Seat() }
|
||||
|
||||
// set takes the key as a watch handed it and says whether the followed seat changed.
|
||||
func (f *takeover) set(value []byte, found bool) (changed bool, err error) {
|
||||
t, err := health.DecodeTakeover(value, found)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
changed = f.t.Seat() != t.Seat()
|
||||
f.t = t
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// followTakeover watches the take-over key for the life of the process, and asks the holder to read the followed
|
||||
// seat's open conditions again when it changes. A key it cannot read leaves the seat it followed.
|
||||
func followTakeover(f *takeover, h *Holder) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.State(health.KeeperModule+"."+health.TakeoverState).Watch(health.TakeoverKey, func(c stdio.StateChange) error {
|
||||
changed, err := f.set(c.Value, c.Op != "delete" && len(c.Value) > 0)
|
||||
if err != nil {
|
||||
logf("[messenger] the take-over key is unreadable (%v); still hearing %s", err, f.seat())
|
||||
return nil
|
||||
}
|
||||
if changed {
|
||||
logf("[messenger] the take-over key now names %s", f.seat())
|
||||
h.AskSync()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("[messenger] not watching the take-over key yet (%v); hearing %s", err, f.seat())
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ var examples = []example{{
|
||||
explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " +
|
||||
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " +
|
||||
"does not repair them.",
|
||||
actions: []Action{{Label: "Silence for a week", Verb: "mesh-controller.conditions",
|
||||
actions: []Action{{Label: "Silence for a week", Verb: "mesh-conditions.conditions",
|
||||
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}},
|
||||
resolved: "No failed services on shanks any more", popup: true,
|
||||
title: "3 failed services on shanks",
|
||||
|
||||
@@ -3,7 +3,7 @@ module messenger
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -74,9 +74,6 @@
|
||||
"channel"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.condition-raised",
|
||||
"mesh-controller.condition-changed",
|
||||
"mesh-controller.condition-cleared",
|
||||
"mesh-conditions.condition-raised",
|
||||
"mesh-conditions.condition-changed",
|
||||
"mesh-conditions.condition-cleared",
|
||||
@@ -92,14 +89,10 @@
|
||||
"refused"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.conditions",
|
||||
"seat:mesh-conditions.conditions",
|
||||
"seat:node-engine.root-free",
|
||||
"seat:issue-tracker.tracking"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-conditions.takeover"
|
||||
],
|
||||
"state": [
|
||||
"open",
|
||||
"sent",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
// The tool of this module that sets and restores a key-value bucket's expiry (novox/hq ADR 0582, "How it is
|
||||
// checked": the bus module's expiry tool sets and restores a bucket's expiry and records the act; issue 774). The
|
||||
// grants part calls it at the lease write, to take the 15 seconds off the lease bucket, and at the hand-back, to put
|
||||
// them back. Like the removal tools it refuses without `why`, reads what it changes first, reads it back after, and
|
||||
// says what it did as an event of the module; it also records the act through the seat mesh-hand-acts (ADR 0565
|
||||
// clause 11), because it changes what the mesh keeps.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const bucketExpirySet = "bucket-expiry-set"
|
||||
|
||||
// kvPrefix is the name a bucket's stream carries.
|
||||
const kvPrefix = "KV_"
|
||||
|
||||
// Recorded records one hand act through the seat mesh-hand-acts.
|
||||
type Recorded func(handacts.Request) error
|
||||
|
||||
// RecordedBySeat records an act through the runtime, to the seat mesh-hand-acts alone.
|
||||
func RecordedBySeat(q handacts.Request) error {
|
||||
_, err := handacts.Recorder{Ask: stdio.Ask}.Record(q)
|
||||
return err
|
||||
}
|
||||
|
||||
// BucketExpirySet is what nats_bucket_expiry answers and says.
|
||||
type BucketExpirySet struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Was string `json:"was"`
|
||||
Now string `json:"now"`
|
||||
Why string `json:"why"`
|
||||
Warrant string `json:"warrant,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Changed bool `json:"changed"`
|
||||
Said string `json:"said"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// expiryText names an expiry: a duration, or "none".
|
||||
func expiryText(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return "none"
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// parseExpiry reads `max_age`: "none" removes the expiry, otherwise a positive duration such as 15s.
|
||||
func parseExpiry(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "none" {
|
||||
return 0, nil
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 {
|
||||
return 0, fmt.Errorf("max_age is required: a positive duration such as 15s, or none to remove the expiry (got %q): nothing was changed", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetBucketExpiry sets one key-value bucket's expiry (MaxAge of its stream) after reading it, reads it back, records
|
||||
// the act and says so. A bucket already at that expiry is answered as such and records nothing: asking again is safe.
|
||||
// A name that is no key-value bucket is refused and nothing changes.
|
||||
func SetBucketExpiry(ctx context.Context, bus Bus, said Said, record Recorded, a map[string]any) (any, error) {
|
||||
w, err := why(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket := arg(a, "bucket")
|
||||
if !name.MatchString(bucket) {
|
||||
return nil, errors.New("bucket is required, one bucket's name as nats_buckets lists it (letters, digits, - and _): nothing was changed")
|
||||
}
|
||||
maxAge, err := parseExpiry(arg(a, "max_age"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dry, _ := a["dry_run"].(bool)
|
||||
js, closeBus, err := bus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeBus()
|
||||
stream := kvPrefix + bucket
|
||||
out := BucketExpirySet{Bucket: bucket, Why: w, Warrant: strings.TrimSpace(arg(a, "warrant")), DryRun: dry, At: time.Now().UTC()}
|
||||
sctx, cancel := step(ctx)
|
||||
s, err := js.Stream(sctx, stream)
|
||||
cancel()
|
||||
if errors.Is(err, jetstream.ErrStreamNotFound) {
|
||||
return nil, fmt.Errorf("%s is no key-value bucket (there is no stream %s): nothing was changed", bucket, stream)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, unanswered(err, fmt.Sprintf("the bucket %s could not be read, so its expiry was not changed", bucket))
|
||||
}
|
||||
cfg := s.CachedInfo().Config
|
||||
out.Was, out.Now = expiryText(cfg.MaxAge), expiryText(maxAge)
|
||||
if cfg.MaxAge == maxAge {
|
||||
out.Said = fmt.Sprintf("%s already expires after %s: nothing to change", bucket, out.Now)
|
||||
return out, nil
|
||||
}
|
||||
if dry {
|
||||
out.Said = fmt.Sprintf("%s expires after %s; it would be set to %s", bucket, out.Was, out.Now)
|
||||
return out, nil
|
||||
}
|
||||
cfg.MaxAge = maxAge
|
||||
// A stream refuses a window of duplicates longer than its expiry; a bucket made with 15 s carries 15 s.
|
||||
if maxAge > 0 && (cfg.Duplicates == 0 || cfg.Duplicates > maxAge) {
|
||||
cfg.Duplicates = maxAge
|
||||
}
|
||||
sctx, cancel = step(ctx)
|
||||
_, err = js.UpdateStream(sctx, cfg)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, unanswered(err, fmt.Sprintf("the expiry of %s was not changed", bucket))
|
||||
}
|
||||
sctx, cancel = step(ctx)
|
||||
s, err = js.Stream(sctx, stream)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, unanswered(err, fmt.Sprintf("the new expiry of %s was accepted but could not be read back", bucket))
|
||||
}
|
||||
if got := s.CachedInfo().Config.MaxAge; got != maxAge {
|
||||
return nil, fmt.Errorf("%s reads back with an expiry of %s after the change to %s was accepted", bucket, expiryText(got), out.Now)
|
||||
}
|
||||
out.Changed = true
|
||||
out.Said = fmt.Sprintf("%s expiry set from %s to %s", bucket, out.Was, out.Now)
|
||||
what := fmt.Sprintf("set the expiry of the bucket %s from %s to %s", bucket, out.Was, out.Now)
|
||||
if out.Warrant != "" {
|
||||
what += " (warrant " + out.Warrant + ")"
|
||||
}
|
||||
if err := record(handacts.Request{What: what, Why: w, Cause: "bucket-expiry"}); err != nil {
|
||||
return nil, fmt.Errorf("the expiry of %s WAS changed (%s) but the hand act could not be recorded: %w", bucket, what, err)
|
||||
}
|
||||
if err := said(bucketExpirySet, out); err != nil {
|
||||
return nil, fmt.Errorf("the expiry of %s WAS changed (%s) but the audit event could not be said: %w", bucket, w, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expiryTools is the module's tool that sets a bucket's expiry.
|
||||
func expiryTools(bus Bus, said Said, record Recorded) []stdio.Tool {
|
||||
return []stdio.Tool{{Name: "nats_bucket_expiry",
|
||||
Description: "Sets or restores the expiry of one key-value bucket: `max_age` is a duration such as 15s, or `none` to keep keys with no expiry (e.g. the lease bucket of mesh-controller at the lease write, back to 15s at the hand-back; novox/hq ADR 0582). Needs `why`, kept in the audit trail and recorded as a hand act through mesh-hand-acts (`warrant` names the operator's ask when there is one); refuses a name that is no key-value bucket; reads the expiry first and back after, and a bucket already at that expiry is answered as such. `dry_run` only reads. The bus refuses any bucket the module's credential is not granted.",
|
||||
Input: map[string]any{"bucket": str("the bucket as nats_buckets lists it, e.g. mesh-controller_lease"), "max_age": str("a duration such as 15s, or none to remove the expiry"),
|
||||
"why": str("why it is changed, for the audit trail (required)"), "warrant": str("the operator's ask this follows, when there is one"),
|
||||
"dry_run": map[string]any{"type": "boolean", "description": "read the expiry, change nothing"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return SetBucketExpiry(context.Background(), bus, said, record, a)
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// A stand-in bus: the upstream server the mesh runs, in this process, with a bucket made as the controller makes its
|
||||
// lease bucket (15 seconds) and a plain stream that is no bucket.
|
||||
func expiryBus(t *testing.T) (Bus, jetstream.JetStream) {
|
||||
t.Helper()
|
||||
s, err := server.NewServer(&server.Options{Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(5 * time.Second) {
|
||||
t.Fatal("the server did not start")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
nc, err := nats.Connect(s.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, _ := jetstream.New(nc)
|
||||
ctx := context.Background()
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-controller_lease", TTL: 15 * time.Second}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNMENTS", Subjects: []string{"a.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func(context.Context) (jetstream.JetStream, func(), error) { return js, func() {}, nil }, js
|
||||
}
|
||||
|
||||
func maxAge(t *testing.T, js jetstream.JetStream, stream string) time.Duration {
|
||||
t.Helper()
|
||||
s, err := js.Stream(context.Background(), stream)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s.CachedInfo().Config.MaxAge
|
||||
}
|
||||
|
||||
// 774/S6/AC1: a bucket with a 15 s expiry set to none reads back with none, one hand act and bucket-expiry-set;
|
||||
// restored to 15 s it reads back with 15 s again; asking for what it already has records nothing.
|
||||
func TestABucketsExpiryIsSetAndRestoredAndRecorded(t *testing.T) {
|
||||
bus, js := expiryBus(t)
|
||||
var acts []handacts.Request
|
||||
var events []string
|
||||
record := func(q handacts.Request) error { acts = append(acts, q); return nil }
|
||||
said := func(e string, _ any) error { events = append(events, e); return nil }
|
||||
call := func(max string, extra map[string]any) (BucketExpirySet, error) {
|
||||
a := map[string]any{"bucket": "mesh-controller_lease", "max_age": max, "why": "the lease write", "warrant": "ask-1"}
|
||||
for k, v := range extra {
|
||||
a[k] = v
|
||||
}
|
||||
out, err := SetBucketExpiry(context.Background(), bus, said, record, a)
|
||||
if err != nil {
|
||||
return BucketExpirySet{}, err
|
||||
}
|
||||
return out.(BucketExpirySet), nil
|
||||
}
|
||||
if out, err := call("none", map[string]any{"dry_run": true}); err != nil || out.Changed || maxAge(t, js, "KV_mesh-controller_lease") != 15*time.Second {
|
||||
t.Fatalf("a dry run changed the bucket or failed: %+v %v", out, err)
|
||||
}
|
||||
out, err := call("none", nil)
|
||||
if err != nil || !out.Changed || out.Was != "15s" || out.Now != "none" || maxAge(t, js, "KV_mesh-controller_lease") != 0 {
|
||||
t.Fatalf("none: %+v %v", out, err)
|
||||
}
|
||||
if len(acts) != 1 || !strings.Contains(acts[0].What, "mesh-controller_lease from 15s to none") || !strings.Contains(acts[0].What, "ask-1") || acts[0].Why != "the lease write" {
|
||||
t.Errorf("the hand act is %+v", acts)
|
||||
}
|
||||
if len(events) != 1 || events[0] != "bucket-expiry-set" {
|
||||
t.Errorf("events are %v", events)
|
||||
}
|
||||
if out, err = call("none", nil); err != nil || out.Changed || len(acts) != 1 || len(events) != 1 {
|
||||
t.Errorf("asking again for none changed or recorded something: %+v %v acts %d events %d", out, err, len(acts), len(events))
|
||||
}
|
||||
if out, err = call("15s", nil); err != nil || !out.Changed || maxAge(t, js, "KV_mesh-controller_lease") != 15*time.Second || len(acts) != 2 {
|
||||
t.Errorf("restore: %+v %v", out, err)
|
||||
}
|
||||
// A hand act the seat does not take is said loudly, not as success.
|
||||
_, err = SetBucketExpiry(context.Background(), bus, said, func(handacts.Request) error { return errors.New("no responders") }, map[string]any{"bucket": "mesh-controller_lease", "max_age": "none", "why": "x"})
|
||||
if err == nil || !strings.Contains(err.Error(), "WAS changed") {
|
||||
t.Errorf("an unrecorded act was answered %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S6/AC2: no why, a stream that is no bucket, and a bad expiry change nothing and say why.
|
||||
func TestABucketExpiryRefusesWithoutWhyOrABucket(t *testing.T) {
|
||||
bus, js := expiryBus(t)
|
||||
record := func(handacts.Request) error { t.Fatal("an act was recorded for a refused call"); return nil }
|
||||
said := func(string, any) error { t.Fatal("an event was said for a refused call"); return nil }
|
||||
for _, c := range []struct {
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "none"}, "why is required"},
|
||||
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "none", "why": " "}, "why is required"},
|
||||
{map[string]any{"bucket": "ASSIGNMENTS", "max_age": "none", "why": "x"}, "no key-value bucket"},
|
||||
{map[string]any{"bucket": "nothing", "max_age": "none", "why": "x"}, "no key-value bucket"},
|
||||
{map[string]any{"bucket": "a.b", "max_age": "none", "why": "x"}, "bucket is required"},
|
||||
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "", "why": "x"}, "max_age is required"},
|
||||
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "-5s", "why": "x"}, "max_age is required"},
|
||||
} {
|
||||
if _, err := SetBucketExpiry(context.Background(), bus, said, record, c.args); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%v: %v, want a refusal saying %q", c.args, err, c.want)
|
||||
}
|
||||
}
|
||||
if got := maxAge(t, js, "KV_mesh-controller_lease"); got != 15*time.Second {
|
||||
t.Errorf("a refused call left the expiry at %s", got)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", append(tools(LiveMonitor(), ReadUsers, UsersWritten), removalTools(LiveBus(), SaidByTheRuntime)...)); err != nil {
|
||||
if err := stdio.Serve("", append(append(tools(LiveMonitor(), ReadUsers, UsersWritten), removalTools(LiveBus(), SaidByTheRuntime)...), expiryTools(LiveBus(), SaidByTheRuntime, RecordedBySeat)...)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -112,12 +112,12 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) {
|
||||
}
|
||||
// The tools that change the bus (issue 1952 N1-N3): listed, their audit events declared, and the credential
|
||||
// they dial with the module's own secret `broker`.
|
||||
for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove", "nats_stream_remove"} {
|
||||
for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove", "nats_stream_remove", "nats_bucket_expiry"} {
|
||||
if !slices.Contains(m.Tools, want) {
|
||||
t.Errorf("tools do not list %s", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"consumer-removed", "bucket-keys-removed", "stream-removed"} {
|
||||
for _, want := range []string{"consumer-removed", "bucket-keys-removed", "stream-removed", "bucket-expiry-set"} {
|
||||
if !slices.Contains(m.Emits, want) {
|
||||
t.Errorf("emits do not list %s: the removal tools say it as the audit line", want)
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ module nats-tools
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
github.com/klauspost/compress v1.20.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -31,7 +31,11 @@
|
||||
"emits": [
|
||||
"consumer-removed",
|
||||
"bucket-keys-removed",
|
||||
"stream-removed"
|
||||
"stream-removed",
|
||||
"bucket-expiry-set"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-hand-acts.hand-act"
|
||||
],
|
||||
"consumes": [],
|
||||
"listens": [
|
||||
@@ -56,7 +60,8 @@
|
||||
"nats_snapshot_verify",
|
||||
"nats_consumer_remove",
|
||||
"nats_bucket_remove",
|
||||
"nats_stream_remove"
|
||||
"nats_stream_remove",
|
||||
"nats_bucket_expiry"
|
||||
],
|
||||
"guards": [
|
||||
8222
|
||||
|
||||
@@ -35,7 +35,7 @@ that seat (ADR 0207).
|
||||
`package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and
|
||||
`<node>.not-current` says the size and since when, which the health says too. `pacman_packages`,
|
||||
`pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no
|
||||
module declares from the node-engine's `packages` (read as `mesh-host.packages`, the engine's manifest's name), else the controller's `declared-packages` until its lease is written, without asking a machine.
|
||||
module declares from the node-engine's `packages` (read as `mesh-host.packages`, the engine's manifest's name), without asking a machine; a node whose engine has written no key is named in the answer (`declared_none`) and the controller is not read (ADR 0565 clause 6).
|
||||
- **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the
|
||||
state `runs` at `<node>.<run>`, written at each step and emitted as it: the package record taken and read back
|
||||
(`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestTheKeeperDeclaresTheModulesStatesAndEvents(t *testing.T) {
|
||||
}
|
||||
// The verbs read which packages each node's modules declare; the process reads no other module's state
|
||||
// (ADR 0507 §5, ADR 0436 §1).
|
||||
if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{delivery.EnginePackagesRead, "mesh-controller.declared-packages"}) {
|
||||
if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{delivery.EnginePackagesRead}) {
|
||||
t.Errorf("reads: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,18 +20,13 @@ import (
|
||||
// through the manifest's `reads`.
|
||||
const DeclaredPackagesState = SourceEngine
|
||||
|
||||
// ControllerDeclaredState is the controller's mirror of the same packages (ADR 0507 §5), the per-node fallback for a node
|
||||
// whose engine has written no key yet, until the controller's lease is written away (ADR 0565 clause 6).
|
||||
const ControllerDeclaredState = "mesh-controller.declared-packages"
|
||||
// SourceEngine is the one source a node's declaration is read from, said in the answer: the node-engine's state, read by
|
||||
// its manifest's name (mesh-host.packages, its bucket mesh-host_packages; mesh-sdk delivery.EnginePackagesRead). The
|
||||
// controller's mirror of the same packages ended with its lease (ADR 0565 clause 6, issue 774): a node whose engine has
|
||||
// written no key has no declaration here, and the answer says so.
|
||||
const SourceEngine = delivery.EnginePackagesRead
|
||||
|
||||
// The sources a node's declaration is read from, said in the answer: the node-engine's state, read by its manifest's
|
||||
// name (mesh-host.packages, its bucket mesh-host_packages; mesh-sdk delivery.EnginePackagesRead), and the mirror.
|
||||
const (
|
||||
SourceEngine = delivery.EnginePackagesRead
|
||||
SourceController = "mesh-controller.declared-packages"
|
||||
)
|
||||
|
||||
// declaredValue is a node's key of `packages`, in mesh-sdk's layout (the controller's mirror unchanged).
|
||||
// declaredValue is a node's key of `packages`, in mesh-sdk's layout.
|
||||
type declaredValue = delivery.DeclaredPackages
|
||||
|
||||
// nodeInstalled is one node's key of `installed` with its not-current mark.
|
||||
@@ -118,53 +113,42 @@ func (m *Machine) installedOn(node string) ([]nodeInstalled, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// declaredOn is what each node's modules declare present, by node and package, with the declaring modules, and the source
|
||||
// that answered for each node: its engine's key, else the controller's mirror; a node neither holds is missing. When the
|
||||
// engine's state cannot be read at all (its bucket is not on the bus yet, or the read is refused) the mirror answers for
|
||||
// every node and the engine's error is returned as a word to say beside the sources; it is an error only when the mirror
|
||||
// cannot be read either (ADR 0565 clause 6).
|
||||
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, string, error) {
|
||||
// declaredOn is what each node's modules declare present, by node and package, with the source that answered for each
|
||||
// node (its engine's key), the nodes whose engine has written none, and a word when the engine's state cannot be read at
|
||||
// all (its bucket is not on the bus yet, or the read is refused): then no node has a declaration and the word says why.
|
||||
// Nothing else is read: the controller's mirror ended with its lease (ADR 0565 clause 6).
|
||||
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, []string, string) {
|
||||
out := map[string]map[string][]string{}
|
||||
from := map[string]string{}
|
||||
engineErr := ""
|
||||
for _, source := range []string{SourceEngine, SourceController} {
|
||||
r := m.States(source)
|
||||
read := map[string]map[string][]string{}
|
||||
readFrom := map[string]string{}
|
||||
var failed error
|
||||
for _, n := range nodes {
|
||||
if from[n] != "" {
|
||||
continue
|
||||
}
|
||||
var v declaredValue
|
||||
found, err := readValue(r, n, &v)
|
||||
if err != nil {
|
||||
failed = err
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
readFrom[n] = source
|
||||
read[n] = map[string][]string{}
|
||||
for _, p := range v.Packages {
|
||||
if !p.Absent {
|
||||
read[n][p.Package] = p.Modules
|
||||
}
|
||||
}
|
||||
var none []string
|
||||
r := m.States(SourceEngine)
|
||||
for _, n := range nodes {
|
||||
var v declaredValue
|
||||
found, err := readValue(r, n, &v)
|
||||
if err != nil {
|
||||
return map[string]map[string][]string{}, map[string]string{}, nil, SourceEngine + " cannot be read: " + err.Error()
|
||||
}
|
||||
if failed != nil {
|
||||
if source == SourceEngine {
|
||||
engineErr = SourceEngine + " cannot be read: " + failed.Error()
|
||||
continue
|
||||
}
|
||||
return nil, nil, "", failed
|
||||
if !found {
|
||||
none = append(none, n)
|
||||
continue
|
||||
}
|
||||
for n, d := range read {
|
||||
out[n], from[n] = d, readFrom[n]
|
||||
from[n] = SourceEngine
|
||||
out[n] = map[string][]string{}
|
||||
for _, p := range v.Packages {
|
||||
if !p.Absent {
|
||||
out[n][p.Package] = p.Modules
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, from, engineErr, nil
|
||||
return out, from, none, ""
|
||||
}
|
||||
|
||||
// declaredWord says which nodes' engines have written no packages key.
|
||||
func declaredWord(none []string) string {
|
||||
if len(none) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "the node-engine has written no packages key for " + strings.Join(none, ", ") + ": no declaration is shown for them"
|
||||
}
|
||||
|
||||
// NodeView is one node in the view.
|
||||
@@ -178,10 +162,10 @@ type NodeView struct {
|
||||
Current bool `json:"current"`
|
||||
NotCurrent *NotCurrent `json:"not_current,omitempty"`
|
||||
// Undeclared is the packages installed explicitly from a repository that no module assigned to the node
|
||||
// declares; null when neither the node-engine nor the controller's mirror holds a declaration of the node.
|
||||
// declares; null when the node-engine holds no declaration of the node.
|
||||
Undeclared []PackageVersion `json:"undeclared"`
|
||||
Foreign []PackageVersion `json:"foreign"`
|
||||
// DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one.
|
||||
// DeclaredFrom is the source that answered for the node's declaration; empty when the engine holds none.
|
||||
DeclaredFrom string `json:"declared_from,omitempty"`
|
||||
}
|
||||
|
||||
@@ -201,7 +185,7 @@ type PackageOnNode struct {
|
||||
Foreign bool `json:"foreign,omitempty"`
|
||||
DeclaredBy []string `json:"declared_by"`
|
||||
Current bool `json:"current"`
|
||||
// DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one.
|
||||
// DeclaredFrom is the source that answered for the node's declaration; empty when the engine holds none.
|
||||
DeclaredFrom string `json:"declared_from,omitempty"`
|
||||
}
|
||||
|
||||
@@ -217,10 +201,7 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
|
||||
for _, ni := range all {
|
||||
nodes = append(nodes, ni.Node)
|
||||
}
|
||||
declared, sources, engineErr, err := m.declaredOn(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
declared, sources, none, engineErr := m.declaredOn(nodes)
|
||||
if name != "" {
|
||||
on := []PackageOnNode{}
|
||||
for _, ni := range all {
|
||||
@@ -242,6 +223,8 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
|
||||
answer := map[string]any{"package": name, "nodes": on}
|
||||
if engineErr != "" {
|
||||
answer["declared_engine_error"] = engineErr
|
||||
} else if w := declaredWord(none); w != "" {
|
||||
answer["declared_none"] = w
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -294,6 +277,8 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
|
||||
out := map[string]any{"nodes": views}
|
||||
if engineErr != "" {
|
||||
out["declared_engine_error"] = engineErr
|
||||
} else if w := declaredWord(none); w != "" {
|
||||
out["declared_none"] = w
|
||||
}
|
||||
for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} {
|
||||
b := bound(key, list, limit)
|
||||
|
||||
@@ -43,7 +43,7 @@ func viewStates(t *testing.T) (*fakeStates, *Machine) {
|
||||
}
|
||||
|
||||
func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
|
||||
states, m := viewStates(t)
|
||||
_, m := viewStates(t)
|
||||
r, err := m.Packages("zsh", 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -56,22 +56,22 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
|
||||
if a.DeclaredFrom != SourceEngine || b.DeclaredFrom != "" {
|
||||
t.Errorf("sources: %q, %q", a.DeclaredFrom, b.DeclaredFrom)
|
||||
}
|
||||
// A node whose engine has written no key reads the controller's mirror, said so; a node with both reads its engine's.
|
||||
var legacy, stale declaredValue
|
||||
json.Unmarshal([]byte(`{"node":"node-b","packages":[{"package":"zsh","modules":["legacy"],"absent":false}]}`), &legacy)
|
||||
json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["stale"],"absent":false}]}`), &stale)
|
||||
mirror := states.store(ControllerDeclaredState)
|
||||
mirror.Put("node-b", legacy)
|
||||
mirror.Put("node-a", stale)
|
||||
// A node whose engine has written no key has no declaration, said so, and no controller state is read (774/S7/AC2);
|
||||
// an engine whose state cannot be read at all is said once beside the sources, for every node (774/S7/AC1 reads the
|
||||
// engine's key above).
|
||||
read := m.States
|
||||
m.States = func(name string) StateReader {
|
||||
if name != SourceEngine && name != StateInstalled {
|
||||
t.Errorf("state %s was read: only the engine's packages are", name)
|
||||
}
|
||||
return read(name)
|
||||
}
|
||||
r2, _ := m.Packages("zsh", 500)
|
||||
on2 := r2["nodes"].([]PackageOnNode)
|
||||
if on2[0].DeclaredFrom != SourceEngine || strings.Join(on2[0].DeclaredBy, ",") != "login,shell" ||
|
||||
on2[1].DeclaredFrom != SourceController || strings.Join(on2[1].DeclaredBy, ",") != "legacy" {
|
||||
t.Errorf("fallback: %+v", on2)
|
||||
if on2[0].DeclaredFrom != SourceEngine || on2[1].DeclaredFrom != "" || len(on2[1].DeclaredBy) != 0 ||
|
||||
!strings.Contains(fmt.Sprint(r2["declared_none"]), "no packages key for node-b") {
|
||||
t.Errorf("a node with no key: %+v %v", on2, r2["declared_none"])
|
||||
}
|
||||
// The engine's state cannot be read (its bucket is not on the bus yet): the mirror answers for every node, and the
|
||||
// answer says why beside declared_from; a mirror that cannot be read either is an error.
|
||||
read := m.States
|
||||
m.States = func(name string) StateReader {
|
||||
if name == SourceEngine {
|
||||
return failingReader{errors.New("is issued and its bucket is not on the bus yet")}
|
||||
@@ -80,19 +80,10 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
|
||||
}
|
||||
r3, err3 := m.Packages("zsh", 500)
|
||||
on3, _ := r3["nodes"].([]PackageOnNode)
|
||||
if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != SourceController || strings.Join(on3[0].DeclaredBy, ",") != "stale" ||
|
||||
on3[1].DeclaredFrom != SourceController || !strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") {
|
||||
if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != "" || on3[1].DeclaredFrom != "" ||
|
||||
!strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") {
|
||||
t.Errorf("engine unreadable: %+v %v", r3, err3)
|
||||
}
|
||||
m.States = func(name string) StateReader {
|
||||
if name == SourceEngine || name == SourceController {
|
||||
return failingReader{errors.New("refused")}
|
||||
}
|
||||
return read(name)
|
||||
}
|
||||
if _, err := m.Packages("zsh", 500); err == nil {
|
||||
t.Error("neither state could be read and no error was said")
|
||||
}
|
||||
m.States = read
|
||||
if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" ||
|
||||
strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current {
|
||||
|
||||
@@ -3,7 +3,7 @@ module pacman
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
"mesh-host.packages",
|
||||
"mesh-controller.declared-packages"
|
||||
"mesh-host.packages"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
|
||||
Reference in New Issue
Block a user