diff --git a/checks/handacts/fallbacks b/checks/handacts/fallbacks index c4fdf709..238233b7 100644 --- a/checks/handacts/fallbacks +++ b/checks/handacts/fallbacks @@ -2,7 +2,4 @@ # the fallback of a recorder (mesh-sdk handacts.Recorder) while the seat mesh-hand-acts has no holder that answers, until # the controller's lease is written to a holder that is no controller. The check of this directory (handacts) refuses any # other, and an entry whose module no longer invokes it: the list only shrinks, and it is empty before the lease write -# (novox/hq ADR 0565 §11; issue 1953, issue 774). -mesh-config hand-act fallback until the lease write: its sets, clears and held values record through mesh-hand-acts first -mesh-config warranted fallback until the lease write: its warrants record through mesh-hand-acts first -mesh-healers drill fallback until the lease write: a drill records through mesh-hand-acts first +# (novox/hq ADR 0565 §11; issue 1953, issue 774). It is empty: no manifest invokes the controller's log (issue 1953, 1955). diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 6fb00de8..73072d92 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -260,16 +261,25 @@ func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) { } } -// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. +// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. It invokes the +// hand-act seat's warranted and no verb of the controller, and the warrant is asked of that seat (ADR 0565 clause 11). func TestTheManifestListsEveryToolServed(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { t.Fatal(err) } var m struct { - Tools []string `json:"tools"` + Tools []string `json:"tools"` + Invokes []string `json:"invokes"` } _ = json.Unmarshal(raw, &m) + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) { + t.Errorf("invokes = %v, want the hand-act seat's warranted alone", m.Invokes) + } + if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || + strings.Contains(string(src), "seat:mesh-controller.warranted") { + t.Error("recordWarrant does not ask the hand-act seat alone") + } listed := map[string]bool{} for _, n := range m.Tools { listed[n] = true diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 9f878b66..897c0a77 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -696,7 +696,7 @@ type ProposalStore interface { Get(key string) (json.RawMessage, bool, error) } -// Recorder records a warrant in the controller's hand-act log: the controller reads the warrant from the +// Recorder records a warrant in the hand-act log of the seat mesh-hand-acts: the controller reads the warrant from the // router's own record, never from this module. type Recorder func(ask string) error @@ -708,9 +708,9 @@ func (sdkAskBus) Publish(subject string, body any, id string) error { func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) } func (sdkAskBus) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() } -// recordWarrant asks the controller to record a warrant this module acted on. +// recordWarrant asks the seat mesh-hand-acts (ADR 0565 clause 11) to record a warrant this module acted on. func recordWarrant(ask string) error { - _, err := stdio.Ask("seat:mesh-controller.warranted", map[string]any{"asker": Module, "ask": ask}) + _, err := stdio.Ask("seat:mesh-hand-acts.warranted", map[string]any{"asker": Module, "ask": ask}) return err } diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index c6e4e206..62e10cde 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -2,7 +2,7 @@ module claude-code go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 require gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index 8c80acee..a164051c 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 9f85df0c..48f1a194 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -22,6 +22,9 @@ "uses": [ "operator-channel" ], + "invokes": [ + "seat:mesh-hand-acts.warranted" + ], "state": [ "servers", "holdings", diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 54b5958b..1f43ab6a 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -3,7 +3,7 @@ module gitea go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index 5fc11b2f..37e06a87 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/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= diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index d5bc1898..9271f010 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -9,13 +9,13 @@ // // module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239) // -// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head -// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and +// Once consumed mesh-controller.checked — a pull request's merge check, judged — and set it as the head +// commit's statuses (setVerdict, kept below for a revert, and bound to no event since ADR 0571 clause 13): `mesh/merge-gate`, the modules of the mesh's graph the change touches, and // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment // saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base // still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since // ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing -// publishes mesh-controller.checked and these two are written for no base. +// publishes a merge check's verdict, this module consumes no event of the controller, and these two are written for no base. // // Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and // writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands @@ -43,7 +43,7 @@ // The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering // the forge for an immediacy nobody asked for is not. -import { emit, on } from "@novox/mesh-sdk/events"; +import { emit } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { statesAnnouncement } from "./kept.js"; import { commitStatusTool } from "./delivery.js"; @@ -649,19 +649,9 @@ if (gitea) { setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000); }; void listenOk.then((ok) => (ok ? pass() : undefined)); - // Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its - // handshake in 10 s, so the import must end while a refused bind is still asked again. - bindInBackground([ - ["mesh-controller.checked", () => on("mesh-controller.checked", (event) => { - const c = (event.body ?? {}) as Checked; - // A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts). - // A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456). - if (!c.group && c.owner && c.repo && c.commit) { - return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id); - } - return setVerdict(client, event); - })], - ], () => console.log("[gitea] watching for new repositories and merged pull requests")); + // No consumer is bound since ADR 0571 clause 13 took the controller's checked out of this module; a consumer added later + // is bound again until it holds, and not awaited (bind.ts, issue 1793). + bindInBackground([], () => console.log("[gitea] watching for new repositories and merged pull requests")); // The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again, // apart from the binds above, so neither waits for the other. void watchOwed((handler) => state(CHECKER_OWED).watch((change) => handler(change)), checks); diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 7d703aff..2fd3ae33 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -86,9 +86,6 @@ "forge.wiki.changed", "forge.other" ], - "consumes": [ - "mesh-controller.checked" - ], "listens": [ { "name": "web", diff --git a/modules/gitea/test/check-owed.test.ts b/modules/gitea/test/check-owed.test.ts index afd43584..d3184a14 100644 --- a/modules/gitea/test/check-owed.test.ts +++ b/modules/gitea/test/check-owed.test.ts @@ -34,7 +34,7 @@ test("the checker's owed is what this module reads, and it consumes no check.sta assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed"); const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed"); - assert.ok(!manifest.consumes.some((c: string) => c.includes("check.status")), "no check.status is consumed"); + assert.ok(!(manifest.consumes ?? []).some((c: string) => c.includes("check.status")), "no check.status is consumed"); }); test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => { diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 1256b0fa..88186265 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -75,8 +75,8 @@ func (runtimeStore) Read(log string) ([]LogEntry, error) { func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event, body) } -// recorder records into the hand-act log of the seat mesh-hand-acts, falling back to the controller's while the seat -// has no holder that answers and the controller still holds its lease (novox/hq ADR 0565 §11). +// recorder records into the hand-act log of the seat mesh-hand-acts alone (novox/hq ADR 0565 §11, issue 1953): the manifest +// invokes no verb of the controller and the SDK recorder has no fall back to its log. var recorder = handacts.Recorder{Ask: stdio.Ask, LeaseAway: func() bool { e, err := stdio.State(LeaseRead).Get(buscompose.ControllerLeaseKey) var raw []byte diff --git a/modules/mesh-config/cmd/mesh-config/takeover.go b/modules/mesh-config/cmd/mesh-config/takeover.go index dad88461..39a6843a 100644 --- a/modules/mesh-config/cmd/mesh-config/takeover.go +++ b/modules/mesh-config/cmd/mesh-config/takeover.go @@ -350,6 +350,20 @@ func (c *Config) compare() (*Comparison, error) { c.mirrorDeclarations(&mirror) var diffs []Difference + // A placed module no catalog manifest names (mesh-wireguard, which the mesh ships computed) whose mirror key holds + // no config key declares nothing on both sides: nothing resolves from it, so nothing differs (issue 1953, L5d). + declaresNothing := map[string]bool{} + for module, d := range mirror.Declared { + if len(d.Config) == 0 { + // Only an indexer that was read and holds no modules.: a failed read says nothing, and the row stays. + if key, kerr := delivery.ModulesKey(module); kerr == nil { + if e, err := c.Store.Get(ModulesRead, key); err == nil && e == nil { + declaresNothing[module] = true + } + } + } + } + // Each placed module's declarations: `declared` against the mirror's key for it. for _, module := range modulesOf(own.Assignments) { var ownRaw []byte @@ -364,6 +378,7 @@ func (c *Config) compare() (*Comparison, error) { mirrorRaw, _ = json.Marshal(normal(d.Config)) } switch { + case ownRaw == nil && declaresNothing[module]: case ownRaw == nil && mirrorRaw == nil: diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"}) case ownRaw == nil: @@ -412,9 +427,15 @@ func (c *Config) compare() (*Comparison, error) { if cerr != nil { diffs = append(diffs, Difference{What: DiffPull, Why: "the indexer's config candidates cannot be read: " + cerr.Error()}) } + settled := map[string]string{} // a pull request the forge says is no longer open → how it ended for _, k := range unionKeys(cands, own.Pulls) { a, aok := cands[k] b, bok := own.Pulls[k] + isSettled, forgeNote := c.pullSettled(firstNonEmpty(a.Pull, b.Pull), settled) + if isSettled { + continue // a merged or closed pull request is no candidate to agree on (the operator, 2026-11-01) + } + before := len(diffs) switch { case !aok: diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"}) @@ -426,6 +447,9 @@ func (c *Config) compare() (*Comparison, error) { case declaresDigest(a.Declares) != declaresDigest(b.Declares): diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"}) } + if forgeNote != "" && len(diffs) > before { + diffs[len(diffs)-1].Why += "; " + forgeNote // the row stays because the forge was not heard: say so in the row + } } // Every placed module's value on every node, resolved both ways. @@ -456,6 +480,9 @@ func (c *Config) compare() (*Comparison, error) { diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why}) } for _, u := range own.Unresolved { + if declaresNothing[u.Module] { + continue + } diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why}) } sort.SliceStable(diffs, func(i, j int) bool { @@ -474,6 +501,53 @@ func (c *Config) compare() (*Comparison, error) { return cmp, nil } +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +// pullSettled is whether the forge says a pull request merged or closed. Without a forge, or when it does not say, the +// pull request counts as open, so its row stays (failure is loud). +func (c *Config) pullSettled(pull string, memo map[string]string) (bool, string) { + if c.Forge == nil || pull == "" { + return false, "" + } + if v, ok := memo[pull]; ok { + return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v) + } + owner, repo, n, err := parsePull(pull) + if err != nil { + return false, "" + } + open, merged, _, err := c.Forge.Pull(owner, repo, n) + switch { + case err != nil: + c.logf("[mesh-config] compare: the forge does not say whether %s is open (%v); its row stays", pull, err) + memo[pull] = forgeSilent + err.Error() + case open: + memo[pull] = "" + case merged: + memo[pull] = "merged" + default: + memo[pull] = "closed" + } + v := memo[pull] + return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v) +} + +// forgeSilent marks a memo entry for a pull request the forge could not be asked about; the rest of the entry is why. +const forgeSilent = "forge-silent: " + +// forgeNoteOf is the words a row carries when the forge was not heard on its pull request, or "". +func forgeNoteOf(v string) string { + if why, ok := strings.CutPrefix(v, forgeSilent); ok { + return "the forge does not say whether the pull request is open (" + why + ")" + } + return "" +} + // normal is declarations in one encoding: a null default as none. func normal(d map[string]Declaration) map[string]Declaration { out := map[string]Declaration{} diff --git a/modules/mesh-config/cmd/mesh-config/takeover_test.go b/modules/mesh-config/cmd/mesh-config/takeover_test.go index c66b21f8..1203c580 100644 --- a/modules/mesh-config/cmd/mesh-config/takeover_test.go +++ b/modules/mesh-config/cmd/mesh-config/takeover_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "slices" "strings" "testing" @@ -88,9 +89,24 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) { {"a pull request only the indexer holds", func(t *testing.T, m *memStore) { candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) }, []string{"pull:novox/mesh-catalog#9"}}, + {"a candidate of a merged pull request is no difference", func(t *testing.T, m *memStore) { + candidate(t, m, "novox/mesh-catalog#11", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) + candidate(t, m, "novox/mesh-catalog#10", "h2", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) + }, []string{"pull:novox/mesh-catalog#10"}}, + {"a forge that cannot be asked keeps the row and says why in it", func(t *testing.T, m *memStore) { + candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) + }, []string{"pull:novox/mesh-catalog#12"}}, + {"a module no manifest names and the mirror declares nothing of", func(t *testing.T, m *memStore) { + m.assign(t, "g14", "mesh-wireguard") + m.mirror(t, MirrorDeclaredRead, "mesh-wireguard", `{"module":"mesh-wireguard","settings":{}}`) + }, nil}, } for _, tc := range cases { c, m, r := anAgreeingMesh(t) + c.Forge = &standForge{state: map[string][2]bool{"novox/mesh-catalog#11": {false, true}}} + if strings.Contains(tc.name, "forge that cannot be asked") { + c.Forge = &standForge{err: errors.New("forge down")} + } tc.change(t, m) cmp, err := c.Compare() if err != nil { @@ -103,6 +119,9 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) { if d.Why == "" { t.Errorf("%s: a difference without its reason: %+v", tc.name, d) } + if strings.Contains(tc.name, "forge that cannot be asked") && !strings.Contains(d.Why, "forge down") { + t.Errorf("%s: the row does not say the forge was not heard: %q", tc.name, d.Why) + } } a, err := c.TakeOver(Call{Why: "compare is empty", Caller: "person.jochen"}) if len(tc.want) > 0 { diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod index a7d21047..6b4fe4ae 100644 --- a/modules/mesh-config/go.mod +++ b/modules/mesh-config/go.mod @@ -2,7 +2,7 @@ module mesh-config go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 require ( github.com/klauspost/compress v1.20.0 // indirect diff --git a/modules/mesh-config/go.sum b/modules/mesh-config/go.sum index 3922e76e..c89159f5 100644 --- a/modules/mesh-config/go.sum +++ b/modules/mesh-config/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/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= diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json index 9554db29..ffe59e18 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -28,8 +28,6 @@ "invokes": [ "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.warranted", - "seat:mesh-controller.hand-act", - "seat:mesh-controller.warranted", "gitea.gitea_get_pull_request" ], "reads": [ diff --git a/modules/mesh-healers/cmd/mesh-healers/healers_test.go b/modules/mesh-healers/cmd/mesh-healers/healers_test.go index 9b6f9dce..ad2de049 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers_test.go @@ -442,8 +442,8 @@ func TestTheVerbsAndReads(t *testing.T) { if a["acting"] != true || len(a["acts"].([]Attempt)) != 1 || len(a["healers"].([]map[string]any)) != 3 { t.Errorf("healers answered %v", a) } - // drill records into mesh-hand-acts as a drill; while that seat has no holder, into the controller's drill until - // the lease is handed away (ADR 0565 §11). + // drill records into mesh-hand-acts as a drill, and never into the controller's log, whoever holds the lease + // (ADR 0565 §11, issue 1955). drills := []struct { name string seatGone bool @@ -452,8 +452,9 @@ func TestTheVerbsAndReads(t *testing.T) { log, err string }{ {"into the seat", false, true, map[string]any{"what": "stopped records", "why": "does it raise"}, handacts.LogSeat, ""}, - {"no holder: the controller's", true, false, map[string]any{"what": "stopped records", "why": "x"}, handacts.LogController, ""}, - {"no holder, lease away", true, true, map[string]any{"what": "stopped records", "why": "x"}, "", "handed its lease away"}, + {"into the seat under a controller's lease", false, false, map[string]any{"what": "stopped records", "why": "x"}, handacts.LogSeat, ""}, + {"no holder: refused, the controller's not asked", true, false, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"}, + {"no holder, lease away", true, true, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"}, {"says nothing", false, true, map[string]any{}, "", "says what"}, } for _, d := range drills { @@ -489,6 +490,11 @@ func TestTheVerbsAndReads(t *testing.T) { case d.log == handacts.LogSeat && !strings.Contains(asked[0], `"verb":"hand-act drill"`): t.Errorf("drill %s asked %v", d.name, asked) } + for _, k := range asked { + if strings.Contains(k, "mesh-controller") { + t.Errorf("drill %s asked the controller: %v", d.name, asked) + } + } } if _, err := w.h.read(WouldState, ""); err != nil { t.Error(err) diff --git a/modules/mesh-healers/cmd/mesh-healers/manifest_test.go b/modules/mesh-healers/cmd/mesh-healers/manifest_test.go index af3745b9..bbbe8d49 100644 --- a/modules/mesh-healers/cmd/mesh-healers/manifest_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/manifest_test.go @@ -49,7 +49,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { }{ {"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil}, {"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}}, - {"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-controller.drill"}}, + {"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act"}}, {"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}}, } { if !slices.Equal(c.got, c.want) { diff --git a/modules/mesh-healers/cmd/mesh-healers/tools.go b/modules/mesh-healers/cmd/mesh-healers/tools.go index d3452b95..c26a30f9 100644 --- a/modules/mesh-healers/cmd/mesh-healers/tools.go +++ b/modules/mesh-healers/cmd/mesh-healers/tools.go @@ -57,8 +57,7 @@ func Tools(h *Healers) []stdio.Tool { }}, {Name: DrillTool, Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it " + - "tests, in the hand-act log of the seat mesh-hand-acts (the controller's drill while that seat has no " + - "holder and the controller holds its lease). A drill is never a repair: S15 never counts it.", + "tests, in the hand-act log of the seat mesh-hand-acts. A drill is never a repair: S15 never counts it.", Input: obj(map[string]any{"what": map[string]any{"type": "string", "description": "what was done on purpose"}, "why": map[string]any{"type": "string", "description": "what the drill tests"}, "condition": map[string]any{"type": "string", "description": "the condition it is meant to raise, if any"}}), @@ -85,8 +84,8 @@ func Tools(h *Healers) []stdio.Tool { } // Drill records a drill (ADR 0565 §11, which ends ADR 0564 Limits 1's refusal): through the seat mesh-hand-acts as -// the act `hand-act drill`, and while that seat has no holder that answers and the controller still holds its lease, -// through the controller's own `drill`, which records it as a drill too. It answers which log took it. +// the act `hand-act drill`. The controller's own `drill` is not asked (issue 1955): a seat with no holder that answers +// refuses the drill, said whole, and nothing is recorded in a second log. func (h *Healers) Drill(what, why, condition string) (any, error) { if what == "" || why == "" { return nil, errors.New("a drill says what was done on purpose and what it tests: nothing was recorded") @@ -95,27 +94,14 @@ func (h *Healers) Drill(what, why, condition string) (any, error) { return nil, errors.New("the hand-act log cannot be reached from here: nothing was recorded") } body := map[string]any{"what": what, "why": why, "verb": handacts.VerbDrill} - controller := map[string]any{"what": what, "why": why} if condition != "" { - body["condition"], controller["condition"] = condition, condition + body["condition"] = condition } answer, err := h.Ask("seat:"+handacts.Seat+".hand-act", body) - if err == nil { - return map[string]any{"log": handacts.LogSeat, "answer": answer}, nil + if err != nil { + return nil, fmt.Errorf("%s.hand-act refused the drill, so nothing was recorded: %w", handacts.Seat, err) } - if !handacts.NoResponder(err) { - return nil, fmt.Errorf("%s.hand-act refused the drill: %w", handacts.Seat, err) - } - if acting, _ := h.Acting(); acting { - return nil, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing was "+ - "recorded (%w)", handacts.Seat, err) - } - answer, cerr := h.Ask("seat:mesh-controller.drill", controller) - if cerr != nil { - return nil, fmt.Errorf("%s has no holder that answers (%v), and the controller's drill refused too: %w", handacts.Seat, - err, cerr) - } - return map[string]any{"log": handacts.LogController, "answer": answer}, nil + return map[string]any{"log": handacts.LogSeat, "answer": answer}, nil } // Heal is the verb heal: one condition judged now by its healer. diff --git a/modules/mesh-healers/go.mod b/modules/mesh-healers/go.mod index 6bec8ef0..73e2b532 100644 --- a/modules/mesh-healers/go.mod +++ b/modules/mesh-healers/go.mod @@ -3,7 +3,7 @@ module mesh-healers go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-healers/go.sum b/modules/mesh-healers/go.sum index 5fc11b2f..37e06a87 100644 --- a/modules/mesh-healers/go.sum +++ b/modules/mesh-healers/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/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= diff --git a/modules/mesh-healers/module.json b/modules/mesh-healers/module.json index 634dd388..6f2f0688 100644 --- a/modules/mesh-healers/module.json +++ b/modules/mesh-healers/module.json @@ -20,8 +20,7 @@ ], "invokes": [ "mesh-grants.grants_assert", - "seat:mesh-hand-acts.hand-act", - "seat:mesh-controller.drill" + "seat:mesh-hand-acts.hand-act" ], "reads": [ "mesh-controller.lease", diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index 1242c101..2a7ee6bd 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -29,6 +29,7 @@ import ( "sync" "time" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose/frombus" @@ -1044,12 +1045,52 @@ func orSaid(words ...string) string { return "" } -// FromBus is the holder's composer over the bus: frombus's plans of a node, at the builds keptAt names, read through -// this module's runtime with its own `reads` (frombus.StatesReads). A plan names no value, so no own secret and no -// credential is read (compose.Planning). -func FromBus() Composer { - r := fromruntime.Reader() +// FromWatched is the holder's composer over one watched copy of its reads (novox/hq ADR 0580 clause 2): frombus's plans +// of a node, at the builds keptAt names, read from the copy (placement, haves, modules, resolved values), a build by key +// and a manifest once per digest; no state is read key by key per composition. A plan names no value, so no own secret +// and no credential is read (compose.Planning). +func FromWatched(ws *stdio.WatchedStates) Composer { + r := fromruntime.WatchedReader(ws) return func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, map[string]string, error) { return frombus.PlansHeld(ctx, r, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) } } + +// WatchedReads are the states the holder reads whole, each from the watched copy: what the composer reads and the +// wants (ADR 0580 clause 2). Its own states and the providers' entries stay read from the bus, after its own writes. +var WatchedReads = []string{PlacementRead, WantsRead, ModulesRead, HaveRead, ResolvedRead} + +// Watching is a Store whose WatchedReads come from the copy; every other state, and every write, from s. +func Watching(s Store, ws *stdio.WatchedStates) Store { return watchingStore{Store: s, ws: ws} } + +type watchingStore struct { + Store + ws *stdio.WatchedStates +} + +func (w watchingStore) Get(state, key string) (*Entry, error) { + if !slices.Contains(WatchedReads, state) { + return w.Store.Get(state, key) + } + v, found, err := w.ws.Get(state, key) + if err != nil || !found { + return nil, err + } + return &Entry{Key: key, Value: v}, nil +} + +func (w watchingStore) Keys(state string) ([]string, error) { + if !slices.Contains(WatchedReads, state) { + return w.Store.Keys(state) + } + return w.ws.Keys(state) +} + +// NodesOfNodeKey is the node a key that starts with it names: a resolved value's `..`, a have's +// `.` or `.node`. A change of either recomposes that node (ADR 0580 clause 2). +func NodesOfNodeKey(key string) []string { + if n, _, ok := strings.Cut(key, "."); ok && n != "" { + return []string{n} + } + return nil +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go index 4b650470..708f4571 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go @@ -3,8 +3,9 @@ package main // The holder against a real bus (novox/hq ADR 0570 clauses 1-3, 5; issue 1954 H5): its states and every input it reads -// are a real nats-server's key-value buckets, its plans composed by mesh-sdk's composition from bus state over the -// in-memory mesh frombustest gives (restic on n1, requiring postgres-database from postgres there). A placement and a +// are a real nats-server's key-value buckets, its plans composed by mesh-sdk's composition from the watched copy of those +// buckets (ADR 0580 clause 2) filled with the mesh frombustest gives (restic on n1, requiring postgres-database from +// postgres there); no input is read key by key per composition. A placement and a // want compose the key; the providers' outcomes (the vault's entry for the credential, postgres's own have) turn it // provided; restic leaving the node makes it retiring and raises the ask. @@ -19,10 +20,12 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/compose/frombus/frombustest" + "git.novox.be/novox/mesh-sdk/go/compose/fromruntime" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" "git.novox.be/novox/mesh-sdk/go/testbus" @@ -166,10 +169,101 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { if err := s.Put(WantsRead, "want."+node+".restic", map[string]any{"v": 1, "fp": fp, "self": true}); err != nil { t.Fatal(err) } + // The rest of the mesh on the bus as its writers keep it: n1's placement, its record in its have, the builds. + putPlacement := func() { + raw, err := delivery.EncodePlacement(mesh.Placement[node]) + if err != nil { + t.Fatal(err) + } + if err := s.Put(PlacementRead, "placement."+node, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } + putPlacement() + for _, n := range []string{node, "n2"} { // n2, where the vault runs, has its record too + rec := mesh.Record[node] + rec.Node = n + recRaw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + if err != nil { + t.Fatal(err) + } + if err := s.Put(HaveRead, mustKey(delivery.HaveNodeKey(n)), json.RawMessage(recRaw)); err != nil { + t.Fatal(err) + } + } + for at, b := range mesh.Builds { + module, fp, _ := strings.Cut(at, " ") + raw, err := delivery.EncodeBuilderBuild(*b) + if err != nil { + t.Fatal(err) + } + if err := s.Put(BuildsRead, mustKey(delivery.BuilderBuildKey(module, fp)), json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } + // The watched copy over the real buckets: each state by one watch, a build and a manifest by key. + ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { + kv, err := s.kv(state) + if err != nil { + return err + } + w, err := kv.WatchAll(ctx) + if err != nil { + return err + } + hand := func(e jetstream.KeyValueEntry) { + op := stdio.OpPut + if e.Operation() != jetstream.KeyValuePut { + op = stdio.OpDelete + } + _ = handle(stdio.StateChange{State: state, Key: e.Key(), Op: op, Value: e.Value(), Revision: e.Revision()}) + } + for e := range w.Updates() { + if e == nil { // the current values are all handed over + break + } + hand(e) + } + go func() { + for e := range w.Updates() { + hand(e) + } + }() + return nil + }, func(state, key string) ([]byte, bool, error) { + e, err := s.Get(state, key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }) + // settled waits until the copy holds a key as the bus does: in the mesh the holder is woken by that same watch. + settled := func(state, key string) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(10 * time.Millisecond) { + e, _ := s.Get(state, key) + v, found, _ := ws.Get(state, key) + if (e == nil && !found) || (e != nil && found && string(e.Value) == string(v)) { + return + } + if time.Now().After(deadline) { + t.Fatalf("the copy of %s never took %s", state, key) + } + } + } + fetch := func(_ context.Context, url string) ([]byte, error) { + for digest, body := range mesh.Archives { + if strings.HasSuffix(url, digest) { + return body, nil + } + } + return nil, errors.New("no archive at " + url) + } + reader := frombus.StatesReader(fromruntime.Watched{WatchedStates: ws}, fetch) a := &asked{} - h := &Holder{Store: s, Asks: a, Now: time.Now, Logf: t.Logf, + h := &Holder{Store: Watching(s, ws), Asks: a, Now: time.Now, Logf: t.Logf, Compose: func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, map[string]string, error) { - return frombus.PlansHeld(ctx, mesh, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) + return frombus.PlansHeld(ctx, reader, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) }} state := func(key string) provisions.Provision { e, _ := s.Get(ProvisionsState, key) @@ -199,6 +293,7 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { "health": "ok"}); err != nil { t.Fatal(err) } + settled(HaveRead, node+".postgres") for i := 0; i < 2; i++ { // the credential judged first, then what depends on it if _, err := h.ComposeNode(ctx, node); err != nil { t.Fatal(err) @@ -217,9 +312,12 @@ func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { } p.Modules = kept mesh.Placement[node] = p + putPlacement() if err := s.Delete(WantsRead, "want."+node+".restic"); err != nil { t.Fatal(err) } + settled(PlacementRead, "placement."+node) + settled(WantsRead, "want."+node+".restic") if _, err := h.ComposeNode(ctx, node); err != nil { t.Fatal(err) } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index 35cb4626..96c53213 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -391,15 +392,60 @@ func TestAModuleHeldWithoutAWantIsComposedAtTheBuildItRuns(t *testing.T) { } return nil, nil, nil } - c := m.compose(t, "novox") - if keptAt["nats"] != fp || len(keptAt) != 1 { - t.Fatalf("composed at %v, want nats at the build it runs and nothing of another node", keptAt) + // ADR 0580 clause 2: the reads come from one watched copy, each state watched once and none read key by key from the + // bus, and a change the copy takes wakes the node it touches (a resolved value its node). + bus := &readsOf{Store: m.s, read: map[string]int{}} + handles, watches := map[string]func(stdio.StateChange) error{}, map[string]int{} + ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { + watches[state]++ + handles[state] = handle + keys, _ := m.s.Keys(state) + for _, k := range keys { + e, _ := m.s.Get(state, k) + _ = handle(stdio.StateChange{Key: k, Op: stdio.OpPut, Value: e.Value, Current: true}) + } + return nil + }, func(string, string) ([]byte, bool, error) { return nil, false, nil }) + m.h.Store = Watching(bus, ws) + wk := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)} + for _, r := range []string{ResolvedRead, HaveRead} { + ws.Follow(r, func(c stdio.StateChange) error { wk.add(NodesOfNodeKey(c.Key)...); return nil }) } - if !slices.Contains(c.Plans, "novox.nats") { - t.Fatalf("no plan written for a module held without a want: %+v", c) + for i := range 2 { + c := m.compose(t, "novox") + if keptAt["nats"] != fp || len(keptAt) != 1 { + t.Fatalf("composed at %v, want nats at the build it runs and nothing of another node", keptAt) + } + if i == 0 && !slices.Contains(c.Plans, "novox.nats") { + t.Fatalf("no plan written for a module held without a want: %+v", c) + } + } + for _, r := range WatchedReads { + if bus.read[r] != 0 || watches[r] > 1 { + t.Errorf("%s was read %d times from the bus and watched %d times", r, bus.read[r], watches[r]) + } + } + // The current haves queued their nodes when watched; a later change of a resolved value or a have queues its node. + wk.pending = map[string]bool{} + _ = handles[ResolvedRead](stdio.StateChange{Key: "g14.dunst.font-size", Op: stdio.OpPut, Value: []byte("13")}) + _ = handles[HaveRead](stdio.StateChange{Key: "ace.restic", Op: stdio.OpDelete}) + if !wk.pending["g14"] || !wk.pending["ace"] || len(wk.pending) != 2 { + t.Errorf("a resolved value's and a have's change queued %v, want g14 and ace", wk.pending) } } +// readsOf counts the reads that reach the bus, by state. +type readsOf struct { + Store + read map[string]int +} + +func (r *readsOf) Get(state, key string) (*Entry, error) { + r.read[state]++ + return r.Store.Get(state, key) +} +func (r *readsOf) Keys(state string) ([]string, error) { r.read[state]++; return r.Store.Keys(state) } + // The holder holds no ticker: it composes on change events alone (ADR 0570 clause 2; issue 1954 H1). func TestTheHolderHoldsNoTimer(t *testing.T) { files, _ := filepath.Glob("*.go") diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go index fda63ee2..dcc74524 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go @@ -105,13 +105,17 @@ func (w *work) run(h *Holder) { func main() { s := runtimeStore{} - h := &Holder{Store: s, Compose: FromBus(), Asks: runtimeAsks{}, Now: time.Now, Logf: logf} + ws := stdio.NewWatchedStates() // one copy of what it reads, each state by its watch (ADR 0580 clause 2) + h := &Holder{Store: Watching(s, ws), Compose: FromWatched(ws), Asks: runtimeAsks{}, Now: time.Now, Logf: logf} w := &work{pending: map[string]bool{}, wake: make(chan struct{}, 1)} go w.run(h) go starting(h) - go stdio.State(PlacementRead).Follow("", func(c stdio.StateChange) error { w.add(NodesOfPlacement(c.Key)...); return nil }) - go stdio.State(WantsRead).Follow("", func(c stdio.StateChange) error { w.add(NodesOfWant(c.Key)...); return nil }) - go stdio.State(ModulesRead).Follow("", func(c stdio.StateChange) error { + // Each watch's handler runs after the copy took its change, so a composition it wakes reads the copy that change made. + go ws.Follow(PlacementRead, func(c stdio.StateChange) error { w.add(NodesOfPlacement(c.Key)...); return nil }) + go ws.Follow(WantsRead, func(c stdio.StateChange) error { w.add(NodesOfWant(c.Key)...); return nil }) + go ws.Follow(ResolvedRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil }) + go ws.Follow(HaveRead, func(c stdio.StateChange) error { w.add(NodesOfNodeKey(c.Key)...); return nil }) + go ws.Follow(ModulesRead, func(c stdio.StateChange) error { if module, err := delivery.ParseModulesKey(c.Key); err == nil { w.add(h.NodesOfModule(module)...) } diff --git a/modules/mesh-provisioner/go.mod b/modules/mesh-provisioner/go.mod index 0e38753f..e9f8e457 100644 --- a/modules/mesh-provisioner/go.mod +++ b/modules/mesh-provisioner/go.mod @@ -3,7 +3,7 @@ module mesh-provisioner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-provisioner/go.sum b/modules/mesh-provisioner/go.sum index 5fc11b2f..37e06a87 100644 --- a/modules/mesh-provisioner/go.sum +++ b/modules/mesh-provisioner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/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= diff --git a/modules/pacman/README.md b/modules/pacman/README.md index fd400323..d5df40c1 100644 --- a/modules/pacman/README.md +++ b/modules/pacman/README.md @@ -30,7 +30,7 @@ that seat (ADR 0207). `package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and `.not-current` says the size and since when, which the health says too. `pacman_packages`, `pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no - module declares from the controller's `declared-packages`, without asking a machine. + module declares from the node-engine's `packages`, else the controller's `declared-packages` until its lease is written, without asking a machine. - **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the state `runs` at `.`, written at each step and emitted as it: the package record taken and read back (`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own diff --git a/modules/pacman/cmd/pacman-tools/keeper_integration_test.go b/modules/pacman/cmd/pacman-tools/keeper_integration_test.go index 4cb3c2f2..d994641c 100644 --- a/modules/pacman/cmd/pacman-tools/keeper_integration_test.go +++ b/modules/pacman/cmd/pacman-tools/keeper_integration_test.go @@ -102,7 +102,7 @@ func TestTheKeeperPutsTheNodesPackagesOnARealBusAndAnotherInstanceReadsThem(t *t } buckets[name] = kv } - declared, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-controller_declared-packages"}) + declared, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "node-engine_packages"}) if err != nil { t.Fatal(err) } diff --git a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go index be4304c0..917f8096 100644 --- a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go +++ b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go @@ -133,7 +133,7 @@ func TestTheKeeperDeclaresTheModulesStatesAndEvents(t *testing.T) { } // The verbs read which packages each node's modules declare; the process reads no other module's state // (ADR 0507 §5, ADR 0436 §1). - if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"mesh-controller.declared-packages"}) { + if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"node-engine.packages", "mesh-controller.declared-packages"}) { t.Errorf("reads: %v", got) } } diff --git a/modules/pacman/cmd/pacman-tools/view.go b/modules/pacman/cmd/pacman-tools/view.go index 1d7918b5..6a1fea92 100644 --- a/modules/pacman/cmd/pacman-tools/view.go +++ b/modules/pacman/cmd/pacman-tools/view.go @@ -1,7 +1,7 @@ package main // The package view (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): verbs that read the module's states and the -// controller's `declared-packages`, so any instance answers for every node and no machine is asked. A node that is +// node-engine's `packages`, so any instance answers for every node and no machine is asked. A node that is // off keeps its last values, shown with the time they describe; a node whose packages are over the bus's cap is // marked not current. A foreign package is listed apart in every answer. @@ -11,21 +11,27 @@ import ( "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) -// DeclaredPackagesState is the controller's state of the packages each node's modules declare (ADR 0507 §5), -// read through the manifest's `reads`. -const DeclaredPackagesState = "mesh-controller.declared-packages" +// DeclaredPackagesState is the node-engine's state of the packages its node's modules declare, one key per node, +// written by that node's engine alone (ADR 0565 clause 6, which moved ADR 0507 §5's mirror off the controller), read +// through the manifest's `reads`. +const DeclaredPackagesState = SourceEngine -// declaredValue is a node's value of `declared-packages`, as the controller writes it. -type declaredValue struct { - Node string `json:"node"` - Packages []struct { - Package string `json:"package"` - Modules []string `json:"modules"` - Absent bool `json:"absent"` - } `json:"packages"` -} +// ControllerDeclaredState is the controller's mirror of the same packages (ADR 0507 §5), the per-node fallback for a node +// whose engine has written no key yet, until the controller's lease is written away (ADR 0565 clause 6). +const ControllerDeclaredState = "mesh-controller.declared-packages" + +// The sources a node's declaration is read from, said in the answer. +const ( + SourceEngine = "node-engine.packages" + SourceController = "mesh-controller.declared-packages" +) + +// declaredValue is a node's key of `packages`, in mesh-sdk's layout (the controller's mirror unchanged). +type declaredValue = delivery.DeclaredPackages // nodeInstalled is one node's key of `installed` with its not-current mark. type nodeInstalled struct { @@ -111,28 +117,53 @@ func (m *Machine) installedOn(node string) ([]nodeInstalled, error) { return out, nil } -// declaredOn is what each node's modules declare present, by node and package, with the declaring modules; a -// node the controller holds nothing for is missing. -func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, error) { - r := m.States(DeclaredPackagesState) +// declaredOn is what each node's modules declare present, by node and package, with the declaring modules, and the source +// that answered for each node: its engine's key, else the controller's mirror; a node neither holds is missing. When the +// engine's state cannot be read at all (its bucket is not on the bus yet, or the read is refused) the mirror answers for +// every node and the engine's error is returned as a word to say beside the sources; it is an error only when the mirror +// cannot be read either (ADR 0565 clause 6). +func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, string, error) { out := map[string]map[string][]string{} - for _, n := range nodes { - var v declaredValue - found, err := readValue(r, n, &v) - if err != nil { - return nil, err - } - if !found { - continue - } - out[n] = map[string][]string{} - for _, p := range v.Packages { - if !p.Absent { - out[n][p.Package] = p.Modules + from := map[string]string{} + engineErr := "" + for _, source := range []string{SourceEngine, SourceController} { + r := m.States(source) + read := map[string]map[string][]string{} + readFrom := map[string]string{} + var failed error + for _, n := range nodes { + if from[n] != "" { + continue + } + var v declaredValue + found, err := readValue(r, n, &v) + if err != nil { + failed = err + break + } + if !found { + continue + } + readFrom[n] = source + read[n] = map[string][]string{} + for _, p := range v.Packages { + if !p.Absent { + read[n][p.Package] = p.Modules + } } } + if failed != nil { + if source == SourceEngine { + engineErr = SourceEngine + " cannot be read: " + failed.Error() + continue + } + return nil, nil, "", failed + } + for n, d := range read { + out[n], from[n] = d, readFrom[n] + } } - return out, nil + return out, from, engineErr, nil } // NodeView is one node in the view. @@ -146,9 +177,11 @@ type NodeView struct { Current bool `json:"current"` NotCurrent *NotCurrent `json:"not_current,omitempty"` // Undeclared is the packages installed explicitly from a repository that no module assigned to the node - // declares; null when the controller holds no declaration of the node. + // declares; null when neither the node-engine nor the controller's mirror holds a declaration of the node. Undeclared []PackageVersion `json:"undeclared"` Foreign []PackageVersion `json:"foreign"` + // DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one. + DeclaredFrom string `json:"declared_from,omitempty"` } // Spread is one package across the nodes: its version on each node that has it. @@ -167,6 +200,8 @@ type PackageOnNode struct { Foreign bool `json:"foreign,omitempty"` DeclaredBy []string `json:"declared_by"` Current bool `json:"current"` + // DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one. + DeclaredFrom string `json:"declared_from,omitempty"` } // Packages is pacman_packages: one package on every node, or the whole view: per node its counts, its explicit @@ -181,14 +216,14 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) { for _, ni := range all { nodes = append(nodes, ni.Node) } - declared, err := m.declaredOn(nodes) + declared, sources, engineErr, err := m.declaredOn(nodes) if err != nil { return nil, err } if name != "" { on := []PackageOnNode{} for _, ni := range all { - p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name]} + p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name], DeclaredFrom: sources[ni.Node]} if p.DeclaredBy == nil { p.DeclaredBy = []string{} } @@ -203,13 +238,17 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) { } on = append(on, p) } - return map[string]any{"package": name, "nodes": on}, nil + answer := map[string]any{"package": name, "nodes": on} + if engineErr != "" { + answer["declared_engine_error"] = engineErr + } + return answer, nil } views := []NodeView{} versions := map[string]map[string]string{} for _, ni := range all { v := NodeView{Node: ni.Node, At: ni.Value.At, Packages: len(ni.Value.Packages), Current: ni.NotCurrent == nil, - NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}} + NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}, DeclaredFrom: sources[ni.Node]} d, known := declared[ni.Node] if known { v.Undeclared = []PackageVersion{} @@ -252,6 +291,9 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) { } } out := map[string]any{"nodes": views} + if engineErr != "" { + out["declared_engine_error"] = engineErr + } for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} { b := bound(key, list, limit) out[key] = b[key] diff --git a/modules/pacman/cmd/pacman-tools/view_test.go b/modules/pacman/cmd/pacman-tools/view_test.go index 8a823c55..6e9dbd51 100644 --- a/modules/pacman/cmd/pacman-tools/view_test.go +++ b/modules/pacman/cmd/pacman-tools/view_test.go @@ -1,19 +1,22 @@ package main // The package view over the bus (novox/hq ADR 0507 §6, §7; acceptance 1840/AC6): two nodes' `installed` keys and -// their `declared-packages`, answered without asking any machine. +// the node-engine's `packages`, answered without asking any machine. import ( "context" "encoding/json" + "errors" "fmt" "strings" "testing" "time" + + stdio "git.novox.be/novox/mesh-sdk/go" ) // viewStates is two nodes' packages on the bus: node-a current and declared, node-b over the cap since a while, -// with a foreign package, and no declaration the controller holds. +// with a foreign package, and no declaration the node-engine published. func viewStates(t *testing.T) (*fakeStates, *Machine) { t.Helper() states := &fakeStates{} @@ -40,7 +43,7 @@ func viewStates(t *testing.T) (*fakeStates, *Machine) { } func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { - _, m := viewStates(t) + states, m := viewStates(t) r, err := m.Packages("zsh", 500) if err != nil { t.Fatal(err) @@ -50,6 +53,47 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { t.Fatalf("%+v", on) } a, b := on[0], on[1] + if a.DeclaredFrom != SourceEngine || b.DeclaredFrom != "" { + t.Errorf("sources: %q, %q", a.DeclaredFrom, b.DeclaredFrom) + } + // A node whose engine has written no key reads the controller's mirror, said so; a node with both reads its engine's. + var legacy, stale declaredValue + json.Unmarshal([]byte(`{"node":"node-b","packages":[{"package":"zsh","modules":["legacy"],"absent":false}]}`), &legacy) + json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["stale"],"absent":false}]}`), &stale) + mirror := states.store(ControllerDeclaredState) + mirror.Put("node-b", legacy) + mirror.Put("node-a", stale) + r2, _ := m.Packages("zsh", 500) + on2 := r2["nodes"].([]PackageOnNode) + if on2[0].DeclaredFrom != SourceEngine || strings.Join(on2[0].DeclaredBy, ",") != "login,shell" || + on2[1].DeclaredFrom != SourceController || strings.Join(on2[1].DeclaredBy, ",") != "legacy" { + t.Errorf("fallback: %+v", on2) + } + // The engine's state cannot be read (its bucket is not on the bus yet): the mirror answers for every node, and the + // answer says why beside declared_from; a mirror that cannot be read either is an error. + read := m.States + m.States = func(name string) StateReader { + if name == SourceEngine { + return failingReader{errors.New("is issued and its bucket is not on the bus yet")} + } + return read(name) + } + r3, err3 := m.Packages("zsh", 500) + on3, _ := r3["nodes"].([]PackageOnNode) + if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != SourceController || strings.Join(on3[0].DeclaredBy, ",") != "stale" || + on3[1].DeclaredFrom != SourceController || !strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") { + t.Errorf("engine unreadable: %+v %v", r3, err3) + } + m.States = func(name string) StateReader { + if name == SourceEngine || name == SourceController { + return failingReader{errors.New("refused")} + } + return read(name) + } + if _, err := m.Packages("zsh", 500); err == nil { + t.Error("neither state could be read and no error was said") + } + m.States = read if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" || strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current { t.Errorf("node-a: %+v", a) @@ -158,3 +202,10 @@ func TestANodeArgumentIsOneKeyToken(t *testing.T) { t.Errorf("%q %v", n, err) } } + +// failingReader is a state that cannot be read. +type failingReader struct{ err error } + +func (f failingReader) Get(string) (*stdio.StateEntry, error) { return nil, f.err } +func (f failingReader) Keys() ([]string, error) { return nil, f.err } +func (f failingReader) History(string) ([]stdio.StateRevision, error) { return nil, f.err } diff --git a/modules/pacman/go.mod b/modules/pacman/go.mod index 2c81e65d..179b9905 100644 --- a/modules/pacman/go.mod +++ b/modules/pacman/go.mod @@ -3,7 +3,7 @@ module pacman go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 github.com/nats-io/nats-server/v2 v2.11.17 ) diff --git a/modules/pacman/go.sum b/modules/pacman/go.sum index 5fc11b2f..37e06a87 100644 --- a/modules/pacman/go.sum +++ b/modules/pacman/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/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= diff --git a/modules/pacman/module.json b/modules/pacman/module.json index 97eaad3f..0aa330d4 100644 --- a/modules/pacman/module.json +++ b/modules/pacman/module.json @@ -12,6 +12,7 @@ } ], "reads": [ + "node-engine.packages", "mesh-controller.declared-packages" ], "uses": [