diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 00b5aa2..2ee61ae 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -509,6 +509,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // composes its own (issue 774). Applied: appliedStoreFor(opts.state), Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))), + Held: func() map[string]string { + if c := composerNow.Load(); c != nil { + return c.LastHeld() + } + return nil + }, Revisions: func(ctx context.Context, key string) ([]agent.Update, error) { got, err := session.KVHistory(ctx, composedBucket, key) if err != nil { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index b9ff6ff..6fc5f71 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -136,6 +136,9 @@ type Env struct { // Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want // names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1). Revisions func(ctx context.Context, key string) ([]Update, error) + // Held is what the node's composer held in its last pass, each module with why, as the verb composition shows it + // for the node's own (issue 774); nil says nothing. + Held func() map[string]string // Settled is told a module's apply or removal that succeeded, once what runs holds it (Everything), outside the // agent's lock: the declaration the node applies now names what that composition names, its filled files among // them. The node-engine's filling tells the provisioner's client then to fill again what it refused (mesh-host diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9887661..4efc91e 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -12,6 +12,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -1312,6 +1313,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( defer historyMu.Unlock() history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)}) } + b.env.Held = func() map[string]string { return map[string]string{"mesh-build-go": "its binding is unset"} } stop := b.run() defer stop() unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) { @@ -1493,6 +1495,16 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil { t.Error("a digest no revision has was answered") } + // Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account + // and the adoption among them, and what the composer's last pass held. + ownKey, ownRaw, ownDig := encodedAs(t, delivery.Composed{Node: node, Own: delivery.NodeOwn{Provisioner: "mesh-provisioner-client", + Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := delivery.ComposedNodeKey(node); return k }()) + keep(ownKey, ownRaw) + if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil || + view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" || + view.Own.Held["mesh-build-go"] == "" { + t.Errorf("the node's own composition reads %+v %+v (%v)", view, view.Own, err) + } // novox/hq ADR 0593 ยง6: the engine publishes each composition with its plan in its composition state; the verb reads // that revision by the digest its have and want name, its resources' (the plan left out of the digest). planned, _ := delivery.DecodeComposed(draw) diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 938c2aa..4035b8b 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -6,6 +6,7 @@ import ( "fmt" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/judge" ) @@ -32,6 +33,26 @@ type CompositionView struct { Judged JudgedView `json:"judged"` // Diff is every resource that differs from what runs, when asked. Diff []judge.ResourceChange `json:"diff,omitempty"` + // Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's + // own composition dropped the provisioner client's account, and nothing read could show it). + Own *OwnView `json:"own,omitempty"` +} + +// OwnView is the node's own fields of a composition (delivery.NodeOwn) beside what runs of them, and what the node's +// composer held in its last pass. +type OwnView struct { + // Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as + // what runs names it. Empty: the declaration names none, and every filled write is refused. + Provisioner string `json:"provisioner_account"` + RunsProvisioner string `json:"runs_provisioner_account,omitempty"` + // Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node. + Adoption *declaration.Adoption `json:"adoption,omitempty"` + // Identities, Groups and Libraries count what the revision names of each. + Identities int `json:"identities,omitempty"` + Groups int `json:"groups,omitempty"` + Libraries int `json:"libraries,omitempty"` + // Held is each module the node's composer held in its last pass, with why. + Held map[string]string `json:"held,omitempty"` } // WantView is a want as the verb shows it. @@ -171,6 +192,17 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo } view.Runs = rv } + if module == NodeModule { + o := read.C.Own + view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups), + Libraries: len(o.Libraries)} + if running != nil { + view.Own.RunsProvisioner = running.Own.Provisioner + } + if a.env.Held != nil { + view.Own.Held = a.env.Held() + } + } j := judge.JudgeMove(running, read.C) view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words} if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 45a07f6..6607fc6 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -218,6 +218,13 @@ type Pass struct { Earlier bool `json:"earlier,omitempty"` } +// LastHeld is what the last pass held, each module with why; nil before a pass or when it held none. +func (c *Composer) LastHeld() map[string]string { + c.mu.Lock() + defer c.mu.Unlock() + return maps.Clone(c.last.Held) +} + // askedWithin is how recent a pass answers an ask of the verb compose without a new one: the first wait after a seat // that did not answer (laterAgain's first). var askedWithin = 5 * time.Second