Files
mesh-host/internal/selfcompose/candidate.go
T
jschoubben f6bda12a32 The node whole publishes its compositions and answers a candidate (H6)
ADR 0593 §6, §7, §9: the integration test runs the node agent against
the real bus under the grant the grants part composes for a node: it
publishes web's composition in composed-<node> with the digest its have
names, and answers compose-candidate for the indexer's candidate,
changing nothing. A node that cannot compose now answers unready, so
the checker counts it as no answer by the verdict and never by words.
The golden grant reads the indexer's candidates and verdicts as it
reads the builder's builds.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:39:12 +01:00

297 lines
10 KiB
Go

package selfcompose
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"sort"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/compose/frombus"
)
// **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate.
// Given the key of the indexer's candidate (delivery.CandidatesBucket, `<group>.<heads hash>`), the node composes itself
// twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed
// values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's
// fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left
// out). It answers per module placed here composed (its digest, and whether the candidate changes it), refused by the
// candidate, or held without it too, and the verdict of its own composition. It applies nothing and writes nothing: it
// composes on a copy of the shadow's inputs, so neither the shadow's answers nor the node's own composer move.
// errNotReady is a shadow that has not read every input yet.
var errNotReady = errors.New("this node's shadow has not read every input yet")
// ComposeCandidate is the node's answer to ask, within delivery.ComposeAnswerBound (or ctx's deadline, if sooner).
func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandidateAsk) delivery.ComposeCandidateAnswer {
began := time.Now()
s.mu.Lock()
c := s.c
s.mu.Unlock()
node := c.env.Node
a := delivery.ComposeCandidateAnswer{Node: node, Candidate: ask.Candidate}
refuse := func(why string) delivery.ComposeCandidateAnswer {
a.Verdict, a.Refused, a.Modules, a.Own = delivery.ComposeCandidateRefused, why, nil, delivery.ModuleComposed{}
a.Took = time.Since(began).Milliseconds()
return a
}
// A node that cannot compose now (its shadow not ready, a read the bus did not answer, the bound passed) answers
// unready: the checker takes it as no answer (ADR 0593 §9) by the verdict, the words only saying why.
unready := func(why string) delivery.ComposeCandidateAnswer {
a = refuse(why)
a.Verdict = delivery.ComposeCandidateUnready
return a
}
if _, _, err := delivery.ParseCompositionKey(ask.Candidate); err != nil {
return refuse(err.Error())
}
ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound)
defer cancel()
if !s.ready.Load() {
return unready(errNotReady.Error())
}
base := c.scratch()
raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate)
if err != nil {
return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err))
}
if raw == nil {
return refuse("the indexer holds no candidate " + ask.Candidate)
}
cand, err := delivery.DecodeCandidate(raw)
if err != nil {
return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err))
}
laid := c.scratch()
touched, err := laid.layCandidate(ctx, cand)
if err != nil {
return unready(err.Error())
}
var running []delivery.Composed
if c.env.Running != nil {
running = c.env.Running()
}
baseComps, baseHeld, baseErr := base.composeShadow(ctx, running, nil, nil)
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) {
if !ask.Input {
return
}
in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope)
if err != nil {
return
}
// The request's input carries no catalogue (composer.Request.Input): the cases give the manifests.
in.Catalogue = nil
for i := range in.Nodes {
in.Nodes[i].Kept = nil
}
if body, err := json.Marshal(in); err == nil {
a.Input = body
}
})
if ctx.Err() != nil {
return unready(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound))
}
switch {
case laidErr != nil && BusFailed(laidErr):
return unready("a read on the bus did not answer: " + laidErr.Error())
case laidErr != nil && baseErr != nil:
// The node composes none of its own without the candidate either: the candidate changes nothing of that.
a.Verdict, a.Modules = delivery.ComposeCandidateComposed, []delivery.ModuleComposed{}
a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, Words: laidErr.Error()}
a.Took = time.Since(began).Milliseconds()
return a
case laidErr != nil:
return refuse(laidErr.Error())
}
byModule := func(comps map[string]delivery.Composed) map[string]delivery.Composed {
out := map[string]delivery.Composed{}
for _, comp := range comps {
out[comp.Module] = comp
}
return out
}
was, now := byModule(baseComps), byModule(laidComps)
one := func(module string) (delivery.ModuleComposed, bool) {
m := delivery.ModuleComposed{Module: module}
if module == "" {
m.Module = delivery.HaveNodeWord
}
if why, held := laidHeld[module]; held {
m.State, m.Words = delivery.ModuleRefusedState, why
if _, before := baseHeld[module]; before || baseErr != nil {
m.State = delivery.ModuleHeldState
}
return m, true
}
comp, ok := now[module]
if !ok {
return m, false
}
dig, err := delivery.ComposedDigestOf(comp)
if err != nil {
m.State, m.Words = delivery.ModuleRefusedState, err.Error()
return m, true
}
m.State, m.FP, m.Digest = delivery.ModuleComposedState, comp.FP, dig
if before, ok := was[module]; !ok {
m.Changed = true
} else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig {
m.Changed = true
}
return m, true
}
modules := map[string]bool{}
for m := range now {
modules[m] = m != ""
}
for m := range laidHeld {
modules[m] = m != "" && m != agent.NodeModule
}
names := make([]string, 0, len(modules))
for m, isModule := range modules {
if isModule {
names = append(names, m)
}
}
sort.Strings(names)
a.Modules = []delivery.ModuleComposed{}
refused := false
for _, m := range names {
if mc, ok := one(m); ok {
a.Modules = append(a.Modules, mc)
refused = refused || mc.State == delivery.ModuleRefusedState
}
}
if own, ok := one(""); ok {
a.Own = own
} else {
a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState,
Words: "the node composes no composition of its own"}
}
refused = refused || a.Own.State == delivery.ModuleRefusedState
a.Verdict = delivery.ComposeCandidateComposed
if refused {
a.Verdict = delivery.ComposeCandidateRefused
}
a.Took = time.Since(began).Milliseconds()
return a
}
// scratch is a composer over a copy of what c holds of its inputs, for composing in shadow without moving c.
func (c *Composer) scratch() *Composer {
out := New(c.env)
c.mu.Lock()
defer c.mu.Unlock()
out.placements = maps.Clone(c.placements)
out.records = maps.Clone(c.records)
out.inOwn = maps.Clone(c.inOwn)
out.modules = maps.Clone(c.modules)
out.values = maps.Clone(c.values)
out.manifests = maps.Clone(c.manifests)
out.archives = maps.Clone(c.archives)
out.builds = maps.Clone(c.builds)
out.sealedOwn = maps.Clone(c.sealedOwn)
out.ownHanded = maps.Clone(c.ownHanded)
out.certified = c.certified
out.placementKeys = c.placementKeys
return out
}
// layCandidate lays the candidate's modules over the indexer's: each at its fingerprint, with its manifest's digest and
// the indexer's verdict on it (read from its verdicts, none read taken as accepted); a module the candidate removes goes.
// It answers the modules laid.
func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (map[string]bool, error) {
touched := map[string]bool{}
for _, pull := range cand.Pulls {
for module, cm := range pull.Fps {
if cm.None2 == delivery.CandidateNoneNoModule {
c.mu.Lock()
delete(c.modules, module)
c.mu.Unlock()
continue
}
if cm.FP2 == "" {
continue // no fingerprint at the preview: composed as the trunk holds it
}
m := delivery.Module{FP2: cm.FP2, Manifest: cm.Manifest, Repository: pull.Repository, Commit: pull.Head.Head}
if cm.Manifest != "" {
key, err := delivery.VerdictsKey(cm.Manifest)
if err != nil {
return nil, err
}
raw, err := c.get(ctx, delivery.VerdictsBucket, key)
if err != nil {
return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err)
}
if raw != nil {
v, err := delivery.DecodeManifestVerdict(raw)
if err != nil {
return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err)
}
m.Verdict, m.Problems, m.Warnings = v.State, v.Problems, v.Warnings
}
}
c.mu.Lock()
c.modules[module] = m
c.mu.Unlock()
touched[module] = true
}
}
return touched, nil
}
// composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build
// it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when
// given, is handed the scope.
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) {
node := c.env.Node
c.mu.Lock()
placement, placed := c.placements[node]
modules := maps.Clone(c.modules)
inOwn := c.inOwnOf(placement)
c.mu.Unlock()
if !placed {
return nil, nil, errors.New("this node's key in the seat's placement state is gone: it composes nothing of its own")
}
fps := shadowBuilds(running, placement, modules, inOwn)
// A module the laid candidate changes is composed at the candidate's build, though the node runs another.
for module := range fps {
if mod, ok := modules[module]; ok && laid[module] {
fps[module] = mod.FP2
}
}
kept := map[string]string{}
for module, fp := range fps {
if mod, ok := modules[module]; ok && mod.FP2 == fp {
continue
}
kept[module] = fp
}
wants := map[string]delivery.Want{agent.NodeModule: {}}
for module, fp := range fps {
wants[module] = delivery.Want{FP: fp}
}
c.mu.Lock()
c.wants = wants
c.mu.Unlock()
scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue}
if input != nil {
input(scope)
}
comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps)
if err != nil {
return nil, nil, err
}
for key, comp := range comps {
comps[key] = c.keepRunning(comp, running)
}
return comps, held, nil
}