Files
mesh-host/internal/selfcompose/candidate.go
T
jschoubben 3fa41dfe1a Serve node-engine.compose-candidate from the node's shadow (H4)
ADR 0593 §7: a check's composition is the checker's, made from each
placed node's answer, since only a node can compose with its own sealed
values. The node composes the indexer's candidate in shadow on a copy of
its shadow's inputs, the candidate's modules laid over the indexer's
with their manifest verdicts, and answers per module composed (changed
or not), refused or held, and its own composition's verdict, within the
answer bound; it applies, writes and hands nothing. With input it also
answers the composition input, for the sandbox path.

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

290 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
}
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 refuse(errNotReady.Error())
}
base := c.scratch()
raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate)
if err != nil {
return refuse(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 refuse(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 refuse(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound))
}
switch {
case laidErr != nil && BusFailed(laidErr):
return refuse("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
}