Join: L3's mesh-sdk (f30a53b2) onto A1's wip/registry-a1 (c5fc2ee)

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 14:17:29 +01:00
8 changed files with 116 additions and 72 deletions
@@ -2,7 +2,7 @@ package catalogue
import "testing"
// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch.
// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it.
func TestTheEngineServesComposeCandidate(t *testing.T) {
for _, v := range EngineVerbs() {
if v.Name != "compose-candidate" {
@@ -10,7 +10,7 @@ func TestTheEngineServesComposeCandidate(t *testing.T) {
}
props, _ := v.Input["properties"].(map[string]any)
input, _ := props["input"].(map[string]any)
if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil {
if !v.Optional || props["candidate"] == nil || input == nil || input["type"] != "boolean" {
t.Fatalf("compose-candidate reads as %+v", v)
}
return
+12 -9
View File
@@ -1,23 +1,26 @@
package catalogue
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
import "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
// EngineSeat is the node seat every node's engine holds.
const EngineSeat = contractseats.NodeEngine
//
// Deprecated: the seat is defined whole in mesh-contracts (nodeengine.Seat, its verbs, events, subjects and message
// types); kept for its importers until their next change takes it from there (testdata/seat-names.txt).
const EngineSeat = nodeengine.Seat
// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the
// generated seat list (design 26).
// generated seat list (design 26), each with its description and its input's schema as discovery shows them. Their
// names are nodeengine.Verbs().
func EngineVerbs() []Verb {
s, _ := seatIn(defaultSeats, EngineSeat)
return append([]Verb(nil), s.Serves...)
}
// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime
// proved it and the ask (ADR 0563 §1).
var EngineEvents = func() []string {
s, _ := seatIn(defaultSeats, EngineSeat)
return append([]string(nil), s.Emits...)
}()
// EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime
// proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4).
//
// Deprecated: the seat's contract names them, nodeengine.Events(); kept for its importers.
var EngineEvents = nodeengine.Events()
// seatIn is the seat of that name in a list.
func seatIn(list []Seat, name string) (Seat, bool) {
+9 -2
View File
@@ -54,10 +54,13 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) {
if err := json.Unmarshal(raw, &compiled); err != nil {
t.Fatal(err)
}
whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker"}
whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"}
dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true}
// Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it.
addedVerbs := map[string]bool{"node-engine.pull-waits": true}
// A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the
// checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it).
retyped := map[string]bool{"node-engine.compose-candidate": true}
norm := func(v any) any {
b, _ := json.Marshal(v)
var out any
@@ -100,7 +103,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) {
}
continue
}
kept = append(kept, byName[v.Name])
kv := byName[v.Name]
if retyped[want.Name+"."+v.Name] {
kv.Input = v.Input
}
kept = append(kept, kv)
}
g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts
// A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list.
+25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
@@ -88,6 +89,10 @@ type BuiltArtifact struct {
Kind string `json:"kind,omitempty"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
// References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build
// keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one
// of them reads. A reader that does not know the field reads Reference alone.
References []string `json:"references,omitempty"`
}
const artifactStore = "artifact-store://"
@@ -210,6 +215,10 @@ type BuilderBuild struct {
// Imports are, on a done key, the mesh packages the built programs import, as the build module answered them
// (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports).
Imports []string `json:"imports,omitempty"`
// Needs are, on a done key only, the reader capabilities (`form:<name>`) the build's reference forms need of a
// node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field
// reads the build as needing none.
Needs []string `json:"needs,omitempty"`
}
func (b BuilderBuild) check() error {
@@ -244,6 +253,22 @@ func (b BuilderBuild) check() error {
if p := a.referenceProblem(); p != "" {
return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p)
}
if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) {
return fmt.Errorf("delivery: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference)
}
for _, r := range a.References {
if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" {
return fmt.Errorf("delivery: the reference %q of the %s %q %s", r, a.Kind, a.Name, p)
}
}
}
for _, n := range b.Needs {
if !strings.HasPrefix(n, "form:") || len(n) == len("form:") {
return fmt.Errorf("delivery: the need %q of the build is not form:<name>", n)
}
}
if len(b.Needs) > 0 && b.State != BuildDone {
return fmt.Errorf("delivery: a %s build names needs; only a done key does", b.State)
}
for language, id := range b.Asks {
if b.State != BuildBuilding {
+24 -8
View File
@@ -26,11 +26,15 @@ func doneBuild() BuilderBuild {
func builderSamples() map[string]BuilderBuild {
failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"}
both := doneBuild()
both.Needs = []string{"form:artifact-registry"}
both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest}
return map[string]BuilderBuild{
"queued": {State: BuildQueued, Manifest: digestOf('4')},
"building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')},
"done": doneBuild(),
"failed": failed,
"done with needs and both forms": both,
"queued": {State: BuildQueued, Manifest: digestOf('4')},
"building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')},
"done": doneBuild(),
"failed": failed,
}
}
@@ -90,10 +94,17 @@ func TestBuilderBuildChecked(t *testing.T) {
edit func(*BuilderBuild)
words []string
}{
"done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}},
"a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}},
"an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}},
"an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}},
"done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}},
"a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}},
"an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}},
"an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}},
"a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}},
"a reference not among the references": {func(b *BuilderBuild) {
b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest}
}, []string{"server", "references"}},
"a reference of another artifact": {func(b *BuilderBuild) {
b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest}
}, []string{"server", "other"}},
"an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}},
"a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}},
"a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}},
@@ -119,6 +130,11 @@ func TestBuilderBuildChecked(t *testing.T) {
}
// a queued or building value may not already carry an outcome
q := builderSamples()["queued"]
q.Needs = []string{"form:x"}
if _, err := EncodeBuilderBuild(q); err == nil {
t.Error("a queued build with needs accepted")
}
q.Needs = nil
q.Artifacts = doneBuild().Artifacts
if _, err := EncodeBuilderBuild(q); err == nil {
t.Error("a queued build with artifacts accepted")
+40 -47
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb
@@ -16,7 +18,9 @@ import (
const (
// EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat).
EngineComposeCandidateVerb = "compose-candidate"
//
// Deprecated: the seat's contract names it, nodeengine.VerbComposeCandidate; kept for its importers.
EngineComposeCandidateVerb = nodeengine.VerbComposeCandidate
// ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's
// answer (ADR 0593 §7, §9).
ComposeAnswerBound = 60 * time.Second
@@ -45,53 +49,41 @@ const (
ModuleHeldState = "held"
)
// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey,
// `<escaped group>.<heads hash>`); the engine reads that candidate itself and lays its modules over its placement.
// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the
// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer
// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7).
type ComposeCandidateAsk struct {
Candidate string `json:"candidate"`
Input bool `json:"input,omitempty"`
}
// The verb's ask and answer are the seat's contract, generated in mesh-contracts (seats/node-engine): mesh-sdk keeps
// these names for their importers and the checks below over them, until the seat has a client package (ADR 0625 §4).
type (
// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey,
// `<escaped group>.<heads hash>`); the engine reads that candidate itself and lays its modules over its placement.
// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the
// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer
// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7).
//
// Deprecated: the seat's contract is nodeengine.ComposeCandidateInput; kept for its importers.
ComposeCandidateAsk = nodeengine.ComposeCandidateInput
// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node
// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate
// at all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the
// engine took in milliseconds, and, when asked (ComposeCandidateAsk.Input), the composition input it composed from.
//
// Deprecated: the seat's contract is nodeengine.ComposeCandidateAnswer; kept for its importers.
ComposeCandidateAnswer = nodeengine.ComposeCandidateAnswer
// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the
// candidate's FP2 for a module the candidate touches, else its newest done build as composed-<node> holds it), its
// state, the composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one
// composed without it (the checker's "what the change reaches on this node"), and why, for a refused or held one.
//
// Deprecated: the seat's contract is nodeengine.Module; kept for its importers.
ModuleComposed = nodeengine.Module
)
// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node
// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at
// all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the engine took in milliseconds, and, when asked
// (ComposeCandidateAsk.Input), the composition input it composed from.
type ComposeCandidateAnswer struct {
V int `json:"v"`
Node string `json:"node"`
Candidate string `json:"candidate"`
Verdict string `json:"verdict"`
Modules []ModuleComposed `json:"modules"`
Own ModuleComposed `json:"own"`
Refused string `json:"refused,omitempty"`
Took int64 `json:"took"`
Input json.RawMessage `json:"input,omitempty"`
}
// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's
// FP2 for a module the candidate touches, else its newest done build as composed-<node> holds it), its state, the
// composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without
// it (the checker's "what the change reaches on this node"), and why, for a refused or held one.
type ModuleComposed struct {
Module string `json:"module"`
FP string `json:"fp,omitempty"`
State string `json:"state"`
Digest string `json:"digest,omitempty"`
Changed bool `json:"changed,omitempty"`
Words string `json:"words,omitempty"`
}
func (a ComposeCandidateAsk) check() error {
func checkComposeAsk(a ComposeCandidateAsk) error {
if _, _, err := ParseCompositionKey(a.Candidate); err != nil {
return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err)
}
return nil
}
func (m ModuleComposed) check(node string) error {
func checkComposedModule(m ModuleComposed, node string) error {
if m.Module == "" {
return fmt.Errorf("delivery: a module's composition on %s names no module", node)
}
@@ -111,7 +103,7 @@ func (m ModuleComposed) check(node string) error {
return nil
}
func (a ComposeCandidateAnswer) check() error {
func checkComposeAnswer(a ComposeCandidateAnswer) error {
if err := checkName("node", a.Node); err != nil {
return err
}
@@ -133,7 +125,7 @@ func (a ComposeCandidateAnswer) check() error {
}
refused := false
for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) {
if err := m.check(a.Node); err != nil {
if err := checkComposedModule(m, a.Node); err != nil {
return err
}
if i > 1 && a.Modules[i-2].Module >= m.Module {
@@ -156,6 +148,7 @@ func (a ComposeCandidateAnswer) check() error {
func strictDecode(data []byte, v any, what string) error {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written
if err := d.Decode(v); err != nil {
return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err)
}
@@ -164,7 +157,7 @@ func strictDecode(data []byte, v any, what string) error {
// EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key.
func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) {
if err := a.check(); err != nil {
if err := checkComposeAsk(a); err != nil {
return nil, err
}
return json.Marshal(a)
@@ -176,13 +169,13 @@ func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) {
if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil {
return ComposeCandidateAsk{}, err
}
return a, a.check()
return a, checkComposeAsk(a)
}
// EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest,
// a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give.
func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) {
if err := a.check(); err != nil {
if err := checkComposeAnswer(a); err != nil {
return nil, err
}
a.V = Version
@@ -199,5 +192,5 @@ func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) {
if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil {
return ComposeCandidateAnswer{}, err
}
return a, a.check()
return a, checkComposeAnswer(a)
}
+4 -3
View File
@@ -2,6 +2,7 @@ package delivery
import (
"encoding/json"
"reflect"
"strings"
"testing"
@@ -32,16 +33,16 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) {
good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812,
Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"},
Modules: []ModuleComposed{
{Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true},
{Module: "netcheck", Fp: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true},
{Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"},
{Module: "zz", State: ModuleHeldState, Words: "held before"},
}, Input: json.RawMessage(`{"machines":["ace"]}`)}
}, Input: map[string]any{"machines": []any{"ace"}}}
raw, err = EncodeComposeCandidateAnswer(good)
if err != nil {
t.Fatal(err)
}
back, err := DecodeComposeCandidateAnswer(raw)
if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) {
if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) {
t.Fatalf("answer round trip: %v, %+v", err, back)
}
for name, mutate := range map[string]func(a *ComposeCandidateAnswer){
-1
View File
@@ -11,7 +11,6 @@ catalogue/checker_seat.go: CheckerSeat
catalogue/control_records_seats.go: HandActsSeat
catalogue/control_records_seats.go: WantsSeat
catalogue/delivery_seat.go: DeliverySeat
catalogue/engine_seat.go: EngineSeat
catalogue/enrolment_seat.go: EnrolmentSeat
catalogue/environment_into.go: EnvironmentSeat
catalogue/environment_into.go: LoginShellSeat