Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f074fb8df4 | ||
|
|
10789ce9fd | ||
|
|
d6a069ae49 | ||
|
|
12c2574c9d | ||
|
|
8bf85863ce | ||
|
|
f61265f7a0 | ||
|
|
22ff0d1000 | ||
|
|
7d43574783 | ||
|
|
96b129e544 | ||
|
|
0d71d8b56c | ||
|
|
96a37f58ca | ||
|
|
030f9ee4e7 | ||
|
|
c8240c38bb | ||
|
|
1555241393 | ||
|
|
add6f143ca | ||
|
|
b46089b698 | ||
|
|
c193d2b389 | ||
|
|
10447d8c1e | ||
|
|
5be6576117 | ||
|
|
24fad2b92a | ||
|
|
d3eb5cae80 | ||
|
|
2043200201 | ||
|
|
ef618e8199 | ||
|
|
c00005c141 | ||
|
|
bd5f170af0 | ||
|
|
6bbb8329e4 | ||
|
|
e6b913cac7 | ||
|
|
411c1b8de8 | ||
|
|
64e6688ec8 | ||
|
|
2d57c9663a | ||
|
|
5c1729523b | ||
|
|
1628db6c80 | ||
|
|
0b9f19b612 |
@@ -62,3 +62,29 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's sshd takes a burst of the mesh's machines fetching at once: the image's MaxStartups raised from OpenSSH's
|
||||
// default 10:30:100 to 100:30:200 through SSH_MAX_STARTUPS, which dropped ace's thirteen connections at once.
|
||||
func TestTheForgesSshdTakesABurstOfTheMeshsMachines(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "server" {
|
||||
continue
|
||||
}
|
||||
env, _ := r["env"].(map[string]any)
|
||||
if env["SSH_MAX_STARTUPS"] != "100:30:200" {
|
||||
t.Errorf("SSH_MAX_STARTUPS is %v, not 100:30:200", env["SSH_MAX_STARTUPS"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("the manifest has no server container")
|
||||
}
|
||||
|
||||
@@ -266,7 +266,8 @@
|
||||
"env": {
|
||||
"DB_TYPE": "postgres",
|
||||
"USER_UID": "1000",
|
||||
"USER_GID": "1000"
|
||||
"USER_GID": "1000",
|
||||
"SSH_MAX_STARTUPS": "100:30:200"
|
||||
},
|
||||
"env-file": [
|
||||
"${dir:state}/server.env"
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/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=
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -21,11 +21,16 @@ account alone writes its buckets.
|
||||
|
||||
## How a value resolves
|
||||
|
||||
From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`,
|
||||
`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
|
||||
For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's
|
||||
own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's
|
||||
`modules.<module>` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the
|
||||
node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest)
|
||||
laid over it key by key on that node. So a module from its own repository resolves as a catalogue module does. A placed
|
||||
module none of whose manifests can be read is listed under `unresolved` with why and said in the log, and keeps the
|
||||
values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back
|
||||
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value
|
||||
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
|
||||
start, whenever a mirror moves, and every five minutes, writing only the values that moved.
|
||||
start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved.
|
||||
|
||||
## The verb
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ type Config struct {
|
||||
RouterWithin, RouterEvery time.Duration
|
||||
endedMu sync.Mutex
|
||||
ended map[string]endedWord
|
||||
|
||||
// manifests is each manifest's declarations read, by digest: a manifest never changes (manifestDeclarations).
|
||||
manifestsMu sync.Mutex
|
||||
manifests map[string]ModuleDeclarations
|
||||
}
|
||||
|
||||
// Call is one call of the verb, its arguments read.
|
||||
@@ -336,8 +340,8 @@ func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error
|
||||
joinKeys(decl.Config))
|
||||
}
|
||||
if !assigned(s, level.Node, level.Module) {
|
||||
return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+
|
||||
"is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
|
||||
return rule{}, false, fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node it "+
|
||||
"is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node,
|
||||
strings.Join(nodesOf(s, level.Module), ", "))
|
||||
}
|
||||
trusted := d.Trusted
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
|
||||
@@ -120,9 +122,81 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) {
|
||||
m.states[read][key] = []byte(raw)
|
||||
}
|
||||
|
||||
// assign places a module on a node, in the seat mesh-placement's key for the node.
|
||||
func (m *memStore) assign(t *testing.T, node, module string) {
|
||||
m.mirror(t, AssignmentsRead, node+"."+module,
|
||||
fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
|
||||
t.Helper()
|
||||
m.place(t, node, module, true)
|
||||
}
|
||||
|
||||
// unassign takes a module off a node's placement.
|
||||
func (m *memStore) unassign(t *testing.T, node, module string) {
|
||||
t.Helper()
|
||||
m.place(t, node, module, false)
|
||||
}
|
||||
|
||||
func (m *memStore) place(t *testing.T, node, module string, on bool) {
|
||||
t.Helper()
|
||||
type placed struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
p := struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Modules []placed `json:"modules"`
|
||||
}{V: 1, Node: node}
|
||||
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
|
||||
if err := json.Unmarshal(e.Value, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var kept []placed
|
||||
for _, x := range p.Modules {
|
||||
if x.Module != module {
|
||||
kept = append(kept, x)
|
||||
}
|
||||
}
|
||||
if on {
|
||||
kept = append(kept, placed{Module: module})
|
||||
}
|
||||
p.Modules = kept
|
||||
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
|
||||
func (m *memStore) manifest(t *testing.T, module, raw string) string {
|
||||
t.Helper()
|
||||
if !json.Valid([]byte(raw)) {
|
||||
t.Fatalf("not JSON: %s", raw)
|
||||
}
|
||||
digest := delivery.ManifestDigest([]byte(raw))
|
||||
value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.mirror(t, ManifestsRead, digest, string(value))
|
||||
return digest
|
||||
}
|
||||
|
||||
// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.<module>.
|
||||
func (m *memStore) declare(t *testing.T, module, raw string) {
|
||||
t.Helper()
|
||||
digest := m.manifest(t, module, raw)
|
||||
m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`,
|
||||
digest, module))
|
||||
}
|
||||
|
||||
// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them.
|
||||
func (m *memStore) want(t *testing.T, node, module, fp, raw string) {
|
||||
t.Helper()
|
||||
digest := m.manifest(t, module, raw)
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest))
|
||||
m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp))
|
||||
}
|
||||
|
||||
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
|
||||
@@ -154,10 +228,10 @@ const theList = `{"keys":{"agent-managed-settings":{"type":"object","description
|
||||
func aMesh(t *testing.T) (*Config, *memStore) {
|
||||
t.Helper()
|
||||
m := newStore()
|
||||
m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
|
||||
m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{
|
||||
m.declare(t, "dunst", dunstDeclared)
|
||||
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.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
||||
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
|
||||
for _, node := range []string{"g14", "novox"} {
|
||||
m.assign(t, node, "dunst")
|
||||
@@ -298,8 +372,8 @@ func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
|
||||
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"},
|
||||
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
|
||||
"no module value for the whole mesh"},
|
||||
{"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
||||
"it is assigned to: g14, novox"},
|
||||
{"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
||||
"it is placed on: g14, novox"},
|
||||
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
|
||||
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
|
||||
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
|
||||
@@ -472,7 +546,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and
|
||||
// **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and
|
||||
// said as config-changed; a pass over unchanged sources writes nothing.
|
||||
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
@@ -489,7 +563,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing
|
||||
if len(m.events) != 1 || m.events[0].Action != "resolve" {
|
||||
t.Errorf("an assignment raised %+v", m.events)
|
||||
}
|
||||
_ = m.Delete(AssignmentsRead, "ace.dunst")
|
||||
m.unassign(t, "ace", "dunst")
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("an unassignment moved %+v", r.Moved)
|
||||
}
|
||||
@@ -552,42 +626,110 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it
|
||||
// resolves nothing and takes the module's resolved values away, as a removed key does.
|
||||
func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
m.assign(t, "ace", "dunst")
|
||||
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("the assignment moved %+v (%v)", r.Moved, err)
|
||||
// despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config
|
||||
// alone): required trusted values, no default.
|
||||
const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{
|
||||
"praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true},
|
||||
"praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}`
|
||||
|
||||
// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer
|
||||
// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read
|
||||
// is said unresolved with why, in the listing and the log, and keeps what was resolved for it.
|
||||
func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) {
|
||||
fp := "src2:" + strings.Repeat("ab", 32)
|
||||
cases := []struct {
|
||||
name string
|
||||
given func(t *testing.T, m *memStore)
|
||||
// resolved is each key's resolved value on novox, `<module>.<key>` to source and value ("" for none).
|
||||
resolved map[string]string
|
||||
absent []string
|
||||
unresolved string
|
||||
}{
|
||||
{name: "a module of its own repository resolves from the trunk's manifest, its value set",
|
||||
given: func(t *testing.T, m *memStore) {
|
||||
m.declare(t, "de-spiegel", despiegel)
|
||||
m.assign(t, "novox", "de-spiegel")
|
||||
must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey(
|
||||
"praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"}))
|
||||
// The controller's mirror says otherwise, and is not read.
|
||||
m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`)
|
||||
},
|
||||
resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`,
|
||||
"de-spiegel.praktijk-www-name": "unset "}},
|
||||
{name: "the wanted build's manifest lays its keys over the trunk's on its node",
|
||||
given: func(t *testing.T, m *memStore) {
|
||||
m.declare(t, "de-spiegel", despiegel)
|
||||
m.assign(t, "novox", "de-spiegel")
|
||||
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
|
||||
"praktijk-name":{"type":"string","description":"d","default":"old.example"},
|
||||
"old-key":{"type":"number","description":"d","default":3}}}`)
|
||||
},
|
||||
resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`,
|
||||
"de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}},
|
||||
{name: "a removed want resolves from the trunk alone",
|
||||
given: func(t *testing.T, m *memStore) {
|
||||
m.declare(t, "de-spiegel", despiegel)
|
||||
m.assign(t, "novox", "de-spiegel")
|
||||
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
|
||||
"old-key":{"type":"number","description":"d","default":3}}}`)
|
||||
m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`)
|
||||
},
|
||||
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}},
|
||||
{name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept",
|
||||
given: func(t *testing.T, m *memStore) {
|
||||
must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"),
|
||||
Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset}))
|
||||
m.assign(t, "novox", "de-spiegel")
|
||||
},
|
||||
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "},
|
||||
unresolved: "the indexer holds no modules.de-spiegel"},
|
||||
}
|
||||
for _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} {
|
||||
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`)
|
||||
r, err := c.Reconcile()
|
||||
if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("%s moved %+v (%v)", ended, r.Moved, err)
|
||||
}
|
||||
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
|
||||
t.Errorf("%s: the module keeps its resolved values", ended)
|
||||
}
|
||||
m.assign(t, "ace", "dunst")
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
|
||||
t.Fatalf("the assignment standing again moved %+v", r.Moved)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := newStore()
|
||||
tc.given(t, m)
|
||||
var said []string
|
||||
c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, want := range tc.resolved {
|
||||
module, key, _ := strings.Cut(k, ".")
|
||||
r := m.resolved(t, "novox", module, key)
|
||||
if got := r.Source + " " + string(r.Value); got != want {
|
||||
t.Errorf("%s resolved as %s, want %s", k, got, want)
|
||||
}
|
||||
}
|
||||
for _, k := range tc.absent {
|
||||
module, key, _ := strings.Cut(k, ".")
|
||||
if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil {
|
||||
t.Errorf("%s is resolved: %s", k, e.Value)
|
||||
}
|
||||
}
|
||||
l, err := c.List(Call{Module: "de-spiegel"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.unresolved == "" {
|
||||
if len(l.Unresolved) != 0 {
|
||||
t.Errorf("unresolved: %+v", l.Unresolved)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" ||
|
||||
!strings.Contains(l.Unresolved[0].Why, tc.unresolved) {
|
||||
t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved)
|
||||
}
|
||||
if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) {
|
||||
t.Errorf("the log does not say it: %q", said)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1098: the fields the controller adds to an assignment change nothing, under the same key.
|
||||
func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+
|
||||
`"build":"b-1","artifact":{"name":"dunst","version":"1.2"},"commit":"abc123","rollout":"r-9",`+
|
||||
`"since":"2026-10-18T00:00:00Z","ended":"","state":"","future":{"x":1}}`)
|
||||
r, err := c.Reconcile()
|
||||
if err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("an assignment with the new fields moved %+v (%v)", r.Moved, err)
|
||||
}
|
||||
expect(t, m, "ace", "10", SourceDefault)
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 0 {
|
||||
t.Errorf("a quiet pass moved %+v", r.Moved)
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined
|
||||
// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3).
|
||||
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare
|
||||
// them, the list of predefined keys, and where each module is placed (issue 1953).
|
||||
|
||||
// The types a config value may have (ADR 0348 §4).
|
||||
const (
|
||||
@@ -69,7 +69,34 @@ type ModuleDeclarations struct {
|
||||
Config map[string]Declaration `json:"settings"`
|
||||
}
|
||||
|
||||
// ParseModuleDeclarations reads one key of the declared-config mirror.
|
||||
// ManifestDeclarations is the config a manifest declares (ADR 0348 §1, §4): its `config`, or for a module not yet
|
||||
// moved its `settings` (kind, default and why), the two never both. Only these two fields are read, so a manifest
|
||||
// field this module's SDK does not know yet never leaves a module's config unresolved.
|
||||
func ManifestDeclarations(raw []byte) (ModuleDeclarations, error) {
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Config map[string]Declaration `json:"config"`
|
||||
Settings map[string]Declaration `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("not a manifest: %w", err)
|
||||
}
|
||||
if m.Config != nil && m.Settings != nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("%s declares both config and settings, and a module uses one form "+
|
||||
"(novox/hq ADR 0348 §1)", m.Module)
|
||||
}
|
||||
decl := m.Config
|
||||
if decl == nil {
|
||||
decl = m.Settings
|
||||
}
|
||||
raw, err := json.Marshal(ModuleDeclarations{Module: m.Module, Config: decl})
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, err
|
||||
}
|
||||
return ParseModuleDeclarations(raw)
|
||||
}
|
||||
|
||||
// ParseModuleDeclarations reads one module's declarations in the layout ModuleDeclarations writes.
|
||||
func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) {
|
||||
var m ModuleDeclarations
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
@@ -121,24 +148,10 @@ type Published struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Assignment is one key of the assignments bucket: a module on a node. The value also carries the build, commit and
|
||||
// rollout that put it there and more; those are read where they are reported, and an unknown field never fails a
|
||||
// parse. **Ended** and **State** are set on a value that is not a standing assignment (novox/hq ADR 0408 clause 4):
|
||||
// it reads exactly as a removed key (issue 1098).
|
||||
// Assignment is a module placed on a node, as the seat mesh-placement's key `placement.<node>` names it.
|
||||
type Assignment struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Ended json.RawMessage `json:"ended,omitempty"`
|
||||
State json.RawMessage `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// Standing is whether the value is a standing assignment: neither ended nor in a state.
|
||||
func (a Assignment) Standing() bool { return !fieldSet(a.Ended) && !fieldSet(a.State) }
|
||||
|
||||
// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty.
|
||||
func fieldSet(f json.RawMessage) bool {
|
||||
s := strings.TrimSpace(string(f))
|
||||
return s != "" && s != "null" && s != `""`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// rule is the validation a value is held to, from a module's declaration or a predefined key.
|
||||
|
||||
@@ -17,6 +17,8 @@ type Listed struct {
|
||||
Predefined map[string]Published `json:"predefined,omitempty"`
|
||||
// Withheld is why the newest list of predefined keys is not published, when it is not.
|
||||
Withheld string `json:"withheld,omitempty"`
|
||||
// Unresolved is each placed module the call narrows to whose declarations cannot be read, with why (issue 1953).
|
||||
Unresolved []Unresolved `json:"unresolved,omitempty"`
|
||||
// Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502).
|
||||
Held []HeldValue `json:"held,omitempty"`
|
||||
}
|
||||
@@ -42,12 +44,18 @@ func (c *Config) List(call Call) (*Listed, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
|
||||
for _, u := range s.Unresolved {
|
||||
if (call.Module == "" || u.Module == call.Module) && (call.Node == "" || u.Node == call.Node) {
|
||||
out.Unresolved = append(out.Unresolved, u)
|
||||
}
|
||||
}
|
||||
if call.Module != "" {
|
||||
if _, ok := s.Declared[call.Module]; !ok {
|
||||
return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module)
|
||||
if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 {
|
||||
return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+
|
||||
"config", call.Module)
|
||||
}
|
||||
}
|
||||
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
|
||||
resolved := Resolve(s)
|
||||
for _, k := range sortedKeys(resolved) {
|
||||
r := resolved[k]
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the
|
||||
// published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve,
|
||||
// asked under this module's own name, design 57 K4).
|
||||
// It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept
|
||||
// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr.
|
||||
// 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.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -115,7 +116,9 @@ func resolving(c *Config) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
|
||||
// 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} {
|
||||
go func(read string) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
time.Sleep(wait)
|
||||
|
||||
@@ -573,7 +573,7 @@ func TestTheProposalArgumentsAreReadStrictly(t *testing.T) {
|
||||
// not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names.
|
||||
func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
m.mirror(t, DeclaredConfigRead, "mesh-issues", `{"module":"mesh-issues","settings":{
|
||||
m.declare(t, "mesh-issues", `{"module":"mesh-issues","settings":{
|
||||
"owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`)
|
||||
m.assign(t, "novox", "mesh-issues")
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json"))
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A module only pulled in by another on a node, and handed to the provisioner there, has a value of its own in the
|
||||
// assignments mirror (mesh-controller internal/inventory AssignmentValues, issue 765): what requires it, its plan,
|
||||
// and no time it was assigned. Its values resolve there like an assigned module's, so the node's client can fill its
|
||||
// templates; they go when the value goes.
|
||||
func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","required-by":"desk",`+
|
||||
`"provisioning":{"templates":{"source":"x","digest":"y"},"files":[]}}`)
|
||||
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("a pulled-in module's value moved %+v (%v)", r.Moved, err)
|
||||
}
|
||||
expect(t, m, "ace", "10", SourceDefault)
|
||||
_ = m.Delete(AssignmentsRead, "ace.dunst")
|
||||
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
|
||||
}
|
||||
|
||||
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
|
||||
// while the two sources stand; it goes when neither names it.
|
||||
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
|
||||
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
|
||||
}
|
||||
expect(t, m, "ace", "10", SourceDefault)
|
||||
_ = m.Delete(PlacementRead, "placement.ace")
|
||||
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("a module no longer placed moved %+v", r.Moved)
|
||||
}
|
||||
}
|
||||
@@ -378,8 +378,8 @@ func heldRule(s Sources, call Call, level Level) (rule, PullDecl, string, error)
|
||||
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not a registered module, so a value cannot be held for it", level.Module)
|
||||
}
|
||||
if !assigned(s, level.Node, level.Module) {
|
||||
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node "+
|
||||
"it is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
|
||||
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node "+
|
||||
"it is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node,
|
||||
strings.Join(nodesOf(s, level.Module), ", "))
|
||||
}
|
||||
return d.rule(), pd, declDigest(d), nil
|
||||
|
||||
@@ -23,7 +23,7 @@ func n8nPullDeclares() map[string]any {
|
||||
func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) {
|
||||
t.Helper()
|
||||
c, m, r := aProposingMesh(t)
|
||||
m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`)
|
||||
m.declare(t, "n8n", `{"module":"n8n","settings":{}}`)
|
||||
m.assign(t, "ace", "n8n")
|
||||
f := &standForge{state: map[string][2]bool{}}
|
||||
c.Forge = f
|
||||
@@ -112,7 +112,7 @@ func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or
|
||||
// node level, for a clear, for an undeclared key, and for a module not assigned to the node; and trunk-only
|
||||
// node level, for a clear, for an undeclared key, and for a module not placed on the node; and trunk-only
|
||||
// proposals are judged as before.
|
||||
func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) {
|
||||
c, m, _, _ := aHoldingMesh(t)
|
||||
@@ -142,7 +142,7 @@ func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T)
|
||||
"a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"},
|
||||
"a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"},
|
||||
"a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"},
|
||||
"another node": {func(c *Call) { c.Node = "g14" }, "is not assigned to g14"},
|
||||
"another node": {func(c *Call) { c.Node = "g14" }, "is not placed on g14"},
|
||||
"another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"},
|
||||
"a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"},
|
||||
} {
|
||||
@@ -342,7 +342,7 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes
|
||||
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil {
|
||||
t.Errorf("resolved before the trunk declares the key: %s", e.Value)
|
||||
}
|
||||
m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
|
||||
m.declare(t, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
|
||||
`"type":"number","required":true,"minimum":1,"maximum":65535}}}`)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning,
|
||||
@@ -20,12 +22,21 @@ const (
|
||||
PredefinedState = "predefined"
|
||||
ProposalsState = "proposals"
|
||||
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
// PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each
|
||||
// node's key `placement.<node>` with the modules placed there.
|
||||
PlacementRead = "mesh-delivery-assigner.placement"
|
||||
DeclaredConfigRead = "mesh-controller.declared-config"
|
||||
PredefinedRead = "mesh-controller.predefined"
|
||||
// node's key `placement.<node>` with the modules placed there. It is the one source of which module runs where
|
||||
// (issue 1953): the controller's assignments are no longer read.
|
||||
PlacementRead = delivery.PlacementRead
|
||||
// WantsRead is the assigner's wants, `want.<node>.<module>`: the build each node is to run (ADR 0539 §1).
|
||||
WantsRead = delivery.AssignerModule + ".wants"
|
||||
// BuildsRead is the builder's builds, `<module>.<fingerprint hex>`: each build's manifest digest (ADR 0525).
|
||||
BuildsRead = delivery.BuilderModule + ".builds"
|
||||
// ModulesRead is the indexer's modules, `<module>`: the trunk's manifest digest of each module, from whichever
|
||||
// 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"
|
||||
@@ -123,24 +134,59 @@ type Resolved struct {
|
||||
// ResolvedKey is a resolved value's key in mesh-config_resolved.
|
||||
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
|
||||
|
||||
// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of
|
||||
// predefined keys, and every value set.
|
||||
// Sources is everything a resolution reads: where each module is placed, every module's declarations, the published
|
||||
// list of predefined keys, and every value set.
|
||||
type Sources struct {
|
||||
Assignments []Assignment
|
||||
Declared map[string]ModuleDeclarations
|
||||
Predefined map[string]Published
|
||||
Values map[string]Value
|
||||
// Declared is each module's declarations as its manifests declare them: the trunk's, with every key only a wanted
|
||||
// build still declares (declarationsOf). It is what a set is judged against.
|
||||
Declared map[string]ModuleDeclarations
|
||||
// OnNode is, by `<node>.<module>`, the declarations of the build the node's want names laid over the trunk's: what
|
||||
// that node resolves. A placed module absent here resolves from Declared.
|
||||
OnNode map[string]ModuleDeclarations
|
||||
// Unresolved is each placed module whose declarations could not be read, with why: never skipped silently
|
||||
// (issue 1953). Its resolved values are kept as they were until its declarations read again.
|
||||
Unresolved []Unresolved
|
||||
Predefined map[string]Published
|
||||
Values map[string]Value
|
||||
// Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held
|
||||
// for them (pulls.go); neither is resolved.
|
||||
Pulls map[string]PullDecl
|
||||
Kept map[string]KeptValue
|
||||
}
|
||||
|
||||
// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is.
|
||||
// Unresolved is a module placed on a node whose values are not resolved, and why.
|
||||
type Unresolved struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// declarationsOn is the declarations a module on a node resolves from, and whether any are held.
|
||||
func (s Sources) declarationsOn(node, module string) (ModuleDeclarations, bool) {
|
||||
if d, ok := s.OnNode[node+"."+module]; ok {
|
||||
return d, true
|
||||
}
|
||||
d, ok := s.Declared[module]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// unresolved is whether a module on a node is unresolved.
|
||||
func (s Sources) unresolved(node, module string) bool {
|
||||
for _, u := range s.Unresolved {
|
||||
if u.Node == node && u.Module == module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve is every placed module's every declared value on its node, keyed as mesh-config_resolved is. A placed
|
||||
// module with no declarations is in s.Unresolved, said there (sources).
|
||||
func Resolve(s Sources) map[string]Resolved {
|
||||
out := map[string]Resolved{}
|
||||
for _, a := range s.Assignments {
|
||||
decl, ok := s.Declared[a.Module]
|
||||
decl, ok := s.declarationsOn(a.Node, a.Module)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -196,7 +242,7 @@ type Moved struct {
|
||||
From json.RawMessage `json:"from,omitempty"`
|
||||
To json.RawMessage `json:"to,omitempty"`
|
||||
Source string `json:"source"`
|
||||
// Gone says the module no longer takes the key there: unassigned, or no longer declared.
|
||||
// Gone says the module no longer takes the key there: no longer placed, or no longer declared.
|
||||
Gone bool `json:"gone,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,73 +4,30 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Reading the sources, writing the resolved values, and publishing the list of predefined keys.
|
||||
|
||||
// sources reads everything a resolution reads, from the bus as it is now.
|
||||
// sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the
|
||||
// seat mesh-placement, and each placed module's declarations from its own manifests as built, never from the
|
||||
// controller (issue 1953, ADR 0565 §3).
|
||||
func (c *Config) sources() (Sources, error) {
|
||||
s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{},
|
||||
Values: map[string]Value{}}
|
||||
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
|
||||
Predefined: map[string]Published{}, Values: map[string]Value{}}
|
||||
var problems []string
|
||||
if err := c.each(AssignmentsRead, func(key string, raw []byte) error {
|
||||
var a Assignment
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" {
|
||||
return fmt.Errorf("the assignment %s is not one: %s", key, raw)
|
||||
}
|
||||
if !a.Standing() {
|
||||
return nil // ended or pending: read as a removed key (issue 1098)
|
||||
}
|
||||
s.Assignments = append(s.Assignments, a)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the assignments cannot be read: %w", err)
|
||||
if err := c.readPlacement(&s); err != nil {
|
||||
return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err)
|
||||
}
|
||||
// And every module the seat's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
|
||||
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
|
||||
// Placement that cannot be read yet (the seat's state not on the bus) leaves the assignments alone, said.
|
||||
standing := map[string]bool{}
|
||||
for _, a := range s.Assignments {
|
||||
standing[a.Node+"."+a.Module] = true
|
||||
}
|
||||
if err := c.each(PlacementRead, func(key string, raw []byte) error {
|
||||
node, ok := strings.CutPrefix(key, "placement.")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
|
||||
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
|
||||
return nil
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if m.Module != "" && !standing[node+"."+m.Module] {
|
||||
standing[node+"."+m.Module] = true
|
||||
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the seat's placement cannot be read, so the assignments alone are resolved: %v", err))
|
||||
}
|
||||
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
|
||||
m, err := ParseModuleDeclarations(raw)
|
||||
if err != nil {
|
||||
// One module's declarations unread leaves that module unresolved, said; the rest resolve.
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", key, err))
|
||||
return nil
|
||||
}
|
||||
s.Declared[m.Module] = m
|
||||
return nil
|
||||
}); err != nil {
|
||||
if err := c.readDeclarations(&s); err != nil {
|
||||
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
|
||||
}
|
||||
for _, u := range s.Unresolved {
|
||||
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
|
||||
}
|
||||
if err := c.each(PredefinedState, func(key string, raw []byte) error {
|
||||
var p Published
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
@@ -95,11 +52,222 @@ func (c *Config) sources() (Sources, error) {
|
||||
return s, err
|
||||
}
|
||||
for _, p := range problems {
|
||||
c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p)
|
||||
c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+
|
||||
"stay as last resolved): %s", p)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key
|
||||
// `placement.<node>`.
|
||||
func (c *Config) readPlacement(s *Sources) error {
|
||||
placed := map[string]bool{}
|
||||
return c.each(PlacementRead, func(key string, raw []byte) error {
|
||||
node, err := delivery.ParsePlacementKey(key)
|
||||
if err != nil {
|
||||
return nil // a key of another kind
|
||||
}
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
|
||||
return fmt.Errorf("the placement %s is not one", key)
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if m.Module != "" && !placed[node+"."+m.Module] {
|
||||
placed[node+"."+m.Module] = true
|
||||
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// readDeclarations reads each placed module's declarations from its manifests as the delivery holds them: the trunk's
|
||||
// (the indexer's `modules.<module>` and its manifest by digest), and, on each node whose want names a build, that
|
||||
// build's (the builder's `<module>.<fp>` names its manifest digest). On a node the want's build is laid over the
|
||||
// trunk's, key by key, so a key only the build it runs declares still resolves there and a key the trunk adds resolves
|
||||
// before its build is wanted; Declared is the trunk's with every key only a wanted build declares. A placed module
|
||||
// none of whose manifests can be read is Unresolved, with why.
|
||||
func (c *Config) readDeclarations(s *Sources) error {
|
||||
wanted := map[string]string{} // <node>.<module> → the fingerprint its want names
|
||||
if err := c.each(WantsRead, func(key string, raw []byte) error {
|
||||
node, module, err := delivery.ParseWantKey(key)
|
||||
if err != nil {
|
||||
return nil // a held key or a node's own want
|
||||
}
|
||||
var w struct {
|
||||
FP string `json:"fp"`
|
||||
Removed bool `json:"removed"`
|
||||
}
|
||||
if json.Unmarshal(raw, &w) == nil && !w.Removed && w.FP != "" {
|
||||
wanted[node+"."+module] = w.FP
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("the assigner's wants: %w", err)
|
||||
}
|
||||
trunk := map[string]ModuleDeclarations{}
|
||||
trunkWhy := map[string]string{}
|
||||
for _, module := range modulesOf(s.Assignments) {
|
||||
d, err := c.trunkDeclarations(module)
|
||||
if err != nil {
|
||||
trunkWhy[module] = err.Error()
|
||||
continue
|
||||
}
|
||||
trunk[module] = d
|
||||
s.Declared[module] = d
|
||||
}
|
||||
for _, a := range s.Assignments {
|
||||
t, hasTrunk := trunk[a.Module]
|
||||
fp, isWanted := wanted[a.Node+"."+a.Module]
|
||||
var built ModuleDeclarations
|
||||
var builtErr error
|
||||
if isWanted {
|
||||
built, builtErr = c.builtDeclarations(a.Module, fp)
|
||||
}
|
||||
switch {
|
||||
case isWanted && builtErr == nil:
|
||||
on := overlay(t, built)
|
||||
s.OnNode[a.Node+"."+a.Module] = on
|
||||
d := s.Declared[a.Module]
|
||||
s.Declared[a.Module] = overlay(built, d) // the trunk's wins in what a set is judged against
|
||||
case hasTrunk:
|
||||
if isWanted {
|
||||
c.logf("[mesh-config] %s on %s resolves from the trunk's manifest alone: the build its want names "+
|
||||
"cannot be read: %v", a.Module, a.Node, builtErr)
|
||||
}
|
||||
default:
|
||||
why := trunkWhy[a.Module]
|
||||
if isWanted {
|
||||
why += "; and the build its want names: " + builtErr.Error()
|
||||
} else {
|
||||
why += "; and no want names a build of it on " + a.Node
|
||||
}
|
||||
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: a.Module, Why: why})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// overlay is base with every key of over laid on it, the module's name kept.
|
||||
func overlay(base, over ModuleDeclarations) ModuleDeclarations {
|
||||
out := ModuleDeclarations{Module: over.Module, Config: map[string]Declaration{}}
|
||||
if out.Module == "" {
|
||||
out.Module = base.Module
|
||||
}
|
||||
for k, d := range base.Config {
|
||||
out.Config[k] = d
|
||||
}
|
||||
for k, d := range over.Config {
|
||||
out.Config[k] = d
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func modulesOf(as []Assignment) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, a := range as {
|
||||
if !seen[a.Module] {
|
||||
seen[a.Module] = true
|
||||
out = append(out, a.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// trunkDeclarations is a module's declarations at the trunk: the manifest the indexer's `modules.<module>` names.
|
||||
func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) {
|
||||
key, err := delivery.ModulesKey(module)
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, err
|
||||
}
|
||||
e, err := c.Store.Get(ModulesRead, key)
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s cannot be read: %w", module, err)
|
||||
}
|
||||
if e == nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no modules.%s, so no manifest of it is known", module)
|
||||
}
|
||||
var m struct {
|
||||
Manifest string `json:"manifest"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s names no manifest", module)
|
||||
}
|
||||
return c.manifestDeclarations(module, m.Manifest)
|
||||
}
|
||||
|
||||
// builtDeclarations is a module's declarations at a build: the manifest the builder's done key names.
|
||||
func (c *Config) builtDeclarations(module, fp string) (ModuleDeclarations, error) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, err
|
||||
}
|
||||
e, err := c.Store.Get(BuildsRead, key)
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the builder's %s cannot be read: %w", key, err)
|
||||
}
|
||||
if e == nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the builder holds no build %s", key)
|
||||
}
|
||||
var b struct {
|
||||
Manifest string `json:"manifest"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Value, &b); err != nil || b.Manifest == "" {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the builder's %s names no manifest", key)
|
||||
}
|
||||
return c.manifestDeclarations(module, b.Manifest)
|
||||
}
|
||||
|
||||
// manifestDeclarations is the declarations of the manifest of a digest in the indexer's manifests, read once: a
|
||||
// manifests key is written once and never changes (ADR 0429 §16), so what it declares is kept by digest.
|
||||
func (c *Config) manifestDeclarations(module, digest string) (ModuleDeclarations, error) {
|
||||
c.manifestsMu.Lock()
|
||||
d, ok := c.manifests[digest]
|
||||
c.manifestsMu.Unlock()
|
||||
if ok {
|
||||
return d, nil
|
||||
}
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, err
|
||||
}
|
||||
e, err := c.Store.Get(ManifestsRead, key)
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s cannot be read: %w", short(digest), err)
|
||||
}
|
||||
if e == nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no manifest %s", short(digest))
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
JSON string `json:"manifest"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Value, &m); err != nil || m.JSON == "" {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is not one", short(digest))
|
||||
}
|
||||
if m.Module != module {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is %s's, not %s's", short(digest), m.Module, module)
|
||||
}
|
||||
d, err = ManifestDeclarations([]byte(m.JSON))
|
||||
if err != nil {
|
||||
return ModuleDeclarations{}, fmt.Errorf("the manifest %s: %w", short(digest), err)
|
||||
}
|
||||
c.manifestsMu.Lock()
|
||||
if c.manifests == nil {
|
||||
c.manifests = map[string]ModuleDeclarations{}
|
||||
}
|
||||
c.manifests[digest] = d
|
||||
c.manifestsMu.Unlock()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// each hands every key of a state to visit.
|
||||
func (c *Config) each(state string, visit func(key string, raw []byte) error) error {
|
||||
keys, err := c.Store.Keys(state)
|
||||
@@ -145,6 +313,13 @@ func (c *Config) writeResolved(s Sources) ([]Moved, error) {
|
||||
return nil, fmt.Errorf("the resolved values cannot be read: %w", err)
|
||||
}
|
||||
want := Resolve(s)
|
||||
// A placed module whose declarations cannot be read keeps what was resolved for it: not knowing is no reason to take
|
||||
// a node's values away (issue 1953).
|
||||
for key, h := range held {
|
||||
if _, ok := want[key]; !ok && h.Source != "unreadable" && s.unresolved(h.Node, h.Module) {
|
||||
want[key] = h
|
||||
}
|
||||
}
|
||||
moved := diff(held, want)
|
||||
var problems []error
|
||||
for _, m := range moved {
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
"gitea.gitea_get_pull_request"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-delivery-assigner.placement",
|
||||
"mesh-controller.declared-config",
|
||||
"mesh-delivery-assigner.wants",
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-controller.predefined"
|
||||
],
|
||||
"state": [
|
||||
|
||||
@@ -330,7 +330,7 @@ func (h *Holder) endStaleAsks() {
|
||||
h.endStaleSettles()
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || r.Declined != nil || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || r.declined() || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
@@ -418,6 +418,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
h.runOf(m, now)
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
|
||||
m.Ready.AskedKey, m.Ready.Asking, m.Ready.AskCheck = id, asking, check.Key
|
||||
m.Ready.Declined, m.Ready.AnsweredAsk = nil, "" // a new ask: an answer to an earlier one does not stand on it
|
||||
h.keep(m)
|
||||
taken = append(taken, m)
|
||||
reads = append(reads, readOf(m, m.Commit))
|
||||
@@ -608,7 +609,7 @@ func (h *Holder) pendingAsks() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
if r := d.Ready; r != nil && r.AskID != "" && r.Approved == nil && r.Declined == nil && !seen[r.AskID] {
|
||||
if r := d.Ready; r != nil && r.AskID != "" && r.Approved == nil && !r.declined() && !seen[r.AskID] {
|
||||
seen[r.AskID] = true
|
||||
out = append(out, r.AskID)
|
||||
}
|
||||
@@ -705,16 +706,19 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() {
|
||||
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
|
||||
switch ans.Option {
|
||||
case OptionApprove:
|
||||
r.Approved = &now
|
||||
r.Approved, r.Declined, r.AnsweredAsk = &now, nil, key
|
||||
h.Logf("[mesh-delivery] %s: the operator approved its merge into main (%s)", d.ID, r.Warrant)
|
||||
case OptionDecline:
|
||||
r.Declined = &now
|
||||
r.Declined, r.AnsweredAsk = &now, key
|
||||
h.Logf("[mesh-delivery] %s: the operator declined its merge into main (%s)", d.ID, r.Warrant)
|
||||
said = append(said, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** The merge of this pull request into main at %s was "+
|
||||
"declined: %s. A new head asks the operator again.", d.Commit, r.Warrant)))
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
if ans.Option == OptionApprove {
|
||||
h.oweApproved(covered[0], now) // said on the bus at once, and again at its bound if its merge has not started (unacted.go)
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -405,3 +406,140 @@ func TestAnAnswerToAnEarlierAskingIsNotTakenAndTheMergeNamesTheAnsweredAsking(t
|
||||
t.Fatalf("the merge is announced %v; wanted the ask id of asking 2, %s", say, sdk.AskingID(key, 2))
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-10-29 (hq#495): a group declined under one ask, a member pushed, and the group asked and approved again at the new
|
||||
// heads: the member whose head did not move kept the earlier Decline beside the new Approve, and nothing was handed, with
|
||||
// no word. An answer stands only for the ask it answers; whichever member is pushed, both merge on the new Approve.
|
||||
func TestADeclineOfAnEarlierAskDoesNotHoldTheApproveOfTheNext(t *testing.T) {
|
||||
const ctlHead, catHead, pushed = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
||||
for _, push := range []struct {
|
||||
repo string
|
||||
number int
|
||||
trunk string
|
||||
}{{"novox/mesh-catalog", 2, "t3t3t3t3t3t3"}, {"novox/mesh-controller", 1, "t1t1t1t1t1t1"}} {
|
||||
t.Run(push.repo, func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
heads := map[string]string{"novox/mesh-controller": ctlHead, "novox/mesh-catalog": catHead}
|
||||
paths := map[string]string{"novox/mesh-controller": "internal/a.go", "novox/mesh-catalog": "modules/app/module.json"}
|
||||
numbers := map[string]int{"novox/mesh-controller": 1, "novox/mesh-catalog": 2}
|
||||
trunks := map[string]string{"novox/mesh-controller": "t1t1t1t1t1t1", "novox/mesh-catalog": "t3t3t3t3t3t3"}
|
||||
round := func(check string) {
|
||||
var open []OpenPull
|
||||
for _, repo := range []string{"novox/mesh-controller", "novox/mesh-catalog"} {
|
||||
w.h.PullUpdated(pr(repo, numbers[repo], heads[repo], "feat/x", paths[repo]))
|
||||
w.h.Checked(judgedAt(repo, numbers[repo], heads[repo], "pass", trunks[repo]))
|
||||
open = append(open, openPull(repo, numbers[repo], heads[repo]))
|
||||
}
|
||||
w.forge.open = open
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: check, Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
}
|
||||
round("check-1")
|
||||
w.op.answer(w.op.open()[0], OptionDecline)
|
||||
w.settleAll()
|
||||
heads[push.repo] = pushed
|
||||
round("check-2")
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("the group at its new heads is not asked once: %v", open)
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("approved at the new heads, yet merged %v; stalled %+v", w.forge.merges, w.h.Stalled())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-10-30 (feat/774-l3-reland): approved at 10:14 and never handed, with no word. Each row an Approve: one whose runs
|
||||
// were kept by a gate before the fix above, a Decline of an earlier ask beside the Approve, merges; one whose merge
|
||||
// cannot start is said on the bus (mesh-delivery-gate.approval) and as an urgent line within approvalBound, and merges
|
||||
// once what held it ends.
|
||||
func TestAnApproveIsActedOnOrSaidWithinItsBound(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
kept bool // the runs carry a Decline of an earlier ask, kept before answers named their ask
|
||||
forgeDown bool // the members' statuses cannot be read, so the pass cannot start
|
||||
}{
|
||||
{name: "a Decline kept before the Approve, as the gate kept it before answers named their ask", kept: true},
|
||||
{name: "an Approve whose merge cannot start is said within its bound", forgeDown: true},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
heads := map[string]string{"novox/mesh-controller": "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "novox/mesh-catalog": "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"}
|
||||
paths := map[string]string{"novox/mesh-controller": "internal/a.go", "novox/mesh-catalog": "modules/app/module.json"}
|
||||
numbers := map[string]int{"novox/mesh-controller": 1, "novox/mesh-catalog": 2}
|
||||
trunks := map[string]string{"novox/mesh-controller": "t1t1t1t1t1t1", "novox/mesh-catalog": "t3t3t3t3t3t3"}
|
||||
var open []OpenPull
|
||||
for _, repo := range []string{"novox/mesh-controller", "novox/mesh-catalog"} {
|
||||
w.h.PullUpdated(pr(repo, numbers[repo], heads[repo], "feat/x", paths[repo]))
|
||||
w.h.Checked(judgedAt(repo, numbers[repo], heads[repo], "pass", trunks[repo]))
|
||||
open = append(open, openPull(repo, numbers[repo], heads[repo]))
|
||||
}
|
||||
w.forge.open = open
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
asked := w.op.open()
|
||||
if len(asked) != 1 {
|
||||
t.Fatalf("the group is not asked once: %v", asked)
|
||||
}
|
||||
w.op.answer(asked[0], OptionApprove)
|
||||
w.forge.mu.Lock()
|
||||
w.forge.down = row.forgeDown
|
||||
w.forge.mu.Unlock()
|
||||
w.h.hearAsks()
|
||||
if row.kept {
|
||||
w.h.mu.Lock()
|
||||
for _, d := range w.h.deliveries {
|
||||
if d.Ready != nil && d.Ready.Approved != nil {
|
||||
before := d.Ready.Approved.Add(-38 * time.Minute)
|
||||
d.Ready.Declined, d.Ready.AnsweredAsk = &before, ""
|
||||
}
|
||||
}
|
||||
w.h.mu.Unlock()
|
||||
}
|
||||
w.settleAll()
|
||||
w.later(approvalBound + time.Second)
|
||||
w.settleAll()
|
||||
states := map[string]bool{}
|
||||
for _, e := range w.said {
|
||||
if e["event-kind"] == approvalEvent && e["group"] == "feat/x" {
|
||||
states[fmt.Sprint(e["state"])] = true
|
||||
}
|
||||
}
|
||||
if !states["approved"] {
|
||||
t.Fatalf("the Approve is not said on the bus: %v", w.said)
|
||||
}
|
||||
var urgent []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.Bound == approvalBound.String() && l.Severity == "urgent" && strings.Contains(l.Says, "feat/x was approved") {
|
||||
urgent = append(urgent, l)
|
||||
}
|
||||
}
|
||||
if !row.forgeDown {
|
||||
if len(w.forge.merges) != 2 || states["not-handed"] || len(urgent) != 0 {
|
||||
t.Fatalf("approved, yet merged %v (said not handed: %v, lines %+v)", w.forge.merges, states["not-handed"], urgent)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(w.forge.merges) != 0 || !states["not-handed"] || len(urgent) != 1 {
|
||||
t.Fatalf("an Approve held past its bound: merged %v, said not handed %v, urgent lines %+v", w.forge.merges, states["not-handed"], urgent)
|
||||
}
|
||||
w.forge.mu.Lock()
|
||||
w.forge.down = false
|
||||
w.forge.mu.Unlock()
|
||||
w.later(askAgainAfter)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("the forge back, yet merged %v; stalled %+v", w.forge.merges, w.h.Stalled())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (h *Holder) withdrawForStatus(e ForgeStatusEvent) {
|
||||
done := map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if !strings.EqualFold(d.Repository, repository) || r == nil || r.AskID == "" || r.Approved != nil || r.Declined != nil || done[r.AskID] {
|
||||
if !strings.EqualFold(d.Repository, repository) || r == nil || r.AskID == "" || r.Approved != nil || r.declined() || done[r.AskID] {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
|
||||
@@ -208,7 +208,7 @@ func (h *Holder) passes() []pass {
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.Declined != nil || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] ||
|
||||
if r == nil || r.Approved == nil || r.declined() || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] ||
|
||||
d.Base != MergeBranch || h.allMerged(r.Heads) {
|
||||
continue
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func (h *Holder) MergeAgain(id, why string) (string, error) {
|
||||
return "", fmt.Errorf("say why: the merge asked again is the operator's word, kept in the log")
|
||||
}
|
||||
c := h.covering(d)
|
||||
if c == nil || c.Approved == nil || c.Declined != nil || c.Heads[d.ID] != d.Commit {
|
||||
if c == nil || c.Approved == nil || c.declined() || c.Heads[d.ID] != d.Commit {
|
||||
return "", fmt.Errorf("%s is not approved at its head %s: nothing to ask again (a new head is asked on its own)", id, shortOf(d.Commit, 12))
|
||||
}
|
||||
if d.MergedAs != "" || (d.Ready != nil && d.Ready.Merged != nil) {
|
||||
|
||||
@@ -524,7 +524,7 @@ func (h *Holder) PullEdited(p PullEvent) {
|
||||
if after, _ := reviewWords(d); after == before {
|
||||
return
|
||||
}
|
||||
if r := h.covering(d); r != nil && r.AskID != "" && r.Approved == nil && r.Declined == nil {
|
||||
if r := h.covering(d); r != nil && r.AskID != "" && r.Approved == nil && !r.declined() {
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, d.ID+"'s review record changed: "+shortOf(d.ReviewedAt, 12)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if !reflect.DeepEqual(m.Consumes, followed) || !slices.Contains(m.Consumes, "mesh-delivery-forge-bridge.forge.**") {
|
||||
t.Fatalf("consumes %v, follows %v (the bridge's forge messages among them)", m.Consumes, followed)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) {
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged", "approval"}) {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
// Nine buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3, the conditions it raised (issue 1275) and the
|
||||
|
||||
@@ -149,11 +149,10 @@ func (h *Holder) handTurn(g *Group, d *Delivery, now time.Time) func() {
|
||||
return nil
|
||||
}
|
||||
for _, m := range order {
|
||||
mc := h.covering(m)
|
||||
if m.Ready == nil || m.Ready.Declined != nil || mc == nil || mc.Approved == nil || mc.Heads[m.ID] != m.Commit || h.readyWaits(m) != "" {
|
||||
return nil // the others are not approved at their heads yet: nothing is merged before they are
|
||||
if h.notHandable(m) != "" {
|
||||
return nil // the others are not approved at their heads yet: nothing is merged before they are (said by notHanded)
|
||||
}
|
||||
reads = append(reads, readOf(m, mc.Heads[m.ID]))
|
||||
reads = append(reads, readOf(m, h.covering(m).Heads[m.ID]))
|
||||
}
|
||||
}
|
||||
if c == nil || c.Approved == nil || c.Heads[d.ID] == "" {
|
||||
@@ -169,6 +168,66 @@ func (h *Holder) handTurn(g *Group, d *Delivery, now time.Time) func() {
|
||||
return func() { h.hand(gid, name, key, target, reads, approved) }
|
||||
}
|
||||
|
||||
// notHandable is why a group member cannot be handed in its group's pass, empty when it can: handTurn's own test, in words.
|
||||
// Under the lock.
|
||||
func (h *Holder) notHandable(m *Delivery) string {
|
||||
mc := h.covering(m)
|
||||
switch {
|
||||
case m.Ready == nil:
|
||||
return m.ID + " has no ready run (" + string(m.State) + ")"
|
||||
case m.Ready.declined():
|
||||
return m.ID + " stands declined since " + m.Ready.Declined.UTC().Format(time.RFC3339)
|
||||
case mc == nil || mc.Approved == nil:
|
||||
return m.ID + " is not approved at its head " + shortOf(m.Commit, 12)
|
||||
case mc.Heads[m.ID] != m.Commit:
|
||||
return m.ID + "'s head " + shortOf(m.Commit, 12) + " is not the head approved"
|
||||
}
|
||||
if w := h.readyWaits(m); w != "" {
|
||||
return m.ID + " waits: " + w
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// notHanded is why the approved merge of d, which waits for nothing, is not handed: its group's pass cannot start, and
|
||||
// says which member holds it and why. Empty when nothing holds it, or when the pass is under way or its refusal is said
|
||||
// already. Under the lock.
|
||||
func (h *Holder) notHanded(g *Group, d *Delivery) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
order := h.unmergedLive(g)
|
||||
if len(order) == 0 {
|
||||
return ""
|
||||
}
|
||||
if first := order[0].Ready; first != nil && (first.merging || !first.LastTry.IsZero() || first.RefusedSince != nil) {
|
||||
return "" // the pass is under way, or its refusal is said on its own line
|
||||
}
|
||||
for _, m := range order {
|
||||
if why := h.notHandable(m); why != "" {
|
||||
return fmt.Sprintf("its group %s is not handed to the forge bridge: %s", g.ID, why)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// noteNotHanded keeps on d's run why its approved merge is not handed, said once in the log when it changes, and as a
|
||||
// stalled line meanwhile (readyLines). Under the lock.
|
||||
func (h *Holder) noteNotHanded(d *Delivery, why string, now time.Time) {
|
||||
r := d.Ready
|
||||
if r == nil || r.NotHanded == why {
|
||||
return
|
||||
}
|
||||
r.NotHanded = why
|
||||
if why == "" {
|
||||
r.NotHandedSince = nil
|
||||
} else {
|
||||
at := now
|
||||
r.NotHandedSince = &at
|
||||
h.Logf("[mesh-delivery] %s is approved, waits for nothing, and is NOT handed to merge: %s", d.ID, why)
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// hand reads a group's required statuses and, when all are a success, writes the target as the member being merged.
|
||||
func (h *Holder) hand(gid, name, key, target string, reads []memberRead, approved map[string]string) {
|
||||
if gid != "" && !h.groupMergeable(gid, target, reads) {
|
||||
|
||||
@@ -120,11 +120,13 @@ type ReadyRun struct {
|
||||
// in new words, so it never keeps a claim the check no longer holds.
|
||||
AskCheck string `json:"ask_check,omitempty"`
|
||||
// Approved and Declined are when the operator chose, Warrant what the router's warrant says of who and how; Refused
|
||||
// why an answer was not taken (it did not hold to the ask).
|
||||
Approved *time.Time `json:"approved,omitempty"`
|
||||
Declined *time.Time `json:"declined,omitempty"`
|
||||
Warrant string `json:"warrant,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// why an answer was not taken (it did not hold to the ask). AnsweredAsk is the ask the answer answers: an answer stands
|
||||
// only for its own ask, so a Decline of an earlier ask never stands beside the Approve of a later one (declined).
|
||||
Approved *time.Time `json:"approved,omitempty"`
|
||||
Declined *time.Time `json:"declined,omitempty"`
|
||||
AnsweredAsk string `json:"answered_ask,omitempty"`
|
||||
Warrant string `json:"warrant,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
|
||||
// One check per delivery (novox/hq ADR 0548 §2, §4, turns.go): HeldSince is when the approved merge began to wait for the
|
||||
// check of other heads HeldFor names (its verdict key), running on a repository it goes into; HandNow the operator's word
|
||||
@@ -137,6 +139,28 @@ type ReadyRun struct {
|
||||
// (writes.go, issue 1945); CannotWriteSince since when. Said at once as a condition.
|
||||
CannotWrite string `json:"cannot_write,omitempty"`
|
||||
CannotWriteSince *time.Time `json:"cannot_write_since,omitempty"`
|
||||
|
||||
// NotHanded is why an approved merge that waits for nothing is not handed to the forge bridge (a member of its group that
|
||||
// cannot be handed, and why), NotHandedSince since when: said once in the log and as a stalled line, never silent.
|
||||
NotHanded string `json:"not_handed,omitempty"`
|
||||
NotHandedSince *time.Time `json:"not_handed_since,omitempty"`
|
||||
|
||||
// UnactedSaid is the ask whose Approve was said not acted on, approvalBound after it (unacted.go): said once per ask.
|
||||
UnactedSaid string `json:"unacted_said,omitempty"`
|
||||
}
|
||||
|
||||
// declined is whether the operator's Decline stands for this run: it answered the ask the run holds now, or the run holds
|
||||
// no ask any more. A Decline of an earlier ask, the run asked again since at other heads (a sibling's new head), does not
|
||||
// stand (2026-10-29: hq#495 declined under one ask and approved under the next was never handed). A run kept before answers
|
||||
// named their ask stands declined unless an Approve came after the Decline.
|
||||
func (r *ReadyRun) declined() bool {
|
||||
if r == nil || r.Declined == nil {
|
||||
return false
|
||||
}
|
||||
if r.AnsweredAsk != "" {
|
||||
return r.AskID == "" || r.AskID == r.AnsweredAsk
|
||||
}
|
||||
return r.Approved == nil || r.Declined.After(*r.Approved)
|
||||
}
|
||||
|
||||
// MergeBranch is the one branch this owner merges into (novox/hq ADR 0398 §4): a merge into any other is its author's.
|
||||
@@ -173,6 +197,9 @@ func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun {
|
||||
func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
var asks []func()
|
||||
h.endStaleAsks()
|
||||
// An Approve whose merge has not started is said, whatever holds it (unacted.go): read before this look hands, so a pass
|
||||
// handed and given back within the last look (a read that failed) counts as not started.
|
||||
h.sayUnacted(now)
|
||||
gates := h.checkGates()
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
@@ -197,7 +224,7 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || r.Declined != nil {
|
||||
if r == nil || r.Merged != nil || r.declined() {
|
||||
continue
|
||||
}
|
||||
if d.State == Rejected {
|
||||
@@ -276,9 +303,11 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
asks = append(asks, approvalAsk)
|
||||
}
|
||||
if waits != "" || r.merging {
|
||||
h.noteNotHanded(d, "", now) // what it waits for is said by its waits, or it is handed
|
||||
continue
|
||||
}
|
||||
if !r.LastTry.IsZero() {
|
||||
h.noteNotHanded(d, "", now)
|
||||
continue // refused: asked again at the next event that decides whether it may merge (mergeAgainNow), with no timer
|
||||
}
|
||||
// A group is merged one member at a time, handed to the forge bridge, and the pass stops at the first refusal (ADR
|
||||
@@ -288,7 +317,10 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
g = nil
|
||||
}
|
||||
if a := h.handTurn(g, d, now); a != nil {
|
||||
h.noteNotHanded(d, "", now)
|
||||
asks = append(asks, a)
|
||||
} else {
|
||||
h.noteNotHanded(d, h.notHanded(g, d), now) // a merge never handed says why (2026-10-29: silent for an hour)
|
||||
}
|
||||
}
|
||||
asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3)
|
||||
@@ -575,6 +607,11 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
Bound: "0s", H2: "none: a new head, which asks the operator again — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d at %s is not merged: the operator's answer %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.Refused)})
|
||||
case r.NotHanded != "" && r.NotHandedSince != nil && r.Approved != nil:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.NotHandedSince).Round(time.Second).String(), Bound: "0s",
|
||||
H2: "none: the member named is approved at its head and ready, or its group is asked anew by a new head — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d at %s is approved and %s", d.Repository, d.Number, shortOf(r.Head, 12), r.NotHanded)})
|
||||
case h.answering(d, now):
|
||||
// The trunk moved and its check was asked again: the gate is answering it, so it is no stall yet (ADR 0505
|
||||
// §4). `show` lists the refusal meanwhile.
|
||||
|
||||
@@ -110,7 +110,7 @@ func (h *Holder) approvedMerges() []approvedMerge {
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.Declined != nil || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] ||
|
||||
if r == nil || r.Approved == nil || r.declined() || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] ||
|
||||
d.State.Final() || d.State == Rejected || d.Base != MergeBranch || r.RefusedSince != nil || h.allMerged(r.Heads) {
|
||||
continue
|
||||
}
|
||||
@@ -382,7 +382,10 @@ type TurnLine struct {
|
||||
func (h *Holder) TurnsRead() map[string]any {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
want := h.wantedTurns()
|
||||
want := map[string]sdk.Turn{}
|
||||
if h.checkGates() {
|
||||
want = h.wantedTurns() // as turnWrites: while the check gates no merge, no turn is written by design
|
||||
}
|
||||
repos := make([]string, 0, len(want))
|
||||
for repo := range want {
|
||||
repos = append(repos, repo)
|
||||
@@ -404,6 +407,9 @@ func (h *Holder) TurnsRead() map[string]any {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
out := map[string]any{"turns": lines, "check-gates-merge": h.checkGates(), "bound": turnBound.String()}
|
||||
if !h.checkGates() {
|
||||
out["why-none"] = "the check gates no merge (check-gates-merge false): no merge waits for a check, so no turn is written"
|
||||
}
|
||||
if h.Turns != nil {
|
||||
out["written"] = h.turnsLoaded
|
||||
var unwritten []string
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **An Approve is acted on, or said at once** (the operator, 2026-10-30: "an approval given by me must get handled
|
||||
// accordingly and you should know about it immediately"). That morning the group feat/774-l3-reland was approved at 10:14
|
||||
// and nothing was handed for an hour, with no word: six of its seven members kept a Decline of an earlier ask beside the
|
||||
// new Approve (the fix of 2026-10-29, reverted with L3, came back with this file), readyAsks skipped them and handTurn
|
||||
// returned nil for the seventh.
|
||||
//
|
||||
// So, whatever holds it: every Approve is said on the bus as `mesh-delivery-gate.approval` {state: approved} when it is
|
||||
// taken; an approved merge whose pass has not started approvalBound after its Approve is said again as {state: not-handed}
|
||||
// with what it waits on, once per ask, and is an urgent stalled line (so an urgent condition) until its pass starts, unless
|
||||
// a line of its own says it already (a held group; a merge waiting for another delivery's check, said at its bound). A
|
||||
// rejected delivery is its author's, not an Approve unacted. A session waits on `mesh-delivery-gate.approval` to hear both.
|
||||
|
||||
// approvalBound is how long after the operator's Approve a merge's pass may take to start before it is said.
|
||||
const approvalBound = 60 * time.Second
|
||||
|
||||
// approvalEvent is the event family of an Approve taken and of an Approve not acted on.
|
||||
const approvalEvent = "approval"
|
||||
|
||||
// unacted is an approved ask whose pass has not started: the run holding its Approve, its owner, its members, and what
|
||||
// the gate says it waits on.
|
||||
type unacted struct {
|
||||
owner *Delivery
|
||||
run *ReadyRun
|
||||
name string
|
||||
members []string
|
||||
waits string
|
||||
// checkHeld is whether a member waits for the check of other heads (ADR 0548 §2): said by the event at once, and as a
|
||||
// line by turnLines at its own bound, not twice.
|
||||
checkHeld bool
|
||||
}
|
||||
|
||||
// approvalStands is whether a run's Approve still stands: no Decline after it, not merged, into the merge branch.
|
||||
func approvalStands(d *Delivery) bool {
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.AskID == "" || len(r.Heads) == 0 || r.Merged != nil || d.MergedAs != "" ||
|
||||
d.State.Final() || d.State == Rejected || d.Base != MergeBranch {
|
||||
return false
|
||||
}
|
||||
return r.Declined == nil || !r.Declined.After(*r.Approved)
|
||||
}
|
||||
|
||||
// unactedAsks are the approved asks whose pass has not started, each once, in the order of their ids. A pass has started
|
||||
// when a member it covers is handed (merging), was tried (refused or asked again), or merged. Under the lock.
|
||||
func (h *Holder) unactedAsks() []unacted {
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
seen := map[string]bool{}
|
||||
var out []unacted
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
if !approvalStands(d) || seen[d.Ready.AskID] {
|
||||
continue
|
||||
}
|
||||
if g := h.groups[d.Group]; d.Group != "" && g != nil && g.Hold != nil {
|
||||
seen[d.Ready.AskID] = true
|
||||
continue // a held group is said by its own urgent line, naming the member and why (groupLines)
|
||||
}
|
||||
r := d.Ready
|
||||
seen[r.AskID] = true
|
||||
u := unacted{owner: d, run: r, name: askGroup(d)}
|
||||
started := false
|
||||
var says []string
|
||||
for mid := range r.Heads {
|
||||
u.members = append(u.members, mid)
|
||||
m := h.deliveries[mid]
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.MergedAs != "" || (m.Ready != nil && (m.Ready.Merged != nil || m.Ready.merging || !m.Ready.LastTry.IsZero() ||
|
||||
m.Ready.RefusedSince != nil)) {
|
||||
started = true
|
||||
break
|
||||
}
|
||||
if m.Ready != nil {
|
||||
u.checkHeld = u.checkHeld || m.Ready.HeldSince != nil
|
||||
if w := firstOf(m.Ready.NotHanded, m.Ready.Waits); w != "" {
|
||||
says = append(says, mid+": "+w)
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
continue
|
||||
}
|
||||
sort.Strings(u.members)
|
||||
sort.Strings(says)
|
||||
u.waits = firstOf(strings.Join(says, "; "), "nothing the gate says: its merge was never handed")
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sayUnacted owes the event {state: not-handed} once per ask for every approved merge past approvalBound whose pass has
|
||||
// not started, and wants a look at the bound of each not yet due. Under the lock; the event is owed, said by Flush.
|
||||
func (h *Holder) sayUnacted(now time.Time) {
|
||||
for _, u := range h.unactedAsks() {
|
||||
due := u.run.Approved.Add(approvalBound)
|
||||
if now.Before(due) {
|
||||
h.lookAt(due, "an Approve whose merge has not started, said at its bound")
|
||||
continue
|
||||
}
|
||||
if u.run.UnactedSaid == u.run.AskID {
|
||||
continue
|
||||
}
|
||||
u.run.UnactedSaid = u.run.AskID
|
||||
h.Logf("[mesh-delivery] %s is approved since %s and its merge is NOT handed: %s", u.name,
|
||||
u.run.Approved.UTC().Format(time.RFC3339), u.waits)
|
||||
u.owner.Owed = append(u.owner.Owed, Effect{Kind: EffectEmit, Event: approvalEvent, Since: now,
|
||||
Line: string(mustJSON(approvalBody(u.owner, "not-handed", u.members, now, u.waits)))})
|
||||
h.keep(u.owner)
|
||||
}
|
||||
}
|
||||
|
||||
// oweApproved owes the event {state: approved} for an ask the operator approved, on the first delivery it covers, and wants
|
||||
// a look at its bound. Under the lock.
|
||||
func (h *Holder) oweApproved(d *Delivery, now time.Time) {
|
||||
r := d.Ready
|
||||
members := make([]string, 0, len(r.Heads))
|
||||
for id := range r.Heads {
|
||||
members = append(members, id)
|
||||
}
|
||||
sort.Strings(members)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: approvalEvent, Since: now,
|
||||
Line: string(mustJSON(approvalBody(d, "approved", members, now, "")))})
|
||||
h.lookAt(now.Add(approvalBound), "an Approve whose merge has not started, said at its bound")
|
||||
}
|
||||
|
||||
// approvalBody is what `mesh-delivery-gate.approval` says: no secret, no address.
|
||||
func approvalBody(d *Delivery, state string, members []string, now time.Time, waits string) map[string]any {
|
||||
r := d.Ready
|
||||
e := map[string]any{"state": state, "group": askGroup(d), "ask": r.AskID, "approved": r.Approved.UTC(), "at": now.UTC(),
|
||||
"members": members, "by": r.Warrant}
|
||||
if waits != "" {
|
||||
e["waits"] = waits
|
||||
e["for"] = now.Sub(*r.Approved).Round(time.Second).String()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// unactedLines are the urgent stalled lines of approved merges whose pass has not started past approvalBound. Under the
|
||||
// lock.
|
||||
func (h *Holder) unactedLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, u := range h.unactedAsks() {
|
||||
since := now.Sub(*u.run.Approved)
|
||||
if since < approvalBound || u.checkHeld {
|
||||
continue
|
||||
}
|
||||
out = append(out, StalledLine{ID: u.owner.ID, Number: u.owner.Number, State: u.owner.State,
|
||||
For: since.Round(time.Second).String(), Bound: approvalBound.String(), Severity: "urgent",
|
||||
H2: "none: the merge is handed once what it waits on ends; delivery_turns and mesh-delivery.show say what holds it",
|
||||
Says: fmt.Sprintf("%s was approved at %s (%d pull requests) and its merge is not handed: %s", u.name,
|
||||
u.run.Approved.UTC().Format(time.RFC3339), len(u.members), u.waits)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -230,6 +230,7 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
out = append(out, h.readyLines(now)...)
|
||||
out = append(out, h.turnLines(now)...) // an approved merge held past its bound by another delivery's check (ADR 0548 §4)
|
||||
out = append(out, h.groupLines(now)...)
|
||||
out = append(out, h.unactedLines(now)...) // an Approve whose merge has not started past its bound (unacted.go)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"emits": [
|
||||
"transition",
|
||||
"group",
|
||||
"merged"
|
||||
"merged",
|
||||
"approval"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
|
||||
@@ -161,7 +161,11 @@ func (m *memAsks) PutLinksAsked(key string, times []time.Time) error {
|
||||
var rootFreeOf = map[*Holder]map[string]string{}
|
||||
|
||||
// told is what the router published to askers, by subject.
|
||||
type told struct{ by map[string][]asks.Warrant }
|
||||
type told struct {
|
||||
by map[string][]asks.Warrant
|
||||
// asked are the asks the router said it took, by subject (sayAsked).
|
||||
asked map[string][]map[string]any
|
||||
}
|
||||
|
||||
func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] }
|
||||
|
||||
@@ -171,7 +175,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
|
||||
t.Helper()
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
store := newMemAsks()
|
||||
w := &told{by: map[string][]asks.Warrant{}}
|
||||
w := &told{by: map[string][]asks.Warrant{}, asked: map[string][]map[string]any{}}
|
||||
bench := NewBench(c.now)
|
||||
bench.SetKinds([]KindInfo{
|
||||
{Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit",
|
||||
@@ -204,6 +208,9 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
|
||||
if wt, ok := body.(asks.Warrant); ok {
|
||||
w.by[subject] = append(w.by[subject], wt)
|
||||
}
|
||||
if m, ok := body.(map[string]any); ok && strings.HasPrefix(subject, "mesh.seat."+asks.Seat+".event."+AskedEvent+".") {
|
||||
w.asked[subject] = append(w.asked[subject], m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return h, tg, dt, c, store, w
|
||||
@@ -325,6 +332,10 @@ func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) {
|
||||
!strings.Contains(rec.HeldWhy, "no Telegram account is linked") {
|
||||
t.Fatalf("not held: %+v %+v", rec, w.of("mesh-delivery"))
|
||||
}
|
||||
// Held or shown, the ask is said taken on the bus for its asker, so a session hears it open (2026-10-30).
|
||||
if said := w.asked[askedSubject("mesh-delivery")]; len(said) != 1 || said[0]["ask"] != "d-1" || said[0]["state"] != StateHeld {
|
||||
t.Fatalf("the ask taken is not said on %s: %+v", askedSubject("mesh-delivery"), said)
|
||||
}
|
||||
if len(dt.sends) != 0 {
|
||||
t.Errorf("the desk was offered an ask with an approval: %+v", dt.sends)
|
||||
}
|
||||
|
||||
@@ -560,6 +560,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
} else {
|
||||
h.keepAsk(key, rec, 0)
|
||||
}
|
||||
h.sayAsked(asker, a, rec)
|
||||
if rec.State == StateHeld {
|
||||
h.Logf("[messenger] %s asks %q (%s): held, not shown: %s", asker, a.Headline, a.ID, rec.HeldWhy)
|
||||
return nil
|
||||
@@ -568,6 +569,31 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AskedEvent is the seat's event that an ask was taken, said for its asker as decided is (ADR 0259 §3): a session
|
||||
// waits on `operator-channel.asked.<asker>` to hear an ask open, and on `operator-channel.decided.<asker>` to hear
|
||||
// its answer (the operator, 2026-10-30: "you should be able to hook into approval requests so you also get a
|
||||
// notification"). The SDK names decided's subject; asked's is named here until the SDK's asks contract carries it.
|
||||
const AskedEvent = "asked"
|
||||
|
||||
// askedSubject is where the router says an ask of an asker was taken.
|
||||
func askedSubject(asker string) string {
|
||||
return "mesh.seat." + asks.Seat + ".event." + AskedEvent + "." + asker
|
||||
}
|
||||
|
||||
// sayAsked says on the bus that an ask was taken, open or held: its id, asker, what it is about, its headline (the
|
||||
// words the operator's phone shows, already judged to carry nothing that may not leave the mesh) and when it ends.
|
||||
// Never its options' bindings or its whole words. A failure is said in the log and holds nothing: the ask stands.
|
||||
func (h *Holder) sayAsked(asker string, a asks.Ask, rec AskRecord) {
|
||||
if h.Publish == nil {
|
||||
return
|
||||
}
|
||||
body := map[string]any{"ask": a.ID, "asker": asker, "about": a.About, "headline": a.Headline, "state": rec.State,
|
||||
"opened": rec.Opened.UTC(), "expires": a.Expires}
|
||||
if err := h.Publish(askedSubject(asker), body, "asked."+ticket()); err != nil {
|
||||
h.Logf("[messenger] the ask %s of %s is taken and could not be said on the bus: %v", a.ID, asker, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ticketsFor is every option's ticket on every kind that can carry the ask's highest level now: an ask with an
|
||||
// approval is answered only where it could be approved (answerLevel). None when no ready channel can.
|
||||
func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket {
|
||||
|
||||
@@ -64,8 +64,8 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
|
||||
m, _ := readManifest(t)
|
||||
verbs := []string{"open", "history", "notify", "tell", "asks"}
|
||||
want := []seat{
|
||||
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
|
||||
ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}},
|
||||
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided", "asked"},
|
||||
ByCaller: []string{"ask", "cancel", "condition", "decided", "asked"}, Records: []string{"asks"}},
|
||||
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
|
||||
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
|
||||
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
"condition"
|
||||
],
|
||||
"emits": [
|
||||
"decided"
|
||||
"decided",
|
||||
"asked"
|
||||
],
|
||||
"by-caller": [
|
||||
"ask",
|
||||
"cancel",
|
||||
"condition",
|
||||
"decided"
|
||||
"decided",
|
||||
"asked"
|
||||
],
|
||||
"records": [
|
||||
"asks"
|
||||
|
||||
Reference in New Issue
Block a user